Compare commits

...

38 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
156 changed files with 18804 additions and 2193 deletions
+1
View File
@@ -15,6 +15,7 @@ node_modules/
coverage/ coverage/
.coverage .coverage
model_cache/ model_cache/
recipe_cache/
# agent / dev tooling # agent / dev tooling
.opencode/ .opencode/
+9
View File
@@ -192,6 +192,15 @@ The system runs in two modes:
- Auto-saves paths to `settings.json` in ComfyUI mode - Auto-saves paths to `settings.json` in ComfyUI mode
- `settings.json.example` is intentionally minimal (see Important Notes); all - `settings.json.example` is intentionally minimal (see Important Notes); all
other defaults live in `DEFAULT_SETTINGS` (`py/services/settings_manager.py`) other defaults live in `DEFAULT_SETTINGS` (`py/services/settings_manager.py`)
- **`folder_paths` vs `extra_folder_paths` — different purposes, do not conflate:**
- `folder_paths` (primary model roots): in ComfyUI plugin mode these come
from the ComfyUI host; in standalone mode they are the ONLY source of
model library paths and are currently edited by hand in `settings.json`.
- `extra_folder_paths` is a **ComfyUI-plugin-mode feature**: paths visible
ONLY to LoRA Manager, not to ComfyUI. Its motivation is that a very large
model library slows ComfyUI itself down, while LoRA Manager handles large
libraries without performance issues — so users keep ComfyUI's library
small and add the bulk via `extra_folder_paths`.
### Frontend UI Architecture ### Frontend UI Architecture
+29 -2
View File
File diff suppressed because one or more lines are too long
+246 -235
View File
@@ -7,190 +7,199 @@
], ],
"allSupporters": [ "allSupporters": [
"Takkan", "Takkan",
"2018cfh",
"megakirbs", "megakirbs",
"Brennok", "Brennok",
"Charles Blakemore", "2018cfh",
"Rob Williams", "Rob Williams",
"Insomnia Art Designs", "Charles Blakemore",
"Arlecchino Shion", "Arlecchino Shion",
"Insomnia Art Designs",
"Mozzel",
"Gingko Biloba", "Gingko Biloba",
"stone9k", "stone9k",
"Kiba",
"onesecondinosaur", "onesecondinosaur",
"Skalabananen", "Skalabananen",
"Sterilized",
"Polymorphic Indeterminate", "Polymorphic Indeterminate",
"Liam MacDougal", "Liam MacDougal",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"Rosenthal", "Rosenthal",
"ClockDaemon",
"Francisco Tatis", "Francisco Tatis",
"Tobi_Swagg", "Tobi_Swagg",
"SG",
"jmack",
"Andrew Wilson", "Andrew Wilson",
"Greybush", "Greybush",
"Ricky Carter", "Ricky Carter",
"JongWon Han", "JongWon Han",
"VantAI", "VantAI",
"レプサイ",
"Michael Wong",
"Illrigger", "Illrigger",
"Tom Corrigan",
"JackieWang",
"FreelancerZ", "FreelancerZ",
"Mozzel", "fnkylove",
"Lilleman",
"Robert Stacey",
"PM",
"Marc Whiffen", "Marc Whiffen",
"Dogwalkerbr",
"Birdy", "Birdy",
"Kiba", "quarz",
"$MetaSamsara", "$MetaSamsara",
"jean jahren",
"Reno Lam", "Reno Lam",
"Aleksander Wujczyk", "Aleksander Wujczyk",
"AM Kuro",
"JSST",
"sig", "sig",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"J\\B/ 8r0wns0n", "J\\B/ 8r0wns0n",
"Snaggwort", "Snaggwort",
"Anthony+Rizzo", "Anthony+Rizzo",
"W+K+White", "W+K+White",
"ClockDaemon", "Baekdoosixt",
"Jonathan Ross", "Jonathan Ross",
"KD", "KD",
"Omnidex", "Omnidex",
"Nolife_M", "Nolife_M",
"Melville Parrish",
"daniel dove",
"Lustre",
"Tyler Trebuchon", "Tyler Trebuchon",
"Release Cabrakan", "Release Cabrakan",
"SG", "JW Sin",
"Alex",
"carozzz", "carozzz",
"Marlon Daniels",
"James Dooley", "James Dooley",
"zenbound", "zenbound",
"Buzzard", "Buzzard",
"jmack",
"Adam Shaw", "Adam Shaw",
"Mark Corneglio", "Mark Corneglio",
"RedrockVP", "RedrockVP",
"James Todd", "James Todd",
"Wicked Choices by ASLPro3D",
"FinalyFree",
"Fyf", "Fyf",
"レプサイ",
"Timmy", "Timmy",
"Johnny", "Johnny",
"Tak",
"Lisster", "Lisster",
"Michael Wong", "Big Red",
"whudunit", "whudunit",
"Tom Corrigan", "Luc Job",
"JackieWang", "corde",
"fnkylove",
"Yushio", "Yushio",
"Vik71it", "Vik71it",
"Bishoujoker",
"Echo", "Echo",
"Lilleman",
"Robert Stacey",
"PM",
"Todd Keck", "Todd Keck",
"Briton Heilbrun", "Briton Heilbrun",
"wildnut",
"Edgar Tejeda", "Edgar Tejeda",
"Sterilized",
"BadassArabianMofo", "BadassArabianMofo",
"Dogwalkerbr", "MiraiKuriyamaSy",
"quarz",
"Pascal Dahle", "Pascal Dahle",
"Greg", "Greg",
"jean jahren", "Akira HentAI",
"AM Kuro", "otaku fra",
"JSST",
"lmsupporter", "lmsupporter",
"andrew.tappan",
"wackop", "wackop",
"Phil", "Phil",
"Greenmoustache",
"Carl G.", "Carl G.",
"wfpearl", "wfpearl",
"jeaness",
"Dsperado", "Dsperado",
"Baekdoosixt",
"Jack B Nimble", "Jack B Nimble",
"Melville Parrish",
"daniel dove",
"Lustre",
"JW Sin",
"Alex",
"bh", "bh",
"Marlon Daniels", "Jwk0205",
"Starkselle", "Starkselle",
"Olive",
"Aaron Bleuer", "Aaron Bleuer",
"LacesOut!", "LacesOut!",
"greebles", "greebles",
"SarcasticHashtag", "SarcasticHashtag",
"Wicked Choices by ASLPro3D", "Some Guy Named Barry",
"M Postkasse",
"Jacob Hoehler", "Jacob Hoehler",
"FinalyFree", "Matt Wenzel",
"Weasyl", "Weasyl",
"Lex Song", "Lex Song",
"Cory Paza", "Cory Paza",
"Tak",
"Gonzalo Andre Allendes Lopez", "Gonzalo Andre Allendes Lopez",
"Big Red", "Serge Bekenkamp",
"AIJimmy", "AIJimmy",
"Luc Job",
"Philip Hempel", "Philip Hempel",
"corde", "dan",
"Bishoujoker",
"aai", "aai",
"wildnut",
"Ran C", "Ran C",
"ViperC", "ViperC",
"itismyelement", "itismyelement",
"Sangheili460", "Sangheili460",
"MagnaInsomnia", "MagnaInsomnia",
"Karl P.", "Karl P.",
"Akira HentAI",
"MiraiKuriyamaSy",
"LarsesFPC", "LarsesFPC",
"otaku fra", "Weird_With_A_Beard",
"andrew.tappan",
"N/A", "N/A",
"The Spawn", "The Spawn",
"graysock", "graysock",
"Pozadine1", "Pozadine1",
"Greenmoustache",
"fancypants",
"jeaness",
"Joboshy",
"Digital",
"JaxMax",
"Bohemian Corporal",
"Dan",
"Jwk0205",
"Bro Xie",
"batblue",
"carey6409",
"Olive",
"太郎 ゲーム",
"Some Guy Named Barry",
"jinxedx",
"M Postkasse",
"AELOX",
"Dankin-Pics",
"Nicfit23",
"wamekukyouzin",
"drum matthieu",
"DogmaR34",
"Matt Wenzel",
"Frank Nitty",
"Christopher Michel",
"runte3221",
"Serge Bekenkamp",
"DougPeterson",
"LeoZero",
"dl0901dm",
"Antonio Pontes",
"kushiroK9",
"Kevin John Duck",
"Dustin Chen",
"dan",
"Blackfish95",
"Mouthlessman",
"Paul Kroll",
"Fraser Cross",
"Bas Imagineer",
"Dušan Ryban",
"Adam Taylor",
"Weird_With_A_Beard",
"Qarob", "Qarob",
"AIGooner", "AIGooner",
"Luc", "Luc",
"ProtonPrince", "ProtonPrince",
"DiffDuck", "DiffDuck",
"fancypants",
"John+Edwards",
"Joboshy",
"Digital",
"JaxMax",
"Bohemian Corporal",
"Dan",
"Bro Xie",
"seed123_AIart",
"batblue",
"carey6409",
"太郎 ゲーム",
"Roslynd",
"jinxedx",
"AELOX",
"Dankin-Pics",
"Nicfit23",
"Cristian Vazquez",
"wamekukyouzin",
"drum matthieu",
"DogmaR34",
"Frank Nitty",
"The Magic Noob",
"Christopher Michel",
"runte3221",
"DougPeterson",
"LeoZero",
"dl0901dm",
"Antonio Pontes",
"Bruce",
"kushiroK9",
"Kevin John Duck",
"Dustin Chen",
"Blackfish95",
"Tori",
"Mouthlessman",
"Paul Kroll",
"Fraser Cross",
"Bas Imagineer",
"John Statham",
"Dušan Ryban",
"Adam Taylor",
"decoy",
"elu3199", "elu3199",
"Hasturkun", "Hasturkun",
"Jon Sandman", "Jon Sandman",
@@ -201,39 +210,38 @@
"wundershark", "wundershark",
"mr_dinosaur", "mr_dinosaur",
"Tyrswood", "Tyrswood",
"linnfrey",
"griffin+dahlberg",
"John+Edwards",
"ElitaSSJ4",
"Matt+J",
"Josef Lanzl",
"New folder (1)",
"seed123_AIart",
"Error_Rule34_Not_found",
"Roslynd",
"Geolog",
"Neco28",
"Resist's Creations - Spicy Edition 🔥",
"David Ortega",
"Wolffen",
"Cristian Vazquez",
"The Magic Noob",
"Jeff",
"nwalker94",
"Bruce",
"Kevin Christopher",
"Chad Idk",
"Tori",
"dd",
"John Statham",
"sjon kreutz",
"Metryman55",
"AlexDuKaNa",
"decoy",
"Ray Wing", "Ray Wing",
"Ranzitho", "Ranzitho",
"Gus", "Gus",
"MJG", "MJG",
"linnfrey",
"griffin+dahlberg",
"ElitaSSJ4",
"Matt+J",
"Josef Lanzl",
"New folder (1)",
"sanborondon",
"Error_Rule34_Not_found",
"jcay015",
"Erik Lopez",
"Mateo Curić",
"Geolog",
"Neco28",
"Eris3D",
"Resist's Creations - Spicy Edition 🔥",
"David Ortega",
"Wolffen",
"a _",
"Jeff",
"nwalker94",
"James Coleman",
"Kevin Christopher",
"Chad Idk",
"dd",
"Sam",
"sjon kreutz",
"Metryman55",
"AlexDuKaNa",
"ae", "ae",
"Tr4shP4nda", "Tr4shP4nda",
"Gamalonia", "Gamalonia",
@@ -248,37 +256,41 @@
"Kland", "Kland",
"Hailshem", "Hailshem",
"Naomi Hale Danchi", "Naomi Hale Danchi",
"epicgamer0020690",
"Joshua Porrata",
"Andrew",
"Brian M", "Brian M",
"sanborondon", "Robert Wegemund",
"Littlehuggy",
"Brian Buie",
"Thought2Form", "Thought2Form",
"jcay015",
"RAIDiation", "RAIDiation",
"Erik Lopez", "Sadlip",
"Mateo Curić",
"Eris3D",
"Gooohokrbe", "Gooohokrbe",
"m", "m",
"OldBones", "OldBones",
"Pierce McBride", "Pierce McBride",
"Zach Gonser", "Zach Gonser",
"Mikko Hemilä", "Mikko Hemilä",
"Jacob McDaniel",
"Jamie Ogletree", "Jamie Ogletree",
"a _", "Temikus",
"James Coleman", "Artokun",
"Michael Taylor",
"Martial", "Martial",
"Emil Andersson", "Emil Andersson",
"Ouro Boros", "Ouro Boros",
"Atilla Berke Pekduyar",
"Decx _",
"Yuji Kaneko", "Yuji Kaneko",
"Rops Alot", "Rops Alot",
"Sam",
"Penfore", "Penfore",
"Gordon Cole", "Gordon Cole",
"Ace Ventura", "Ace Ventura",
"AbstractAss", "AbstractAss",
"David LaVallee", "David LaVallee",
"ken", "ken",
"epicgamer0020690", "Crocket",
"Joshua Porrata",
"keemun", "keemun",
"SuBu", "SuBu",
"RedPIXel", "RedPIXel",
@@ -297,15 +309,19 @@
"KitKatM", "KitKatM",
"socrasteeze", "socrasteeze",
"MudkipMedkitz", "MudkipMedkitz",
"deanbrian",
"Alex Wortman",
"Cody",
"emadsultan",
"InformedViewz",
"Bubbafett",
"leaf",
"Adam Rinehart",
"gzmzmvp", "gzmzmvp",
"takyamtom", "takyamtom",
"Andrew", "Aberr",
"Robert Wegemund",
"Littlehuggy",
"Gregory Kozhemiak", "Gregory Kozhemiak",
"Brian Buie",
"aezin", "aezin",
"Sadlip",
"Eric Whitney", "Eric Whitney",
"Joey Callahan", "Joey Callahan",
"Ivan Tadic", "Ivan Tadic",
@@ -315,17 +331,12 @@
"Elliot E", "Elliot E",
"Morgandel", "Morgandel",
"Theerat Jiramate", "Theerat Jiramate",
"Jacob McDaniel",
"X", "X",
"SloanSteddyAI", "SloanSteddyAI",
"Temikus",
"Artokun",
"Michael Taylor",
"Steven Owens", "Steven Owens",
"hexxish",
"Derek Baker", "Derek Baker",
"Atilla Berke Pekduyar",
"NICHOLAS BAXLEY", "NICHOLAS BAXLEY",
"Decx _",
"Ed Wang", "Ed Wang",
"Saya", "Saya",
"Xeeosat", "Xeeosat",
@@ -333,18 +344,10 @@
"四糸凜音", "四糸凜音",
"esthe", "esthe",
"FrxzenSnxw", "FrxzenSnxw",
"Crocket",
"chriphost", "chriphost",
"ResidentDeviant", "ResidentDeviant",
"deanbrian", "Ginnie",
"Alex Wortman",
"Cody",
"emadsultan",
"InformedViewz",
"Bubbafett",
"leaf",
"Skyfire83", "Skyfire83",
"Adam Rinehart",
"Pitpe11", "Pitpe11",
"IamAyam", "IamAyam",
"TheD1rtyD03", "TheD1rtyD03",
@@ -356,17 +359,25 @@
"SpringBootisTrash", "SpringBootisTrash",
"carsten", "carsten",
"ikok", "ikok",
"quantenmecha",
"Jason+Nash",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"Duk3+Rand0m",
"Nathen+Choi", "Nathen+Choi",
"T", "T",
"D",
"David Schenck", "David Schenck",
"Wolfe7D1", "Wolfe7D1",
"Aberr",
"Andrew Marshall", "Andrew Marshall",
"Taylor Funk", "Taylor Funk",
"elleshar666", "elleshar666",
"Gerald Welly", "Gerald Welly",
"Tee Gee", "Tee Gee",
"ACTUALLY_the_Real_Willem_Dafoe", "ACTUALLY_the_Real_Willem_Dafoe",
"Михал Михалыч",
"tarek helmi", "tarek helmi",
"Kauffy", "Kauffy",
"Max Marklund", "Max Marklund",
@@ -376,13 +387,15 @@
"Vane Holzer", "Vane Holzer",
"psytrax", "psytrax",
"Cyrus Fett", "Cyrus Fett",
"hexxish",
"lh qwe", "lh qwe",
"conner", "conner",
"Xenon Xue",
"Michael Anthony Scott", "Michael Anthony Scott",
"notedfakes", "notedfakes",
"Princess Bright Eyes", "Princess Bright Eyes",
"Michael Scott", "Michael Scott",
"Solixer",
"Jimmy Borup",
"Wes Sims", "Wes Sims",
"Donor4115", "Donor4115",
"Filippo Ferrari", "Filippo Ferrari",
@@ -393,11 +406,19 @@
"momokai", "momokai",
"몽타주", "몽타주",
"kudari", "kudari",
"Whitepinetrader",
"OrganicArtifact", "OrganicArtifact",
"Ginnie",
"Raku", "Raku",
"CHKeeho80", "CHKeeho80",
"nanana", "nanana",
"Alex",
"Karru",
"ChaChanoKo",
"ghoulars",
"null",
"Beau",
"redcarrot",
"powerbot99",
"Fthehappy", "Fthehappy",
"J", "J",
"Jeff+Kesemeyer", "Jeff+Kesemeyer",
@@ -407,39 +428,32 @@
"Doug+Rintoul", "Doug+Rintoul",
"Noor", "Noor",
"Yorunai", "Yorunai",
"D",
"quantenmecha",
"Jason+Nash",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"Duk3+Rand0m",
"Richard", "Richard",
"奚明 刘", "奚明 刘",
"준희 김", "준희 김",
"りん あめ", "りん あめ",
"Михал Михалыч",
"Matt", "Matt",
"Tomohiro Baba", "Tomohiro Baba",
"Noora", "Noora",
"Frogmilk", "Frogmilk",
"SPJ", "SPJ",
"Kor",
"Bryan Rutkowski", "Bryan Rutkowski",
"Noah", "Noah",
"Xenon Xue", "TenaciousD",
"Dmitry Ryzhov", "Dmitry Ryzhov",
"DarkSunset", "DarkSunset",
"Edward Ten Eyck", "Edward Ten Eyck",
"Steam Steam", "Steam Steam",
"CryptoTraderJK", "CryptoTraderJK",
"Davaitamin", "Davaitamin",
"Solixer", "Pete Pain",
"Nathan", "Nathan",
"Jimmy Borup",
"tedcor", "tedcor",
"RHopkirk",
"jinksta187", "jinksta187",
"Fotek Design", "Fotek Design",
"Maxim",
"Manu Thetug", "Manu Thetug",
"Lyavph", "Lyavph",
"Nihongasuki", "Nihongasuki",
@@ -450,8 +464,14 @@
"starbugx", "starbugx",
"dc7431", "dc7431",
"Inversity", "Inversity",
"Whitepinetrader",
"Vir", "Vir",
"Sildoren",
"Darv",
"Seon+Song",
"2turbo",
"Dmitry+Viznesenskiy",
"tanjin90",
"sternenkrieger",
"Pascalou", "Pascalou",
"Patrick+Bryan", "Patrick+Bryan",
"lighthawke", "lighthawke",
@@ -468,23 +488,17 @@
"Bob+Barker", "Bob+Barker",
"Dark_Pest", "Dark_Pest",
"Eldithor", "Eldithor",
"Alex",
"Karru",
"ChaChanoKo",
"ghoulars",
"redcarrot",
"null",
"Beau",
"powerbot99",
"Ko-fi+Supporter", "Ko-fi+Supporter",
"lrdchs2", "lrdchs2",
"Tú Nguyễn Lý Hoàng", "Tú Nguyễn Lý Hoàng",
"shira1011",
"Kalli Core", "Kalli Core",
"Ben D", "Ben D",
"Draven T", "Draven T",
"marioandluigi", "marioandluigi",
"G", "G",
"Ronan Delevacq", "Ronan Delevacq",
"Leslie Andrew Ridings",
"Aquatic Coffee", "Aquatic Coffee",
"Dave Abraham", "Dave Abraham",
"Joaquin Hierrezuelo", "Joaquin Hierrezuelo",
@@ -492,25 +506,27 @@
"StudOx Tech", "StudOx Tech",
"yves.poezevara", "yves.poezevara",
"Jarrid Lee", "Jarrid Lee",
"Kor", "Poophead27 Blyat",
"Joseph Hanson", "Joseph Hanson",
"John Rednoulf", "John Rednoulf",
"Focuschannel", "Focuschannel",
"Boba Smith", "Boba Smith",
"matt",
"somethingtosay8",
"ivistorm", "ivistorm",
"Anthony Faxlandez", "Anthony Faxlandez",
"Sauv", "Sauv",
"TenaciousD",
"Ted Cart", "Ted Cart",
"Sage Himeros",
"Zeeble", "Zeeble",
"Pat Hen", "Pat Hen",
"Pete Pain",
"Draconach", "Draconach",
"Tigon", "Tigon",
"ItsGeneralButtNaked",
"Jordan Shaw", "Jordan Shaw",
"RHopkirk",
"g unit", "g unit",
"Maxim", "Dkom22",
"Marcos Tortosa Carmona",
"Distortik", "Distortik",
"JC", "JC",
"Prompt Pirate", "Prompt Pirate",
@@ -518,11 +534,22 @@
"Marcus thronico", "Marcus thronico",
"zenobeus", "zenobeus",
"ryoma", "ryoma",
"dg",
"Stryker", "Stryker",
"smart.edge5178", "smart.edge5178",
"Menard", "Menard",
"SomeDude", "SomeDude",
"raf8osz", "raf8osz",
"Gold_miner_ego",
"bakeliteboy",
"TequiTequi",
"Homero+Banda",
"Nick",
"Monix",
"Trolinka",
"PredragR",
"Clauzmak",
"Nerick",
"SundayRage", "SundayRage",
"matter", "matter",
"SRCRCOSS", "SRCRCOSS",
@@ -539,13 +566,6 @@
"Mobius2020", "Mobius2020",
"ExLightSaber", "ExLightSaber",
"YaboiRay", "YaboiRay",
"Sildoren",
"Darv",
"Seon+Song",
"2turbo",
"Dmitry+Viznesenskiy",
"tanjin90",
"sternenkrieger",
"boston666", "boston666",
"cocona", "cocona",
"Obsidian.Studios", "Obsidian.Studios",
@@ -553,52 +573,53 @@
"Aquaneo", "Aquaneo",
"blikkies", "blikkies",
"JBsuede", "JBsuede",
"shira1011", "Wolf and Fox Legends",
"ゼクス、六",
"Neko Desco", "Neko Desco",
"Vinarus", "Vinarus",
"Josh Snyder", "Josh Snyder",
"Shock Shockor", "Shock Shockor",
"Goldwaters", "Goldwaters",
"Leslie Andrew Ridings",
"Zude", "Zude",
"Poophead27 Blyat", "Room Light",
"Kyler", "Kyler",
"Justin Blaylock", "Justin Blaylock",
"aRtFuL_DodGeR", "aRtFuL_DodGeR",
"Snorklebort", "Snorklebort",
"TheFusion", "TheFusion",
"MR.Bear", "MR.Bear",
"matt",
"somethingtosay8",
"3zS4QNQ4", "3zS4QNQ4",
"Terminuz", "Terminuz",
"Matt M.", "Matt M.",
"Ivan Imes", "Ivan Imes",
"J M",
"Steven", "Steven",
"Borte", "Borte",
"Sage Himeros", "yyuvuvu",
"Billy Gladky", "Billy Gladky",
"Nomki",
"Probis", "Probis",
"Jack Lawfield", "Jack Lawfield",
"SkibidiRizzler", "SkibidiRizzler",
"Maxon - Plans", "Maxon - Plans",
"Kalle Björk", "Kalle Björk",
"ItsGeneralButtNaked",
"Karlanx", "Karlanx",
"operationancut", "operationancut",
"Nacho Ferrando", "Nacho Ferrando",
"Marcos Tortosa Carmona",
"Dkom22",
"Youguang", "Youguang",
"andrewzpong", "andrewzpong",
"BossGame", "BossGame",
"lrdchs", "lrdchs",
"Tree Tagger", "Tree Tagger",
"Janik",
"AIVORY3D", "AIVORY3D",
"Kevinj", "Kevinj",
"Mitchell Robson", "Mitchell Robson",
"dg",
"POPPIN", "POPPIN",
"meatyalien",
"Tony+V",
"draganjankovic1975dj528",
"kinz",
"YoruHime", "YoruHime",
"Mark+Staaf", "Mark+Staaf",
"Michael+Fürmann", "Michael+Fürmann",
@@ -611,17 +632,7 @@
"thomasand01", "thomasand01",
"Shiba+Sama", "Shiba+Sama",
"Celestial+Kitten", "Celestial+Kitten",
"TequiTequi",
"Homero+Banda",
"bakeliteboy",
"Nick",
"Gold_miner_ego",
"IshouI;_;", "IshouI;_;",
"Monix",
"Trolinka",
"PredragR",
"Clauzmak",
"Nerick",
"SAVEagleBasement", "SAVEagleBasement",
"Adam+Spreer", "Adam+Spreer",
"BillyBoy84", "BillyBoy84",
@@ -629,18 +640,17 @@
"Welkor", "Welkor",
"dubious1one", "dubious1one",
"Brandon Thomas", "Brandon Thomas",
"Dustin Hendel",
"moranqianlong", "moranqianlong",
"Wolf and Fox Legends",
"ゼクス、六",
"Liberation", "Liberation",
"Ninja Tom", "Ninja Tom",
"75marc", "75marc",
"Elemnt", "Elemnt",
"Bradley Turner",
"swra", "swra",
"JollRodrigo", "JollRodrigo",
"Oliverfish", "Oliverfish",
"uruksayshi", "uruksayshi",
"Room Light",
"Patryk Serious", "Patryk Serious",
"nk8", "nk8",
"Kyron Mahan", "Kyron Mahan",
@@ -648,17 +658,18 @@
"Nimhloth", "Nimhloth",
"TBitz33", "TBitz33",
"Anonym dkjglfleeoeldldldlkf", "Anonym dkjglfleeoeldldldlkf",
"Tsani Prodanov",
"Ezokewn", "Ezokewn",
"SendingRavens", "SendingRavens",
"J M",
"Slacks", "Slacks",
"Glenn Hoetker", "Glenn Hoetker",
"JackJohnnyJim", "JackJohnnyJim",
"Khánh Đặng", "Khánh Đặng",
"Michael Hicks",
"Homero Banda", "Homero Banda",
"Michael Docherty", "Michael Docherty",
"yyuvuvu", "MadGod",
"Nomki", "GhostyGhost",
"Paul Hartsuyker", "Paul Hartsuyker",
"elitassj", "elitassj",
"Never_M", "Never_M",
@@ -667,6 +678,7 @@
"Andrew Wilkinson", "Andrew Wilkinson",
"David", "David",
"floeki75pad", "floeki75pad",
"TheJohnes",
"deadwishd", "deadwishd",
"shinonomeiro", "shinonomeiro",
"Snille", "Snille",
@@ -675,7 +687,6 @@
"xybrightsummer", "xybrightsummer",
"jreedatchison", "jreedatchison",
"PhilW", "PhilW",
"Janik",
"Cruel", "Cruel",
"MRBlack", "MRBlack",
"Kiyoe", "Kiyoe",
@@ -685,6 +696,15 @@
"Scott", "Scott",
"Muratoraccio", "Muratoraccio",
"D", "D",
"Daevalus",
"Milky+Mai",
"Krash",
"PP",
"thababydjac",
"belligerencebk",
"tortor",
"Peter",
"T",
"zipzorpp", "zipzorpp",
"Anton", "Anton",
"actual", "actual",
@@ -706,11 +726,7 @@
"plonk", "plonk",
"Anvil+Girl", "Anvil+Girl",
"Kotetsu", "Kotetsu",
"meatyalien",
"Tony+V",
"draganjankovic1975dj528",
"miduzza", "miduzza",
"kinz",
"Somebody", "Somebody",
"てぃんてぃんひーろー", "てぃんてぃんひーろー",
"you+halo9", "you+halo9",
@@ -727,12 +743,12 @@
"4IXplr0r3r", "4IXplr0r3r",
"hayden", "hayden",
"ahoystan", "ahoystan",
"Civitaier",
"BakunyuuWaifu", "BakunyuuWaifu",
"edk", "edk",
"Dustin Hendel", "Joey Leto",
"Anagra Nouma", "Anagra Nouma",
"tafapayo", "tafapayo",
"Bradley Turner",
"ja s", "ja s",
"Doug Mason", "Doug Mason",
"scoreswazey", "scoreswazey",
@@ -747,8 +763,8 @@
"David Murcko", "David Murcko",
"Justin Defer", "Justin Defer",
"Ben Brogger", "Ben Brogger",
"Tsani Prodanov",
"Jack Dole", "Jack Dole",
"dsffsdfsdfsdfsdfsdf",
"V Bj", "V Bj",
"Rj Joplin", "Rj Joplin",
"Kurt", "Kurt",
@@ -757,15 +773,13 @@
"Taylor Dominy", "Taylor Dominy",
"Faith", "Faith",
"Bouya shaka", "Bouya shaka",
"Michael Hicks",
"Maso", "Maso",
"MadGod",
"Kevin Wallace", "Kevin Wallace",
"GhostyGhost",
"ChicRic", "ChicRic",
"Bastard-Sama", "Bastard-Sama",
"mercur", "mercur",
"Sunny", "Sunny",
"Somebody",
"inusanorthcape", "inusanorthcape",
"Kane Sturzebecher", "Kane Sturzebecher",
"Yavizu3d", "Yavizu3d",
@@ -776,7 +790,6 @@
"Evgeniya Smolentseva", "Evgeniya Smolentseva",
"Raf Stahelin", "Raf Stahelin",
"Вячеслав Маринин", "Вячеслав Маринин",
"TheJohnes",
"Cola Matthew", "Cola Matthew",
"OniNoKen", "OniNoKen",
"Iain Wisely", "Iain Wisely",
@@ -819,6 +832,12 @@
"SelfishMedic", "SelfishMedic",
"adderleighn", "adderleighn",
"EnragedAntelope", "EnragedAntelope",
"mcmalt",
"cesasol",
"Null",
"fdfac",
"Eli",
"Somebody",
"8/4", "8/4",
"ivan.morgado.siles", "ivan.morgado.siles",
"SEI", "SEI",
@@ -830,16 +849,7 @@
"gdfgfdgfds", "gdfgfdgfds",
"Benjamin+Doerr", "Benjamin+Doerr",
"D", "D",
"Daevalus",
"MilkyMai",
"Krash",
"PP",
"babydjac",
"belligerencebk",
"tortor",
"Cryphius", "Cryphius",
"Peter+Timothy+Stover",
"Joel+Magnusson",
"Connor+Hall", "Connor+Hall",
"Macho+Grump", "Macho+Grump",
"Morcoddd", "Morcoddd",
@@ -879,13 +889,11 @@
"proto merp", "proto merp",
"_ G3n", "_ G3n",
"Donovan Jenkins", "Donovan Jenkins",
"Civitaier",
"Hans Meier", "Hans Meier",
"jboul", "jboul",
"Michael Eid", "Michael Eid",
"Super Sigma Reborne", "Super Sigma Reborne",
"Veloce", "Veloce",
"Joey Leto",
"Bob barker", "Bob barker",
"Michael Rivera", "Michael Rivera",
"karim ben brik", "karim ben brik",
@@ -916,6 +924,7 @@
"DrB", "DrB",
"wknight", "wknight",
"Moneymaker412K", "Moneymaker412K",
"Jacid",
"unkeiknown", "unkeiknown",
"Towelie", "Towelie",
"Alex Ross", "Alex Ross",
@@ -926,10 +935,12 @@
"john Greene", "john Greene",
"jimyjomson", "jimyjomson",
"JaeHyun Jang", "JaeHyun Jang",
"sbone",
"BigBoss", "BigBoss",
"Chase Kwon", "Chase Kwon",
"Bob Ling", "Bob Ling",
"Inyoshu", "Inyoshu",
"nick Meadows",
"Chad Barnes", "Chad Barnes",
"redlines3", "redlines3",
"Adam Gardner", "Adam Gardner",
@@ -944,6 +955,7 @@
"Somebody", "Somebody",
"Somebody", "Somebody",
"Somebody", "Somebody",
"Somebody",
"CoffeeMage", "CoffeeMage",
"Ken+Suzuki", "Ken+Suzuki",
"hannibal", "hannibal",
@@ -954,8 +966,7 @@
"L C", "L C",
"Dude", "Dude",
"Somebody", "Somebody",
"Somebody",
"CK" "CK"
], ],
"totalCount": 954 "totalCount": 965
} }
+53 -1
View File
@@ -71,9 +71,18 @@ Enriches models linked to an external model site with metadata extracted by an L
| Platform | Link | AI enrichment | Direct download | | Platform | Link | AI enrichment | Direct download |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Hugging Face | yes | yes | yes | | Hugging Face | yes | yes | yes |
| ModelScope | yes | yes | yes | | ModelScope (`modelscope.cn`) | yes | yes | yes |
| ModelScope International (`modelscope.ai`) | yes | yes | yes |
| TensorArt | yes | no (see below) | no | | 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. 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**: **What it does**:
@@ -133,7 +142,9 @@ gaps the LLM leaves behind:
| Field | Deterministic source | LLM role | | 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 | — | | `modelDescription` | author summary + README as HTML | — |
| `civitai.name` | the matched version's label (`modelVersion.showName`) | — |
| `civitai.images` | site example images, then README images | — | | `civitai.images` | site example images, then README images | — |
| `preview_url` | first available example image | may propose one from the README | | `preview_url` | first available example image | may propose one from the README |
| `tags` | site-curated tags, always merged in | proposes additional content tags | | `tags` | site-curated tags, always merged in | proposes additional content tags |
@@ -147,6 +158,47 @@ Models with no source, an unknown source, or a source without model-card access
**Model types**: LoRA, Checkpoint, Embedding **Model types**: LoRA, Checkpoint, Embedding
### Download-time hydration
The same deterministic mapping runs automatically when a model is downloaded
from a model source, so a ModelScope or Hugging Face download lands with the
populated card a CivitAI download produces instead of a bare filename and
hash. Nothing needs to be triggered by hand and no provider is called.
`py/services/model_sources/hydration.py` owns this path:
* `_save_source_metadata()` in `py/routes/handlers/model_source_handlers.py`
creates the sidecar (hash, source link, scanner-cache entry) and then calls
`hydrate_from_source()`. It also runs for a file that was already on disk, so
models downloaded before this existed get topped up on the next attempt.
* Metadata is created through the **owning scanner**
(`scanner._create_default_metadata()`) rather than
`MetadataManager.create_default_metadata()`, so the per-type lazy-hash rule
applies: `CheckpointScanner` and `OtherScanner` store
`hash_status="pending"` with an empty `sha256` for their multi-GB files, and
the generic helper would read a 10 GB checkpoint end to end inside the
download request. Hydration copes with the empty hash — `_matching_versions()`
falls back to the repository basename, which the download just wrote.
* Hydration reuses `PostProcessor` with an empty `llm_output`, so the two paths
cannot drift apart. It reports `metadata_source = "source:<platform>"` rather
than the skill's `agent:enrich_hf_metadata`, and — because no provider ran —
it does not stamp `llm_enriched_at`.
* `model_name` is only written while it still equals the file stem: once a user
renames a model, that choice is kept.
* Only a model whose stored `source_platform`/`source_url` match the repository
being downloaded is updated; a local file that merely shares a name must not
receive another model's card.
* The README and repository payload describe the *repository*, so a short-lived
process-wide `ModelSourceCache` (`shared_source_cache`, 300 s, 32 entries)
keeps a batch over one repository to two HTTP requests.
* Every failure — unreachable site, changed payload shape, broken post-processor
— is logged and swallowed. Metadata hydration can never fail a download.
* Neither stage advances the byte counter, so both are announced to the
progress UI (`_report_phase()``{"status": "metadata", "stage": ...}`) as
they start. Without that the bar sits at 100% reporting `0 B/s` for several
seconds and the download looks stuck. `stage` and `platform` are
machine-readable; the wording is localised in `LoadingManager`.
## Adding a New Skill ## Adding a New Skill
### 1. Create the skill directory ### 1. Create the skill directory
+112 -1
View File
@@ -4,7 +4,7 @@ This document is the canonical set of conventions for translating LoRA Manager U
It applies to **human translators and AI agents** alike. Read it before editing anything in It applies to **human translators and AI agents** alike. Read it before editing anything in
`locales/`. `locales/`.
Source of truth: `locales/en.json` (10 locales, 1982 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). same key structure).
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL). Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
@@ -42,6 +42,41 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
> which had hardcoded "HF" for a button that now also enriches ModelScope models. The > 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, > `modals.linkModelSource.urlPlaceholder` value stays byte-identical to `en.json` (it is a URL,
> the §6 exception). Terminology in §2, "Model source feature". > 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").
--- ---
@@ -330,6 +365,82 @@ in `en`, not "Enrich HF Metadata": they cover ModelScope as well, so no locale m
an `HF` qualifier in `loras.contextMenu.enrichHfAgent` / `loras.bulkOperations.enrichHfAgent` an `HF` qualifier in `loras.contextMenu.enrichHfAgent` / `loras.bulkOperations.enrichHfAgent`
(the key names keep the historical `Hf`; only the values changed). (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) ## 3. Cross-cutting confusion hot-spots (must-fix list)
@@ -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.
+138 -20
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "Abbrechen", "cancel": "Abbrechen",
"confirm": "Bestätigen", "confirm": "Bestätigen",
"reorder": {
"dragHandle": "Zum Neuordnen ziehen"
},
"actions": { "actions": {
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",
@@ -379,7 +382,9 @@
"nav": { "nav": {
"general": "Allgemein", "general": "Allgemein",
"interface": "Oberfläche", "interface": "Oberfläche",
"library": "Bibliothek" "library": "Bibliothek",
"organization": "Organisation",
"modelPaths": "Modellpfade"
}, },
"search": { "search": {
"placeholder": "Einstellungen durchsuchen...", "placeholder": "Einstellungen durchsuchen...",
@@ -580,6 +585,46 @@
"checkpointUnetOverlapInline": "Dieser Pfad wird bereits für einen anderen Modelltyp verwendet. Bitte verwenden Sie separate Ordner für Checkpoints und Diffusionsmodelle." "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": { "priorityTags": {
"title": "Prioritäts-Tags", "title": "Prioritäts-Tags",
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))", "description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
@@ -636,6 +681,22 @@
"validTemplate": "Gültige Vorlage" "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": { "exampleImages": {
"downloadLocation": "Download-Speicherort", "downloadLocation": "Download-Speicherort",
"downloadLocationPlaceholder": "Ordnerpfad für Beispielbilder eingeben", "downloadLocationPlaceholder": "Ordnerpfad für Beispielbilder eingeben",
@@ -868,6 +929,14 @@
"complete": "Automatische Organisation abgeschlossen", "complete": "Automatische Organisation abgeschlossen",
"error": "Fehler: {error}" "error": "Fehler: {error}"
}, },
"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" "enrichHfAgent": "Metadaten mit KI anreichern"
}, },
"contextMenu": { "contextMenu": {
@@ -915,7 +984,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "Basismodell",
"unknown": "Unbekannt"
}, },
"actions": { "actions": {
"openFileLocation": "Dateispeicherort öffnen", "openFileLocation": "Dateispeicherort öffnen",
@@ -1236,47 +1307,75 @@
}, },
"noPaths": { "noPaths": {
"title": "Keine Ordner für weitere Modelle gefunden", "title": "Keine Ordner für weitere Modelle gefunden",
"descriptionStandalone": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die unten stehenden Ordnerpfade zu settings.json hinzu und starten Sie LoRA Manager neu.", "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": "Nur die oben aufgeführten Ordnerschlüssel werden gescannt; nicht benötigte Schlüssel können weggelassen werden.", "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.", "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.", "hintComfyUI": "Weitere Modelle werden aus den Ordnern vae, upscale_models, text_encoders, clip_vision und controlnet von ComfyUI gelesen.",
"openSettings": "Einstellungen öffnen" "openSettings": "Einstellungen öffnen",
"openModelPaths": "Modellordner konfigurieren",
"openSettingsFolder": "Einstellungsordner öffnen"
} }
}, },
"sidebar": { "sidebar": {
"modelRoot": "Stammverzeichnis", "modelRoot": "Stammverzeichnis",
"collapseAll": "Alle Ordner einklappen", "collapseAll": "Alle Ordner einklappen",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "In der Listenansicht nicht verfügbar",
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden", "hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
"showSidebar": "Seitenleiste anzeigen", "showSidebar": "Seitenleiste anzeigen",
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet", "sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "Ansichtsoptionen",
"treeView": "[TODO: Translate] Tree view", "treeView": "Baumansicht",
"listView": "[TODO: Translate] List view", "listView": "Listenansicht",
"recursiveOn": "Unterordner einbeziehen", "recursiveOn": "Unterordner einbeziehen",
"createFolder": "[TODO: Translate] New folder", "createFolder": "Neuer Ordner",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "Neuer Unterordner",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "Leere Ordner anzeigen",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "Ordner \"{name}\" erstellt",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "Ordner konnte nicht erstellt werden: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "Das Erstellen von Ordnern wird auf dieser Seite nicht unterstützt",
"noRoot": "[TODO: Translate] No model root is configured" "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": { "dragDrop": {
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.", "unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
"moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.", "moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.",
"createFolderHint": "Loslassen, um einen neuen Ordner zu erstellen",
"newFolderName": "Neuer Ordnername", "newFolderName": "Neuer Ordnername",
"folderNameHint": "Eingabetaste zum Bestätigen, Escape zum Abbrechen",
"emptyFolderName": "Bitte geben Sie einen Ordnernamen ein", "emptyFolderName": "Bitte geben Sie einen Ordnernamen ein",
"invalidFolderName": "Ordnername enthält ungültige Zeichen", "invalidFolderName": "Ordnername enthält ungültige Zeichen",
"noDragState": "Kein ausstehender Ziehvorgang gefunden" "noDragState": "Kein ausstehender Ziehvorgang gefunden"
}, },
"empty": { "empty": {
"noFolders": "Keine Ordner gefunden", "noFolders": "Keine Ordner gefunden",
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen", "createHint": "Klicken Sie oben auf „Neuer Ordner“, um Ordner zu erstellen"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Auf Updates in diesem Ordner prüfen", "label": "Auf Updates in diesem Ordner prüfen",
@@ -1455,6 +1554,10 @@
"progress": { "progress": {
"currentFile": "Aktuelle Datei:", "currentFile": "Aktuelle Datei:",
"downloading": "Wird heruntergeladen: {name}", "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}", "transferred": "Heruntergeladen: {downloaded} / {total}",
"transferredSimple": "Heruntergeladen: {downloaded}", "transferredSimple": "Heruntergeladen: {downloaded}",
"transferredUnknown": "Heruntergeladen: --", "transferredUnknown": "Heruntergeladen: --",
@@ -1523,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\".", "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" "action": "Alles prüfen"
}, },
"filenameTemplateConfirm": {
"titleApply": "Dateinamen-Vorlage auf Bibliothek anwenden?",
"titleRevert": "Ursprüngliche Dateinamen wiederherstellen?",
"revertButton": "Ursprüngliche Dateinamen wiederherstellen"
},
"bulkAddTags": { "bulkAddTags": {
"title": "Tags zu mehreren Modellen hinzufügen", "title": "Tags zu mehreren Modellen hinzufügen",
"description": "Tags hinzufügen zu", "description": "Tags hinzufügen zu",
@@ -2232,6 +2340,9 @@
"autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen", "autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen",
"autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen", "autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen",
"autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}", "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" "noModelsSelected": "Keine Modelle ausgewählt"
}, },
"recipes": { "recipes": {
@@ -2398,6 +2509,8 @@
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}", "mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert", "downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}", "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", "recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert",
"recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}", "recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}",
"settingsUpdated": "Einstellungen aktualisiert: {setting}", "settingsUpdated": "Einstellungen aktualisiert: {setting}",
@@ -2663,6 +2776,11 @@
"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.", "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", "enable": "Weitere Modelle aktivieren",
"openSettings": "Einstellungen öffnen" "openSettings": "Einstellungen öffnen"
},
"pager": {
"previous": "Vorherige Mitteilung",
"next": "Nächste Mitteilung",
"position": "Mitteilung {current} von {total}"
} }
} }
} }
+127 -9
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm", "confirm": "Confirm",
"reorder": {
"dragHandle": "Drag to reorder"
},
"actions": { "actions": {
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
@@ -379,7 +382,9 @@
"nav": { "nav": {
"general": "General", "general": "General",
"interface": "Interface", "interface": "Interface",
"library": "Library" "library": "Library",
"organization": "Organization",
"modelPaths": "Model Paths"
}, },
"search": { "search": {
"placeholder": "Search settings...", "placeholder": "Search settings...",
@@ -580,6 +585,46 @@
"checkpointUnetOverlapInline": "This path is also used for a different model type. Use separate folders for checkpoints and diffusion models." "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": { "priorityTags": {
"title": "Priority Tags", "title": "Priority Tags",
"description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))", "description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))",
@@ -636,6 +681,22 @@
"validTemplate": "Valid template" "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": { "exampleImages": {
"downloadLocation": "Download Location", "downloadLocation": "Download Location",
"downloadLocationPlaceholder": "Enter folder path for example images", "downloadLocationPlaceholder": "Enter folder path for example images",
@@ -868,6 +929,14 @@
"complete": "Auto-organize complete", "complete": "Auto-organize complete",
"error": "Error: {error}" "error": "Error: {error}"
}, },
"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" "enrichHfAgent": "Enrich Metadata with AI"
}, },
"contextMenu": { "contextMenu": {
@@ -915,7 +984,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "Base Model",
"unknown": "Unknown"
}, },
"actions": { "actions": {
"openFileLocation": "Open File Location", "openFileLocation": "Open File Location",
@@ -1236,11 +1307,13 @@
}, },
"noPaths": { "noPaths": {
"title": "No other-model folders found", "title": "No other-model folders found",
"descriptionStandalone": "Other Models management is on, but none of the configured model folders exist on disk. Add the folder paths below to settings.json and restart LoRA Manager.", "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 the folder keys listed above are scanned; keys you do not need can be omitted.", "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.", "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.", "hintComfyUI": "Other models are read from ComfyUI's vae, upscale_models, text_encoders, clip_vision and controlnet folders.",
"openSettings": "Open Settings" "openSettings": "Open Settings",
"openModelPaths": "Configure Model Folders",
"openSettingsFolder": "Open Settings Folder"
} }
}, },
"sidebar": { "sidebar": {
@@ -1263,20 +1336,46 @@
"unsupported": "Folder creation is not supported on this page", "unsupported": "Folder creation is not supported on this page",
"noRoot": "No model root is configured" "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": { "dragDrop": {
"unableToResolveRoot": "Unable to determine destination path for move.", "unableToResolveRoot": "Unable to determine destination path for move.",
"moveUnsupported": "Move is not supported for this item.", "moveUnsupported": "Move is not supported for this item.",
"createFolderHint": "Release to create new folder",
"newFolderName": "New folder name", "newFolderName": "New folder name",
"folderNameHint": "Press Enter to confirm, Escape to cancel",
"emptyFolderName": "Please enter a folder name", "emptyFolderName": "Please enter a folder name",
"invalidFolderName": "Folder name contains invalid characters", "invalidFolderName": "Folder name contains invalid characters",
"noDragState": "No pending drag operation found" "noDragState": "No pending drag operation found"
}, },
"empty": { "empty": {
"noFolders": "No folders found", "noFolders": "No folders found",
"dragHint": "Drag items here to create folders", "createHint": "Click the New Folder button above to create folders"
"createHint": "Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Check for updates in this folder", "label": "Check for updates in this folder",
@@ -1455,6 +1554,10 @@
"progress": { "progress": {
"currentFile": "Current file:", "currentFile": "Current file:",
"downloading": "Downloading: {name}", "downloading": "Downloading: {name}",
"metadata": "Metadata: {name}",
"indexingFile": "Reading model file...",
"fetchingSourceMetadata": "Fetching metadata from {source}...",
"fetchingMetadata": "Fetching metadata...",
"transferred": "Transferred: {downloaded} / {total}", "transferred": "Transferred: {downloaded} / {total}",
"transferredSimple": "Transferred: {downloaded}", "transferredSimple": "Transferred: {downloaded}",
"transferredUnknown": "Transferred: --", "transferredUnknown": "Transferred: --",
@@ -1523,6 +1626,11 @@
"tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".", "tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".",
"action": "Check All" "action": "Check All"
}, },
"filenameTemplateConfirm": {
"titleApply": "Apply filename template to library?",
"titleRevert": "Restore original filenames?",
"revertButton": "Restore Original Filenames"
},
"bulkAddTags": { "bulkAddTags": {
"title": "Add Tags to Multiple Models", "title": "Add Tags to Multiple Models",
"description": "Add tags to", "description": "Add tags to",
@@ -2232,6 +2340,9 @@
"autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}", "autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}",
"autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models", "autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models",
"autoOrganizeFailed": "Auto-organize failed: {error}", "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" "noModelsSelected": "No models selected"
}, },
"recipes": { "recipes": {
@@ -2398,6 +2509,8 @@
"mappingSaveFailed": "Failed to save base model mappings: {message}", "mappingSaveFailed": "Failed to save base model mappings: {message}",
"downloadTemplatesUpdated": "Download path templates updated", "downloadTemplatesUpdated": "Download path templates updated",
"downloadTemplatesFailed": "Failed to save download path templates: {message}", "downloadTemplatesFailed": "Failed to save download path templates: {message}",
"filenameTemplatesUpdated": "Filename templates updated",
"filenameTemplatesFailed": "Failed to save filename templates: {message}",
"recipesPathUpdated": "Recipes storage path updated", "recipesPathUpdated": "Recipes storage path updated",
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}", "recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
"settingsUpdated": "Settings updated: {setting}", "settingsUpdated": "Settings updated: {setting}",
@@ -2663,6 +2776,11 @@
"content": "Scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files — and download them from CivitAI — from one dedicated page.", "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", "enable": "Enable Other Models",
"openSettings": "Open Settings" "openSettings": "Open Settings"
},
"pager": {
"previous": "Previous message",
"next": "Next message",
"position": "Message {current} of {total}"
} }
} }
} }
+138 -20
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "Cancelar", "cancel": "Cancelar",
"confirm": "Confirmar", "confirm": "Confirmar",
"reorder": {
"dragHandle": "Arrastra para reordenar"
},
"actions": { "actions": {
"save": "Guardar", "save": "Guardar",
"cancel": "Cancelar", "cancel": "Cancelar",
@@ -379,7 +382,9 @@
"nav": { "nav": {
"general": "General", "general": "General",
"interface": "Interfaz", "interface": "Interfaz",
"library": "Biblioteca" "library": "Biblioteca",
"organization": "Organización",
"modelPaths": "Rutas de modelos"
}, },
"search": { "search": {
"placeholder": "Buscar ajustes...", "placeholder": "Buscar ajustes...",
@@ -580,6 +585,46 @@
"checkpointUnetOverlapInline": "Esta ruta ya se usa para otro tipo de modelo. Use carpetas separadas para checkpoints y modelos de difusión." "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": { "priorityTags": {
"title": "Etiquetas prioritarias", "title": "Etiquetas prioritarias",
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))", "description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
@@ -636,6 +681,22 @@
"validTemplate": "Plantilla válida" "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": { "exampleImages": {
"downloadLocation": "Ubicación de descarga", "downloadLocation": "Ubicación de descarga",
"downloadLocationPlaceholder": "Introduce la ruta de la carpeta para imágenes de ejemplo", "downloadLocationPlaceholder": "Introduce la ruta de la carpeta para imágenes de ejemplo",
@@ -868,6 +929,14 @@
"complete": "Auto-organización completada", "complete": "Auto-organización completada",
"error": "Error: {error}" "error": "Error: {error}"
}, },
"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" "enrichHfAgent": "Enriquecer metadatos con IA"
}, },
"contextMenu": { "contextMenu": {
@@ -915,7 +984,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "Modelo base",
"unknown": "Desconocido"
}, },
"actions": { "actions": {
"openFileLocation": "Abrir ubicación del archivo", "openFileLocation": "Abrir ubicación del archivo",
@@ -1236,47 +1307,75 @@
}, },
"noPaths": { "noPaths": {
"title": "No se encontraron carpetas de otros modelos", "title": "No se encontraron carpetas de otros modelos",
"descriptionStandalone": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las rutas de carpetas de abajo a settings.json y reinicia LoRA Manager.", "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 las claves de carpeta listadas arriba; las claves que no necesites puedes omitirlas.", "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.", "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.", "hintComfyUI": "Los otros modelos se leen de las carpetas vae, upscale_models, text_encoders, clip_vision y controlnet de ComfyUI.",
"openSettings": "Abrir configuración" "openSettings": "Abrir configuración",
"openModelPaths": "Configurar carpetas de modelos",
"openSettingsFolder": "Abrir carpeta de ajustes"
} }
}, },
"sidebar": { "sidebar": {
"modelRoot": "Raíz", "modelRoot": "Raíz",
"collapseAll": "Colapsar todas las carpetas", "collapseAll": "Colapsar todas las carpetas",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "No disponible en la vista de lista",
"hideOnThisPage": "Ocultar barra lateral en esta página", "hideOnThisPage": "Ocultar barra lateral en esta página",
"showSidebar": "Mostrar barra lateral", "showSidebar": "Mostrar barra lateral",
"sidebarHiddenNotification": "Barra lateral oculta en la página {page}", "sidebarHiddenNotification": "Barra lateral oculta en la página {page}",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "Opciones de vista",
"treeView": "[TODO: Translate] Tree view", "treeView": "Vista de árbol",
"listView": "[TODO: Translate] List view", "listView": "Vista de lista",
"recursiveOn": "Incluir subcarpetas", "recursiveOn": "Incluir subcarpetas",
"createFolder": "[TODO: Translate] New folder", "createFolder": "Nueva carpeta",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "Nueva subcarpeta",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "Mostrar carpetas vacías",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "Carpeta \"{name}\" creada",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "Error al crear la carpeta: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "La creación de carpetas no es compatible con esta página",
"noRoot": "[TODO: Translate] No model root is configured" "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": { "dragDrop": {
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.", "unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
"moveUnsupported": "El movimiento no es compatible con este elemento.", "moveUnsupported": "El movimiento no es compatible con este elemento.",
"createFolderHint": "Suelta para crear una nueva carpeta",
"newFolderName": "Nombre de la nueva carpeta", "newFolderName": "Nombre de la nueva carpeta",
"folderNameHint": "Presiona Enter para confirmar, Escape para cancelar",
"emptyFolderName": "Por favor, introduce un nombre de carpeta", "emptyFolderName": "Por favor, introduce un nombre de carpeta",
"invalidFolderName": "El nombre de la carpeta contiene caracteres no válidos", "invalidFolderName": "El nombre de la carpeta contiene caracteres no válidos",
"noDragState": "No se encontró ninguna operación de arrastre pendiente" "noDragState": "No se encontró ninguna operación de arrastre pendiente"
}, },
"empty": { "empty": {
"noFolders": "No se encontraron carpetas", "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"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Buscar actualizaciones en esta carpeta", "label": "Buscar actualizaciones en esta carpeta",
@@ -1455,6 +1554,10 @@
"progress": { "progress": {
"currentFile": "Archivo actual:", "currentFile": "Archivo actual:",
"downloading": "Descargando: {name}", "downloading": "Descargando: {name}",
"metadata": "Metadatos: {name}",
"indexingFile": "Leyendo el archivo de modelo...",
"fetchingSourceMetadata": "Obteniendo metadatos de {source}...",
"fetchingMetadata": "Obteniendo metadatos...",
"transferred": "Descargado: {downloaded} / {total}", "transferred": "Descargado: {downloaded} / {total}",
"transferredSimple": "Descargado: {downloaded}", "transferredSimple": "Descargado: {downloaded}",
"transferredUnknown": "Descargado: --", "transferredUnknown": "Descargado: --",
@@ -1523,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\".", "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" "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": { "bulkAddTags": {
"title": "Añadir etiquetas a múltiples modelos", "title": "Añadir etiquetas a múltiples modelos",
"description": "Añadir etiquetas a", "description": "Añadir etiquetas a",
@@ -2232,6 +2340,9 @@
"autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}", "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", "autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos",
"autoOrganizeFailed": "Auto-organización fallida: {error}", "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" "noModelsSelected": "No hay modelos seleccionados"
}, },
"recipes": { "recipes": {
@@ -2398,6 +2509,8 @@
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}", "mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas", "downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}", "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", "recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada",
"recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}", "recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}",
"settingsUpdated": "Configuración actualizada: {setting}", "settingsUpdated": "Configuración actualizada: {setting}",
@@ -2663,6 +2776,11 @@
"content": "Escanea y gestiona archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y descárgalos desde CivitAI, todo desde una página dedicada.", "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", "enable": "Activar otros modelos",
"openSettings": "Abrir configuración" "openSettings": "Abrir configuración"
},
"pager": {
"previous": "Notificación anterior",
"next": "Notificación siguiente",
"position": "Notificación {current} de {total}"
} }
} }
} }
+138 -20
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "Annuler", "cancel": "Annuler",
"confirm": "Confirmer", "confirm": "Confirmer",
"reorder": {
"dragHandle": "Glisser pour réordonner"
},
"actions": { "actions": {
"save": "Enregistrer", "save": "Enregistrer",
"cancel": "Annuler", "cancel": "Annuler",
@@ -379,7 +382,9 @@
"nav": { "nav": {
"general": "Général", "general": "Général",
"interface": "Interface", "interface": "Interface",
"library": "Bibliothèque" "library": "Bibliothèque",
"organization": "Organisation",
"modelPaths": "Chemins de modèles"
}, },
"search": { "search": {
"placeholder": "Rechercher dans les paramètres...", "placeholder": "Rechercher dans les paramètres...",
@@ -580,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." "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": { "priorityTags": {
"title": "Tags prioritaires", "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))", "description": "Personnalisez l'ordre de priorité des tags pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))",
@@ -636,6 +681,22 @@
"validTemplate": "Modèle valide" "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": { "exampleImages": {
"downloadLocation": "Emplacement de téléchargement", "downloadLocation": "Emplacement de téléchargement",
"downloadLocationPlaceholder": "Entrez le chemin du dossier pour les images d'exemple", "downloadLocationPlaceholder": "Entrez le chemin du dossier pour les images d'exemple",
@@ -868,6 +929,14 @@
"complete": "Auto-organisation terminée", "complete": "Auto-organisation terminée",
"error": "Erreur : {error}" "error": "Erreur : {error}"
}, },
"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" "enrichHfAgent": "Enrichir les métadonnées avec l'IA"
}, },
"contextMenu": { "contextMenu": {
@@ -915,7 +984,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "Modèle de base",
"unknown": "Inconnu"
}, },
"actions": { "actions": {
"openFileLocation": "Ouvrir lemplacement du fichier", "openFileLocation": "Ouvrir lemplacement du fichier",
@@ -1236,47 +1307,75 @@
}, },
"noPaths": { "noPaths": {
"title": "Aucun dossier dautres modèles trouvé", "title": "Aucun dossier dautres modèles trouvé",
"descriptionStandalone": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés nexiste sur le disque. Ajoutez les chemins de dossiers ci-dessous à settings.json, puis redémarrez LoRA Manager.", "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": "Seules les clés de dossiers listées ci-dessus sont analysées ; les clés inutiles peuvent être omises.", "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.", "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.", "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" "openSettings": "Ouvrir les paramètres",
"openModelPaths": "Configurer les dossiers de modèles",
"openSettingsFolder": "Ouvrir le dossier des paramètres"
} }
}, },
"sidebar": { "sidebar": {
"modelRoot": "Racine", "modelRoot": "Racine",
"collapseAll": "Réduire tous les dossiers", "collapseAll": "Réduire tous les dossiers",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "Non disponible en vue liste",
"hideOnThisPage": "Masquer la barre latérale sur cette page", "hideOnThisPage": "Masquer la barre latérale sur cette page",
"showSidebar": "Afficher la barre latérale", "showSidebar": "Afficher la barre latérale",
"sidebarHiddenNotification": "Barre latérale masquée sur la page {page}", "sidebarHiddenNotification": "Barre latérale masquée sur la page {page}",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "Options daffichage",
"treeView": "[TODO: Translate] Tree view", "treeView": "Vue arborescente",
"listView": "[TODO: Translate] List view", "listView": "Vue liste",
"recursiveOn": "Inclure les sous-dossiers", "recursiveOn": "Inclure les sous-dossiers",
"createFolder": "[TODO: Translate] New folder", "createFolder": "Nouveau dossier",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "Nouveau sous-dossier",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "Afficher les dossiers vides",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "Dossier \"{name}\" créé",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "Échec de la création du dossier : {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "La création de dossiers nest pas prise en charge sur cette page",
"noRoot": "[TODO: Translate] No model root is configured" "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": { "dragDrop": {
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.", "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.", "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", "newFolderName": "Nom du nouveau dossier",
"folderNameHint": "Appuyez sur Entrée pour confirmer, Échap pour annuler",
"emptyFolderName": "Veuillez saisir un nom de dossier", "emptyFolderName": "Veuillez saisir un nom de dossier",
"invalidFolderName": "Le nom du dossier contient des caractères invalides", "invalidFolderName": "Le nom du dossier contient des caractères invalides",
"noDragState": "Aucune opération de glissement en attente trouvée" "noDragState": "Aucune opération de glissement en attente trouvée"
}, },
"empty": { "empty": {
"noFolders": "Aucun dossier trouvé", "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"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Vérifier les mises à jour dans ce dossier", "label": "Vérifier les mises à jour dans ce dossier",
@@ -1455,6 +1554,10 @@
"progress": { "progress": {
"currentFile": "Fichier actuel :", "currentFile": "Fichier actuel :",
"downloading": "Téléchargement : {name}", "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}", "transferred": "Téléchargé : {downloaded} / {total}",
"transferredSimple": "Téléchargé : {downloaded}", "transferredSimple": "Téléchargé : {downloaded}",
"transferredUnknown": "Téléchargé : --", "transferredUnknown": "Téléchargé : --",
@@ -1523,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\".", "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" "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": { "bulkAddTags": {
"title": "Ajouter des tags à plusieurs modèles", "title": "Ajouter des tags à plusieurs modèles",
"description": "Ajouter des tags à", "description": "Ajouter des tags à",
@@ -2232,6 +2340,9 @@
"autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}", "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", "autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles",
"autoOrganizeFailed": "Échec de l'auto-organisation : {error}", "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é" "noModelsSelected": "Aucun modèle sélectionné"
}, },
"recipes": { "recipes": {
@@ -2398,6 +2509,8 @@
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}", "mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour", "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}", "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", "recipesPathUpdated": "Chemin de stockage des Recipes mis à jour",
"recipesPathSaveFailed": "Échec de la mise à jour du chemin de stockage des Recipes : {message}", "recipesPathSaveFailed": "Échec de la mise à jour du chemin de stockage des Recipes : {message}",
"settingsUpdated": "Paramètres mis à jour : {setting}", "settingsUpdated": "Paramètres mis à jour : {setting}",
@@ -2663,6 +2776,11 @@
"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.", "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", "enable": "Activer les autres modèles",
"openSettings": "Ouvrir les paramètres" "openSettings": "Ouvrir les paramètres"
},
"pager": {
"previous": "Message précédent",
"next": "Message suivant",
"position": "Message {current} sur {total}"
} }
} }
} }
+138 -20
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "ביטול", "cancel": "ביטול",
"confirm": "אישור", "confirm": "אישור",
"reorder": {
"dragHandle": "גרור כדי לשנות סדר"
},
"actions": { "actions": {
"save": "שמירה", "save": "שמירה",
"cancel": "ביטול", "cancel": "ביטול",
@@ -379,7 +382,9 @@
"nav": { "nav": {
"general": "כללי", "general": "כללי",
"interface": "ממשק", "interface": "ממשק",
"library": "ספרייה" "library": "ספרייה",
"organization": "ארגון",
"modelPaths": "נתיבי מודלים"
}, },
"search": { "search": {
"placeholder": "חיפוש בהגדרות...", "placeholder": "חיפוש בהגדרות...",
@@ -580,6 +585,46 @@
"checkpointUnetOverlapInline": "הנתיב הזה כבר נמצא בשימוש עבור סוג מודל אחר. יש להשתמש בתיקיות נפרדות עבור checkpoints ומודלי דיפוזיה." "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": { "priorityTags": {
"title": "תגיות עדיפות", "title": "תגיות עדיפות",
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))", "description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
@@ -636,6 +681,22 @@
"validTemplate": "תבנית תקינה" "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": { "exampleImages": {
"downloadLocation": "מיקום הורדה", "downloadLocation": "מיקום הורדה",
"downloadLocationPlaceholder": "הזן נתיב תיקייה לתמונות דוגמה", "downloadLocationPlaceholder": "הזן נתיב תיקייה לתמונות דוגמה",
@@ -868,6 +929,14 @@
"complete": "ארגון אוטומטי הושלם", "complete": "ארגון אוטומטי הושלם",
"error": "שגיאה: {error}" "error": "שגיאה: {error}"
}, },
"filenameTemplateProgress": {
"initializing": "מאתחל החלת תבנית שם קובץ...",
"starting": "מחיל תבנית שם קובץ על {type}...",
"processing": "מעבד ({processed}/{total}) - {success} שונו שמותם, {skipped} דולגו, {failures} נכשלו",
"completed": "הושלם: {success} שונו שמותם, {skipped} דולגו, {failures} נכשלו",
"complete": "החלת תבנית שם הקובץ הושלמה",
"error": "שגיאה: {error}"
},
"enrichHfAgent": "העשרת מטא-נתונים ב-AI" "enrichHfAgent": "העשרת מטא-נתונים ב-AI"
}, },
"contextMenu": { "contextMenu": {
@@ -915,7 +984,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "מודל בסיס",
"unknown": "לא ידוע"
}, },
"actions": { "actions": {
"openFileLocation": "פתח מיקום קובץ", "openFileLocation": "פתח מיקום קובץ",
@@ -1236,47 +1307,75 @@
}, },
"noPaths": { "noPaths": {
"title": "לא נמצאו תיקיות של מודלים אחרים", "title": "לא נמצאו תיקיות של מודלים אחרים",
"descriptionStandalone": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את נתיבי התיקיות שלמטה ל-settings.json והפעל מחדש את LoRA Manager.", "descriptionStandalone": "ניהול המודלים האחרים פועל, אך לא נמצאו תיקיות של מודלים אחרים. הוסף את תיקיות המודלים שלך תחת הגדרות > נתיבי מודלים, ולאחר מכן הפעל מחדש את LoRA Manager.",
"hintStandalone": "רק מפתחות התיקיות המפורטים למעלה נסרקים; ניתן להשמיט מפתחות שאינך צריך.", "hintStandalone": "נסרקים רק סוגי מודלים מופעלים; הפעל את הסוגים הדרושים לך תחת ספרייה > תיקיות ברירת מחדל.",
"descriptionComfyUI": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את תיקיות המודלים המתאימות לנתיבי המודלים של ComfyUI וטען מחדש עמוד זה.", "descriptionComfyUI": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את תיקיות המודלים המתאימות לנתיבי המודלים של ComfyUI וטען מחדש עמוד זה.",
"hintComfyUI": "מודלים אחרים נקראים מתיקיות vae, upscale_models, text_encoders, clip_vision ו-controlnet של ComfyUI.", "hintComfyUI": "מודלים אחרים נקראים מתיקיות vae, upscale_models, text_encoders, clip_vision ו-controlnet של ComfyUI.",
"openSettings": "פתח הגדרות" "openSettings": "פתח הגדרות",
"openModelPaths": "הגדר תיקיות מודלים",
"openSettingsFolder": "פתח תיקיית הגדרות"
} }
}, },
"sidebar": { "sidebar": {
"modelRoot": "שורש", "modelRoot": "שורש",
"collapseAll": "כווץ את כל התיקיות", "collapseAll": "כווץ את כל התיקיות",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "לא זמין בתצוגת רשימה",
"hideOnThisPage": "הסתר סרגל צד בדף זה", "hideOnThisPage": "הסתר סרגל צד בדף זה",
"showSidebar": "הצג סרגל צד", "showSidebar": "הצג סרגל צד",
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}", "sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "אפשרויות תצוגה",
"treeView": "[TODO: Translate] Tree view", "treeView": "תצוגת עץ",
"listView": "[TODO: Translate] List view", "listView": "תצוגת רשימה",
"recursiveOn": "כלול תיקיות משנה", "recursiveOn": "כלול תיקיות משנה",
"createFolder": "[TODO: Translate] New folder", "createFolder": "תיקייה חדשה",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "תיקיית משנה חדשה",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "הצג תיקיות ריקות",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "התיקייה \"{name}\" נוצרה",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "יצירת התיקייה נכשלה: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "יצירת תיקיות אינה נתמכת בדף זה",
"noRoot": "[TODO: Translate] No model root is configured" "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": { "dragDrop": {
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.", "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
"moveUnsupported": "העברה אינה נתמכת עבור פריט זה.", "moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
"createFolderHint": "שחרר כדי ליצור תיקייה חדשה",
"newFolderName": "שם תיקייה חדשה", "newFolderName": "שם תיקייה חדשה",
"folderNameHint": "הקש Enter לאישור, Escape לביטול",
"emptyFolderName": "אנא הזן שם תיקייה", "emptyFolderName": "אנא הזן שם תיקייה",
"invalidFolderName": "שם התיקייה מכיל תווים לא חוקיים", "invalidFolderName": "שם התיקייה מכיל תווים לא חוקיים",
"noDragState": "לא נמצאה פעולת גרירה ממתינה" "noDragState": "לא נמצאה פעולת גרירה ממתינה"
}, },
"empty": { "empty": {
"noFolders": "לא נמצאו תיקיות", "noFolders": "לא נמצאו תיקיות",
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות", "createHint": "לחץ על כפתור תיקייה חדשה למעלה כדי ליצור תיקיות"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "בדוק עדכונים בתיקייה זו", "label": "בדוק עדכונים בתיקייה זו",
@@ -1455,6 +1554,10 @@
"progress": { "progress": {
"currentFile": "הקובץ הנוכחי:", "currentFile": "הקובץ הנוכחי:",
"downloading": "מוריד: {name}", "downloading": "מוריד: {name}",
"metadata": "מטא-נתונים: {name}",
"indexingFile": "קורא קובץ מודל...",
"fetchingSourceMetadata": "מביא מטא-נתונים מ-{source}...",
"fetchingMetadata": "מביא מטא-נתונים...",
"transferred": "הורד: {downloaded} / {total}", "transferred": "הורד: {downloaded} / {total}",
"transferredSimple": "הורד: {downloaded}", "transferredSimple": "הורד: {downloaded}",
"transferredUnknown": "הורד: --", "transferredUnknown": "הורד: --",
@@ -1523,6 +1626,11 @@
"tip": "רוצים לחלק למנות קטנות? עברו למצב בכמות גדולה, בחרו את המודלים הדרושים ואז השתמשו ב\"בדוק עדכונים לנבחרים\".", "tip": "רוצים לחלק למנות קטנות? עברו למצב בכמות גדולה, בחרו את המודלים הדרושים ואז השתמשו ב\"בדוק עדכונים לנבחרים\".",
"action": "בדוק הכל" "action": "בדוק הכל"
}, },
"filenameTemplateConfirm": {
"titleApply": "להחיל תבנית שם קובץ על הספרייה?",
"titleRevert": "לשחזר שמות קבצים מקוריים?",
"revertButton": "שחזר שמות קבצים מקוריים"
},
"bulkAddTags": { "bulkAddTags": {
"title": "הוסף תגיות למספר מודלים", "title": "הוסף תגיות למספר מודלים",
"description": "הוסף תגיות ל-", "description": "הוסף תגיות ל-",
@@ -2232,6 +2340,9 @@
"autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}", "autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}",
"autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים", "autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים",
"autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}", "autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}",
"filenameTemplateSuccess": "תבנית שם הקובץ הוחלה בהצלחה עבור {count} {type}",
"filenameTemplatePartialSuccess": "החלת תבנית שם הקובץ הושלמה עם {success} ששונה שמם, {failures} שנכשלו מתוך {total} מודלים",
"filenameTemplateFailed": "החלת תבנית שם הקובץ נכשלה: {error}",
"noModelsSelected": "לא נבחרו מודלים" "noModelsSelected": "לא נבחרו מודלים"
}, },
"recipes": { "recipes": {
@@ -2398,6 +2509,8 @@
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}", "mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו", "downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}", "downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
"filenameTemplatesUpdated": "תבניות שמות הקבצים עודכנו",
"filenameTemplatesFailed": "שמירת תבניות שמות הקבצים נכשלה: {message}",
"recipesPathUpdated": "נתיב אחסון המתכונים עודכן", "recipesPathUpdated": "נתיב אחסון המתכונים עודכן",
"recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}", "recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}",
"settingsUpdated": "הגדרות עודכנו: {setting}", "settingsUpdated": "הגדרות עודכנו: {setting}",
@@ -2663,6 +2776,11 @@
"content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.", "content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.",
"enable": "הפעל מודלים אחרים", "enable": "הפעל מודלים אחרים",
"openSettings": "פתח הגדרות" "openSettings": "פתח הגדרות"
},
"pager": {
"previous": "הודעה קודמת",
"next": "הודעה הבאה",
"position": "הודעה {current} מתוך {total}"
} }
} }
} }
+138 -20
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "キャンセル", "cancel": "キャンセル",
"confirm": "確認", "confirm": "確認",
"reorder": {
"dragHandle": "ドラッグして並べ替え"
},
"actions": { "actions": {
"save": "保存", "save": "保存",
"cancel": "キャンセル", "cancel": "キャンセル",
@@ -379,7 +382,9 @@
"nav": { "nav": {
"general": "一般", "general": "一般",
"interface": "インターフェース", "interface": "インターフェース",
"library": "ライブラリ" "library": "ライブラリ",
"organization": "整理",
"modelPaths": "モデルパス"
}, },
"search": { "search": {
"placeholder": "設定を検索...", "placeholder": "設定を検索...",
@@ -580,6 +585,46 @@
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。Checkpoints と diffusion models には別々のフォルダを使用してください。" "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": { "priorityTags": {
"title": "優先タグ", "title": "優先タグ",
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))", "description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
@@ -636,6 +681,22 @@
"validTemplate": "有効なテンプレート" "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": { "exampleImages": {
"downloadLocation": "ダウンロード場所", "downloadLocation": "ダウンロード場所",
"downloadLocationPlaceholder": "例画像のフォルダパスを入力", "downloadLocationPlaceholder": "例画像のフォルダパスを入力",
@@ -868,6 +929,14 @@
"complete": "自動整理が完了しました", "complete": "自動整理が完了しました",
"error": "エラー:{error}" "error": "エラー:{error}"
}, },
"filenameTemplateProgress": {
"initializing": "ファイル名テンプレートの適用を初期化中...",
"starting": "{type}にファイル名テンプレートを適用中...",
"processing": "処理中({processed}/{total}- {success} リネーム、{skipped} スキップ、{failures} 失敗",
"completed": "完了:{success} リネーム、{skipped} スキップ、{failures} 失敗",
"complete": "ファイル名テンプレートの適用が完了しました",
"error": "エラー:{error}"
},
"enrichHfAgent": "メタデータをAIで補完" "enrichHfAgent": "メタデータをAIで補完"
}, },
"contextMenu": { "contextMenu": {
@@ -915,7 +984,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "ベースモデル",
"unknown": "不明"
}, },
"actions": { "actions": {
"openFileLocation": "ファイルの場所を開く", "openFileLocation": "ファイルの場所を開く",
@@ -1236,47 +1307,75 @@
}, },
"noPaths": { "noPaths": {
"title": "その他のモデルのフォルダーが見つかりません", "title": "その他のモデルのフォルダーが見つかりません",
"descriptionStandalone": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。以下のフォルダーパスをsettings.jsonに追加し、LoRA Managerを再起動してください。", "descriptionStandalone": "その他のモデル管理はオンですが、その他のモデルフォルダーが見つかりませんでした。「設定 > モデルパス」でモデルフォルダーを追加し、LoRA Managerを再起動してください。",
"hintStandalone": "スキャンされるのは上記のフォルダーキーのみです。不要なキーは省略できます。", "hintStandalone": "有効になっているモデルタイプのみがスキャンされます。必要なタイプは「ライブラリ > デフォルトルート」で有効にしてください。",
"descriptionComfyUI": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。該当するモデルフォルダーをComfyUIのモデルパスに追加し、このページを再読み込みしてください。", "descriptionComfyUI": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。該当するモデルフォルダーをComfyUIのモデルパスに追加し、このページを再読み込みしてください。",
"hintComfyUI": "その他のモデルは、ComfyUIのvae、upscale_models、text_encoders、clip_vision、controlnetフォルダーから読み込まれます。", "hintComfyUI": "その他のモデルは、ComfyUIのvae、upscale_models、text_encoders、clip_vision、controlnetフォルダーから読み込まれます。",
"openSettings": "設定を開く" "openSettings": "設定を開く",
"openModelPaths": "モデルフォルダーを設定",
"openSettingsFolder": "設定フォルダーを開く"
} }
}, },
"sidebar": { "sidebar": {
"modelRoot": "ルート", "modelRoot": "ルート",
"collapseAll": "すべてのフォルダを折りたたむ", "collapseAll": "すべてのフォルダを折りたたむ",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "リスト表示では利用できません",
"hideOnThisPage": "このページでサイドバーを非表示", "hideOnThisPage": "このページでサイドバーを非表示",
"showSidebar": "サイドバーを表示", "showSidebar": "サイドバーを表示",
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています", "sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "表示オプション",
"treeView": "[TODO: Translate] Tree view", "treeView": "ツリー表示",
"listView": "[TODO: Translate] List view", "listView": "リスト表示",
"recursiveOn": "サブフォルダーを含める", "recursiveOn": "サブフォルダーを含める",
"createFolder": "[TODO: Translate] New folder", "createFolder": "新規フォルダ",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "新規サブフォルダ",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "空のフォルダを表示",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "フォルダ \"{name}\" を作成しました",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "フォルダの作成に失敗しました: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "このページではフォルダを作成できません",
"noRoot": "[TODO: Translate] No model root is configured" "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": { "dragDrop": {
"unableToResolveRoot": "移動先のパスを特定できません。", "unableToResolveRoot": "移動先のパスを特定できません。",
"moveUnsupported": "この項目の移動はサポートされていません。", "moveUnsupported": "この項目の移動はサポートされていません。",
"createFolderHint": "放して新しいフォルダを作成",
"newFolderName": "新しいフォルダ名", "newFolderName": "新しいフォルダ名",
"folderNameHint": "Enterで確定、Escでキャンセル",
"emptyFolderName": "フォルダ名を入力してください", "emptyFolderName": "フォルダ名を入力してください",
"invalidFolderName": "フォルダ名に無効な文字が含まれています", "invalidFolderName": "フォルダ名に無効な文字が含まれています",
"noDragState": "保留中のドラッグ操作が見つかりません" "noDragState": "保留中のドラッグ操作が見つかりません"
}, },
"empty": { "empty": {
"noFolders": "フォルダが見つかりません", "noFolders": "フォルダが見つかりません",
"dragHint": "ここへアイテムをドラッグしてフォルダを作成ます", "createHint": "上部の新規フォルダボタンからフォルダを作成できます"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "このフォルダのアップデートを確認", "label": "このフォルダのアップデートを確認",
@@ -1455,6 +1554,10 @@
"progress": { "progress": {
"currentFile": "現在のファイル:", "currentFile": "現在のファイル:",
"downloading": "ダウンロード中: {name}", "downloading": "ダウンロード中: {name}",
"metadata": "メタデータ: {name}",
"indexingFile": "モデルファイルを読み込み中...",
"fetchingSourceMetadata": "{source} からメタデータを取得中...",
"fetchingMetadata": "メタデータを取得中...",
"transferred": "ダウンロード済み: {downloaded} / {total}", "transferred": "ダウンロード済み: {downloaded} / {total}",
"transferredSimple": "ダウンロード済み: {downloaded}", "transferredSimple": "ダウンロード済み: {downloaded}",
"transferredUnknown": "ダウンロード済み: --", "transferredUnknown": "ダウンロード済み: --",
@@ -1523,6 +1626,11 @@
"tip": "少しずつ確認したい場合は一括モードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。", "tip": "少しずつ確認したい場合は一括モードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
"action": "すべて確認" "action": "すべて確認"
}, },
"filenameTemplateConfirm": {
"titleApply": "ファイル名テンプレートをライブラリに適用しますか?",
"titleRevert": "元のファイル名を復元しますか?",
"revertButton": "元のファイル名を復元"
},
"bulkAddTags": { "bulkAddTags": {
"title": "複数モデルにタグを追加", "title": "複数モデルにタグを追加",
"description": "タグを追加するモデル:", "description": "タグを追加するモデル:",
@@ -2232,6 +2340,9 @@
"autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました", "autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました",
"autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗", "autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗",
"autoOrganizeFailed": "自動整理に失敗しました:{error}", "autoOrganizeFailed": "自動整理に失敗しました:{error}",
"filenameTemplateSuccess": "{count} 件の{type}にファイル名テンプレートを正常に適用しました",
"filenameTemplatePartialSuccess": "ファイル名テンプレートを適用しました:{total} 件中 {success} 件をリネーム、{failures} 件失敗",
"filenameTemplateFailed": "ファイル名テンプレートの適用に失敗しました:{error}",
"noModelsSelected": "モデルが選択されていません" "noModelsSelected": "モデルが選択されていません"
}, },
"recipes": { "recipes": {
@@ -2398,6 +2509,8 @@
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}", "mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました", "downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}", "downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
"filenameTemplatesUpdated": "ファイル名テンプレートを更新しました",
"filenameTemplatesFailed": "ファイル名テンプレートの保存に失敗しました:{message}",
"recipesPathUpdated": "レシピ保存先を更新しました", "recipesPathUpdated": "レシピ保存先を更新しました",
"recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}", "recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}",
"settingsUpdated": "設定が更新されました:{setting}", "settingsUpdated": "設定が更新されました:{setting}",
@@ -2663,6 +2776,11 @@
"content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。", "content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
"enable": "その他のモデルを有効にする", "enable": "その他のモデルを有効にする",
"openSettings": "設定を開く" "openSettings": "設定を開く"
},
"pager": {
"previous": "前の通知",
"next": "次の通知",
"position": "{total} 件中 {current} 件目の通知"
} }
} }
} }
+138 -20
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "취소", "cancel": "취소",
"confirm": "확인", "confirm": "확인",
"reorder": {
"dragHandle": "드래그하여 순서 변경"
},
"actions": { "actions": {
"save": "저장", "save": "저장",
"cancel": "취소", "cancel": "취소",
@@ -379,7 +382,9 @@
"nav": { "nav": {
"general": "일반", "general": "일반",
"interface": "인터페이스", "interface": "인터페이스",
"library": "라이브러리" "library": "라이브러리",
"organization": "정리",
"modelPaths": "모델 경로"
}, },
"search": { "search": {
"placeholder": "설정 검색...", "placeholder": "설정 검색...",
@@ -580,6 +585,46 @@
"checkpointUnetOverlapInline": "이 경로는 다른 모델 유형에 이미 사용 중입니다. checkpoints와 diffusion models에 별도의 폴더를 사용하세요." "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": { "priorityTags": {
"title": "우선순위 태그", "title": "우선순위 태그",
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).", "description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
@@ -636,6 +681,22 @@
"validTemplate": "유효한 템플릿" "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": { "exampleImages": {
"downloadLocation": "다운로드 위치", "downloadLocation": "다운로드 위치",
"downloadLocationPlaceholder": "예시 이미지 폴더 경로를 입력하세요", "downloadLocationPlaceholder": "예시 이미지 폴더 경로를 입력하세요",
@@ -868,6 +929,14 @@
"complete": "자동 정리 완료", "complete": "자동 정리 완료",
"error": "오류: {error}" "error": "오류: {error}"
}, },
"filenameTemplateProgress": {
"initializing": "파일명 템플릿 적용 초기화 중...",
"starting": "{type}에 파일명 템플릿 적용 중...",
"processing": "처리 중 ({processed}/{total}) - {success}개 이름 변경, {skipped}개 건너뜀, {failures}개 실패",
"completed": "완료: {success}개 이름 변경, {skipped}개 건너뜀, {failures}개 실패",
"complete": "파일명 템플릿 적용 완료",
"error": "오류: {error}"
},
"enrichHfAgent": "AI로 메타데이터 보강" "enrichHfAgent": "AI로 메타데이터 보강"
}, },
"contextMenu": { "contextMenu": {
@@ -915,7 +984,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "베이스 모델",
"unknown": "알 수 없음"
}, },
"actions": { "actions": {
"openFileLocation": "파일 위치 열기", "openFileLocation": "파일 위치 열기",
@@ -1236,47 +1307,75 @@
}, },
"noPaths": { "noPaths": {
"title": "기타 모델 폴더를 찾을 수 없습니다", "title": "기타 모델 폴더를 찾을 수 없습니다",
"descriptionStandalone": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 아래 폴더 경로를 settings.json에 추가한 뒤 LoRA Manager를 재시작하세요.", "descriptionStandalone": "기타 모델 관리가 켜져 있지만, 기타 모델 폴더를 찾을 수 없습니다. 설정 → 모델 경로에서 모델 폴더를 추가한 뒤 LoRA Manager를 재시작하세요.",
"hintStandalone": "위에 나열된 폴더 키만 스캔됩니다. 필요 없는 키는 생략할 수 있습니다.", "hintStandalone": "활성화된 모델 유형만 스캔됩니다. 라이브러리 → 기본 루트에서 필요한 유형을 활성화하세요.",
"descriptionComfyUI": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 해당 모델 폴더를 ComfyUI 모델 경로에 추가한 뒤 이 페이지를 새로 고침하세요.", "descriptionComfyUI": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 해당 모델 폴더를 ComfyUI 모델 경로에 추가한 뒤 이 페이지를 새로 고침하세요.",
"hintComfyUI": "기타 모델은 ComfyUI의 vae, upscale_models, text_encoders, clip_vision, controlnet 폴더에서 읽어옵니다.", "hintComfyUI": "기타 모델은 ComfyUI의 vae, upscale_models, text_encoders, clip_vision, controlnet 폴더에서 읽어옵니다.",
"openSettings": "설정 열기" "openSettings": "설정 열기",
"openModelPaths": "모델 폴더 구성",
"openSettingsFolder": "설정 폴더 열기"
} }
}, },
"sidebar": { "sidebar": {
"modelRoot": "루트", "modelRoot": "루트",
"collapseAll": "모든 폴더 접기", "collapseAll": "모든 폴더 접기",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
"hideOnThisPage": "이 페이지에서 사이드바 숨기기", "hideOnThisPage": "이 페이지에서 사이드바 숨기기",
"showSidebar": "사이드바 표시", "showSidebar": "사이드바 표시",
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다", "sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "보기 옵션",
"treeView": "[TODO: Translate] Tree view", "treeView": "트리 보기",
"listView": "[TODO: Translate] List view", "listView": "목록 보기",
"recursiveOn": "하위 폴더 포함", "recursiveOn": "하위 폴더 포함",
"createFolder": "[TODO: Translate] New folder", "createFolder": "새 폴더",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "새 하위 폴더",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "빈 폴더 표시",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "\"{name}\" 폴더를 생성했습니다",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "폴더 생성 실패: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "이 페이지에서는 폴더를 만들 수 없습니다",
"noRoot": "[TODO: Translate] No model root is configured" "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": { "dragDrop": {
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.", "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
"moveUnsupported": "이 항목은 이동을 지원하지 않습니다.", "moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
"createFolderHint": "놓아서 새 폴더 만들기",
"newFolderName": "새 폴더 이름", "newFolderName": "새 폴더 이름",
"folderNameHint": "Enter를 눌러 확인, Escape를 눌러 취소",
"emptyFolderName": "폴더 이름을 입력하세요", "emptyFolderName": "폴더 이름을 입력하세요",
"invalidFolderName": "폴더 이름에 잘못된 문자가 포함되어 있습니다", "invalidFolderName": "폴더 이름에 잘못된 문자가 포함되어 있습니다",
"noDragState": "보류 중인 드래그 작업을 찾을 수 없습니다" "noDragState": "보류 중인 드래그 작업을 찾을 수 없습니다"
}, },
"empty": { "empty": {
"noFolders": "폴더를 찾을 수 없습니다", "noFolders": "폴더를 찾을 수 없습니다",
"dragHint": "항목을 여기로 드래그하여 폴더를 만니다", "createHint": "위의 새 폴더 버튼을 클릭하여 폴더를 만들 수 있습니다"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "이 폴더의 업데이트 확인", "label": "이 폴더의 업데이트 확인",
@@ -1455,6 +1554,10 @@
"progress": { "progress": {
"currentFile": "현재 파일:", "currentFile": "현재 파일:",
"downloading": "다운로드 중: {name}", "downloading": "다운로드 중: {name}",
"metadata": "메타데이터: {name}",
"indexingFile": "모델 파일 읽는 중...",
"fetchingSourceMetadata": "{source}에서 메타데이터 가져오는 중...",
"fetchingMetadata": "메타데이터 가져오는 중...",
"transferred": "다운로드됨: {downloaded} / {total}", "transferred": "다운로드됨: {downloaded} / {total}",
"transferredSimple": "다운로드됨: {downloaded}", "transferredSimple": "다운로드됨: {downloaded}",
"transferredUnknown": "다운로드됨: --", "transferredUnknown": "다운로드됨: --",
@@ -1523,6 +1626,11 @@
"tip": "나눠서 진행하고 싶다면 일괄 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.", "tip": "나눠서 진행하고 싶다면 일괄 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.",
"action": "전체 확인" "action": "전체 확인"
}, },
"filenameTemplateConfirm": {
"titleApply": "라이브러리에 파일명 템플릿을 적용하시겠습니까?",
"titleRevert": "원본 파일명을 복원하시겠습니까?",
"revertButton": "원본 파일명 복원"
},
"bulkAddTags": { "bulkAddTags": {
"title": "여러 모델에 태그 추가", "title": "여러 모델에 태그 추가",
"description": "다음에 태그를 추가합니다:", "description": "다음에 태그를 추가합니다:",
@@ -2232,6 +2340,9 @@
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다", "autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패", "autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
"autoOrganizeFailed": "자동 정리 실패: {error}", "autoOrganizeFailed": "자동 정리 실패: {error}",
"filenameTemplateSuccess": "{count}개의 {type}에 파일명 템플릿이 성공적으로 적용되었습니다",
"filenameTemplatePartialSuccess": "파일명 템플릿 적용 완료: 전체 {total}개 중 {success}개 이름 변경, {failures}개 실패",
"filenameTemplateFailed": "파일명 템플릿 적용 실패: {error}",
"noModelsSelected": "선택된 모델이 없습니다" "noModelsSelected": "선택된 모델이 없습니다"
}, },
"recipes": { "recipes": {
@@ -2398,6 +2509,8 @@
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}", "mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다", "downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}", "downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
"filenameTemplatesUpdated": "파일명 템플릿이 업데이트되었습니다",
"filenameTemplatesFailed": "파일명 템플릿 저장 실패: {message}",
"recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다", "recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다",
"recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}", "recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}",
"settingsUpdated": "설정 업데이트됨: {setting}", "settingsUpdated": "설정 업데이트됨: {setting}",
@@ -2663,6 +2776,11 @@
"content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.", "content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.",
"enable": "기타 모델 활성화", "enable": "기타 모델 활성화",
"openSettings": "설정 열기" "openSettings": "설정 열기"
},
"pager": {
"previous": "이전 알림",
"next": "다음 알림",
"position": "전체 {total}개 중 {current}번째 알림"
} }
} }
} }
+138 -20
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "Отмена", "cancel": "Отмена",
"confirm": "Подтвердить", "confirm": "Подтвердить",
"reorder": {
"dragHandle": "Перетащите, чтобы изменить порядок"
},
"actions": { "actions": {
"save": "Сохранить", "save": "Сохранить",
"cancel": "Отмена", "cancel": "Отмена",
@@ -379,7 +382,9 @@
"nav": { "nav": {
"general": "Общее", "general": "Общее",
"interface": "Интерфейс", "interface": "Интерфейс",
"library": "Библиотека" "library": "Библиотека",
"organization": "Организация",
"modelPaths": "Пути к моделям"
}, },
"search": { "search": {
"placeholder": "Поиск в настройках...", "placeholder": "Поиск в настройках...",
@@ -580,6 +585,46 @@
"checkpointUnetOverlapInline": "Этот путь уже используется для другого типа модели. Используйте отдельные папки для checkpoints и diffusion models." "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": { "priorityTags": {
"title": "Приоритетные теги", "title": "Приоритетные теги",
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).", "description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
@@ -636,6 +681,22 @@
"validTemplate": "Действительный шаблон" "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": { "exampleImages": {
"downloadLocation": "Место загрузки", "downloadLocation": "Место загрузки",
"downloadLocationPlaceholder": "Введите путь к папке для примеров изображений", "downloadLocationPlaceholder": "Введите путь к папке для примеров изображений",
@@ -868,6 +929,14 @@
"complete": "Автоматическая организация завершена", "complete": "Автоматическая организация завершена",
"error": "Ошибка: {error}" "error": "Ошибка: {error}"
}, },
"filenameTemplateProgress": {
"initializing": "Инициализация применения шаблона имён файлов...",
"starting": "Применение шаблона имён файлов к {type}...",
"processing": "Обработка ({processed}/{total}) — {success} переименовано, {skipped} пропущено, {failures} не удалось",
"completed": "Завершено: {success} переименовано, {skipped} пропущено, {failures} не удалось",
"complete": "Применение шаблона имён файлов завершено",
"error": "Ошибка: {error}"
},
"enrichHfAgent": "Обогатить метаданные с помощью ИИ" "enrichHfAgent": "Обогатить метаданные с помощью ИИ"
}, },
"contextMenu": { "contextMenu": {
@@ -915,7 +984,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "Базовая модель",
"unknown": "Неизвестно"
}, },
"actions": { "actions": {
"openFileLocation": "Открыть расположение файла", "openFileLocation": "Открыть расположение файла",
@@ -1236,47 +1307,75 @@
}, },
"noPaths": { "noPaths": {
"title": "Папки других моделей не найдены", "title": "Папки других моделей не найдены",
"descriptionStandalone": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте указанные ниже пути к папкам в settings.json и перезапустите LoRA Manager.", "descriptionStandalone": "Управление другими моделями включено, но папки других моделей не найдены. Добавьте свои папки моделей в разделе «Настройки → Пути к моделям», затем перезапустите LoRA Manager.",
"hintStandalone": "Сканируются только перечисленные выше ключи папок; ненужные ключи можно опустить.", "hintStandalone": "Сканируются только включённые типы моделей; включите нужные типы в разделе «Библиотека → Корневые папки».",
"descriptionComfyUI": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте соответствующие папки моделей в пути к моделям ComfyUI и перезагрузите эту страницу.", "descriptionComfyUI": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте соответствующие папки моделей в пути к моделям ComfyUI и перезагрузите эту страницу.",
"hintComfyUI": "Другие модели читаются из папок vae, upscale_models, text_encoders, clip_vision и controlnet в ComfyUI.", "hintComfyUI": "Другие модели читаются из папок vae, upscale_models, text_encoders, clip_vision и controlnet в ComfyUI.",
"openSettings": "Открыть настройки" "openSettings": "Открыть настройки",
"openModelPaths": "Настроить папки моделей",
"openSettingsFolder": "Открыть папку настроек"
} }
}, },
"sidebar": { "sidebar": {
"modelRoot": "Корень", "modelRoot": "Корень",
"collapseAll": "Свернуть все папки", "collapseAll": "Свернуть все папки",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "Недоступно в виде списка",
"hideOnThisPage": "Скрыть боковую панель на этой странице", "hideOnThisPage": "Скрыть боковую панель на этой странице",
"showSidebar": "Показать боковую панель", "showSidebar": "Показать боковую панель",
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}", "sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "Параметры отображения",
"treeView": "[TODO: Translate] Tree view", "treeView": "Дерево",
"listView": "[TODO: Translate] List view", "listView": "Список",
"recursiveOn": "Включать вложенные папки", "recursiveOn": "Включать вложенные папки",
"createFolder": "[TODO: Translate] New folder", "createFolder": "Новая папка",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "Новая вложенная папка",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "Показывать пустые папки",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "Папка \"{name}\" создана",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "Не удалось создать папку: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "Создание папок не поддерживается на этой странице",
"noRoot": "[TODO: Translate] No model root is configured" "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": { "dragDrop": {
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.", "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
"moveUnsupported": "Перемещение этого элемента не поддерживается.", "moveUnsupported": "Перемещение этого элемента не поддерживается.",
"createFolderHint": "Отпустите, чтобы создать новую папку",
"newFolderName": "Имя новой папки", "newFolderName": "Имя новой папки",
"folderNameHint": "Нажмите Enter для подтверждения, Escape для отмены",
"emptyFolderName": "Пожалуйста, введите имя папки", "emptyFolderName": "Пожалуйста, введите имя папки",
"invalidFolderName": "Имя папки содержит недопустимые символы", "invalidFolderName": "Имя папки содержит недопустимые символы",
"noDragState": "Ожидающая операция перетаскивания не найдена" "noDragState": "Ожидающая операция перетаскивания не найдена"
}, },
"empty": { "empty": {
"noFolders": "Папки не найдены", "noFolders": "Папки не найдены",
"dragHint": "Перетащите элементы сюда, чтобы создать папки", "createHint": "Нажмите кнопку «Новая папка» вверху, чтобы создать папки"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Проверить обновления в этой папке", "label": "Проверить обновления в этой папке",
@@ -1455,6 +1554,10 @@
"progress": { "progress": {
"currentFile": "Текущий файл:", "currentFile": "Текущий файл:",
"downloading": "Скачивается: {name}", "downloading": "Скачивается: {name}",
"metadata": "Метаданные: {name}",
"indexingFile": "Чтение файла модели...",
"fetchingSourceMetadata": "Получение метаданных из {source}...",
"fetchingMetadata": "Получение метаданных...",
"transferred": "Скачано: {downloaded} / {total}", "transferred": "Скачано: {downloaded} / {total}",
"transferredSimple": "Скачано: {downloaded}", "transferredSimple": "Скачано: {downloaded}",
"transferredUnknown": "Скачано: --", "transferredUnknown": "Скачано: --",
@@ -1523,6 +1626,11 @@
"tip": "Хотите проверять по частям? Переключитесь в массовый режим, выберите нужные модели и используйте \"Проверить обновления для выбранных\".", "tip": "Хотите проверять по частям? Переключитесь в массовый режим, выберите нужные модели и используйте \"Проверить обновления для выбранных\".",
"action": "Проверить всё" "action": "Проверить всё"
}, },
"filenameTemplateConfirm": {
"titleApply": "Применить шаблон имён файлов к библиотеке?",
"titleRevert": "Восстановить исходные имена файлов?",
"revertButton": "Восстановить исходные имена файлов"
},
"bulkAddTags": { "bulkAddTags": {
"title": "Добавить теги к нескольким моделям", "title": "Добавить теги к нескольким моделям",
"description": "Добавить теги к", "description": "Добавить теги к",
@@ -2232,6 +2340,9 @@
"autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}", "autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}",
"autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей", "autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей",
"autoOrganizeFailed": "Ошибка автоматической организации: {error}", "autoOrganizeFailed": "Ошибка автоматической организации: {error}",
"filenameTemplateSuccess": "Шаблон имён файлов успешно применён для {count} {type}",
"filenameTemplatePartialSuccess": "Шаблон имён файлов применён: переименовано {success}, не удалось {failures} из {total} моделей",
"filenameTemplateFailed": "Не удалось применить шаблон имён файлов: {error}",
"noModelsSelected": "Модели не выбраны" "noModelsSelected": "Модели не выбраны"
}, },
"recipes": { "recipes": {
@@ -2398,6 +2509,8 @@
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}", "mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены", "downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}", "downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
"filenameTemplatesUpdated": "Шаблоны имён файлов обновлены",
"filenameTemplatesFailed": "Не удалось сохранить шаблоны имён файлов: {message}",
"recipesPathUpdated": "Путь хранения рецептов обновлён", "recipesPathUpdated": "Путь хранения рецептов обновлён",
"recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}", "recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}",
"settingsUpdated": "Настройки обновлены: {setting}", "settingsUpdated": "Настройки обновлены: {setting}",
@@ -2663,6 +2776,11 @@
"content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.", "content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.",
"enable": "Включить другие модели", "enable": "Включить другие модели",
"openSettings": "Открыть настройки" "openSettings": "Открыть настройки"
},
"pager": {
"previous": "Предыдущее уведомление",
"next": "Следующее уведомление",
"position": "Уведомление {current} из {total}"
} }
} }
} }
+138 -20
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "取消", "cancel": "取消",
"confirm": "确认", "confirm": "确认",
"reorder": {
"dragHandle": "拖拽以调整顺序"
},
"actions": { "actions": {
"save": "保存", "save": "保存",
"cancel": "取消", "cancel": "取消",
@@ -379,7 +382,9 @@
"nav": { "nav": {
"general": "通用", "general": "通用",
"interface": "界面", "interface": "界面",
"library": "库" "library": "库",
"organization": "整理",
"modelPaths": "模型路径"
}, },
"search": { "search": {
"placeholder": "搜索设置...", "placeholder": "搜索设置...",
@@ -580,6 +585,46 @@
"checkpointUnetOverlapInline": "此路径已被用于另一种模型类型。请为 checkpoints 和 diffusion models 使用不同的文件夹。" "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": { "priorityTags": {
"title": "优先标签", "title": "优先标签",
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))", "description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
@@ -636,6 +681,22 @@
"validTemplate": "有效模板" "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": { "exampleImages": {
"downloadLocation": "下载位置", "downloadLocation": "下载位置",
"downloadLocationPlaceholder": "输入示例图片文件夹路径", "downloadLocationPlaceholder": "输入示例图片文件夹路径",
@@ -868,6 +929,14 @@
"complete": "自动整理已完成", "complete": "自动整理已完成",
"error": "错误:{error}" "error": "错误:{error}"
}, },
"filenameTemplateProgress": {
"initializing": "正在初始化应用文件名模板...",
"starting": "正在为 {type} 应用文件名模板...",
"processing": "处理中({processed}/{total}- 已重命名 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
"completed": "完成:已重命名 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
"complete": "文件名模板应用完成",
"error": "错误:{error}"
},
"enrichHfAgent": "AI 元数据增强" "enrichHfAgent": "AI 元数据增强"
}, },
"contextMenu": { "contextMenu": {
@@ -915,7 +984,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "基础模型",
"unknown": "未知"
}, },
"actions": { "actions": {
"openFileLocation": "打开文件位置", "openFileLocation": "打开文件位置",
@@ -1236,47 +1307,75 @@
}, },
"noPaths": { "noPaths": {
"title": "未找到其他模型文件夹", "title": "未找到其他模型文件夹",
"descriptionStandalone": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将下面的文件夹路径添加到 settings.json,然后重启 LoRA Manager。", "descriptionStandalone": "其他模型管理已开启,但未找到其他模型文件夹。请在“设置 → 模型路径”中添加你的模型文件夹,然后重启 LoRA Manager。",
"hintStandalone": "只会扫描上面列出的文件夹键;不需要的键可以省略。", "hintStandalone": "仅扫描已启用的模型类型;请在“库 → 默认根目录”中启用你需要的类型。",
"descriptionComfyUI": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将对应的模型文件夹添加到 ComfyUI 的模型路径,然后重新加载此页面。", "descriptionComfyUI": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将对应的模型文件夹添加到 ComfyUI 的模型路径,然后重新加载此页面。",
"hintComfyUI": "其他模型从 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 文件夹中读取。", "hintComfyUI": "其他模型从 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 文件夹中读取。",
"openSettings": "打开设置" "openSettings": "打开设置",
"openModelPaths": "配置模型文件夹",
"openSettingsFolder": "打开设置文件夹"
} }
}, },
"sidebar": { "sidebar": {
"modelRoot": "根目录", "modelRoot": "根目录",
"collapseAll": "折叠所有文件夹", "collapseAll": "折叠所有文件夹",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "列表视图下不可用",
"hideOnThisPage": "隐藏此页面侧边栏", "hideOnThisPage": "隐藏此页面侧边栏",
"showSidebar": "显示侧边栏", "showSidebar": "显示侧边栏",
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏", "sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "视图选项",
"treeView": "[TODO: Translate] Tree view", "treeView": "树形视图",
"listView": "[TODO: Translate] List view", "listView": "列表视图",
"recursiveOn": "包含子文件夹", "recursiveOn": "包含子文件夹",
"createFolder": "[TODO: Translate] New folder", "createFolder": "新建文件夹",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "新建子文件夹",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "显示空文件夹",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "已创建文件夹 \"{name}\"",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "创建文件夹失败: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "此页面不支持创建文件夹",
"noRoot": "[TODO: Translate] No model root is configured" "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": { "dragDrop": {
"unableToResolveRoot": "无法确定移动的目标路径。", "unableToResolveRoot": "无法确定移动的目标路径。",
"moveUnsupported": "此条目不支持移动。", "moveUnsupported": "此条目不支持移动。",
"createFolderHint": "释放以创建新文件夹",
"newFolderName": "新文件夹名称", "newFolderName": "新文件夹名称",
"folderNameHint": "按 Enter 确认,Escape 取消",
"emptyFolderName": "请输入文件夹名称", "emptyFolderName": "请输入文件夹名称",
"invalidFolderName": "文件夹名称包含无效字符", "invalidFolderName": "文件夹名称包含无效字符",
"noDragState": "未找到待处理的拖放操作" "noDragState": "未找到待处理的拖放操作"
}, },
"empty": { "empty": {
"noFolders": "未找到文件夹", "noFolders": "未找到文件夹",
"dragHint": "拖拽项目到此处以创建文件夹", "createHint": "点击上方的新建文件夹按钮即可创建文件夹"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "检查此文件夹的更新", "label": "检查此文件夹的更新",
@@ -1455,6 +1554,10 @@
"progress": { "progress": {
"currentFile": "当前文件:", "currentFile": "当前文件:",
"downloading": "下载中:{name}", "downloading": "下载中:{name}",
"metadata": "元数据:{name}",
"indexingFile": "正在读取模型文件...",
"fetchingSourceMetadata": "正在从 {source} 获取元数据...",
"fetchingMetadata": "正在获取元数据...",
"transferred": "已下载:{downloaded} / {total}", "transferred": "已下载:{downloaded} / {total}",
"transferredSimple": "已下载:{downloaded}", "transferredSimple": "已下载:{downloaded}",
"transferredUnknown": "已下载:--", "transferredUnknown": "已下载:--",
@@ -1523,6 +1626,11 @@
"tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。", "tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。",
"action": "检查全部" "action": "检查全部"
}, },
"filenameTemplateConfirm": {
"titleApply": "将文件名模板应用到库?",
"titleRevert": "恢复原始文件名?",
"revertButton": "恢复原始文件名"
},
"bulkAddTags": { "bulkAddTags": {
"title": "批量添加标签", "title": "批量添加标签",
"description": "为多个模型添加标签", "description": "为多个模型添加标签",
@@ -2232,6 +2340,9 @@
"autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}", "autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}",
"autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型", "autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型",
"autoOrganizeFailed": "自动整理失败:{error}", "autoOrganizeFailed": "自动整理失败:{error}",
"filenameTemplateSuccess": "文件名模板已成功应用,共 {count} 个 {type}",
"filenameTemplatePartialSuccess": "文件名模板应用完成:已重命名 {success} 个,{failures} 个失败,共 {total} 个模型",
"filenameTemplateFailed": "应用文件名模板失败:{error}",
"noModelsSelected": "未选中模型" "noModelsSelected": "未选中模型"
}, },
"recipes": { "recipes": {
@@ -2398,6 +2509,8 @@
"mappingSaveFailed": "保存基础模型映射失败:{message}", "mappingSaveFailed": "保存基础模型映射失败:{message}",
"downloadTemplatesUpdated": "下载路径模板已更新", "downloadTemplatesUpdated": "下载路径模板已更新",
"downloadTemplatesFailed": "保存下载路径模板失败:{message}", "downloadTemplatesFailed": "保存下载路径模板失败:{message}",
"filenameTemplatesUpdated": "文件名模板已更新",
"filenameTemplatesFailed": "保存文件名模板失败:{message}",
"recipesPathUpdated": "配方存储路径已更新", "recipesPathUpdated": "配方存储路径已更新",
"recipesPathSaveFailed": "更新配方存储路径失败:{message}", "recipesPathSaveFailed": "更新配方存储路径失败:{message}",
"settingsUpdated": "设置已更新:{setting}", "settingsUpdated": "设置已更新:{setting}",
@@ -2663,6 +2776,11 @@
"content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。", "content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
"enable": "启用其他模型", "enable": "启用其他模型",
"openSettings": "打开设置" "openSettings": "打开设置"
},
"pager": {
"previous": "上一条通知",
"next": "下一条通知",
"position": "第 {current} 条通知,共 {total} 条"
} }
} }
} }
+138 -20
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "取消", "cancel": "取消",
"confirm": "確認", "confirm": "確認",
"reorder": {
"dragHandle": "拖曳以調整順序"
},
"actions": { "actions": {
"save": "儲存", "save": "儲存",
"cancel": "取消", "cancel": "取消",
@@ -379,7 +382,9 @@
"nav": { "nav": {
"general": "通用", "general": "通用",
"interface": "介面", "interface": "介面",
"library": "模型庫" "library": "模型庫",
"organization": "整理",
"modelPaths": "模型路徑"
}, },
"search": { "search": {
"placeholder": "搜尋設定...", "placeholder": "搜尋設定...",
@@ -580,6 +585,46 @@
"checkpointUnetOverlapInline": "此路徑已被用於另一種模型類型。請為 checkpoints 和 diffusion models 使用不同的資料夾。" "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": { "priorityTags": {
"title": "優先標籤", "title": "優先標籤",
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))", "description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
@@ -636,6 +681,22 @@
"validTemplate": "範本有效" "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": { "exampleImages": {
"downloadLocation": "下載位置", "downloadLocation": "下載位置",
"downloadLocationPlaceholder": "輸入範例圖片的資料夾路徑", "downloadLocationPlaceholder": "輸入範例圖片的資料夾路徑",
@@ -868,6 +929,14 @@
"complete": "自動整理完成", "complete": "自動整理完成",
"error": "錯誤:{error}" "error": "錯誤:{error}"
}, },
"filenameTemplateProgress": {
"initializing": "正在初始化檔案名稱範本套用...",
"starting": "正在將檔案名稱範本套用至 {type}...",
"processing": "處理中({processed}/{total}- 已重新命名 {success},已略過 {skipped},失敗 {failures}",
"completed": "完成:已重新命名 {success},已略過 {skipped},失敗 {failures}",
"complete": "檔案名稱範本套用完成",
"error": "錯誤:{error}"
},
"enrichHfAgent": "AI 中繼資料增強" "enrichHfAgent": "AI 中繼資料增強"
}, },
"contextMenu": { "contextMenu": {
@@ -915,7 +984,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "基礎模型",
"unknown": "未知"
}, },
"actions": { "actions": {
"openFileLocation": "開啟檔案位置", "openFileLocation": "開啟檔案位置",
@@ -1236,47 +1307,75 @@
}, },
"noPaths": { "noPaths": {
"title": "找不到其他模型資料夾", "title": "找不到其他模型資料夾",
"descriptionStandalone": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將下方的資料夾路徑加入 settings.json,然後重新啟動 LoRA Manager。", "descriptionStandalone": "其他模型管理已開啟,但找不到其他模型資料夾。請在「設定 > 模型路徑」中加入您的模型資料夾,然後重新啟動 LoRA Manager。",
"hintStandalone": "會掃描上方列出的資料夾鍵;不需要的鍵可以省略。", "hintStandalone": "會掃描已啟用的模型類型;請在「模型庫 > 預設根目錄」中啟用您需要的類型。",
"descriptionComfyUI": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將對應的模型資料夾加入 ComfyUI 的模型路徑,然後重新載入此頁面。", "descriptionComfyUI": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將對應的模型資料夾加入 ComfyUI 的模型路徑,然後重新載入此頁面。",
"hintComfyUI": "其他模型會從 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 資料夾讀取。", "hintComfyUI": "其他模型會從 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 資料夾讀取。",
"openSettings": "開啟設定" "openSettings": "開啟設定",
"openModelPaths": "設定模型資料夾",
"openSettingsFolder": "開啟設定資料夾"
} }
}, },
"sidebar": { "sidebar": {
"modelRoot": "根目錄", "modelRoot": "根目錄",
"collapseAll": "全部摺疊資料夾", "collapseAll": "全部摺疊資料夾",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "清單檢視下無法使用",
"hideOnThisPage": "隱藏此頁面側邊欄", "hideOnThisPage": "隱藏此頁面側邊欄",
"showSidebar": "顯示側邊欄", "showSidebar": "顯示側邊欄",
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏", "sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "檢視選項",
"treeView": "[TODO: Translate] Tree view", "treeView": "樹狀檢視",
"listView": "[TODO: Translate] List view", "listView": "清單檢視",
"recursiveOn": "包含子資料夾", "recursiveOn": "包含子資料夾",
"createFolder": "[TODO: Translate] New folder", "createFolder": "新增資料夾",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "新增子資料夾",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "顯示空資料夾",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "已建立資料夾 \"{name}\"",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "建立資料夾失敗: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "此頁面不支援建立資料夾",
"noRoot": "[TODO: Translate] No model root is configured" "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": { "dragDrop": {
"unableToResolveRoot": "無法確定移動的目標路徑。", "unableToResolveRoot": "無法確定移動的目標路徑。",
"moveUnsupported": "此項目不支援移動。", "moveUnsupported": "此項目不支援移動。",
"createFolderHint": "放開以建立新資料夾",
"newFolderName": "新資料夾名稱", "newFolderName": "新資料夾名稱",
"folderNameHint": "按 Enter 確認,Escape 取消",
"emptyFolderName": "請輸入資料夾名稱", "emptyFolderName": "請輸入資料夾名稱",
"invalidFolderName": "資料夾名稱包含無效字元", "invalidFolderName": "資料夾名稱包含無效字元",
"noDragState": "未找到待處理的拖放操作" "noDragState": "未找到待處理的拖放操作"
}, },
"empty": { "empty": {
"noFolders": "未找到資料夾", "noFolders": "未找到資料夾",
"dragHint": "將項目拖到此處以建立資料夾", "createHint": "點擊上方的新增資料夾按鈕即可建立資料夾"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "檢查此資料夾的更新", "label": "檢查此資料夾的更新",
@@ -1455,6 +1554,10 @@
"progress": { "progress": {
"currentFile": "目前檔案:", "currentFile": "目前檔案:",
"downloading": "下載中:{name}", "downloading": "下載中:{name}",
"metadata": "中繼資料:{name}",
"indexingFile": "正在讀取模型檔案...",
"fetchingSourceMetadata": "正在從 {source} 取得中繼資料...",
"fetchingMetadata": "正在取得中繼資料...",
"transferred": "已下載:{downloaded} / {total}", "transferred": "已下載:{downloaded} / {total}",
"transferredSimple": "已下載:{downloaded}", "transferredSimple": "已下載:{downloaded}",
"transferredUnknown": "已下載:--", "transferredUnknown": "已下載:--",
@@ -1523,6 +1626,11 @@
"tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。", "tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。",
"action": "全部檢查" "action": "全部檢查"
}, },
"filenameTemplateConfirm": {
"titleApply": "要將檔案名稱範本套用至模型庫嗎?",
"titleRevert": "要還原原始檔案名稱嗎?",
"revertButton": "還原原始檔案名稱"
},
"bulkAddTags": { "bulkAddTags": {
"title": "新增標籤到多個模型", "title": "新增標籤到多個模型",
"description": "新增標籤到", "description": "新增標籤到",
@@ -2232,6 +2340,9 @@
"autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理", "autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理",
"autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型", "autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型",
"autoOrganizeFailed": "自動整理失敗:{error}", "autoOrganizeFailed": "自動整理失敗:{error}",
"filenameTemplateSuccess": "已成功為 {count} 個 {type} 套用檔案名稱範本",
"filenameTemplatePartialSuccess": "檔案名稱範本套用完成:已重新命名 {success} 個,{failures} 個失敗,共 {total} 個模型",
"filenameTemplateFailed": "套用檔案名稱範本失敗:{error}",
"noModelsSelected": "未選擇任何模型" "noModelsSelected": "未選擇任何模型"
}, },
"recipes": { "recipes": {
@@ -2398,6 +2509,8 @@
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}", "mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
"downloadTemplatesUpdated": "下載路徑範本已更新", "downloadTemplatesUpdated": "下載路徑範本已更新",
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}", "downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
"filenameTemplatesUpdated": "檔案名稱範本已更新",
"filenameTemplatesFailed": "儲存檔案名稱範本失敗:{message}",
"recipesPathUpdated": "配方儲存路徑已更新", "recipesPathUpdated": "配方儲存路徑已更新",
"recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}", "recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}",
"settingsUpdated": "設定已更新:{setting}", "settingsUpdated": "設定已更新:{setting}",
@@ -2663,6 +2776,11 @@
"content": "在專屬頁面中掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。", "content": "在專屬頁面中掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
"enable": "啟用其他模型", "enable": "啟用其他模型",
"openSettings": "開啟設定" "openSettings": "開啟設定"
},
"pager": {
"previous": "上一則通知",
"next": "下一則通知",
"position": "第 {current} 則通知,共 {total} 則"
} }
} }
} }
+18
View File
@@ -24,9 +24,11 @@ from ..services.use_cases import (
AutoOrganizeUseCase, AutoOrganizeUseCase,
BulkMetadataRefreshUseCase, BulkMetadataRefreshUseCase,
DownloadModelUseCase, DownloadModelUseCase,
FilenameTemplateUseCase,
) )
from ..services.websocket_progress_callback import ( from ..services.websocket_progress_callback import (
WebSocketBroadcastCallback, WebSocketBroadcastCallback,
WebSocketFilenameTemplateProgressCallback,
WebSocketProgressCallback, WebSocketProgressCallback,
) )
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
@@ -37,6 +39,7 @@ from .handlers.model_handlers import (
ModelAutoOrganizeHandler, ModelAutoOrganizeHandler,
ModelCivitaiHandler, ModelCivitaiHandler,
ModelDownloadHandler, ModelDownloadHandler,
ModelFilenameTemplateHandler,
ModelHandlerSet, ModelHandlerSet,
ModelListingHandler, ModelListingHandler,
ModelManagementHandler, ModelManagementHandler,
@@ -83,6 +86,9 @@ class BaseModelRoutes(ABC):
self.model_lifecycle_service: ModelLifecycleService | None = None self.model_lifecycle_service: ModelLifecycleService | None = None
self.websocket_progress_callback = WebSocketProgressCallback() self.websocket_progress_callback = WebSocketProgressCallback()
self.metadata_progress_callback = WebSocketBroadcastCallback() self.metadata_progress_callback = WebSocketBroadcastCallback()
self.filename_template_progress_callback = (
WebSocketFilenameTemplateProgressCallback()
)
self._handler_set: ModelHandlerSet | None = None self._handler_set: ModelHandlerSet | None = None
self._handler_mapping: Dict[str, Callable[[web.Request], Awaitable[web.Response]]] | None = None self._handler_mapping: Dict[str, Callable[[web.Request], Awaitable[web.Response]]] | None = None
@@ -202,6 +208,17 @@ class BaseModelRoutes(ABC):
ws_manager=self._ws_manager, ws_manager=self._ws_manager,
logger=logger, logger=logger,
) )
filename_template_use_case = FilenameTemplateUseCase(
scanner=service.scanner,
lifecycle_service=self._ensure_lifecycle_service(),
lock_provider=self._ws_manager,
model_type=service.model_type,
)
filename_template = ModelFilenameTemplateHandler(
use_case=filename_template_use_case,
progress_callback=self.filename_template_progress_callback,
logger=logger,
)
updates = ModelUpdateHandler( updates = ModelUpdateHandler(
service=service, service=service,
update_service=update_service, update_service=update_service,
@@ -218,6 +235,7 @@ class BaseModelRoutes(ABC):
civitai=civitai, civitai=civitai,
move=move, move=move,
auto_organize=auto_organize, auto_organize=auto_organize,
filename_template=filename_template,
updates=updates, updates=updates,
) )
+148 -4
View File
@@ -54,12 +54,14 @@ from ...utils.constants import (
SUPPORTED_MEDIA_EXTENSIONS, SUPPORTED_MEDIA_EXTENSIONS,
VALID_LORA_TYPES, VALID_LORA_TYPES,
VALID_OTHER_CIVITAI_TYPES, VALID_OTHER_CIVITAI_TYPES,
folder_path_schema,
) )
from .model_source_handlers import ModelSourceHandler from .model_source_handlers import ModelSourceHandler
from .agent_handlers import AgentHandler from .agent_handlers import AgentHandler
from .download_routing_handlers import DownloadRoutingHandler from .download_routing_handlers import DownloadRoutingHandler
from .model_handlers import ModelCivitaiHandler from .model_handlers import ModelCivitaiHandler
from ...utils.civitai_utils import rewrite_preview_url from ...utils.civitai_utils import rewrite_preview_url
from ...utils.directory_browser import browse_directory
from ...utils.example_images_paths import ( from ...utils.example_images_paths import (
find_non_compliant_items_in_example_images_root, find_non_compliant_items_in_example_images_root,
is_valid_example_images_root, is_valid_example_images_root,
@@ -421,6 +423,11 @@ def _wsl_to_windows_path(wsl_path: str) -> str | None:
return None return None
def _has_gui_display() -> bool:
"""Check whether a GUI session is reachable for xdg-open."""
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
class PromptServerProtocol(Protocol): class PromptServerProtocol(Protocol):
"""Subset of PromptServer used by the handlers.""" """Subset of PromptServer used by the handlers."""
@@ -1575,6 +1582,30 @@ class SettingsHandler:
availability_error, availability_error,
) )
response_data["other_models_paths_available"] = None response_data["other_models_paths_available"] = None
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
response_data["standalone_mode"] = standalone_mode
if standalone_mode:
# Standalone reads its model roots exclusively from
# settings.json, so the Model Paths settings UI needs the
# current values plus the editable-key schema. In plugin mode
# the paths come from the ComfyUI host and stay hidden.
folder_paths = self._settings.get("folder_paths") or {}
# A fresh install is seeded from settings.json.example, whose
# folder_paths are documentation placeholders — hide them so
# the UI starts with empty editors instead of fake paths.
get_placeholders = getattr(
self._settings, "get_template_folder_path_placeholders", None
)
placeholders = get_placeholders() if get_placeholders else set()
if placeholders:
folder_paths = {
key: [p for p in paths if p not in placeholders]
if isinstance(paths, list)
else paths
for key, paths in folder_paths.items()
}
response_data["folder_paths"] = folder_paths
response_data["folder_path_schema"] = folder_path_schema()
settings_file = getattr(self._settings, "settings_file", None) settings_file = getattr(self._settings, "settings_file", None)
if settings_file: if settings_file:
response_data["settings_file"] = settings_file response_data["settings_file"] = settings_file
@@ -2759,12 +2790,40 @@ class ModelLibraryHandler:
normalized_type, scanner = await self._get_scanner_for_type(model_type) normalized_type, scanner = await self._get_scanner_for_type(model_type)
if not normalized_type: if not normalized_type:
# The lookup cannot be served as a fully interactive list. Two
# cases share this branch: a CivitAI type with no scanner at all
# (Wildcards, Workflows, Hypernetwork, Poses, AestheticGradient)
# and an Other-model type while the opt-in master switch is off.
# Answer 200 with the CivitAI list marked read-only plus a
# machine-readable reason, so clients can still show the
# versions and explain why the actions are missing. Legacy
# clients keep working: they only read `success`/`versions`.
reason = (
"other_models_disabled"
if self._normalize_model_type(model_type) == "other"
else "model_type_unsupported"
)
return web.json_response( return web.json_response(
{ {
"success": False, "success": True,
"error": f'Model type "{model_type}" is not supported', "modelId": model_id,
}, "modelName": model_name,
status=400, "modelType": model_type,
"supported": False,
"reason": reason,
"versions": [
{
"id": version.get("id"),
"name": version.get("name", ""),
"thumbnailUrl": version.get("images")[0]["url"]
if version.get("images")
else None,
"inLibrary": False,
"hasBeenDownloaded": False,
}
for version in versions
],
}
) )
if not scanner: if not scanner:
@@ -2806,6 +2865,7 @@ class ModelLibraryHandler:
"modelId": model_id, "modelId": model_id,
"modelName": model_name, "modelName": model_name,
"modelType": model_type, "modelType": model_type,
"supported": True,
"versions": enriched_versions, "versions": enriched_versions,
} }
) )
@@ -3393,6 +3453,18 @@ class FileSystemHandler:
subprocess.Popen(["open", "-R", settings_file]) subprocess.Popen(["open", "-R", settings_file])
else: else:
folder = os.path.dirname(settings_file) folder = os.path.dirname(settings_file)
if not _has_gui_display():
# Headless/SSH session: xdg-open cannot open a file
# manager, so hand the path to the browser for copying
# instead of reporting a success that never happened.
return web.json_response(
{
"success": True,
"message": "Headless session: path available for copying",
"path": settings_file,
"mode": "clipboard",
}
)
subprocess.Popen(["xdg-open", folder]) subprocess.Popen(["xdg-open", folder])
return web.json_response( return web.json_response(
@@ -3426,6 +3498,76 @@ class FileSystemHandler:
logger.error("Failed to open wildcards location: %s", exc, exc_info=True) logger.error("Failed to open wildcards location: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def browse_directory(self, request: web.Request) -> web.Response:
"""Browse a directory for the settings-UI directory picker."""
try:
data = await request.json()
payload, status = browse_directory(data.get("path", ""))
return web.json_response(payload, status=status)
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON"}, status=400
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to browse directory: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def validate_path(self, request: web.Request) -> web.Response:
"""Validate a filesystem path for the settings UI.
A well-formed request always returns HTTP 200; invalid paths are
reported via ``error_code`` in the payload. HTTP 400 is reserved for
malformed requests (missing path, invalid JSON).
"""
try:
data = await request.json()
raw_path = data.get("path")
expect = data.get("expect", "directory")
if not raw_path or not isinstance(raw_path, str):
return web.json_response(
{"success": False, "error": "Missing path parameter"}, status=400
)
# Business path convention: abspath only, never realpath.
path = os.path.abspath(os.path.expanduser(raw_path))
exists = os.path.exists(path)
is_directory = os.path.isdir(path) if exists else False
readable = bool(exists and os.access(path, os.R_OK))
writable = bool(exists and os.access(path, os.W_OK))
error_code = None
if not exists:
error_code = "path_not_found"
elif expect == "directory" and not is_directory:
error_code = "not_a_directory"
elif expect == "file" and not os.path.isfile(path):
error_code = "not_a_file"
elif not readable:
error_code = "not_readable"
elif not writable:
error_code = "not_writable"
return web.json_response(
{
"success": True,
"path": path,
"exists": exists,
"is_directory": is_directory,
"readable": readable,
"writable": writable,
"error_code": error_code,
}
)
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON"}, status=400
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to validate path: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class CustomWordsHandler: class CustomWordsHandler:
"""Handler for autocomplete via TagFTSIndex.""" """Handler for autocomplete via TagFTSIndex."""
@@ -4070,6 +4212,8 @@ class MiscHandlerSet:
"open_settings_location": self.filesystem.open_settings_location, "open_settings_location": self.filesystem.open_settings_location,
"open_backup_location": self.filesystem.open_backup_location, "open_backup_location": self.filesystem.open_backup_location,
"open_wildcards_location": self.filesystem.open_wildcards_location, "open_wildcards_location": self.filesystem.open_wildcards_location,
"browse_directory": self.filesystem.browse_directory,
"validate_path": self.filesystem.validate_path,
"search_custom_words": self.custom_words.search_custom_words, "search_custom_words": self.custom_words.search_custom_words,
"search_wildcards": self.wildcards.search_wildcards, "search_wildcards": self.wildcards.search_wildcards,
"get_supporters": self.supporters.get_supporters, "get_supporters": self.supporters.get_supporters,
+143 -1
View File
@@ -37,10 +37,14 @@ from ...services.use_cases import (
DownloadModelEarlyAccessError, DownloadModelEarlyAccessError,
DownloadModelUseCase, DownloadModelUseCase,
DownloadModelValidationError, DownloadModelValidationError,
FilenameTemplateUseCase,
MetadataRefreshProgressReporter, MetadataRefreshProgressReporter,
) )
from ...services.websocket_manager import WebSocketManager from ...services.websocket_manager import WebSocketManager
from ...services.websocket_progress_callback import WebSocketProgressCallback from ...services.websocket_progress_callback import (
WebSocketFilenameTemplateProgressCallback,
WebSocketProgressCallback,
)
from ...services.download_queue_service import DownloadQueueService from ...services.download_queue_service import DownloadQueueService
from ...services.errors import RateLimitError, ResourceNotFoundError from ...services.errors import RateLimitError, ResourceNotFoundError
from ...utils.civitai_utils import resolve_license_payload from ...utils.civitai_utils import resolve_license_payload
@@ -1910,6 +1914,11 @@ class ModelDownloadHandler:
response_payload["status"] = status response_payload["status"] = status
if "message" in progress_data: if "message" in progress_data:
response_payload["message"] = progress_data["message"] response_payload["message"] = progress_data["message"]
# Post-transfer stage (indexing / source metadata); polling
# consumers need it to tell "working" from "stuck".
for field in ("stage", "platform"):
if field in progress_data:
response_payload[field] = progress_data[field]
elif status is None and "message" in progress_data: elif status is None and "message" in progress_data:
response_payload["message"] = progress_data["message"] response_payload["message"] = progress_data["message"]
@@ -2499,6 +2508,70 @@ class ModelMoveHandler:
self._logger.error("Error creating folder: %s", exc, exc_info=True) self._logger.error("Error creating folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def delete_folder(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
try:
folder_path = data.get("folder_path")
if not folder_path:
return web.json_response(
{"success": False, "error": "Folder path is required"}, status=400
)
dry_run = bool(data.get("dry_run"))
result = await self._move_service.delete_folder(
folder_path, dry_run=dry_run
)
if result.get("success"):
if not dry_run:
_broadcast_models_changed()
return web.json_response(result, status=200)
# "not_empty" / "busy" are conflicts between the tree the client
# rendered and the on-disk truth; everything else is a bad request.
code = result.get("code")
status = 409 if code in ("not_empty", "busy") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error deleting folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def rename_folder(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
try:
folder_path = data.get("folder_path")
new_name = data.get("new_name")
if not folder_path:
return web.json_response(
{"success": False, "error": "Folder path is required"}, status=400
)
if not new_name:
return web.json_response(
{"success": False, "error": "New folder name is required"}, status=400
)
result = await self._move_service.rename_folder(folder_path, new_name)
if result.get("success"):
if result.get("renamed"):
_broadcast_models_changed()
return web.json_response(result, status=200)
# A name collision or a staged delete inside the subtree is a
# conflict with the state the client rendered, not a bad request.
code = result.get("code")
status = 409 if code in ("target_exists", "busy") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error renaming folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def move_model(self, request: web.Request) -> web.Response: async def move_model(self, request: web.Request) -> web.Response:
try: try:
data = await request.json() data = await request.json()
@@ -2623,6 +2696,71 @@ class ModelAutoOrganizeHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelFilenameTemplateHandler:
"""Apply the configured filename template to existing library models."""
def __init__(
self,
*,
use_case: FilenameTemplateUseCase,
progress_callback: WebSocketFilenameTemplateProgressCallback,
logger: logging.Logger,
) -> None:
self._use_case = use_case
self._progress_callback = progress_callback
self._logger = logger
async def apply_filename_template(self, request: web.Request) -> web.Response:
try:
file_paths = None
if request.method == "POST":
try:
data = await request.json()
file_paths = data.get("file_paths")
except Exception: # pragma: no cover - permissive path
pass
else:
# GET variant (browser extension is GET-only): comma-separated
# file_paths query parameter.
raw_file_paths = request.query.get("file_paths")
if raw_file_paths:
file_paths = [
path.strip()
for path in raw_file_paths.split(",")
if path.strip()
]
result = await self._use_case.execute(
file_paths=file_paths,
progress_callback=self._progress_callback,
)
_broadcast_models_changed()
return web.json_response(result.to_dict())
except AutoOrganizeInProgressError:
return web.json_response(
{
"success": False,
"error": "Another library operation is already running. Please wait for it to complete.",
},
status=409,
)
except Exception as exc:
self._logger.error(
"Error in apply_filename_template: %s", exc, exc_info=True
)
try:
await self._progress_callback.on_progress(
{
"type": "filename_template_progress",
"status": "error",
"error": str(exc),
}
)
except Exception: # pragma: no cover - defensive reporting
pass
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelUpdateHandler: class ModelUpdateHandler:
"""Handle update tracking requests.""" """Handle update tracking requests."""
@@ -3390,6 +3528,7 @@ class ModelHandlerSet:
civitai: ModelCivitaiHandler civitai: ModelCivitaiHandler
move: ModelMoveHandler move: ModelMoveHandler
auto_organize: ModelAutoOrganizeHandler auto_organize: ModelAutoOrganizeHandler
filename_template: ModelFilenameTemplateHandler
updates: ModelUpdateHandler updates: ModelUpdateHandler
def to_route_mapping( def to_route_mapping(
@@ -3450,8 +3589,11 @@ class ModelHandlerSet:
"move_model": self.move.move_model, "move_model": self.move.move_model,
"move_models_bulk": self.move.move_models_bulk, "move_models_bulk": self.move.move_models_bulk,
"create_folder": self.move.create_folder, "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, "auto_organize_models": self.auto_organize.auto_organize_models,
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress, "get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
"apply_filename_template": self.filename_template.apply_filename_template,
"get_model_notes": self.query.get_model_notes, "get_model_notes": self.query.get_model_notes,
"get_model_preview_url": self.query.get_model_preview_url, "get_model_preview_url": self.query.get_model_preview_url,
"get_model_civitai_url": self.query.get_model_civitai_url, "get_model_civitai_url": self.query.get_model_civitai_url,
+96 -31
View File
@@ -30,6 +30,7 @@ from ...services.model_sources import (
SourceRef, SourceRef,
detect_source, detect_source,
get_download_source, get_download_source,
hydrate_from_source,
is_valid_source_id, is_valid_source_id,
list_sources, list_sources,
normalize_metadata_source, normalize_metadata_source,
@@ -85,25 +86,77 @@ def _infer_model_type(model_root: str) -> tuple[Any, str]:
return _DEFAULT_MODEL_CLASS, _DEFAULT_SCANNER_GETTER 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( async def _save_source_metadata(
dest_path: str, ref: SourceRef, model_root: str dest_path: str, ref: SourceRef, model_root: str, *, download_id: str | None = None
) -> None: ) -> None:
"""Create a proper .metadata.json and add the model to the scanner cache. """Create a proper .metadata.json and add the model to the scanner cache.
Uses ``MetadataManager.create_default_metadata()`` which computes the The metadata is created through the owning scanner rather than
SHA256 hash, extracts safetensors header metadata (base_model), and ``MetadataManager.create_default_metadata()``, because that is the only
produces a fully-populated ``LoraMetadata`` (or ``CheckpointMetadata`` / factory that knows when hashing must be deferred: ``CheckpointScanner`` and
``EmbeddingMetadata``) object. We then overlay the external-source fields ``OtherScanner`` deliberately record ``hash_status="pending"`` with an empty
and register the model in the in-memory scanner cache so it appears ``sha256`` for their multi-GB files, and the generic helper would read a
immediately without a full filesystem walk. 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: try:
model_class, scanner_getter_name = _infer_model_type(model_root) model_class, scanner_getter_name = _infer_model_type(model_root)
# 1. Create proper metadata (computes SHA256, reads safetensors headers) scanner = None
metadata = await MetadataManager.create_default_metadata( scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
dest_path, model_class=model_class 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: if metadata is None:
logger.warning("create_default_metadata returned None for %s", dest_path) logger.warning("create_default_metadata returned None for %s", dest_path)
return return
@@ -120,8 +173,8 @@ async def _save_source_metadata(
# 3. Save metadata atomically # 3. Save metadata atomically
await MetadataManager.save_metadata(dest_path, metadata) await MetadataManager.save_metadata(dest_path, metadata)
logger.info( logger.info(
"Saved %s metadata (source=%s) for %s", "Saved %s metadata (source=%s, hash_status=%s) for %s",
ref.platform, ref.url, dest_path, ref.platform, ref.url, getattr(metadata, "hash_status", "?"), dest_path,
) )
# 4. Determine relative folder path for cache # 4. Determine relative folder path for cache
@@ -132,13 +185,16 @@ async def _save_source_metadata(
folder = rel.replace(os.sep, "/") if rel != "." else "" folder = rel.replace(os.sep, "/") if rel != "." else ""
# 5. Add to scanner cache (same as CivitAI's _execute_download does) # 5. Add to scanner cache (same as CivitAI's _execute_download does)
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None) if scanner is not None:
if scanner_getter is not None: metadata_dict = normalize_metadata_source(metadata.to_dict())
scanner = await scanner_getter() await scanner.add_model_to_cache(metadata_dict, folder)
if scanner is not None: logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
metadata_dict = normalize_metadata_source(metadata.to_dict())
await scanner.add_model_to_cache(metadata_dict, folder) # 6. Top up from the site's public API. Runs last so the scanner-cache
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder) # 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: except Exception as exc:
logger.warning("Failed to save source metadata for %s: %s", dest_path, exc) logger.warning("Failed to save source metadata for %s: %s", dest_path, exc)
@@ -466,15 +522,6 @@ class ModelSourceHandler:
os.makedirs(target_dir, exist_ok=True) os.makedirs(target_dir, exist_ok=True)
dest_path = os.path.join(target_dir, file_base) 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_model_source: file already exists, skipping — %s", dest_path)
return web.json_response({
"success": True,
"message": f"File already exists: {dest_path}",
"path": dest_path,
})
# Built per request: sites that redirect to a CDN hand out a # 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. # time-limited token in the redirect, so the URL must never be cached.
resolve_url = source.file_download_url(repo, filename, revision) resolve_url = source.file_download_url(repo, filename, revision)
@@ -482,6 +529,20 @@ class ModelSourceHandler:
platform=source.platform, source_id=repo, url=source.canonical_url(repo) 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 # Set up progress callback if download_id is provided
progress_callback = None progress_callback = None
if download_id: if download_id:
@@ -530,7 +591,9 @@ class ModelSourceHandler:
progress_callback=progress_callback, progress_callback=progress_callback,
) )
if ok: if ok:
await _save_source_metadata(dest_path, ref, model_root) await _save_source_metadata(
dest_path, ref, model_root, download_id=download_id
)
return web.json_response({ return web.json_response({
"success": True, "success": True,
"message": f"Downloaded to {dest_path}", "message": f"Downloaded to {dest_path}",
@@ -557,7 +620,9 @@ class ModelSourceHandler:
progress_callback=progress_callback, progress_callback=progress_callback,
) )
if success: if success:
await _save_source_metadata(dest_path, ref, model_root) await _save_source_metadata(
dest_path, ref, model_root, download_id=download_id
)
return web.json_response({ return web.json_response({
"success": True, "success": True,
"message": f"Downloaded to {result}", "message": f"Downloaded to {result}",
+7 -158
View File
@@ -9,7 +9,6 @@ import re
import asyncio import asyncio
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Protocol, Tuple from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Protocol, Tuple
from aiohttp import web from aiohttp import web
@@ -34,6 +33,7 @@ from ...utils.civitai_utils import (
rewrite_preview_url, rewrite_preview_url,
) )
from ...utils.constants import NSFW_LEVELS from ...utils.constants import NSFW_LEVELS
from ...utils.directory_browser import WINDOWS_DRIVES_TOKEN, browse_directory
from ...utils.exif_utils import ExifUtils from ...utils.exif_utils import ExifUtils
from ...utils.recipe_open_stats import RecipeOpenStats from ...utils.recipe_open_stats import RecipeOpenStats
from ...recipes.merger import GenParamsMerger from ...recipes.merger import GenParamsMerger
@@ -3124,11 +3124,10 @@ class RecipeWorkflowHandler:
class BatchImportHandler: class BatchImportHandler:
"""Handle batch import operations for recipes.""" """Handle batch import operations for recipes."""
# Virtual path token for the Windows drive list. Browsing up from a drive # Virtual path token for the Windows drive list. Kept as a class
# root (e.g. C:\) lands here so users can switch drives without typing a # attribute for backwards compatibility; the canonical definition lives
# path. Only meaningful on Windows; elsewhere it falls through to normal # in py/utils/directory_browser.py.
# path handling and fails the existence check. WINDOWS_DRIVES_TOKEN = WINDOWS_DRIVES_TOKEN
WINDOWS_DRIVES_TOKEN = "__drives__"
def __init__( def __init__(
self, self,
@@ -3301,131 +3300,8 @@ class BatchImportHandler:
"""Browse a directory and return its contents (subdirectories and files).""" """Browse a directory and return its contents (subdirectories and files)."""
try: try:
data = await request.json() data = await request.json()
directory_path = data.get("path", "") payload, status = browse_directory(data.get("path", ""))
return web.json_response(payload, status=status)
if os.name == "nt" and directory_path == self.WINDOWS_DRIVES_TOKEN:
return self._windows_drives_response()
# 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 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())
# 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 = (
self.WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
)
else:
parent_path = str(path.parent)
return web.json_response(
{
"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),
}
)
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,
)
except json.JSONDecodeError: except json.JSONDecodeError:
return web.json_response( return web.json_response(
{"success": False, "error": "Invalid JSON"}, {"success": False, "error": "Invalid JSON"},
@@ -3434,30 +3310,3 @@ class BatchImportHandler:
except Exception as exc: except Exception as exc:
self._logger.error("Error browsing directory: %s", exc, exc_info=True) self._logger.error("Error browsing directory: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
def _windows_drives_response(self) -> web.Response:
"""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 web.json_response(
{
"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),
}
)
+2
View File
@@ -37,6 +37,8 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"), RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"), RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"), RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
RouteDefinition("POST", "/api/lm/browse-directory", "browse_directory"),
RouteDefinition("POST", "/api/lm/validate-path", "validate_path"),
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"), RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"), RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"), RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
+8
View File
@@ -41,11 +41,19 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"), RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"),
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"), RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"), RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"),
RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"),
RouteDefinition("POST", "/api/lm/{prefix}/rename-folder", "rename_folder"),
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition( RouteDefinition(
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress" "GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
), ),
RouteDefinition(
"GET", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
),
RouteDefinition(
"POST", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
),
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"), RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"), RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"), RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
+6 -1
View File
@@ -83,11 +83,16 @@ class OtherRoutes(BaseModelRoutes):
# resolved to no existing folder. Render an actionable empty state # resolved to no existing folder. Render an actionable empty state
# instead of an apparently broken empty grid. # instead of an apparently broken empty grid.
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
return { context = {
"other_disabled": False, "other_disabled": False,
"other_no_paths": not bool(config.other_roots), "other_no_paths": not bool(config.other_roots),
"standalone_mode": standalone_mode, "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: def _get_expected_model_types(self) -> str:
"""Get expected model types string for error messages""" """Get expected model types string for error messages"""
+14 -1
View File
@@ -21,7 +21,20 @@ NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
# otherwise delete them because they are untracked and, in released tags, # otherwise delete them because they are untracked and, in released tags,
# not listed in ``.gitignore``. ``-e`` excludes a path from cleaning # not listed in ``.gitignore``. ``-e`` excludes a path from cleaning
# regardless of whether it is ignored. # regardless of whether it is ignored.
_PRESERVE_DIRS = ('settings.json', 'civitai', 'wildcards', 'backups', 'stats', 'logs', 'cache', 'model_cache') # ``cache`` covers the resolved cache tree (cache/model, cache/recipe,
# cache/fts, ...); the legacy ``recipe_cache`` / ``model_cache`` directories
# are listed too because a portable install can predate the cache/ move.
_PRESERVE_DIRS = (
'settings.json',
'civitai',
'wildcards',
'backups',
'stats',
'logs',
'cache',
'model_cache',
'recipe_cache',
)
def _clean_excludes() -> List[str]: def _clean_excludes() -> List[str]:
+3 -18
View File
@@ -33,8 +33,8 @@ from ..model_sources import (
resolve_source_ref, resolve_source_ref,
source_label, source_label,
) )
from ..model_sources.hydration import load_model_card, resolve_site_base_model
from ..websocket_manager import ws_manager from ..websocket_manager import ws_manager
from .base_model_resolver import resolve_base_model
from .post_processor import PostProcessor from .post_processor import PostProcessor
from .skill_registry import SkillRegistry from .skill_registry import SkillRegistry
from .skills.enrich_hf_metadata.readme_processor import ( from .skills.enrich_hf_metadata.readme_processor import (
@@ -466,12 +466,7 @@ class AgentService:
raw_basename = os.path.splitext(os.path.basename(model_path))[0] raw_basename = os.path.splitext(os.path.basename(model_path))[0]
variables["asset_base_url"] = source.asset_base_url(ref.source_id) variables["asset_base_url"] = source.asset_base_url(ref.source_id)
cache_key = f"{ref.platform}:{ref.source_id}" readme = await load_model_card(source, ref.source_id, cache)
readme = cache.readmes.get(cache_key) if cache is not None else None
if readme is None:
readme = await source.fetch_model_card(ref.source_id)
if cache is not None and readme:
cache.readmes[cache_key] = readme
# Sites such as ModelScope keep part of the model card outside the # Sites such as ModelScope keep part of the model card outside the
# README (author summary, curated tags, per-file example images). The # README (author summary, curated tags, per-file example images). The
@@ -507,17 +502,7 @@ class AgentService:
async def _resolve_site_base_model(self, source_context: ModelCardContext) -> str: async def _resolve_site_base_model(self, source_context: ModelCardContext) -> str:
"""Resolve the site's base-model hints to a canonical name, or ``""``.""" """Resolve the site's base-model hints to a canonical name, or ``""``."""
from ...metadata_ops import list_base_models return await resolve_site_base_model(source_context)
hints = [*source_context.base_model_aliases, source_context.base_model]
if not any(hints):
return ""
try:
known_names = await list_base_models()
except Exception as exc:
logger.debug("Failed to list base models for site resolution: %s", exc)
return ""
return resolve_base_model(hints, known_names)
async def _build_prompt_context( async def _build_prompt_context(
self, self,
+58 -29
View File
@@ -48,6 +48,7 @@ class PostProcessor:
readme_content: str = "", readme_content: str = "",
source_context: Optional["ModelCardContext"] = None, source_context: Optional["ModelCardContext"] = None,
resolved_base_model: str = "", resolved_base_model: str = "",
metadata_source: str = "agent:enrich_hf_metadata",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Route *llm_output* to the correct skill post-processor. """Route *llm_output* to the correct skill post-processor.
@@ -63,13 +64,18 @@ class PostProcessor:
hints resolve to, used when the LLM did not supply one (which is the hints resolve to, used when the LLM did not supply one (which is the
normal case when the LLM was skipped). normal case when the LLM was skipped).
*metadata_source* records who produced the metadata. The AI skill
keeps its historical value; the deterministic download-time hydration
passes its own so the two remain distinguishable. ``llm_enriched_at``
is only stamped when *llm_output* actually carries a provider answer.
Returns a dict with keys ``success`` (bool), ``updated_fields`` (list), Returns a dict with keys ``success`` (bool), ``updated_fields`` (list),
``preview_downloaded`` (bool), and ``errors`` (list). ``preview_downloaded`` (bool), and ``errors`` (list).
""" """
if skill_name == "enrich_hf_metadata": if skill_name == "enrich_hf_metadata":
return await self._process_enrich_hf_metadata( return await self._process_enrich_hf_metadata(
model_path, llm_output, metadata, readme_content, source_context, model_path, llm_output, metadata, readme_content, source_context,
resolved_base_model, resolved_base_model, metadata_source,
) )
return { return {
"success": False, "success": False,
@@ -89,6 +95,7 @@ class PostProcessor:
readme_content: str = "", readme_content: str = "",
source_context: Optional["ModelCardContext"] = None, source_context: Optional["ModelCardContext"] = None,
resolved_base_model: str = "", resolved_base_model: str = "",
metadata_source: str = "agent:enrich_hf_metadata",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
from ...metadata_ops import ( from ...metadata_ops import (
apply_metadata_updates, apply_metadata_updates,
@@ -135,6 +142,17 @@ class PostProcessor:
if new_base and self._should_overwrite(current_base, is_source_model): if new_base and self._should_overwrite(current_base, is_source_model):
updates["base_model"] = new_base updates["base_model"] = new_base
# model_name — the site's own display name, so a source download never
# shows up under its local filename. Written only while the name is
# still the untouched file stem: once a user renames a model that
# choice is theirs to keep.
site_name = ((source_context.model_name if source_context else "") or "").strip()
if is_source_model and site_name:
current_name = (metadata.get("model_name") or "").strip()
file_stem = (metadata.get("file_name") or "").strip()
if not current_name or current_name == file_stem:
updates["model_name"] = site_name
# trigger words → civitai.trainedWords # trigger words → civitai.trainedWords
new_triggers = llm_output.get("trigger_words", []) new_triggers = llm_output.get("trigger_words", [])
trigger_words_empty = True trigger_words_empty = True
@@ -142,14 +160,9 @@ class PostProcessor:
cleaned = [t.strip() for t in new_triggers if t.strip()] cleaned = [t.strip() for t in new_triggers if t.strip()]
cleaned = [t for t in cleaned if t.lower() not in ("none", "null", "n/a")] cleaned = [t for t in cleaned if t.lower() not in ("none", "null", "n/a")]
trigger_words_empty = not cleaned trigger_words_empty = not cleaned
current_civitai = metadata.get("civitai") or {} current_triggers = (metadata.get("civitai") or {}).get("trainedWords") or []
current_triggers = current_civitai.get("trainedWords") or []
if self._should_overwrite_list(current_triggers, is_source_model): if self._should_overwrite_list(current_triggers, is_source_model):
trig_civitai = dict(current_civitai) self._merge_civitai(updates, metadata, trainedWords=cleaned)
if "civitai" in updates and isinstance(updates["civitai"], dict):
trig_civitai.update(updates["civitai"])
trig_civitai["trainedWords"] = cleaned
updates["civitai"] = trig_civitai
# modelDescription — the author's own summary (when the site keeps one # modelDescription — the author's own summary (when the site keeps one
# outside the README, e.g. ModelScope's ``Description``) followed by the # outside the README, e.g. ModelScope's ``Description``) followed by the
@@ -175,12 +188,16 @@ class PostProcessor:
if not short_desc: if not short_desc:
short_desc = site_description short_desc = site_description
if short_desc and is_source_model: if short_desc and is_source_model:
current_civitai = metadata.get("civitai") or {} self._merge_civitai(updates, metadata, description=short_desc)
desc_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict): # The version label completes the card the way a CivitAI download does:
desc_civitai.update(updates["civitai"]) # the UI renders `civitai.name` as the version chip. It is per file,
desc_civitai["description"] = short_desc # so a collection repository shows that checkpoint's own label.
updates["civitai"] = desc_civitai 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 # gallery images → civitai.images (site example images, YAML frontmatter
# widget entries, and Sample Gallery markdown tables in the README body) # widget entries, and Sample Gallery markdown tables in the README body)
@@ -244,12 +261,7 @@ class PostProcessor:
all_images = _dedupe_images(site_images + readme_images) all_images = _dedupe_images(site_images + readme_images)
if all_images: if all_images:
gallery_images = all_images gallery_images = all_images
current_civitai = metadata.get("civitai") or {} self._merge_civitai(updates, metadata, images=all_images)
gallery_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
gallery_civitai.update(updates["civitai"])
gallery_civitai["images"] = all_images
updates["civitai"] = gallery_civitai
# tags — the site's curated tags are authoritative content vocabulary, so # tags — the site's curated tags are authoritative content vocabulary, so
# they are kept alongside whatever the LLM proposed (the LLM is skipped # they are kept alongside whatever the LLM proposed (the LLM is skipped
@@ -269,9 +281,12 @@ class PostProcessor:
if len(merged) > len(existing_tags) or is_source_model: if len(merged) > len(existing_tags) or is_source_model:
updates["tags"] = merged updates["tags"] = merged
# metadata_source & llm_enriched_at (always set) # metadata_source is recorded for provenance; llm_enriched_at only means
updates["metadata_source"] = "agent:enrich_hf_metadata" # something when a provider actually answered, so the deterministic
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat() # download-time hydration does not claim an enrichment that never ran.
updates["metadata_source"] = metadata_source
if llm_output:
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat()
# LLM confidence, stored for the enrichment evaluation harness. The key # LLM confidence, stored for the enrichment evaluation harness. The key
# must NOT start with an underscore: `BaseModelMetadata.from_dict()` # must NOT start with an underscore: `BaseModelMetadata.from_dict()`
@@ -292,12 +307,7 @@ class PostProcessor:
if instance_prompt: if instance_prompt:
site_triggers = [instance_prompt] site_triggers = [instance_prompt]
if site_triggers: if site_triggers:
current_civitai = metadata.get("civitai") or {} self._merge_civitai(updates, metadata, trainedWords=site_triggers)
trig_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
trig_civitai.update(updates["civitai"])
trig_civitai["trainedWords"] = site_triggers
updates["civitai"] = trig_civitai
preview_remote_url = (llm_output.get("preview_url") or "").strip() preview_remote_url = (llm_output.get("preview_url") or "").strip()
# Fallback: if the LLM couldn't find a preview image in the cleaned # Fallback: if the LLM couldn't find a preview image in the cleaned
@@ -371,6 +381,25 @@ class PostProcessor:
"", "unknown", "", "unknown",
) )
@staticmethod
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 @staticmethod
def _should_overwrite_list(current_list: List[str], is_source_model: bool) -> bool: def _should_overwrite_list(current_list: List[str], is_source_model: bool) -> bool:
"""Return ``True`` when a list field should be overwritten.""" """Return ``True`` when a list field should be overwritten."""
+89 -1
View File
@@ -33,7 +33,7 @@ from ..utils.constants import (
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
from ..utils.file_utils import calculate_sha256, calculate_autov3 from ..utils.file_utils import calculate_sha256, calculate_autov3
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import sanitize_folder_name from ..utils.utils import calculate_filename_for_model, sanitize_folder_name
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager from ..utils.metadata_manager import MetadataManager
from .service_registry import ServiceRegistry from .service_registry import ServiceRegistry
@@ -45,6 +45,7 @@ from .errors import RateLimitError
from .aria2_downloader import Aria2Error, get_aria2_downloader from .aria2_downloader import Aria2Error, get_aria2_downloader
from .aria2_transfer_state import Aria2TransferStateStore from .aria2_transfer_state import Aria2TransferStateStore
from .download_queue_service import DownloadQueueService from .download_queue_service import DownloadQueueService
from .model_lifecycle_service import ModelLifecycleService, load_local_metadata
# Download to temporary file first # Download to temporary file first
import tempfile import tempfile
@@ -2746,6 +2747,7 @@ class DownloadManager:
else None else None
) )
downloaded_metadata: List[Dict[str, Any]] = []
for index, entry in enumerate(metadata_entries): for index, entry in enumerate(metadata_entries):
file_path_for_adjust = getattr( file_path_for_adjust = getattr(
entry, "file_path", actual_file_paths[index] entry, "file_path", actual_file_paths[index]
@@ -2788,6 +2790,15 @@ class DownloadManager:
if scanner is not None: if scanner is not None:
await scanner.add_model_to_cache(metadata_dict, relative_path) await scanner.add_model_to_cache(metadata_dict, relative_path)
downloaded_metadata.append(metadata_dict)
await self._apply_download_filename_template(
scanner=scanner,
model_type=model_type,
downloaded_metadata=downloaded_metadata,
download_id=download_id,
)
if transfer_backend == "aria2" and download_id: if transfer_backend == "aria2" and download_id:
await self._aria2_state_store.remove(download_id) await self._aria2_state_store.remove(download_id)
@@ -2827,6 +2838,83 @@ class DownloadManager:
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
async def _apply_download_filename_template(
self,
*,
scanner,
model_type: str,
downloaded_metadata: List[Dict[str, Any]],
download_id: Optional[str],
) -> None:
"""Rename freshly downloaded models according to the filename template.
Best-effort post-download step: any failure (including name conflicts)
is logged and skipped so a successful download is never turned into a
failure by a rename problem.
"""
try:
if scanner is None or not downloaded_metadata:
return
template = get_settings_manager().get_download_filename_template(
model_type
)
if not template:
return
lifecycle_service = ModelLifecycleService(
scanner=scanner,
metadata_manager=MetadataManager,
metadata_loader=load_local_metadata,
recipe_scanner_factory=ServiceRegistry.get_recipe_scanner,
)
for metadata_dict in downloaded_metadata:
file_path = metadata_dict.get("file_path")
if not isinstance(file_path, str) or not file_path:
continue
new_stem = calculate_filename_for_model(metadata_dict, model_type)
if not new_stem:
continue
current_stem = os.path.splitext(os.path.basename(file_path))[0]
if new_stem == current_stem or os.path.normcase(
new_stem
) == os.path.normcase(current_stem):
continue
try:
result = await lifecycle_service.rename_model(
file_path=file_path, new_file_name=new_stem
)
except ValueError as exc:
logger.warning(
"Keeping original filename for %s: %s", file_path, exc
)
continue
new_file_path = result.get("new_file_path")
if download_id and isinstance(new_file_path, str):
info = self._active_downloads.get(download_id)
if info is None:
continue
if info.get("file_path") == file_path:
info["file_path"] = new_file_path
extracted = info.get("extracted_paths")
if isinstance(extracted, list):
info["extracted_paths"] = [
new_file_path if path == file_path else path
for path in extracted
]
except Exception as exc: # Rename phase must never fail the download
logger.warning(
"Filename template rename failed for %s download: %s",
model_type,
exc,
exc_info=True,
)
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]: def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
if model_type in ("checkpoint", "other"): if model_type in ("checkpoint", "other"):
return { return {
+11 -5
View File
@@ -714,12 +714,18 @@ class LoraService(BaseModelService):
), ),
) )
# Return minimal data needed for cycling # Return minimal data needed for cycling. usage_tips is only included
return [ # when non-empty so widget consumers (recommended strength range cues)
{ # can build their lookup without inflating the payload.
result = []
for lora in available_loras:
entry = {
"file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"], "file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
"model_name": lora.get("model_name", lora["file_name"]), "model_name": lora.get("model_name", lora["file_name"]),
"folder": lora.get("folder", ""), "folder": lora.get("folder", ""),
} }
for lora in available_loras usage_tips = lora.get("usage_tips")
] if usage_tips:
entry["usage_tips"] = usage_tips
result.append(entry)
return result
+26 -3
View File
@@ -19,6 +19,28 @@ from .model_sources import has_external_source
logger = logging.getLogger(__name__) 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): class MetadataProviderProtocol(Protocol):
"""Subset of metadata provider interface consumed by the sync service.""" """Subset of metadata provider interface consumed by the sync service."""
@@ -115,9 +137,10 @@ class MetadataSyncService:
) )
if "trainedWords" in existing_civitai: if "trainedWords" in existing_civitai:
existing_trained = existing_civitai.get("trainedWords", []) existing_trained = existing_civitai.get("trainedWords", []) or []
new_trained = civitai_metadata.get("trainedWords", []) new_trained = civitai_metadata.get("trainedWords", []) or []
merged_trained = list(set(existing_trained + new_trained)) # Order preserving merge: the saved order drives prompt order.
merged_trained = _merge_ordered_unique(existing_trained, new_trained)
merged_civitai["trainedWords"] = merged_trained merged_civitai["trainedWords"] = merged_trained
local_metadata["civitai"] = merged_civitai local_metadata["civitai"] = merged_civitai
+315 -2
View File
@@ -2,13 +2,15 @@ import asyncio
import fnmatch import fnmatch
import os import os
import logging import logging
import shutil
from typing import Any, Dict, List, Optional, Sequence, Set from typing import Any, Dict, List, Optional, Sequence, Set
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from ..services.model_lifecycle_service import _require_path_in_library_roots from ..services.model_lifecycle_service import _require_path_in_library_roots
from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -41,10 +43,22 @@ class AutoOrganizeResult:
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary""" """Convert result to dictionary"""
if self.operation_type == 'filename_template':
message = (
f'Filename template applied: {self.success_count} renamed, '
f'{self.skipped_count} skipped, {self.failure_count} failed '
f'out of {self.total} total'
)
else:
message = (
f'Auto-organize {self.operation_type} completed: '
f'{self.success_count} moved, {self.skipped_count} skipped, '
f'{self.failure_count} failed out of {self.total} total'
)
result: Dict[str, Any] = { result: Dict[str, Any] = {
'success': self.status != 'error', 'success': self.status != 'error',
'status': self.status, 'status': self.status,
'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total', 'message': message,
'summary': { 'summary': {
'total': self.total, 'total': self.total,
'success': self.success_count, 'success': self.success_count,
@@ -536,6 +550,305 @@ class ModelMoveService:
return rel.replace(os.sep, "/") return rel.replace(os.sep, "/")
return "" 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]: async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
"""Move a single model file """Move a single model file
+24
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import os import os
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast from 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 from ..services.model_update_service import ModelUpdateService
async def load_local_metadata(metadata_path: str) -> Dict[str, Any]:
"""Load a metadata sidecar JSON, returning an empty dict when missing.
Thin equivalent of ``MetadataSyncService.load_local_metadata`` for callers
(download manager, use cases) that do not hold a sync-service instance.
"""
if not os.path.exists(metadata_path):
return {}
try:
with open(metadata_path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
except Exception as exc:
logger.warning("Failed to load metadata from %s: %s", metadata_path, exc)
return {}
return payload if isinstance(payload, dict) else {}
async def delete_model_artifacts( async def delete_model_artifacts(
target_dir: str, file_name: str, main_extension: str | None = None target_dir: str, file_name: str, main_extension: str | None = None
) -> List[str]: ) -> List[str]:
@@ -404,6 +425,9 @@ class ModelLifecycleService:
if metadata and new_metadata_path: if metadata and new_metadata_path:
metadata["file_name"] = new_file_name metadata["file_name"] = new_file_name
metadata["file_path"] = new_file_path metadata["file_path"] = new_file_path
# Preserve the pre-rename stem so the original download filename
# stays recoverable after template-driven renames.
metadata.setdefault("original_file_name", old_file_name)
if metadata.get("preview_url"): if metadata.get("preview_url"):
old_preview = str(metadata["preview_url"]) old_preview = str(metadata["preview_url"])
+210
View File
@@ -1505,6 +1505,216 @@ class ModelScanner:
await self._persist_current_cache() await self._persist_current_cache()
self.bump_cache_version() self.bump_cache_version()
async def remove_known_folder(self, folder: str) -> None:
"""Forget a folder (and its subtree) that no longer exists on disk.
Counterpart of :meth:`add_known_folder`, called after a directory is
removed between scans (e.g. via the delete-folder API) so folder trees
and the move/download destination pickers stop offering it without a
full rescan. Ancestors are kept on purpose: every recorded ancestor
exists on disk in its own right, so only the removed subtree is dropped.
Cache entries that referenced the now-missing directory are purged as
well, which keeps a stale (phantom) model card from surviving the
deletion. When ``all_folders`` has not been recorded yet (legacy
snapshot) only the cache purge runs the scheduled backfill walk
rebuilds the folder list from disk.
"""
normalized = folder.replace("\\", "/").strip("/")
if not normalized:
return
cache = self._cache
if cache is None:
return
prefix = f"{normalized}/"
folders_changed = False
recorded = getattr(cache, "all_folders", None)
if recorded is not None:
updated = [
entry
for entry in recorded
if entry != normalized and not entry.startswith(prefix)
]
if updated != list(recorded):
cache.all_folders = updated
folders_changed = True
stale_paths = [
item.get("file_path")
for item in (cache.raw_data or [])
if self._folder_within(item.get("folder", ""), normalized)
]
if stale_paths:
# The purge persists the cache — including the already updated
# all_folders list — and bumps the version itself.
await self._batch_update_cache_for_deleted_models(stale_paths)
folders = set(item.get("folder", "") for item in cache.raw_data)
cache.folders = sorted(folders, key=lambda x: x.lower())
elif folders_changed:
await self._persist_current_cache()
self.bump_cache_version()
@staticmethod
def _folder_within(candidate: str, target: str) -> bool:
"""Return True when *candidate* is *target* or lives below it."""
return candidate == target or candidate.startswith(f"{target}/")
@staticmethod
def _rekey_path(value: str, old_prefix: str, new_prefix: str) -> str:
"""Move a stored path (or URL) from *old_prefix* onto *new_prefix*."""
if not value:
return value
normalized = value.replace("\\", "/")
if normalized.startswith(old_prefix):
return new_prefix + normalized[len(old_prefix):]
return value
async def rename_known_folder(
self,
previous_folder: str,
new_folder: str,
*,
previous_path: str,
new_path: str,
) -> bool:
"""Re-key folder, cache and metadata records after a directory rename.
Counterpart of :meth:`add_known_folder` / :meth:`remove_known_folder`.
A rename keeps every file, so nothing may be dropped: the recorded
folder list, the affected cache entries (``file_path``/``folder``/
``preview_url``), the hash index and the on-disk metadata sidecars are
all rewritten onto the new prefix. That is what lets a folder full of
models be renamed without a rescan and without breaking per-model
bookkeeping.
Args:
previous_folder: Library-relative folder name before the rename
new_folder: Library-relative folder name after the rename
previous_path: Absolute directory path before the rename
new_path: Absolute directory path after the rename
Returns:
True when any recorded data was rewritten.
"""
previous = previous_folder.replace("\\", "/").strip("/")
current = new_folder.replace("\\", "/").strip("/")
if not previous or not current or previous == current:
return False
old_rel_prefix = f"{previous}/"
new_rel_prefix = f"{current}/"
old_abs_prefix = f"{str(previous_path).replace(chr(92), '/').rstrip('/')}/"
new_abs_prefix = f"{str(new_path).replace(chr(92), '/').rstrip('/')}/"
cache = self._cache
if cache is None:
return False
changed = False
recorded = getattr(cache, "all_folders", None)
if recorded is not None:
rekeyed = sorted(
(
self._rekey_folder_name(entry, previous, old_rel_prefix, new_rel_prefix)
for entry in recorded
),
key=lambda entry: entry.lower(),
)
if rekeyed != list(recorded):
cache.all_folders = rekeyed
changed = True
excluded = getattr(self, "_excluded_models", None)
if excluded:
rekeyed_excluded = [
self._rekey_path(entry, old_abs_prefix, new_abs_prefix)
for entry in excluded
]
if rekeyed_excluded != list(excluded):
self._excluded_models = rekeyed_excluded
changed = True
touched: List[Dict[str, Any]] = []
for item in cache.raw_data or []:
folder_value = item.get("folder", "") or self._calculate_folder(
item.get("file_path", "")
)
if not self._folder_within(folder_value, previous):
continue
old_file_path = item.get("file_path", "")
if old_file_path:
cache.remove_from_version_index(item)
item["file_path"] = self._rekey_path(
old_file_path, old_abs_prefix, new_abs_prefix
)
hash_value = (item.get("sha256") or "").lower()
if hash_value:
self._hash_index.remove_by_path(old_file_path, hash_value)
self._hash_index.add_entry(
hash_value, item["file_path"], item.get("autov3") or None
)
item["folder"] = self._rekey_folder_name(
folder_value, previous, old_rel_prefix, new_rel_prefix
)
if item.get("preview_url"):
item["preview_url"] = self._rekey_path(
item["preview_url"], old_abs_prefix, new_abs_prefix
)
touched.append(item)
if touched:
changed = True
await self._rewrite_sidecar_paths(touched)
folders = set(item.get("folder", "") for item in cache.raw_data)
cache.folders = sorted(folders, key=lambda x: x.lower())
cache.rebuild_version_index()
await cache.resort()
if changed:
await self._persist_current_cache()
self.bump_cache_version()
return changed
@staticmethod
def _rekey_folder_name(
entry: str, previous: str, old_rel_prefix: str, new_rel_prefix: str
) -> str:
"""Move a library-relative folder name (and its subtree) under a new name."""
if entry == previous:
return new_rel_prefix.rstrip("/")
if entry.startswith(old_rel_prefix):
return new_rel_prefix + entry[len(old_rel_prefix):]
return entry
async def _rewrite_sidecar_paths(self, entries: List[Dict[str, Any]]) -> None:
"""Point each model's metadata sidecar at its new location.
Sidecars travel with the renamed directory, so only the recorded
``file_path``/``preview_url`` inside them need rewriting. Failures are
logged and skipped a stale sidecar is repaired by the next metadata
refresh, and must not abort the rename.
"""
for item in entries:
file_path = item.get("file_path")
if not file_path:
continue
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
if not os.path.exists(metadata_path):
continue
try:
await self._update_metadata_paths(metadata_path, file_path)
except Exception as exc: # pragma: no cover - defensive
logger.warning(
"Failed to rewrite metadata sidecar %s: %s", metadata_path, exc
)
def _schedule_all_folders_backfill(self) -> None: def _schedule_all_folders_backfill(self) -> None:
"""Kick off a one-shot background folder walk if none is running.""" """Kick off a one-shot background folder walk if none is running."""
if self._all_folders_backfill_running: if self._all_folders_backfill_running:
+10 -1
View File
@@ -24,7 +24,12 @@ from .base import (
is_valid_source_id, is_valid_source_id,
) )
from .huggingface import HuggingFaceSource from .huggingface import HuggingFaceSource
from .modelscope import ModelScopeSource from .hydration import (
hydrate_from_source,
load_model_card,
resolve_site_base_model,
)
from .modelscope import ModelScopeIntlSource, ModelScopeSource
from .registry import ( from .registry import (
LEGACY_HF_URL_FIELD, LEGACY_HF_URL_FIELD,
SOURCE_PLATFORM_FIELD, SOURCE_PLATFORM_FIELD,
@@ -52,6 +57,7 @@ __all__ = [
"ModelSourceCache", "ModelSourceCache",
"ModelSourceError", "ModelSourceError",
"HuggingFaceSource", "HuggingFaceSource",
"ModelScopeIntlSource",
"ModelScopeSource", "ModelScopeSource",
"SOURCE_PLATFORM_FIELD", "SOURCE_PLATFORM_FIELD",
"SOURCE_URL_FIELD", "SOURCE_URL_FIELD",
@@ -68,9 +74,12 @@ __all__ = [
"get_source", "get_source",
"get_source_platform", "get_source_platform",
"has_external_source", "has_external_source",
"hydrate_from_source",
"is_valid_source_id", "is_valid_source_id",
"list_sources", "list_sources",
"load_model_card",
"normalize_metadata_source", "normalize_metadata_source",
"resolve_site_base_model",
"resolve_source_ref", "resolve_source_ref",
"source_group_key", "source_group_key",
"source_label", "source_label",
+30
View File
@@ -45,6 +45,7 @@ USER_AGENT = "ComfyUI-LoRA-Manager/1.0"
GROUP_PREFIXES: dict[str, str] = { GROUP_PREFIXES: dict[str, str] = {
"huggingface": "hf", "huggingface": "hf",
"modelscope": "ms", "modelscope": "ms",
"modelscope-ai": "msai",
"tensorart": "ta", "tensorart": "ta",
} }
@@ -77,6 +78,30 @@ class ModelCardContext:
description: str = "" description: str = ""
"""Author-written summary shown on the model page, outside the README.""" """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: str = ""
"""Base model as reported by the site (possibly a site-local id).""" """Base model as reported by the site (possibly a site-local id)."""
@@ -104,6 +129,11 @@ class ModelCardContext:
return not any( return not any(
( (
self.description, self.description,
self.model_name,
self.model_name_localized,
self.version_name,
self.license,
self.model_type,
self.base_model, self.base_model,
self.base_model_aliases, self.base_model_aliases,
self.official_tags, self.official_tags,
+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",
]
+169 -38
View File
@@ -1,4 +1,4 @@
"""ModelScope (魔搭社区) model source. """ModelScope (魔搭社区) model sources.
ModelScope exposes the same "model card as README.md" convention as ModelScope exposes the same "model card as README.md" convention as
Hugging Face, including a YAML frontmatter block that often carries Hugging Face, including a YAML frontmatter block that often carries
@@ -10,11 +10,13 @@ none of which requires an API key for public models:
the same content through the API, used as a fallback when the resolve the same content through the API, used as a fallback when the resolve
URL is unavailable. URL is unavailable.
* ``/api/v1/models/{owner}/{name}`` the model-detail payload behind the * ``/api/v1/models/{owner}/{name}`` the model-detail payload behind the
model page. It carries the author's summary (``Description``), the model page. It carries the repository's display name (``Name`` /
site-curated tags (``OfficialTags``), and, per published version, the ``ChineseName``), the author's summary (``Description``), the license, the
model filenames (``MuseInfo.versions[].stats.fileList``) together with AIGC type, the site tags (``OfficialTags``, falling back to ``Tags``), and,
that file's example images (``coverImages``) and trigger words. See per published version, the model filenames
:meth:`ModelScopeSource.fetch_model_card_context`. (``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 * ``/api/v1/models/{owner}/{name}/repo/files?Revision=..`` the file
listing backing the download picker. It reports real sizes for LFS listing backing the download picker. It reports real sizes for LFS
files (not the pointer size), so no extra HEAD request is needed. files (not the pointer size), so no extra HEAD request is needed.
@@ -28,6 +30,12 @@ valid; the CDN URL must never be cached.
The README and the detail payload both describe the whole repository rather 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 than one file, so a per-run ``ModelSourceCache`` keeps them from being read
again for every checkpoint of a collection repository. 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 from __future__ import annotations
@@ -36,7 +44,7 @@ import json
import logging import logging
import os import os
import re import re
from typing import TYPE_CHECKING, Any, Optional from typing import TYPE_CHECKING, Any, Iterable, Optional
from .base import ( from .base import (
ModelCardContext, ModelCardContext,
@@ -52,18 +60,28 @@ if TYPE_CHECKING: # pragma: no cover - typing only
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_URL_PATTERN = re.compile( #: ModelScope runs two independent catalogues. ``modelscope.com`` is a
r"https?://(?:www\.)?modelscope\.(?:cn|com)/models/(?P<id>[^/?#\s]+/[^/?#\s]+)" #: 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 #: Trailing view segments the site appends to a model URL; accepted verbatim
#: when the user pastes a browser tab URL. #: when the user pastes a browser tab URL.
_VIEW_SEGMENTS = r"(?:summary|files|model-file|readme|community|evaluation)?" _VIEW_SEGMENTS = r"(?:summary|files|model-file|readme|community|evaluation)?"
_STRICT_URL_PATTERN = re.compile(
r"https?://(?:www\.)?modelscope\.(?:cn|com)/models/(?P<id>[^/?#\s]+/[^/?#\s]+)" def _url_patterns(hosts: str) -> tuple[re.Pattern[str], re.Pattern[str]]:
rf"/?{_VIEW_SEGMENTS}/?$" """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 #: ``master`` is ModelScope's default branch; ``main`` is tried as a fallback
#: for repos imported from Hugging Face. #: for repos imported from Hugging Face.
@@ -71,7 +89,12 @@ _REVISIONS = ("master", "main")
class ModelScopeSource(ModelSource): class ModelScopeSource(ModelSource):
"""ModelScope (``modelscope.cn``).""" """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" platform = "modelscope"
label = "ModelScope" label = "ModelScope"
@@ -79,15 +102,18 @@ class ModelScopeSource(ModelSource):
supports_download = True supports_download = True
default_revision = "master" default_revision = "master"
default_subdir = "modelscope" default_subdir = "modelscope"
url_pattern = _URL_PATTERN
strict_url_pattern = _STRICT_URL_PATTERN #: 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: def canonical_url(self, source_id: str) -> str:
return f"https://modelscope.cn/models/{source_id}" return f"{self.base_url}/models/{source_id}"
def asset_base_url(self, source_id: str, revision: str = "") -> str: def asset_base_url(self, source_id: str, revision: str = "") -> str:
return ( return (
f"https://modelscope.cn/models/{source_id}/resolve/" f"{self.base_url}/models/{source_id}/resolve/"
f"{self.resolve_revision(revision)}" f"{self.resolve_revision(revision)}"
) )
@@ -96,7 +122,7 @@ class ModelScopeSource(ModelSource):
for revision in _REVISIONS: for revision in _REVISIONS:
text = await fetch_text( text = await fetch_text(
f"https://modelscope.cn/models/{source_id}/resolve/{revision}/README.md" f"{self.base_url}/models/{source_id}/resolve/{revision}/README.md"
) )
if text: if text:
return text return text
@@ -105,7 +131,7 @@ class ModelScopeSource(ModelSource):
# environments where the CDN resolve host is blocked. # environments where the CDN resolve host is blocked.
for revision in _REVISIONS: for revision in _REVISIONS:
text = await fetch_text( text = await fetch_text(
"https://modelscope.cn/api/v1/models/" f"{self.base_url}/api/v1/models/"
f"{source_id}/repo?Revision={revision}&FilePath=README.md" f"{source_id}/repo?Revision={revision}&FilePath=README.md"
) )
if text: if text:
@@ -158,7 +184,7 @@ class ModelScopeSource(ModelSource):
return cache.provider[cache_key] return cache.provider[cache_key]
status, payload = await fetch_json( status, payload = await fetch_json(
f"https://modelscope.cn/api/v1/models/{source_id}" f"{self.base_url}/api/v1/models/{source_id}"
) )
if status != 200 or not isinstance(payload, dict): if status != 200 or not isinstance(payload, dict):
logger.debug( logger.debug(
@@ -185,7 +211,7 @@ class ModelScopeSource(ModelSource):
revision = self.resolve_revision(revision) revision = self.resolve_revision(revision)
status, payload = await fetch_json( status, payload = await fetch_json(
"https://modelscope.cn/api/v1/models/" f"{self.base_url}/api/v1/models/"
f"{source_id}/repo/files?Revision={revision}" f"{source_id}/repo/files?Revision={revision}"
) )
@@ -208,18 +234,37 @@ class ModelScopeSource(ModelSource):
self, source_id: str, filename: str, revision: str = "" self, source_id: str, filename: str, revision: str = ""
) -> str: ) -> str:
return ( return (
f"https://modelscope.cn/models/{source_id}/resolve/" f"{self.base_url}/models/{source_id}/resolve/"
f"{self.resolve_revision(revision)}/{filename}" f"{self.resolve_revision(revision)}/{filename}"
) )
def page_url_for_file(self, source_id: str, filename: str) -> str: def page_url_for_file(self, source_id: str, filename: str) -> str:
return ( return (
f"https://modelscope.cn/models/{source_id}/file/view/" f"{self.base_url}/models/{source_id}/file/view/"
f"{self.default_revision}/{filename}" f"{self.default_revision}/{filename}"
) )
__all__ = ["ModelScopeSource"] 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"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -229,6 +274,33 @@ __all__ = ["ModelScopeSource"]
#: Trigger-word values that mean "the author left this blank". #: Trigger-word values that mean "the author left this blank".
_EMPTY_TRIGGER_VALUES = frozenset({"none", "null", "n/a"}) _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: def _clean_text(value: Any) -> str:
"""Return a stripped string for *value*, or ``""`` for anything else.""" """Return a stripped string for *value*, or ``""`` for anything else."""
@@ -259,9 +331,13 @@ def _build_card_context(
context = ModelCardContext( context = ModelCardContext(
description=_clean_text(data.get("Description")), 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=_first_string(data.get("BaseModel")),
base_model_aliases=_base_model_aliases(data), base_model_aliases=_base_model_aliases(data),
official_tags=_official_tags(data.get("OfficialTags")), official_tags=_official_tags(data),
) )
versions = _matching_versions( versions = _matching_versions(
@@ -271,6 +347,7 @@ def _build_card_context(
sha256=sha256, sha256=sha256,
) )
if versions: if versions:
context.version_name = _version_label(versions)
context.example_images = _cover_image_urls(versions) context.example_images = _cover_image_urls(versions)
context.trigger_words = _version_trigger_words(versions) context.trigger_words = _version_trigger_words(versions)
return context return context
@@ -303,26 +380,63 @@ def _base_model_aliases(data: dict[str, Any]) -> list[str]:
return aliases return aliases
def _official_tags(value: Any) -> list[str]: def _official_tags(data: dict[str, Any]) -> list[str]:
"""Extract the site-curated tag values from ``OfficialTags``. """Return the content tags the site publishes for the repository.
ModelScope's entries are dicts carrying an English ``Tag`` plus a ``OfficialTags`` is ModelScope's curated content vocabulary and is
``ChineseName``; the English value is the curated content vocabulary, so preferred whenever it is populated. Plenty of AIGC repositories leave it
that is the one surfaced here. 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.
""" """
tags: list[str] = []
if not isinstance(value, list): if not isinstance(value, list):
return tags return []
tags: list[str] = []
for entry in value: for entry in value:
if not isinstance(entry, dict): tag = _clean_text(entry.get("Tag") if isinstance(entry, dict) else entry)
continue if tag:
tag = _clean_text(entry.get("Tag"))
if tag and tag not in tags:
tags.append(tag) tags.append(tag)
return tags 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]: def _version_files(version: dict[str, Any]) -> list[str]:
"""Return the model filenames covered by one ``MuseInfo.versions`` entry. """Return the model filenames covered by one ``MuseInfo.versions`` entry.
@@ -359,6 +473,23 @@ def _version_show_name(version: dict[str, Any]) -> str:
return _clean_text(model_version.get("showName")).lower() 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]: def _file_digests(data: dict[str, Any]) -> dict[str, str]:
"""Return ``basename -> sha256`` for every published weight file. """Return ``basename -> sha256`` for every published weight file.
+4 -1
View File
@@ -13,15 +13,18 @@ from typing import Any, Dict, Mapping, Optional
from .base import GROUP_PREFIXES, ModelSource, SourceRef, clean_source_url from .base import GROUP_PREFIXES, ModelSource, SourceRef, clean_source_url
from .huggingface import HuggingFaceSource from .huggingface import HuggingFaceSource
from .modelscope import ModelScopeSource from .modelscope import ModelScopeIntlSource, ModelScopeSource
from .tensorart import TensorArtSource from .tensorart import TensorArtSource
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
#: Order matters only for disambiguation; the URL patterns are disjoint. #: 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, ...] = ( _SOURCES: tuple[ModelSource, ...] = (
HuggingFaceSource(), HuggingFaceSource(),
ModelScopeSource(), ModelScopeSource(),
ModelScopeIntlSource(),
TensorArtSource(), TensorArtSource(),
) )
+250 -246
View File
@@ -6,7 +6,9 @@ import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple 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.cache_paths import CacheType, resolve_cache_path_with_migration
from ..utils.file_lock import exclusive_lock
from .model_sources import normalize_metadata_source from .model_sources import normalize_metadata_source
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -257,267 +259,271 @@ class PersistentModelCache:
return return
try: try:
with self._db_lock: with self._db_lock:
conn = self._connect() # Cross-process serialization: another LoRA Manager instance may
try: # share this settings directory, and the read-merge-write below
conn.execute("PRAGMA foreign_keys = ON") # spans several statements.
conn.execute("BEGIN") 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_rows = [self._prepare_model_row(model_type, item) for item in raw_data]
model_map: Dict[str, Tuple[Any, ...]] = { model_map: Dict[str, Tuple[Any, ...]] = {
row[1]: row for row in model_rows if row[1] # row[1] is file_path row[1]: row for row in model_rows if row[1] # row[1] is file_path
} }
existing_models = conn.execute( existing_models = conn.execute(
"SELECT " "SELECT "
+ ", ".join(self._MODEL_COLUMNS[1:]) + ", ".join(self._MODEL_COLUMNS[1:])
+ " FROM models WHERE model_type = ?", + " 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 = ?",
(model_type,), (model_type,),
).fetchall() ).fetchall()
existing_autov3_map: Dict[str, set[str]] = {} existing_model_map: Dict[str, sqlite3.Row] = {
for row in existing_autov3_rows: row["file_path"]: row for row in existing_models
autov3_value = (row["autov3"] or "").lower() }
if not autov3_value:
continue
existing_autov3_map.setdefault(autov3_value, set()).add(row["file_path"])
new_autov3_map: Dict[str, set[str]] = {} to_remove_models = [
for autov3_value, paths in autov3_hash_index.items(): (model_type, path)
normalized_autov3 = (autov3_value or "").lower() for path in existing_model_map.keys()
if not normalized_autov3: 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 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: for path in paths:
if path: if path:
bucket.add(path) bucket.add(path)
autov3_inserts: List[Tuple[str, str, str]] = [] hash_inserts: List[Tuple[str, str, str]] = []
autov3_deletes: List[Tuple[str, str, str]] = [] hash_deletes: List[Tuple[str, str, str]] = []
all_autov3 = set(existing_autov3_map.keys()) | set(new_autov3_map.keys()) all_shas = set(existing_hash_map.keys()) | set(new_hash_map.keys())
for autov3_value in all_autov3: for sha_value in all_shas:
existing_paths = existing_autov3_map.get(autov3_value, set()) existing_paths = existing_hash_map.get(sha_value, set())
new_paths = new_autov3_map.get(autov3_value, set()) new_paths = new_hash_map.get(sha_value, set())
for path in existing_paths - new_paths: 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: 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( conn.executemany(
"DELETE FROM autov3_index WHERE model_type = ? AND autov3 = ? AND file_path = ?", "DELETE FROM hash_index WHERE model_type = ? AND sha256 = ? AND file_path = ?",
autov3_deletes, hash_deletes,
) )
if autov3_inserts: if hash_inserts:
conn.executemany( conn.executemany(
"INSERT OR IGNORE INTO autov3_index (model_type, autov3, file_path) VALUES (?, ?, ?)", "INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
autov3_inserts, hash_inserts,
) )
existing_excluded_rows = conn.execute( if autov3_hash_index is not None:
"SELECT file_path FROM excluded_models WHERE model_type = ?", existing_autov3_rows = conn.execute(
(model_type,), "SELECT autov3, file_path FROM autov3_index WHERE model_type = ?",
).fetchall() (model_type,),
existing_excluded = {row["file_path"] for row in existing_excluded_rows} ).fetchall()
new_excluded = {path for path in excluded_models if path} 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 = [ new_autov3_map: Dict[str, set[str]] = {}
(model_type, path) for autov3_value, paths in autov3_hash_index.items():
for path in existing_excluded - new_excluded normalized_autov3 = (autov3_value or "").lower()
] if not normalized_autov3:
excluded_inserts = [ continue
(model_type, path) bucket = new_autov3_map.setdefault(normalized_autov3, set())
for path in new_excluded - existing_excluded for path in paths:
] if path:
bucket.add(path)
if excluded_deletes: autov3_inserts: List[Tuple[str, str, str]] = []
conn.executemany( autov3_deletes: List[Tuple[str, str, str]] = []
"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: all_autov3 = set(existing_autov3_map.keys()) | set(new_autov3_map.keys())
conn.execute( for autov3_value in all_autov3:
"DELETE FROM folders WHERE model_type = ?", 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,), (model_type,),
) ).fetchall()
folder_inserts = [ existing_excluded = {row["file_path"] for row in existing_excluded_rows}
(model_type, path) for path in all_folders if path new_excluded = {path for path in excluded_models 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() excluded_deletes = [
finally: (model_type, path)
conn.close() 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: except Exception as exc:
logger.warning("Failed to persist cache for %s: %s", model_type, exc) logger.warning("Failed to persist cache for %s: %s", model_type, exc)
@@ -650,16 +656,14 @@ class PersistentModelCache:
conn.execute(f"ALTER TABLE models ADD COLUMN {column} {definition}") conn.execute(f"ALTER TABLE models ADD COLUMN {column} {definition}")
def _connect(self, readonly: bool = False) -> sqlite3.Connection: def _connect(self, readonly: bool = False) -> sqlite3.Connection:
uri = False if readonly and not os.path.exists(self._db_path):
path = self._db_path raise FileNotFoundError(self._db_path)
if readonly: return connect_cache_db(
if not os.path.exists(path): self._db_path,
raise FileNotFoundError(path) readonly=readonly,
path = f"file:{path}?mode=ro" detect_types=sqlite3.PARSE_DECLTYPES,
uri = True row_factory=sqlite3.Row,
conn = sqlite3.connect(path, check_same_thread=False, uri=uri, detect_types=sqlite3.PARSE_DECLTYPES) )
conn.row_factory = sqlite3.Row
return conn
def _prepare_model_row(self, model_type: str, item: Dict[str, Any]) -> Tuple[Any, ...]: 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 # Keep `source_*` and the legacy `hf_url` alias consistent no matter
+79 -46
View File
@@ -19,7 +19,9 @@ import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Set, Tuple 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.cache_paths import CacheType, resolve_cache_path_with_migration
from ..utils.file_lock import exclusive_lock
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -170,65 +172,98 @@ class PersistentRecipeCache:
recipes: List[Dict[str, Any]], recipes: List[Dict[str, Any]],
json_paths: Optional[Dict[str, str]] = None, json_paths: Optional[Dict[str, str]] = None,
image_id_map: 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. """Save all recipes to SQLite cache.
Args: Args:
recipes: List of recipe dictionaries to persist. recipes: List of recipe dictionaries to persist.
json_paths: Optional mapping of recipe_id -> json_path for file stats. json_paths: Optional mapping of recipe_id -> json_path for file stats.
image_id_map: Optional precomputed civitai image_id recipe_id mapping. 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(): if not self.is_enabled():
return return False
if not self._schema_initialized: if not self._schema_initialized:
self._initialize_schema() self._initialize_schema()
if not self._schema_initialized: if not self._schema_initialized:
return return False
try: try:
with self._db_lock: with self._db_lock:
conn = self._connect() # Cross-process serialization: another LoRA Manager instance may
try: # share this settings directory, and a full-table replace is a
conn.execute("PRAGMA foreign_keys = ON") # read-modify-write that SQLite alone cannot make atomic.
conn.execute("BEGIN") with exclusive_lock(self._db_path):
conn = self._connect()
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
# Clear existing data if skip_if_empty and not recipes:
conn.execute("DELETE FROM 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 # Clear existing data
recipe_rows = [] conn.execute("DELETE FROM recipes")
for recipe in recipes:
recipe_id = str(recipe.get("id", ""))
if not recipe_id:
continue
json_path = "" # Prepare and insert all rows
if json_paths: recipe_rows = []
json_path = json_paths.get(recipe_id, "") for recipe in recipes:
recipe_id = str(recipe.get("id", ""))
if not recipe_id:
continue
row = self._prepare_recipe_row(recipe, json_path) json_path = ""
recipe_rows.append(row) if json_paths:
json_path = json_paths.get(recipe_id, "")
if recipe_rows: row = self._prepare_recipe_row(recipe, json_path)
placeholders = ", ".join(["?"] * len(self._RECIPE_COLUMNS)) recipe_rows.append(row)
columns = ", ".join(self._RECIPE_COLUMNS)
conn.executemany( if recipe_rows:
f"INSERT INTO recipes ({columns}) VALUES ({placeholders})", placeholders = ", ".join(["?"] * len(self._RECIPE_COLUMNS))
recipe_rows, 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.commit()
conn.execute( logger.debug("Persisted %d recipes to cache", len(recipe_rows))
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)", return True
("image_id_map", json.dumps(image_id_map or {})), finally:
) conn.close()
conn.commit()
logger.debug("Persisted %d recipes to cache", len(recipe_rows))
finally:
conn.close()
except Exception as exc: except Exception as exc:
logger.warning("Failed to persist recipe cache: %s", exc) logger.warning("Failed to persist recipe cache: %s", exc)
return False
def get_file_stats(self) -> Dict[str, Tuple[float, int]]: def get_file_stats(self) -> Dict[str, Tuple[float, int]]:
"""Return stored file stats for all cached recipes. """Return stored file stats for all cached recipes.
@@ -486,16 +521,14 @@ class PersistentRecipeCache:
logger.warning("Failed to initialize persistent recipe cache schema: %s", exc) logger.warning("Failed to initialize persistent recipe cache schema: %s", exc)
def _connect(self, readonly: bool = False) -> sqlite3.Connection: def _connect(self, readonly: bool = False) -> sqlite3.Connection:
uri = False if readonly and not os.path.exists(self._db_path):
path = self._db_path raise FileNotFoundError(self._db_path)
if readonly: return connect_cache_db(
if not os.path.exists(path): self._db_path,
raise FileNotFoundError(path) readonly=readonly,
path = f"file:{path}?mode=ro" detect_types=sqlite3.PARSE_DECLTYPES,
uri = True row_factory=sqlite3.Row,
conn = sqlite3.connect(path, check_same_thread=False, uri=uri, detect_types=sqlite3.PARSE_DECLTYPES) )
conn.row_factory = sqlite3.Row
return conn
def _prepare_recipe_row(self, recipe: Dict[str, Any], json_path: str) -> Tuple[Any, ...]: 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.""" """Convert a recipe dict to a row tuple for SQLite insertion."""
+8 -10
View File
@@ -16,6 +16,7 @@ import threading
import time import time
from typing import Any, Dict, List, Optional, Set, Tuple 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.cache_paths import CacheType, resolve_cache_path_with_migration
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -633,16 +634,13 @@ class RecipeFTSIndex:
def _connect(self, readonly: bool = False) -> sqlite3.Connection: def _connect(self, readonly: bool = False) -> sqlite3.Connection:
"""Create a database connection.""" """Create a database connection."""
uri = False if readonly and not os.path.exists(self._db_path):
path = self._db_path raise FileNotFoundError(self._db_path)
if readonly: return connect_cache_db(
if not os.path.exists(path): self._db_path,
raise FileNotFoundError(path) readonly=readonly,
path = f"file:{path}?mode=ro" row_factory=sqlite3.Row,
uri = True )
conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
conn.row_factory = sqlite3.Row
return conn
def _remove_recipe_locked(self, conn: sqlite3.Connection, recipe_id: str) -> None: def _remove_recipe_locked(self, conn: sqlite3.Connection, recipe_id: str) -> None:
"""Remove a recipe entry. Caller must hold the lock.""" """Remove a recipe entry. Caller must hold the lock."""
+124 -16
View File
@@ -116,6 +116,12 @@ class RecipeScanner:
self._persistent_cache: Optional[PersistentRecipeCache] = None self._persistent_cache: Optional[PersistentRecipeCache] = None
self._civitai_client: Any = None # Lazily initialized from registry self._civitai_client: Any = None # Lazily initialized from registry
self._json_path_map: Dict[str, str] = {} # recipe_id -> json_path self._json_path_map: Dict[str, str] = {} # recipe_id -> json_path
# True when the last scan refused to prune the stored cache because
# every recorded recipe file was missing (see
# :meth:`_initialize_recipe_cache_sync`). Keeps dependent background
# work (FTS index) aligned with the stored rows instead of the
# intentionally out-of-sync in-memory view.
self._prune_skipped: bool = False
if lora_scanner: if lora_scanner:
self._lora_scanner = lora_scanner self._lora_scanner = lora_scanner
if checkpoint_scanner: if checkpoint_scanner:
@@ -1651,8 +1657,12 @@ class RecipeScanner:
'pageType': 'recipes', 'pageType': 'recipes',
}) })
self._schedule_post_scan_enrichment() self._schedule_post_scan_enrichment()
# Schedule FTS index build in background (non-blocking) # Schedule FTS index build in background (non-blocking). When the
self._schedule_fts_index_build() # prune was skipped the in-memory cache is intentionally out of sync
# with the stored rows, so leave the existing index alone instead of
# rebuilding it from the empty view.
if not self._prune_skipped:
self._schedule_fts_index_build()
except Exception as e: except Exception as e:
logger.error(f"Recipe Scanner: Error initializing cache in background: {e}") logger.error(f"Recipe Scanner: Error initializing cache in background: {e}")
# Ensure the cache is never None so the page stops showing the # Ensure the cache is never None so the page stops showing the
@@ -1723,6 +1733,7 @@ class RecipeScanner:
""" """
loop = None loop = None
scan_start_time: Optional[float] = None scan_start_time: Optional[float] = None
self._prune_skipped = False
try: try:
# Ensure cache exists to avoid None reference errors # Ensure cache exists to avoid None reference errors
if self._cache is None: if self._cache is None:
@@ -1749,14 +1760,38 @@ class RecipeScanner:
logger.warning(f"Recipes directory not found: {recipes_dir}") logger.warning(f"Recipes directory not found: {recipes_dir}")
return self._cache return self._cache
# Record which directory the scan actually used. When the Recipes
# Storage Path is empty this falls back to the first LoRA root, and
# a support reader needs that path to tell a real wipe apart from a
# scan that looked somewhere else (see the prune guard below).
logger.info(f"Recipe scan directory: {recipes_dir}")
# Try to load from persistent cache first # Try to load from persistent cache first
persisted = self._persistent_cache.load_cache() persisted = self._persistent_cache.load_cache()
if persisted: if persisted:
recipes, changed, json_paths = self._reconcile_recipe_cache( (
persisted, recipes_dir recipes,
) changed,
json_paths,
skipped_prune_reason,
) = self._reconcile_recipe_cache(persisted, recipes_dir)
self._json_path_map = json_paths self._json_path_map = json_paths
if skipped_prune_reason:
# Every persisted recipe file vanished at once. That is not a
# reliable deletion signal: a drive that did not mount, a
# recipes_path that silently fell back to another root, or a
# shared cache touched by a second instance all look exactly
# like this. Keep the stored cache and skip the prune, so the
# only copy of the user's recipes is not destroyed.
logger.warning(
f"Recipe cache prune skipped: {skipped_prune_reason}. "
f"Keeping {len(persisted.raw_data)} stored recipe(s); this "
"session reports no recipes until the files are found again."
)
self._prune_skipped = True
return self._cache
if not changed: if not changed:
# Fast path: use cached data directly # Fast path: use cached data directly
logger.info( logger.info(
@@ -1770,7 +1805,10 @@ class RecipeScanner:
if self._backfill_source_path_if_needed(recipes, json_paths): if self._backfill_source_path_if_needed(recipes, json_paths):
self._cache.image_id_map = self._build_image_id_map() self._cache.image_id_map = self._build_image_id_map()
self._persistent_cache.save_cache( self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map recipes,
json_paths,
self._cache.image_id_map,
skip_if_empty=True,
) )
else: else:
# Use persisted map, or rebuild if empty (e.g. first startup # Use persisted map, or rebuild if empty (e.g. first startup
@@ -1798,7 +1836,10 @@ class RecipeScanner:
self._cache.image_id_map = self._build_image_id_map() self._cache.image_id_map = self._build_image_id_map()
# Persist updated cache # Persist updated cache
self._persistent_cache.save_cache( self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map recipes,
json_paths,
self._cache.image_id_map,
skip_if_empty=True,
) )
return self._cache return self._cache
@@ -1825,7 +1866,10 @@ class RecipeScanner:
# Persist for next startup # Persist for next startup
self._persistent_cache.save_cache( self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map recipes,
json_paths,
self._cache.image_id_map,
skip_if_empty=True,
) )
if report_progress: if report_progress:
@@ -1862,7 +1906,7 @@ class RecipeScanner:
self, self,
persisted: PersistedRecipeData, persisted: PersistedRecipeData,
recipes_dir: str, recipes_dir: str,
) -> Tuple[List[Dict[str, Any]], bool, Dict[str, str]]: ) -> Tuple[List[Dict[str, Any]], bool, Dict[str, str], Optional[str]]:
"""Reconcile persisted cache with current filesystem state. """Reconcile persisted cache with current filesystem state.
Args: Args:
@@ -1870,7 +1914,11 @@ class RecipeScanner:
recipes_dir: Path to the recipes directory. recipes_dir: Path to the recipes directory.
Returns: Returns:
Tuple of (recipes list, changed flag, json_paths dict). Tuple of (recipes list, changed flag, json_paths dict,
skipped_prune_reason). The last element is ``None`` on a normal
reconcile. When it is a string, the scan saw every persisted recipe
file disappear at once; the caller must then keep the persisted
cache instead of overwriting it. The reason text is user-facing.
""" """
recipes: List[Dict[str, Any]] = [] recipes: List[Dict[str, Any]] = []
json_paths: Dict[str, str] = {} json_paths: Dict[str, str] = {}
@@ -1951,12 +1999,67 @@ class RecipeScanner:
time.sleep(0) time.sleep(0)
# Check for deleted files # Check for deleted files
for json_path in persisted.file_stats.keys(): orphaned_stats = [
if json_path not in current_files: json_path
changed = True for json_path in persisted.file_stats.keys()
logger.debug("Recipe file deleted: %s", json_path) if json_path not in current_files
]
if orphaned_stats:
changed = True
# This single line plus the resolved scan directory logged by the
# caller are the evidence a support reader gets for a recipes path
# that moved; the per-file lines stay at debug to avoid flooding.
if len(orphaned_stats) > 10:
logger.info(
f"Recipe reconcile: {len(orphaned_stats)} of "
f"{len(persisted.file_stats)} cached recipe file(s) are not in "
f"{recipes_dir} (first: {orphaned_stats[0]}, "
f"last: {orphaned_stats[-1]})"
)
else:
for json_path in orphaned_stats:
logger.debug("Recipe file deleted: %s", json_path)
return recipes, changed, json_paths skipped_prune_reason: Optional[str] = None
if not current_files and persisted.file_stats:
metadata_is_coherent = self._persisted_metadata_is_coherent(persisted)
if metadata_is_coherent:
skipped_prune_reason = (
f"every recipe file recorded in the cache "
f"({len(persisted.file_stats)}) is missing from {recipes_dir}"
)
else:
# The stored row set and its recorded file stats disagree, so
# this cache is stale rather than a faithful record of recipes
# that have just gone missing. Pruning it is safe.
logger.info(
f"Recipe reconcile: stored cache is inconsistent "
f"({len(persisted.raw_data)} row(s) vs "
f"{len(persisted.file_stats)} file record(s)); falling back "
"to a normal prune."
)
return recipes, changed, json_paths, skipped_prune_reason
@staticmethod
def _persisted_metadata_is_coherent(persisted: PersistedRecipeData) -> bool:
"""Return True when the stored rows and their file stats describe one set.
The prune guard treats "no recipe files found" as a signal that the
directory moved out from under us, which is only meaningful when the
stored cache is a faithful record of recipes that exist on disk. A cache
whose row set and file-stat set have diverged (left behind by an older
reconcile) carries recipes that were already orphaned, so it is not
evidence of a fresh disappearance.
"""
stats_ids = {
os.path.basename(json_path)[: -len(".recipe.json")]
for json_path in persisted.file_stats
if os.path.basename(json_path).lower().endswith(".recipe.json")
}
rows_ids = {str(recipe.get("id", "")) for recipe in persisted.raw_data}
rows_ids.discard("")
return bool(rows_ids) and rows_ids == stats_ids
# Metadata key recording that the one-shot source_path backfill has run. # Metadata key recording that the one-shot source_path backfill has run.
_SOURCE_PATH_BACKFILL_MARKER = "source_path_backfilled" _SOURCE_PATH_BACKFILL_MARKER = "source_path_backfilled"
@@ -2626,6 +2729,10 @@ class RecipeScanner:
try: try:
# Invalidate persistent cache so the sync path does a # Invalidate persistent cache so the sync path does a
# full directory scan instead of reconciling stale data. # full directory scan instead of reconciling stale data.
# This is the deliberate escape hatch from the
# all-missing prune guard: an explicit user rebuild is
# allowed to clear the stored cache, while an implicit
# startup scan is not.
if self._persistent_cache: if self._persistent_cache:
self._persistent_cache.save_cache([], {}) self._persistent_cache.save_cache([], {})
self._json_path_map = {} self._json_path_map = {}
@@ -2656,7 +2763,8 @@ class RecipeScanner:
# Schedule non-blocking background work # Schedule non-blocking background work
self._schedule_post_scan_enrichment() self._schedule_post_scan_enrichment()
self._schedule_fts_index_build() if not self._prune_skipped:
self._schedule_fts_index_build()
return cast(RecipeCache, self._cache) return cast(RecipeCache, self._cache)
+95 -7
View File
@@ -19,6 +19,7 @@ from typing import (
Mapping, Mapping,
Optional, Optional,
Sequence, Sequence,
Set,
Tuple, Tuple,
) )
@@ -37,6 +38,7 @@ from ..utils.constants import (
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
from ..utils.settings_paths import ( from ..utils.settings_paths import (
APP_NAME, APP_NAME,
_portable_env_override,
ensure_settings_file, ensure_settings_file,
get_legacy_settings_path, get_legacy_settings_path,
get_settings_dir_override, get_settings_dir_override,
@@ -96,6 +98,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"recipes_path": "", "recipes_path": "",
"base_model_path_mappings": {}, "base_model_path_mappings": {},
"download_path_templates": {}, "download_path_templates": {},
"download_filename_templates": {},
"folder_paths": {}, "folder_paths": {},
"extra_folder_paths": {}, "extra_folder_paths": {},
"example_images_path": "", "example_images_path": "",
@@ -172,13 +175,23 @@ class SettingsManager:
self._check_environment_variables() self._check_environment_variables()
self._collect_configuration_warnings() self._collect_configuration_warnings()
if ( portable_override = _portable_env_override()
os.environ.get("LORA_MANAGER_PORTABLE", "0") == "1" if portable_override is True and not is_settings_dir_pinned():
and not is_settings_dir_pinned()
):
if not self.settings.get("use_portable_settings"): if not self.settings.get("use_portable_settings"):
self.settings["use_portable_settings"] = True self.settings["use_portable_settings"] = True
self._save_settings() 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: if self._needs_initial_save:
self._save_settings() self._save_settings()
@@ -297,6 +310,29 @@ class SettingsManager:
return payload == template 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( def _merge_template_with_defaults(
self, defaults: Dict[str, Any], template: Mapping[str, Any] self, defaults: Dict[str, Any], template: Mapping[str, Any]
) -> Dict[str, Any]: ) -> Dict[str, Any]:
@@ -1208,19 +1244,27 @@ class SettingsManager:
if self._bootstrap_reason == "missing": if self._bootstrap_reason == "missing":
message = ( message = (
"LoRA Manager created a default settings.json because no configuration was found. " "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: else:
message = ( message = (
"LoRA Manager could not locate any configured model directories. " "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( self._add_startup_message(
code="missing-model-paths", code="missing-model-paths",
title="Model folders need setup", title="Model folders need setup",
message=message, message=message,
severity="warning", 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, dismissible=False,
) )
@@ -1233,6 +1277,7 @@ class SettingsManager:
defaults = copy.deepcopy(DEFAULT_SETTINGS) defaults = copy.deepcopy(DEFAULT_SETTINGS)
defaults["base_model_path_mappings"] = {} defaults["base_model_path_mappings"] = {}
defaults["download_path_templates"] = {} defaults["download_path_templates"] = {}
defaults["download_filename_templates"] = {}
defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy() defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
defaults.setdefault("folder_paths", {}) defaults.setdefault("folder_paths", {})
defaults.setdefault("extra_folder_paths", {}) defaults.setdefault("extra_folder_paths", {})
@@ -2381,6 +2426,49 @@ class SettingsManager:
model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "") 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 _SETTINGS_MANAGER: Optional["SettingsManager"] = None
_SETTINGS_MANAGER_LOCK = Lock() _SETTINGS_MANAGER_LOCK = Lock()
+8 -10
View File
@@ -20,6 +20,7 @@ import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Set 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 from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -677,16 +678,13 @@ class TagFTSIndex:
def _connect(self, readonly: bool = False) -> sqlite3.Connection: def _connect(self, readonly: bool = False) -> sqlite3.Connection:
"""Create a database connection.""" """Create a database connection."""
uri = False if readonly and not os.path.exists(self._db_path):
path = self._db_path raise FileNotFoundError(self._db_path)
if readonly: return connect_cache_db(
if not os.path.exists(path): self._db_path,
raise FileNotFoundError(path) readonly=readonly,
path = f"file:{path}?mode=ro" row_factory=sqlite3.Row,
uri = True )
conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
conn.row_factory = sqlite3.Row
return conn
def _build_fts_query(self, query: str) -> str: def _build_fts_query(self, query: str) -> str:
"""Build an FTS5 query string with prefix matching. """Build an FTS5 query string with prefix matching.
+2
View File
@@ -20,6 +20,7 @@ from .example_images import (
ImportExampleImagesUseCase, ImportExampleImagesUseCase,
ImportExampleImagesValidationError, ImportExampleImagesValidationError,
) )
from .filename_template_use_case import FilenameTemplateUseCase
__all__ = [ __all__ = [
"AutoOrganizeInProgressError", "AutoOrganizeInProgressError",
@@ -34,4 +35,5 @@ __all__ = [
"DownloadExampleImagesUseCase", "DownloadExampleImagesUseCase",
"ImportExampleImagesUseCase", "ImportExampleImagesUseCase",
"ImportExampleImagesValidationError", "ImportExampleImagesValidationError",
"FilenameTemplateUseCase",
] ]
@@ -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]
+29
View File
@@ -20,6 +20,8 @@ class WebSocketManager:
self._last_init_progress: Dict[str, Dict[str, Any]] = {} self._last_init_progress: Dict[str, Dict[str, Any]] = {}
# Add auto-organize progress tracking # Add auto-organize progress tracking
self._auto_organize_progress: Optional[Dict[str, Any]] = None self._auto_organize_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 # Add recipe rematch progress tracking
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
self._auto_organize_lock = asyncio.Lock() self._auto_organize_lock = asyncio.Lock()
@@ -170,6 +172,13 @@ class WebSocketManager:
progress_entry['status'] = data['status'] progress_entry['status'] = data['status']
if 'message' in data: if 'message' in data:
progress_entry['message'] = data['message'] 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 self._download_progress[download_id] = progress_entry
@@ -199,6 +208,26 @@ class WebSocketManager:
"""Clear auto-organize progress data""" """Clear auto-organize progress data"""
self._auto_organize_progress = None self._auto_organize_progress = None
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._filename_template_progress.get('status')
return status in ['started', 'processing']
async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]): async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]):
"""Broadcast recipe rematch progress to connected clients""" """Broadcast recipe rematch progress to connected clients"""
# Store progress data in memory # Store progress data in memory
@@ -21,6 +21,14 @@ class WebSocketProgressCallback(ProgressCallback):
await ws_manager.broadcast_auto_organize_progress(progress_data) 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: class WebSocketBroadcastCallback:
"""Generic WebSocket progress callback broadcasting to all clients.""" """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
+23
View File
@@ -127,6 +127,29 @@ def other_sub_type_folder_keys() -> Dict[str, List[str]]:
# Precomputed inverse of OTHER_MODEL_FOLDER_SUBTYPES, keeping the table order. # 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() 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]: def normalize_other_sub_types(value: Any) -> List[str]:
"""Normalize a stored/requested enabled-sub_type list. """Normalize a stored/requested enabled-sub_type list.
+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),
}
+155 -25
View File
@@ -2,7 +2,7 @@ import inspect
import logging import logging
import os import os
import re import re
from typing import TYPE_CHECKING, Any, Dict, Optional from typing import TYPE_CHECKING, Any, Dict, Mapping, MutableMapping, Optional
from ..recipes.constants import GEN_PARAM_KEYS from ..recipes.constants import GEN_PARAM_KEYS
from ..services.metadata_service import get_default_metadata_provider, get_metadata_provider from ..services.metadata_service import get_default_metadata_provider, get_metadata_provider
@@ -13,9 +13,20 @@ from ..services.downloader import get_downloader
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager from ..utils.metadata_manager import MetadataManager
from ..utils.video_metadata import get_video_dimensions
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Placeholder dimensions written when the real ones cannot be determined.
# Kept for backwards compatibility with pre-existing metadata entries.
_DEFAULT_MEDIA_WIDTH = 720
_DEFAULT_MEDIA_HEIGHT = 1280
# Example metadata entries carry a marker: ``customImages`` use their ``id``
# while ``images`` use the positional index. Either way the marker must be a
# plain filename-safe token, never a path fragment.
_ENTRY_MARKER_PATTERN = re.compile(r"^(?:custom_|image_)?([^./\\]+)$")
_preview_service = PreviewAssetService( _preview_service = PreviewAssetService(
metadata_manager=MetadataManager, metadata_manager=MetadataManager,
downloader_factory=get_downloader, downloader_factory=get_downloader,
@@ -66,6 +77,141 @@ def _build_metadata_sync_service(settings_manager: "SettingsManager") -> Metadat
) )
def _read_media_dimensions(path: str, is_video: bool) -> tuple[int, int]:
"""Return ``(width, height)`` for an example image or video file.
Videos are read from their container headers (PIL cannot open them) so the
showcase viewer sizes the gallery to the real aspect ratio. Falls back to
the legacy ``720x1280`` placeholder when the dimensions cannot be
determined e.g. an unreadable file or an exotic codec which only
affects the displayed aspect ratio, never the file itself.
"""
dimensions = None
if is_video:
dimensions = get_video_dimensions(path)
else:
try:
from PIL import Image
if os.path.exists(path):
with Image.open(path) as img:
dimensions = img.size
except Exception:
dimensions = None
if dimensions:
width, height = dimensions
if width > 0 and height > 0:
return int(width), int(height)
return _DEFAULT_MEDIA_WIDTH, _DEFAULT_MEDIA_HEIGHT
def _is_video_entry(file_path: Optional[str], entry: Mapping[str, Any]) -> bool:
"""Return True when an example entry points at a video file.
The local file extension wins over the recorded ``type`` because files in
the wild are frequently mislabelled (animated WebP saved as ``.mp4``);
``_read_media_dimensions`` handles that correctly either way.
"""
if file_path:
ext = os.path.splitext(file_path)[1].lower()
if ext in SUPPORTED_MEDIA_EXTENSIONS["videos"]:
return True
if ext in SUPPORTED_MEDIA_EXTENSIONS["images"]:
return False
return str(entry.get("type", "")).lower() == "video"
def _resolve_local_file(
entry: Mapping[str, Any],
index: int,
local_files: Mapping[str, str],
) -> Optional[str]:
"""Map a metadata entry onto its example file inside the model folder.
Reads the entry's own marker (``id`` for ``customImages``, positional
``index`` for ``images``) with an anchored regex, so the identifier can
never bleed into a neighbouring filename the way a prefix comparison can.
"""
marker = entry.get("id")
if not isinstance(marker, str) or not marker:
marker = str(index)
match = _ENTRY_MARKER_PATTERN.fullmatch(marker)
if not match:
return None
return local_files.get(match.group(1))
def repair_local_video_dimensions(
metadata: MutableMapping[str, Any],
local_files: Mapping[str, str],
*,
dry_run: bool = False,
) -> int:
"""Backfill real video dimensions for an entry that has local files.
Only entries with an empty ``url`` are considered: those have no remote
source, so the local file is the single source of truth for their size and
rewriting them cannot discard API-supplied data. Entries whose dimensions
already match the file are left byte-identical.
Args:
metadata: Raw metadata payload (mutated in place unless ``dry_run``).
local_files: ``{identifier: path}`` for files present in the model's
example folder, where the identifier is the entry's ``id`` (for
``customImages``) or its positional index (for ``images``).
dry_run: Count the fixes without mutating ``metadata``.
Returns:
The number of entries that were (or would be) repaired.
"""
civitai = metadata.get("civitai")
if not isinstance(civitai, dict):
return 0
repaired = 0
for key in ("customImages", "images"):
entries = civitai.get(key)
if not isinstance(entries, list) or not entries:
continue
for index, entry in enumerate(entries):
if not isinstance(entry, dict):
continue
if entry.get("url", "") != "":
# Remote-backed entry: never rebuilt from local state.
continue
file_path = _resolve_local_file(entry, index, local_files)
if not file_path or not os.path.isfile(file_path):
continue
dimensions = _read_media_dimensions(
file_path, _is_video_entry(file_path, entry)
)
width, height = dimensions
if width <= 0 or height <= 0:
continue
if entry.get("width") == width and entry.get("height") == height:
continue
if not dry_run:
entry["width"] = width
entry["height"] = height
repaired += 1
return repaired
def _get_metadata_sync_service() -> MetadataSyncService: def _get_metadata_sync_service() -> MetadataSyncService:
"""Return the shared metadata sync service, initialising it lazily.""" """Return the shared metadata sync service, initialising it lazily."""
@@ -231,28 +377,20 @@ class MetadataUpdater:
file_ext = os.path.splitext(path)[1].lower() file_ext = os.path.splitext(path)[1].lower()
is_video = file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos'] is_video = file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
width, height = _read_media_dimensions(path, is_video)
# Create image metadata entry # Create image metadata entry
image_entry = { image_entry = {
"url": "", # Empty URL as required "url": "", # Empty URL as required
"nsfwLevel": 0, "nsfwLevel": 0,
"width": 720, # Default dimensions "width": width,
"height": 1280, "height": height,
"type": "video" if is_video else "image", "type": "video" if is_video else "image",
"meta": None, "meta": None,
"hasMeta": False, "hasMeta": False,
"hasPositivePrompt": False "hasPositivePrompt": False
} }
# If it's an image, try to get actual dimensions (optional enhancement)
try:
from PIL import Image
if not is_video and os.path.exists(path):
with Image.open(path) as img:
image_entry["width"], image_entry["height"] = img.size
except:
# If PIL fails or is unavailable, use default dimensions
pass
images.append(image_entry) images.append(image_entry)
# Update the model's civitai.images field # Update the model's civitai.images field
@@ -322,13 +460,15 @@ class MetadataUpdater:
file_ext = os.path.splitext(path)[1].lower() file_ext = os.path.splitext(path)[1].lower()
is_video = file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos'] is_video = file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
width, height = _read_media_dimensions(path, is_video)
# Create image metadata entry # Create image metadata entry
image_entry = { image_entry = {
"url": "", # Empty URL as requested "url": "", # Empty URL as requested
"id": short_id, "id": short_id,
"nsfwLevel": 0, "nsfwLevel": 0,
"width": 720, # Default dimensions "width": width,
"height": 1280, "height": height,
"type": "video" if is_video else "image", "type": "video" if is_video else "image",
"meta": None, "meta": None,
"hasMeta": False, "hasMeta": False,
@@ -353,16 +493,6 @@ class MetadataUpdater:
except Exception as e: except Exception as e:
logger.warning(f"Failed to extract metadata from {os.path.basename(path)}: {e}") logger.warning(f"Failed to extract metadata from {os.path.basename(path)}: {e}")
# If it's an image, try to get actual dimensions
try:
from PIL import Image
if not is_video and os.path.exists(path):
with Image.open(path) as img:
image_entry["width"], image_entry["height"] = img.size
except:
# If PIL fails or is unavailable, use default dimensions
pass
# Append to existing customImages array # Append to existing customImages array
custom_images.append(image_entry) custom_images.append(image_entry)
+146 -2
View File
@@ -15,12 +15,20 @@ from ..utils.example_images_paths import (
) )
from ..utils.metadata_manager import MetadataManager from ..utils.metadata_manager import MetadataManager
from ..utils.example_images_processor import ExampleImagesProcessor from ..utils.example_images_processor import ExampleImagesProcessor
from ..utils.example_images_metadata import update_cache_from_metadata from ..utils.example_images_metadata import (
repair_local_video_dimensions,
update_cache_from_metadata,
)
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CURRENT_NAMING_VERSION = 2 # Increment this when naming conventions change CURRENT_NAMING_VERSION = 3 # Increment this when naming conventions change
# Example files worth inspecting during the dimension repair.
_REPAIRABLE_EXTENSIONS = frozenset(
SUPPORTED_MEDIA_EXTENSIONS["images"] + SUPPORTED_MEDIA_EXTENSIONS["videos"]
)
class _SettingsProxy: class _SettingsProxy:
@@ -185,6 +193,9 @@ class ExampleImagesMigration:
if from_version < 2 and to_version >= 2: if from_version < 2 and to_version >= 2:
await ExampleImagesMigration._migrate_to_v2(model_folders) await ExampleImagesMigration._migrate_to_v2(model_folders)
if from_version < 3 and to_version >= 3:
await ExampleImagesMigration._migrate_to_v3(example_images_path, model_folders)
# Update version in progress file # Update version in progress file
progress_file = os.path.join(example_images_path, '.download_progress.json') progress_file = os.path.join(example_images_path, '.download_progress.json')
try: try:
@@ -438,3 +449,136 @@ class ExampleImagesMigration:
migration_errors += 1 migration_errors += 1
logger.info(f"Migration to v2 complete: migrated {count} custom examples across {updated_models} models with {migration_errors} errors") logger.info(f"Migration to v2 complete: migrated {count} custom examples across {updated_models} models with {migration_errors} errors")
@staticmethod
def _build_local_file_map(folder):
"""Map entry markers to their files inside a model's example folder.
Keys are the marker alone (``custom_<id>`` ``<id>``,
``image_<index>`` ``<index>``) so they line up with the metadata
entries' ``id``/positional index without any prefix ambiguity.
"""
local_files = {}
try:
entries = os.listdir(folder)
except OSError as exc:
logger.debug("Could not list example folder %s: %s", folder, exc)
return local_files
for name in entries:
stem, ext = os.path.splitext(name)
if ext.lower() not in _REPAIRABLE_EXTENSIONS:
continue
if stem.startswith("custom_"):
local_files[stem[len("custom_"):]] = os.path.join(folder, name)
elif stem.startswith("image_"):
local_files[stem[len("image_"):]] = os.path.join(folder, name)
return local_files
@staticmethod
async def _find_scanner_for_hash(model_hash):
"""Return the scanner owning ``model_hash``, or ``None``."""
lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
for scanner in (lora_scanner, checkpoint_scanner, embedding_scanner):
if scanner is None:
continue
try:
if scanner.has_hash(model_hash):
return scanner
except Exception as exc: # pragma: no cover - defensive
logger.debug("has_hash check failed for %s: %s", type(scanner).__name__, exc)
return None
@staticmethod
async def _migrate_to_v3(example_images_path, model_folders):
"""Backfill real dimensions for locally imported example videos.
Imported videos were stored with a hardcoded ``720x1280`` placeholder
(issue #1115), so landscape clips were rendered inside a portrait
container. Only entries with an empty ``url`` are touched those have
no remote source, which makes the local file authoritative and the
rewrite lossless. Entries already carrying the right size are left
untouched, so re-running this migration is a no-op.
This runs once per library via the ``naming_version`` gate in
``run_migrations``; it is deliberately not wired into any request path.
"""
repaired_entries = 0
updated_models = 0
migration_errors = 0
logger.info(
"Starting v3 migration (local example video dimensions) for %d model folders",
len(model_folders),
)
for folder in model_folders:
try:
model_hash = os.path.basename(folder)
if not model_hash or len(model_hash) != 64:
continue
local_files = ExampleImagesMigration._build_local_file_map(folder)
if not local_files:
continue
scanner = await ExampleImagesMigration._find_scanner_for_hash(model_hash)
if scanner is None:
logger.debug(
"Model %s not found in any scanner cache, skipping dimension repair",
model_hash,
)
continue
cache = await scanner.get_cached_data()
model_data = None
for item in cache.raw_data:
if item.get("sha256") == model_hash:
model_data = item
break
if not model_data:
continue
file_path = model_data.get("file_path")
if not file_path:
continue
payload = await MetadataManager.load_metadata_payload(file_path)
if not isinstance(payload, dict):
continue
repaired = repair_local_video_dimensions(payload, local_files)
if repaired <= 0:
continue
# The model cache shape differs from the on-disk payload, so
# persist the file first and let the cache sync re-read it.
await MetadataManager.save_metadata(file_path, payload)
await update_cache_from_metadata(scanner, file_path, payload)
repaired_entries += repaired
updated_models += 1
except Exception as exc:
logger.error(
"Failed to repair example video dimensions for %s: %s",
folder,
exc,
)
migration_errors += 1
logger.info(
"Migration to v3 complete: repaired %d example entr(ies) across %d model(s) "
"with %d error(s)",
repaired_entries,
updated_models,
migration_errors,
)
+146
View File
@@ -0,0 +1,146 @@
"""Cross-process advisory locking for shared LoRA Manager state.
Two LoRA Manager processes (the ComfyUI plugin and a standalone server, or two
ComfyUI installs pointed at the same settings directory) can open the same cache
database. SQLite serializes individual statements, but it cannot make a
read-modify-write *sequence* atomic across processes: two full-table cache
replacements can interleave so that one process's snapshot overwrites the
other's.
This module provides a small advisory file lock for those sequences. It is
deliberately non-fatal: if locking is unavailable or the wait times out, callers
keep working with SQLite's own ``busy_timeout`` as the fallback.
"""
from __future__ import annotations
import logging
import os
import time
logger = logging.getLogger(__name__)
# How long to wait for another process to release the lock before giving up.
DEFAULT_LOCK_TIMEOUT_SECONDS = 30.0
_POLL_INTERVAL_SECONDS = 0.05
# Windows byte-range locks; fcntl.flock on POSIX.
try: # pragma: no cover - platform dependent
import fcntl
except ImportError: # pragma: no cover - Windows
fcntl = None # type: ignore[assignment]
try: # pragma: no cover - Windows only
import msvcrt
except ImportError: # pragma: no cover - POSIX
msvcrt = None # type: ignore[assignment]
class FileLockUnavailable(RuntimeError):
"""Raised when the lock could not be acquired within the timeout."""
def lock_path_for(db_path: str) -> str:
"""Return the sibling lock file path used for *db_path*."""
absolute = os.path.abspath(db_path)
directory = os.path.dirname(absolute)
if not directory:
raise ValueError(f"Cannot derive a lock directory from {db_path!r}")
return os.path.join(directory, f".{os.path.basename(absolute)}.lock")
class CrossProcessLock:
"""A best-effort advisory lock backed by a lock file.
The lock file is a sibling of the guarded resource and is never deleted:
unlinking it would let a second process create a fresh inode and lock that
instead, defeating mutual exclusion.
"""
def __init__(self, path: str, timeout: float = DEFAULT_LOCK_TIMEOUT_SECONDS):
self.path = path
self.timeout = timeout
self._handle = None
def acquire(self) -> bool:
"""Try to take the lock, waiting up to ``timeout`` seconds.
Returns:
True when the lock is held (including when another lock is already
held by *this* process the calls are not reentrant, so callers must
not nest them). False when locking is unsupported or timed out; the
caller should proceed and rely on the SQLite busy timeout instead.
"""
if fcntl is None and msvcrt is None: # pragma: no cover - exotic platform
return False
os.makedirs(os.path.dirname(self.path), exist_ok=True)
try:
handle = open(self.path, "a+b")
except OSError as exc:
logger.debug("Could not open lock file %s: %s", self.path, exc)
return False
deadline = time.monotonic() + max(0.0, self.timeout)
while True:
if self._try_lock(handle):
self._handle = handle
return True
if time.monotonic() >= deadline:
handle.close()
return False
time.sleep(_POLL_INTERVAL_SECONDS)
def release(self) -> None:
"""Release the lock if held. Safe to call more than once."""
handle = self._handle
if handle is None:
return
self._handle = None
try:
self._unlock(handle)
except OSError as exc: # pragma: no cover - defensive
logger.debug("Failed to release lock %s: %s", self.path, exc)
finally:
try:
handle.close()
except OSError: # pragma: no cover - defensive
pass
def __enter__(self) -> "CrossProcessLock":
self.acquire()
return self
def __exit__(self, *_exc_info: object) -> None:
self.release()
# -- platform primitives -------------------------------------------------
def _try_lock(self, handle) -> bool:
if fcntl is not None:
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
except OSError:
return False
if msvcrt is not None: # pragma: no cover - Windows
try:
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
return True
except OSError:
return False
return False
def _unlock(self, handle) -> None:
if fcntl is not None:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
return
if msvcrt is not None: # pragma: no cover - Windows
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
def exclusive_lock(db_path: str, timeout: float = DEFAULT_LOCK_TIMEOUT_SECONDS):
"""Return a :class:`CrossProcessLock` for the database at *db_path*."""
return CrossProcessLock(lock_path_for(db_path), timeout=timeout)
+31 -1
View File
@@ -174,12 +174,42 @@ def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
return target_path return target_path
def _portable_env_override() -> Optional[bool]:
"""Return the portable mode forced by ``LORA_MANAGER_PORTABLE``, if any.
Returns:
``True`` when the variable enables portable mode, ``False`` when it is
explicitly set to ``"0"``, and ``None`` when it is unset or holds some
other value (in which case the persisted settings flag decides).
"""
raw = os.environ.get(_LM_PORTABLE_ENV)
if raw is None:
return None
if raw == "1":
return True
if raw == "0":
return False
return None
def _should_use_portable_settings(path: str, logger: logging.Logger) -> bool: def _should_use_portable_settings(path: str, logger: logging.Logger) -> bool:
"""Return ``True`` when the env var forces it or the settings file enables it.""" """Return ``True`` when the env var forces it or the settings file enables it."""
if os.environ.get(_LM_PORTABLE_ENV, "0") == "1": override = _portable_env_override()
if override is True:
logger.debug("Portable mode enabled via %s", _LM_PORTABLE_ENV) logger.debug("Portable mode enabled via %s", _LM_PORTABLE_ENV)
return True return True
if override is False:
# Explicit opt-out. Without this, a single `LORA_MANAGER_PORTABLE=1`
# run would pin the shared plugin settings.json to portable mode
# forever, with no way back except editing that file by hand.
logger.info(
"Portable mode disabled via %s=%s",
_LM_PORTABLE_ENV,
os.environ.get(_LM_PORTABLE_ENV, ""),
)
return False
if not os.path.exists(path): if not os.path.exists(path):
return False return False
+104
View File
@@ -1,4 +1,5 @@
from difflib import SequenceMatcher from difflib import SequenceMatcher
import logging
import os import os
import re import re
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -7,6 +8,8 @@ from ..config import config
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
import asyncio import asyncio
logger = logging.getLogger(__name__)
def get_lora_info(lora_name): def get_lora_info(lora_name):
"""Get the lora path and trigger words from cache""" """Get the lora path and trigger words from cache"""
@@ -598,6 +601,107 @@ def calculate_relative_path_for_model(
return formatted_path return formatted_path
def calculate_filename_for_model(
model_data: Dict[str, Any], model_type: str = "lora"
) -> str:
"""Calculate the filename stem for a model using the filename template.
Mirrors the data extraction of :func:`calculate_relative_path_for_model`
but renders a single filename (no path segments). Missing values resolve
to empty segments instead of the path-oriented defaults ("Anonymous" /
"no tags") so templates degrade gracefully.
Args:
model_data: Model data from scanner cache
model_type: Type of model ('lora', 'checkpoint', 'embedding')
Returns:
Sanitized filename stem without extension, or an empty string when no
template is configured, the template is invalid, or the rendered name
is empty.
"""
settings_manager = get_settings_manager()
template = settings_manager.get_download_filename_template(model_type)
if not template:
return ""
# A filename template must render a single name, never folder segments.
if "/" in template or "\\" in template:
logger.warning(
"Filename template for %s contains a path separator and is ignored: %r",
model_type,
template,
)
return ""
civitai_data = model_data.get("civitai", {})
author = ""
if isinstance(civitai_data, dict) and civitai_data.get("id") is not None:
creator_info = civitai_data.get("creator") or {}
author = creator_info.get("username") or ""
base_model = model_data.get("base_model", "")
base_model_mappings = settings_manager.get("base_model_path_mappings", {})
mapped_base_model = base_model_mappings.get(base_model, base_model)
lowercase_tags = [
tag.lower() for tag in model_data.get("tags", []) if isinstance(tag, str)
]
first_tag = settings_manager.resolve_priority_tag_for_model(
lowercase_tags, model_type
)
model_name = model_data.get("model_name", "")
version_name = ""
if isinstance(civitai_data, dict):
version_name = civitai_data.get("name") or ""
sha256 = model_data.get("sha256") or ""
hash_short = sha256[:10].lower() if isinstance(sha256, str) else ""
file_path = model_data.get("file_path") or ""
if isinstance(file_path, str) and file_path:
original_name = os.path.splitext(os.path.basename(file_path))[0]
else:
original_name = os.path.splitext(str(model_data.get("file_name", "")))[0]
def _sanitize_value(value: Any) -> str:
# sanitize_folder_name falls back to "unnamed" for empty input; for
# templates an empty value must stay empty so segments collapse.
text = str(value) if value else ""
return sanitize_folder_name(text) if text else ""
replacements = {
"{model_name}": _sanitize_value(model_name),
"{version_name}": _sanitize_value(version_name),
"{base_model}": _sanitize_value(mapped_base_model),
"{author}": _sanitize_value(author),
"{first_tag}": _sanitize_value(first_tag),
"{hash_short}": hash_short,
"{original_name}": _sanitize_value(original_name),
}
result = template
for placeholder, value in replacements.items():
result = result.replace(placeholder, value)
if model_type == "embedding":
result = result.replace(" ", "_")
# Strip characters that are illegal in filenames on common filesystems.
result = re.sub(r'[:*?"<>|]', "", result)
# Collapse runs of identical separators introduced by empty substitutions.
result = re.sub(r"([-_. ])\1+", r"\1", result)
# Drop separators left dangling next to each other ("- -" -> "-").
result = re.sub(r" ?([-_.]) (?=[-_.])", r"\1", result)
# A stem must not start or end with separators, spaces or dots.
result = result.strip("-_. ")
return result
def remove_empty_dirs(path): def remove_empty_dirs(path):
"""Recursively remove empty directories starting from the given path. """Recursively remove empty directories starting from the given path.
+623
View File
@@ -0,0 +1,623 @@
"""Read intrinsic dimensions from video containers without external tooling.
PIL cannot open ``.mp4``/``.webm`` files, so example videos imported through
the "Add examples" flow used to fall back to a hardcoded ``720x1280`` (portrait)
entry, which forced the showcase viewer to letterbox landscape videos.
This module reads the dimensions out of the container headers themselves:
* ISO base media files (``.mp4``/``.mov``/``.m4v``) ``moov/trak/tkhd``,
falling back to the sample description of the video track.
* WebM/Matroska (``.webm``/``.mkv``) ``Segment/Tracks/TrackEntry/Video``
``PixelWidth``/``PixelHeight``.
* Animated WebP (``RIFF``/``WEBP``) handled because users routinely save
animated examples with a video extension.
The container signature decides which reader runs, so a mislabelled file
(a ``.mp4`` that is really WebM) still reports the right dimensions.
Both readers stream over the file: only container headers are read, so a
multi-gigabyte ``mdat`` is never pulled into memory (it is seeked past).
"""
from __future__ import annotations
import functools
import logging
import os
import struct
from typing import BinaryIO, Iterator, Optional, Tuple
logger = logging.getLogger(__name__)
ISO_MEDIA_EXTENSIONS = frozenset({".mp4", ".m4v", ".mov"})
EBML_MEDIA_EXTENSIONS = frozenset({".webm", ".mkv"})
_EBML_MAGIC = b"\x1a\x45\xdf\xa3"
# Cap recursion into nesting containers so a crafted/corrupt file cannot blow
# the Python stack.
_MAX_BOX_DEPTH = 12
_MAX_EBML_DEPTH = 12
# Header structs (``tkhd``, sample entries) are tiny; guard against a bogus
# size claiming the whole file.
_MAX_HEADER_PAYLOAD = 1024 * 1024
_WIDTH_HEIGHT_UNSET = (0, 0)
@functools.lru_cache(maxsize=4096)
def _get_video_dimensions_cached(
path: str, _mtime_ns: int, _size: int
) -> Optional[Tuple[int, int]]:
"""Return ``(width, height)`` for ``path``, or ``None`` on any failure.
``_mtime_ns`` and ``_size`` participate in the cache key only so a replaced
file is re-probed; they are never read by the parser.
"""
try:
return _read_video_dimensions(path)
except Exception:
logger.debug("Failed to read video dimensions for %s", path, exc_info=True)
return None
def _read_video_dimensions(path: str) -> Optional[Tuple[int, int]]:
"""Dispatch to the ISO or EBML reader based on the container's magic bytes.
Real libraries contain files whose extension lies about their container
(a ``.mp4`` that is really WebM, typically), so the sniffed signature wins
and the extension is only a fallback.
"""
ext = os.path.splitext(path)[1].lower()
file_size = os.path.getsize(path)
with open(path, "rb") as stream:
magic = stream.read(12)
if _looks_like_iso_media(magic):
return _read_iso_media_dimensions(stream, file_size)
if magic[:4] == _EBML_MAGIC:
return _read_ebml_dimensions(stream, file_size)
if magic[:4] == b"RIFF" and magic[8:12] == b"WEBP":
return _read_riff_webp_dimensions(stream, file_size)
# Signature is inconclusive (truncated or unusual file): fall back to
# the extension.
if ext in EBML_MEDIA_EXTENSIONS:
return _read_ebml_dimensions(stream, file_size)
if ext in ISO_MEDIA_EXTENSIONS:
return _read_iso_media_dimensions(stream, file_size)
return None
def _looks_like_iso_media(magic: bytes) -> bool:
"""Return True when the leading bytes are an ISO base media box header."""
return len(magic) >= 8 and magic[4:8] in {
b"ftyp",
b"moov",
b"mdat",
b"free",
b"skip",
b"wide",
}
def get_video_dimensions(path: str) -> Optional[Tuple[int, int]]:
"""Return the intrinsic ``(width, height)`` of a local video file.
Returns ``None`` when the extension is unsupported, the file is missing or
corrupt, or the dimensions cannot be determined. Never raises.
"""
if not path:
return None
try:
stat = os.stat(path)
except OSError:
return None
return _get_video_dimensions_cached(path, stat.st_mtime_ns, stat.st_size)
def _clear_video_dimensions_cache() -> None:
"""Drop the dimension cache (used by tests)."""
_get_video_dimensions_cached.cache_clear()
# --------------------------------------------------------------------------- #
# ISO base media (MP4 / MOV)
# --------------------------------------------------------------------------- #
def _iter_boxes(
stream: BinaryIO, end: int, depth: int = 0
) -> Iterator[Tuple[bytes, int, int]]:
"""Yield ``(type, payload_start, box_end)`` for boxes in ``[tell, end)``.
The stream is left at the next box boundary after each yielded box.
"""
if depth > _MAX_BOX_DEPTH:
return
while True:
start = stream.tell()
if start + 8 > end:
return
header = stream.read(8)
if len(header) < 8:
return
size, box_type = struct.unpack(">I4s", header)
header_size = 8
if size == 1:
# 64-bit ``largesize`` follows the type.
extended = stream.read(8)
if len(extended) < 8:
return
size = struct.unpack(">Q", extended)[0]
header_size = 16
elif size == 0:
# Box extends to the end of the enclosing container.
size = end - start
if size < header_size or start + size > end:
return
yield box_type, start + header_size, start + size
stream.seek(start + size)
def _read_iso_media_dimensions(
stream: BinaryIO, file_size: int
) -> Optional[Tuple[int, int]]:
"""Walk ``moov`` looking for the video track's dimensions."""
stream.seek(0)
moov: Optional[Tuple[int, int]] = None
for box_type, payload_start, box_end in _iter_boxes(stream, file_size):
if box_type == b"moov":
moov = (payload_start, box_end)
break
if moov is None:
return None
stream.seek(moov[0])
for box_type, payload_start, box_end in _iter_boxes(stream, moov[1], depth=1):
if box_type != b"trak":
continue
dimensions = _read_trak_dimensions(stream, payload_start, box_end)
if dimensions is not None:
return dimensions
return None
def _read_trak_dimensions(
stream: BinaryIO, trak_start: int, trak_end: int
) -> Optional[Tuple[int, int]]:
"""Return the dimensions of a ``trak`` when it describes a video track."""
stream.seek(trak_start)
is_video = False
tkhd_dimensions = _WIDTH_HEIGHT_UNSET
stsd_dimensions = _WIDTH_HEIGHT_UNSET
for box_type, payload_start, box_end in _iter_boxes(stream, trak_end, depth=2):
if box_type == b"tkhd":
tkhd_dimensions = _parse_tkhd(stream, payload_start, box_end)
elif box_type == b"mdia":
stream.seek(payload_start)
media = _read_mdia_dimensions(stream, payload_start, box_end)
if media is not None:
is_video, stsd_dimensions = media
if not is_video:
return None
# ``tkhd`` is preferred: it is display space, and its 16.16 fixed point
# encoding keeps non-integer dimensions (odd crops produce those).
for width, height in (tkhd_dimensions, stsd_dimensions):
if width > 0 and height > 0:
return int(round(width)), int(round(height))
return None
def _read_mdia_dimensions(
stream: BinaryIO, mdia_start: int, mdia_end: int
) -> Optional[Tuple[bool, Tuple[float, float]]]:
"""Return ``(is_video, dimensions)`` for a ``mdia`` box."""
handler_type = b""
stsd_dimensions = _WIDTH_HEIGHT_UNSET
for box_type, payload_start, box_end in _iter_boxes(stream, mdia_end, depth=3):
if box_type == b"hdlr":
handler_type = _parse_handler_type(stream, payload_start, box_end)
elif box_type == b"minf":
stream.seek(payload_start)
stsd_dimensions = _read_minf_dimensions(stream, payload_start, box_end)
return handler_type == b"vide", stsd_dimensions
def _read_minf_dimensions(
stream: BinaryIO, minf_start: int, minf_end: int
) -> Tuple[float, float]:
"""Return the sample-entry dimensions declared under ``minf/stbl/stsd``."""
for box_type, payload_start, box_end in _iter_boxes(stream, minf_end, depth=4):
if box_type != b"stbl":
continue
stream.seek(payload_start)
for inner_type, inner_start, inner_end in _iter_boxes(
stream, box_end, depth=5
):
if inner_type == b"stsd":
return _parse_stsd(stream, inner_start, inner_end)
return _WIDTH_HEIGHT_UNSET
def _parse_tkhd(
stream: BinaryIO, payload_start: int, box_end: int
) -> Tuple[float, float]:
"""Parse the 16.16 fixed point width/height trailer of a ``tkhd`` box."""
size = box_end - payload_start
if size < 8 or size > _MAX_HEADER_PAYLOAD:
return _WIDTH_HEIGHT_UNSET
stream.seek(box_end - 8)
trailer = stream.read(8)
if len(trailer) < 8:
return _WIDTH_HEIGHT_UNSET
width, height = struct.unpack(">II", trailer)
return width / 65536.0, height / 65536.0
def _parse_handler_type(
stream: BinaryIO, payload_start: int, box_end: int
) -> bytes:
"""Parse the handler type from an ``hdlr`` box.
Layout: version/flags (4) + pre_defined (4) + handler_type (4).
"""
if box_end - payload_start < 12:
return b""
stream.seek(payload_start)
data = stream.read(12)
if len(data) < 12:
return b""
return data[8:12]
def _parse_stsd(
stream: BinaryIO, payload_start: int, box_end: int
) -> Tuple[float, float]:
"""Parse the visual sample entry dimensions from an ``stsd`` box.
Only the first entry is inspected: video tracks are single-entry in every
container we import from.
"""
if box_end - payload_start < 16:
return _WIDTH_HEIGHT_UNSET
stream.seek(payload_start)
header = stream.read(8) # version/flags + entry_count
if len(header) < 8:
return _WIDTH_HEIGHT_UNSET
entry_start = payload_start + 8
if entry_start + 8 > box_end:
return _WIDTH_HEIGHT_UNSET
stream.seek(entry_start)
entry_header = stream.read(8)
if len(entry_header) < 8:
return _WIDTH_HEIGHT_UNSET
entry_size = struct.unpack(">I", entry_header[:4])[0]
header_size = 8
if entry_size == 1:
extended = stream.read(8)
if len(extended) < 8:
return _WIDTH_HEIGHT_UNSET
entry_size = struct.unpack(">Q", extended)[0]
header_size = 16
elif entry_size == 0:
entry_size = box_end - entry_start
if entry_size < header_size + 8 or entry_start + entry_size > box_end:
return _WIDTH_HEIGHT_UNSET
# Visual sample entries: 6 bytes reserved + 2 bytes data_reference_index,
# then width (2) and height (2).
stream.seek(entry_start + header_size + 6 + 2)
dimensions = stream.read(4)
if len(dimensions) < 4:
return _WIDTH_HEIGHT_UNSET
width, height = struct.unpack(">HH", dimensions)
return float(width), float(height)
# --------------------------------------------------------------------------- #
# WebM / Matroska (EBML)
# --------------------------------------------------------------------------- #
# EBML element IDs (stored with their length marker, as they appear on disk).
_ID_SEGMENT = 0x18538067
_ID_TRACKS = 0x1654AE6B
_ID_TRACK_ENTRY = 0xAE
_ID_TRACK_TYPE = 0x83
_ID_VIDEO = 0xE0
_ID_PIXEL_WIDTH = 0xB0
_ID_PIXEL_HEIGHT = 0xBA
# Nested containers we descend into while hunting for video dimensions.
_EBML_CONTAINER_IDS = frozenset({_ID_SEGMENT, _ID_TRACKS, _ID_TRACK_ENTRY})
def _read_ebml_vint(stream: BinaryIO, *, keep_marker: bool) -> Optional[Tuple[int, int]]:
"""Read an EBML variable-length integer.
Returns ``(value, byte_length)``. For element IDs the marker bit is kept
(``keep_marker=True``) because IDs are compared in their on-disk form; for
sizes the marker is stripped to yield the actual payload length.
"""
first = stream.read(1)
if not first:
return None
first_byte = first[0]
if first_byte == 0:
return None
length = 1
mask = 0x80
while not first_byte & mask:
mask >>= 1
length += 1
if length > 8:
return None
value = first_byte if keep_marker else first_byte & (mask - 1)
remaining = length - 1
if remaining:
extra = stream.read(remaining)
if len(extra) < remaining:
return None
for byte in extra:
value = (value << 8) | byte
return value, length
def _read_ebml_dimensions(
stream: BinaryIO, file_size: int
) -> Optional[Tuple[int, int]]:
"""Parse ``Segment/Tracks`` for the first video ``TrackEntry``."""
stream.seek(0)
header = stream.read(4)
if header != _EBML_MAGIC:
return None
return _walk_ebml(stream, 0, file_size, depth=0)
def _walk_ebml(
stream: BinaryIO, start: int, end: int, *, depth: int
) -> Optional[Tuple[int, int]]:
"""Recursively scan EBML elements in ``[start, end)`` for video dimensions."""
if depth > _MAX_EBML_DEPTH:
return None
stream.seek(start)
while stream.tell() < end:
element_start = stream.tell()
element_id = _read_ebml_vint(stream, keep_marker=True)
if element_id is None:
return None
element_id_value = element_id[0]
size_field = _read_ebml_vint(stream, keep_marker=False)
if size_field is None:
return None
payload_size, size_length = size_field
payload_start = element_start + element_id[1] + size_length
# A size field of all-ones marks an unknown-size element, which is
# legal for Segment/Tracks; treat it as "until the parent ends".
unknown_size = payload_size == (1 << (7 * size_length)) - 1
payload_end = end if unknown_size else payload_start + payload_size
if payload_end > end:
return None
if element_id_value == _ID_VIDEO:
dimensions = _read_ebml_video(stream, payload_start, min(payload_end, end))
if dimensions is not None:
return dimensions
elif element_id_value == _ID_TRACK_ENTRY:
track = _read_ebml_track_entry(
stream, payload_start, min(payload_end, end)
)
if track is not None:
return track
elif element_id_value in _EBML_CONTAINER_IDS:
found = _walk_ebml(
stream, payload_start, min(payload_end, end), depth=depth + 1
)
if found is not None:
return found
if unknown_size:
# Cannot resume after an unknown-size element; its siblings cannot
# be located reliably, so stop scanning this level.
return None
stream.seek(payload_end)
return None
def _read_ebml_track_entry(
stream: BinaryIO, start: int, end: int
) -> Optional[Tuple[int, int]]:
"""Return dimensions when a ``TrackEntry`` is a video track."""
track_type: Optional[int] = None
dimensions: Optional[Tuple[int, int]] = None
stream.seek(start)
while stream.tell() < end:
element_start = stream.tell()
element_id = _read_ebml_vint(stream, keep_marker=True)
if element_id is None:
return None
size_field = _read_ebml_vint(stream, keep_marker=False)
if size_field is None:
return None
payload_size, size_length = size_field
payload_start = element_start + element_id[1] + size_length
payload_end = min(payload_start + payload_size, end)
if element_id[0] == _ID_TRACK_TYPE:
track_type = _read_ebml_uint(stream, payload_start, payload_end)
elif element_id[0] == _ID_VIDEO:
dimensions = _read_ebml_video(stream, payload_start, payload_end)
stream.seek(payload_end)
# Track type 1 is video.
if track_type == 1 and dimensions is not None:
return dimensions
return None
def _read_ebml_video(
stream: BinaryIO, start: int, end: int
) -> Optional[Tuple[int, int]]:
"""Return ``PixelWidth``/``PixelHeight`` from a ``Video`` element."""
width: Optional[int] = None
height: Optional[int] = None
stream.seek(start)
while stream.tell() < end:
element_start = stream.tell()
element_id = _read_ebml_vint(stream, keep_marker=True)
if element_id is None:
return None
size_field = _read_ebml_vint(stream, keep_marker=False)
if size_field is None:
return None
payload_size, size_length = size_field
payload_start = element_start + element_id[1] + size_length
payload_end = min(payload_start + payload_size, end)
if element_id[0] == _ID_PIXEL_WIDTH:
width = _read_ebml_uint(stream, payload_start, payload_end)
elif element_id[0] == _ID_PIXEL_HEIGHT:
height = _read_ebml_uint(stream, payload_start, payload_end)
stream.seek(payload_end)
if width and height and width > 0 and height > 0:
return width, height
return None
def _read_ebml_uint(stream: BinaryIO, start: int, end: int) -> Optional[int]:
"""Read an unsigned big-endian integer element payload."""
length = end - start
if length <= 0 or length > 8:
return None
stream.seek(start)
raw = stream.read(length)
if len(raw) < length:
return None
value = 0
for byte in raw:
value = (value << 8) | byte
return value
# --------------------------------------------------------------------------- #
# RIFF / WebP (animated examples are often renamed to ``.mp4``)
# --------------------------------------------------------------------------- #
def _read_riff_webp_dimensions(
stream: BinaryIO, file_size: int
) -> Optional[Tuple[int, int]]:
"""Return dimensions from a WebP file's first dimension-bearing chunk."""
stream.seek(12)
while stream.tell() + 8 <= file_size:
header = stream.read(8)
if len(header) < 8:
return None
fourcc, chunk_size = struct.unpack("<4sI", header)
payload_start = stream.tell()
if fourcc == b"VP8X":
payload = stream.read(10)
if len(payload) < 10:
return None
# Canvas size is stored minus one, as 24-bit little endian values.
width = int.from_bytes(payload[4:7], "little") + 1
height = int.from_bytes(payload[7:10], "little") + 1
return width, height
if fourcc == b"VP8 ":
# Frame tag (3 bytes, bit 0 = key frame) then the key frame start
# code 0x9d 0x01 0x2a and the 16-bit dimensions.
payload = stream.read(10)
if len(payload) < 10:
return None
start = payload.find(b"\x9d\x01\x2a")
if start < 0 or start + 7 > len(payload):
return None
width, height = struct.unpack("<HH", payload[start + 3 : start + 7])
return width & 0x3FFF, height & 0x3FFF
if fourcc == b"VP8L":
payload = stream.read(5)
if len(payload) < 5 or payload[0] != 0x2F:
return None
bits = int.from_bytes(payload[1:5], "little")
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
# Skip this chunk (payloads are padded to an even byte boundary).
stream.seek(payload_start + chunk_size + (chunk_size & 1))
return None
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.2.2" version = "1.2.3"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",
+2 -3
View File
@@ -225,10 +225,9 @@ def main() -> int:
args = parser.parse_args() args = parser.parse_args()
# Get project root (parent of .agents directory) # Get project root: this script lives in <project_root>/scripts/e2e/.
script_dir = os.path.dirname(os.path.abspath(__file__)) script_dir = os.path.dirname(os.path.abspath(__file__))
skill_dir = os.path.dirname(script_dir) project_root = os.path.dirname(os.path.dirname(script_dir))
project_root = os.path.dirname(os.path.dirname(os.path.dirname(skill_dir)))
managed_pids = read_managed_pids(args.port) managed_pids = read_managed_pids(args.port)
+42
View File
@@ -118,6 +118,44 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Banner Pager (cycles through multiple active banners) */
.banner-pager {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
margin-left: var(--space-2);
}
.banner-pager-btn {
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-base);
font-size: 0.75em;
padding: 0;
}
.banner-pager-btn:hover {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
}
.banner-pager-indicator {
font-size: 0.8em;
color: var(--text-muted);
min-width: 2.8em;
text-align: center;
font-variant-numeric: tabular-nums;
}
/* Dismiss Button */ /* Dismiss Button */
.banner-dismiss { .banner-dismiss {
position: absolute; position: absolute;
@@ -184,6 +222,10 @@
justify-content: flex-start; justify-content: flex-start;
} }
.banner-pager {
margin-left: 0;
}
.banner-action { .banner-action {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -31,6 +31,10 @@
/* Textarea Styling */ /* Textarea Styling */
#batchUrlInput { #batchUrlInput {
width: 100%; width: 100%;
/* Content-box sizing made the border box wider than the modal's content
box, so the right border/halo fell outside the clipped area and was cut
off. Include padding and border in the declared width. */
box-sizing: border-box;
min-height: 120px; min-height: 120px;
padding: 12px; padding: 12px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
+30
View File
@@ -97,6 +97,32 @@
width: 0%; width: 0%;
} }
/* The transfer is done but the backend is still indexing the file and reading
the model site's API. A sheen over the full bar reads as "busy" where a
motionless 100% bar reads as "stuck". */
.current-item-bar.is-indeterminate {
position: relative;
overflow: hidden;
}
.current-item-bar.is-indeterminate::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.5) 50%,
transparent 100%
);
animation: progress-sheen 1.2s ease-in-out infinite;
}
@keyframes progress-sheen {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
.current-item-percent { .current-item-percent {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-color-secondary, var(--text-color)); color: var(--text-color-secondary, var(--text-color));
@@ -131,4 +157,8 @@
.current-item-bar { .current-item-bar {
transition: none; transition: none;
} }
.current-item-bar.is-indeterminate::after {
animation: none;
}
} }
+28 -1
View File
@@ -46,8 +46,20 @@
pointer-events: none; pointer-events: none;
} }
/* Destructive entries. The token used to be the nonexistent `--danger-color`,
which made the declaration invalid at computed-value time: the colour then
fell back to the menu's inherited text colour, so every "Delete …" entry in
the folder and model-card context menus rendered plain. */
.context-menu-item.delete-item { .context-menu-item.delete-item {
color: var(--danger-color); color: var(--lora-error);
}
/* The shared .context-menu-item:hover paints the accent background, which the
red label does not read against destructive entries get their own wash. */
.context-menu-item.delete-item:hover,
.context-menu-item.delete-item:focus-visible {
background-color: var(--lora-error-bg);
color: var(--lora-error);
} }
.context-menu-item i { .context-menu-item i {
@@ -55,6 +67,21 @@
text-align: center; text-align: center;
} }
/* Muted counter shown next to a menu label (e.g. how many empty folders the
"Show empty folders" toggle would reveal) */
.context-menu-count {
color: var(--text-muted);
font-size: 12px;
}
/* The count keeps the label/tally muted even while the row is hovered, since
the accent background would otherwise wash the muted colour out. */
.context-menu-item:hover .context-menu-count,
.context-menu-item:focus-visible .context-menu-count {
color: var(--lora-text);
opacity: 0.8;
}
/* Section Headers */ /* Section Headers */
.context-menu-section-header { .context-menu-section-header {
padding: 6px 12px 2px; padding: 6px 12px 2px;
@@ -27,6 +27,12 @@
justify-content: center; justify-content: center;
} }
/* Self-managed by SettingsManager: stacks above the settings modal like the
directory picker (settings panels sit at 10000/10002). */
#filenameTemplateConfirmModal {
z-index: 10010;
}
.delete-modal-content { .delete-modal-content {
max-width: 500px; max-width: 500px;
width: 90%; width: 90%;
@@ -0,0 +1,179 @@
/* Directory Picker Modal */
/* Stacks above the settings modal: settings tooltips/combobox panels sit at
10000/10002, so 10010 keeps the picker on top of everything settings-side. */
#directoryPickerModal {
z-index: 10010;
}
.directory-picker-content {
max-width: 560px;
display: flex;
flex-direction: column;
}
.directory-picker-content h3 {
color: var(--text-color);
margin-bottom: var(--space-2);
}
/* Manual path row */
#directoryPickerModal .directory-picker-path-row {
display: flex;
gap: 8px;
margin-bottom: var(--space-2);
}
#directoryPickerModal .directory-picker-path-row input {
flex: 1;
min-width: 0;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-family: inherit;
font-size: 0.9em;
}
#directoryPickerModal .directory-picker-path-row input:focus {
outline: none;
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
}
/* Directory browser (class names shared with the batch import browser) */
#directoryPickerModal .directory-browser {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--lora-surface);
overflow: hidden;
}
#directoryPickerModal .browser-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--bg-color);
border-bottom: 1px solid var(--border-color);
}
#directoryPickerModal .back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--card-bg);
color: var(--text-color);
cursor: pointer;
transition: var(--transition-base);
}
#directoryPickerModal .back-btn:hover {
border-color: var(--lora-accent);
background: var(--bg-color);
}
#directoryPickerModal .back-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#directoryPickerModal .current-path {
flex: 1;
padding: 6px 10px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
font-size: 0.9em;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#directoryPickerModal .browser-content {
max-height: 300px;
overflow-y: auto;
padding: 12px;
}
#directoryPickerModal .folder-list {
display: flex;
flex-direction: column;
gap: 4px;
}
#directoryPickerModal .folder-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: var(--transition-base);
border: 1px solid transparent;
}
#directoryPickerModal .folder-item:hover {
background: var(--lora-surface-hover, oklch(from var(--lora-accent) l c h / 0.1));
border-color: var(--lora-accent);
}
#directoryPickerModal .folder-item i {
color: #fbbf24;
font-size: 1.1em;
}
#directoryPickerModal .item-name {
flex: 1;
font-size: 0.9em;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#directoryPickerModal .browser-footer {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 10px 12px;
background: var(--bg-color);
border-top: 1px solid var(--border-color);
}
#directoryPickerModal .directory-picker-error {
margin-top: 8px;
padding: 8px 10px;
border-radius: var(--border-radius-xs);
background: oklch(from var(--lora-error) l c h / 0.12);
color: var(--lora-error);
font-size: 0.85em;
word-break: break-word;
}
#directoryPickerModal .directory-picker-empty {
padding: var(--space-2);
text-align: center;
color: var(--text-color);
opacity: 0.6;
font-size: 0.9em;
}
/* Dark theme adjustments */
[data-theme="dark"] #directoryPickerModal .directory-browser {
background: var(--card-bg);
}
[data-theme="dark"] #directoryPickerModal .browser-header,
[data-theme="dark"] #directoryPickerModal .browser-footer {
background: var(--lora-surface);
}
[data-theme="dark"] #directoryPickerModal .folder-item i {
color: #fcd34d;
}
@@ -12,6 +12,10 @@
.input-group input, .input-group input,
.input-group select { .input-group select {
width: 100%; width: 100%;
/* Include padding/border in the declared width so full-width fields do not
spill past the modal's content box, where their right border gets
clipped by the step's overflow-x: hidden. */
box-sizing: border-box;
padding: 8px; padding: 8px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
@@ -720,6 +724,9 @@
/* Textarea for multi-URL input */ /* Textarea for multi-URL input */
#modelUrl { #modelUrl {
width: 100%; width: 100%;
/* Content-box sizing pushed the border box 2px past the step's content
edge, clipping the right border. Include padding/border in the width. */
box-sizing: border-box;
padding: 8px; padding: 8px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
@@ -768,6 +775,16 @@
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
/* Fields sit flush against the scrollable step's content edge; the global
focus outline (offset: 2px) has its left/right edges clipped by the step's
overflow-x. Draw the ring inset so the full outline stays visible.
(Same fix as #importModal in import-modal.css.) */
#downloadModal input:focus-visible,
#downloadModal select:focus-visible,
#downloadModal textarea:focus-visible {
outline-offset: -2px;
}
#downloadModal .download-step .modal-actions { #downloadModal .download-step .modal-actions {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
+119 -3
View File
@@ -747,13 +747,13 @@
} }
.priority-tags-input.settings-input-error { .priority-tags-input.settings-input-error {
border-color: var(--danger-color, #dc2626); border-color: var(--lora-error);
box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12); box-shadow: 0 0 0 2px rgba(from var(--lora-error) r g b / 0.12);
} }
.settings-input-error-message { .settings-input-error-message {
font-size: 0.8em; font-size: 0.8em;
color: var(--danger-color, #dc2626); color: var(--lora-error);
display: none; display: none;
} }
@@ -1692,6 +1692,87 @@ input:checked + .toggle-slider:before {
color: white; color: white;
} }
/* Browse (directory picker) button boxed accent style used on the dynamic
extra-folder-path / model-path rows, mirroring .remove-path-btn. Static
path fields use the .inset variant below instead. */
#settingsModal .browse-path-btn {
width: 32px;
height: 32px;
padding: 0;
border-radius: var(--border-radius-xs);
border: 1px solid var(--lora-accent);
background: transparent;
color: var(--lora-accent);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-base);
flex-shrink: 0;
}
#settingsModal .browse-path-btn:hover {
background: var(--lora-accent);
color: white;
}
/* Inset variant (static path fields): the button floats inside the right
edge of the input, so the setting row keeps its single-control look and
narrow columns never push it onto a second line. */
#settingsModal .browse-path-btn.inset {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-color);
opacity: 0.55;
}
#settingsModal .browse-path-btn.inset:hover {
background: transparent;
color: var(--lora-accent);
opacity: 1;
}
#settingsModal input.has-inset-browse {
padding-right: 34px;
}
/* Advisory path validation feedback (wraps below the input row) */
#settingsModal .text-input-wrapper,
#settingsModal .path-control {
flex-wrap: wrap;
}
#settingsModal .path-control > .text-input-wrapper {
flex: 1;
min-width: 0;
}
.path-validation {
display: none;
flex-basis: 100%;
width: 100%;
margin-top: 4px;
font-size: 0.8em;
line-height: 1.4;
color: var(--lora-error);
}
.path-validation.visible {
display: flex;
align-items: center;
gap: 6px;
}
.path-validation.valid {
color: var(--lora-success);
}
/* Highlight animation for setting items targeted from Doctor actions */ /* Highlight animation for setting items targeted from Doctor actions */
@keyframes settings-highlight-pulse { @keyframes settings-highlight-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(from var(--lora-accent) r g b / 0.4); } 0%, 100% { box-shadow: 0 0 0 0 rgba(from var(--lora-accent) r g b / 0.4); }
@@ -1780,3 +1861,38 @@ input:checked + .toggle-slider:before {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
/* Standalone Model Paths: pending-restart cues */
.settings-nav-item.has-pending-restart {
position: relative;
}
.settings-nav-item.has-pending-restart::after {
content: '';
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--lora-warning, #e67e22);
}
.model-paths-restart-notice {
display: none;
margin-top: 8px;
padding: 10px 14px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--lora-warning, #e67e22);
background: rgba(230, 126, 34, 0.08);
color: var(--lora-warning, #e67e22);
font-size: 0.85em;
line-height: 1.4;
align-items: center;
gap: 8px;
}
.model-paths-restart-notice.visible {
display: flex;
}
+32
View File
@@ -28,6 +28,38 @@
width: 100%; width: 100%;
} }
/* Tags row: the base model badge shares one line with the compact tags. */
.recipe-tags-row {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
}
.recipe-tags-row #recipeTagsContainer {
flex: 1;
min-width: 0;
}
/* Header base model badge: reuses the card .base-model-label pill shape but
swaps the on-image overlay styling (text shadow, backdrop blur) for the
accent-tinted chip look used by resource rows in this modal. */
.recipe-base-model-badge {
flex-shrink: 0;
max-width: 160px;
text-shadow: none;
backdrop-filter: none;
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
padding: 2px 8px;
}
.recipe-base-model-badge.is-unknown {
background: var(--surface-subtle);
color: var(--text-color);
opacity: 0.6;
}
.recipe-modal-header h2 { .recipe-modal-header h2 {
margin: 0 0 var(--space-1); margin: 0 0 var(--space-1);
padding: var(--space-1); padding: var(--space-1);
+76 -10
View File
@@ -92,38 +92,104 @@
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
padding: 4px 8px; padding: 4px 8px;
position: relative; position: relative;
cursor: grab;
transition: transform 0.18s ease; transition: transform 0.18s ease;
} }
.metadata-item:active { /* --- Shared chip reordering (tags + trigger words) ------------------------ */
/* Chips in a list that is actually sortable advertise the grab gesture only
then, so lists that cannot be reordered never lie about it. */
.metadata-items.pointer-sort-enabled .metadata-item {
cursor: grab;
}
.metadata-items.pointer-sort-enabled .metadata-item:active {
cursor: grabbing; cursor: grabbing;
} }
.metadata-item-dragging { /* Grip handle: always in the DOM, revealed when the list is sortable */
.reorder-handle {
display: none;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
margin-left: -2px;
border: none;
background: transparent;
color: var(--text-color);
opacity: 0.4;
font-size: 0.8em;
line-height: 1;
cursor: grab;
/* Keep a touch drag on the handle from scrolling the surrounding panel */
touch-action: none;
user-select: none;
transition: opacity 0.2s ease, color 0.2s ease;
}
.has-sortable-words .reorder-handle {
display: inline-flex;
}
/* Tag chips have no flex gap (unlike trigger word tags), so the grip needs its
own spacing before the tag text */
.metadata-item .reorder-handle {
margin-right: 4px;
}
.reorder-handle:hover {
opacity: 0.9;
color: var(--lora-accent);
}
.reorder-handle:active {
cursor: grabbing;
}
/* Hint shown in the edit controls row while reordering is available */
.reorder-hint {
display: none;
align-items: center;
gap: 4px;
margin-right: auto;
font-size: 0.75em;
color: var(--text-color);
opacity: 0.6;
white-space: nowrap;
}
.has-sortable-words .reorder-hint {
display: inline-flex;
}
/* Snapped-to-grid transition for the remaining chips while dragging */
.reorder-sorting > * {
transition: transform 0.18s ease;
}
/* The lifted chip that follows the pointer */
.reorder-dragging {
box-shadow: var(--shadow-dialog); box-shadow: var(--shadow-dialog);
cursor: grabbing; cursor: grabbing;
opacity: 0.95; opacity: 0.95;
transition: none; transition: none;
} }
.metadata-item-placeholder { /* Drop target left behind by the lifted chip */
.reorder-placeholder {
border: 1px dashed var(--lora-accent); border: 1px dashed var(--lora-accent);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
pointer-events: none; pointer-events: none;
} }
.metadata-items-sorting .metadata-item { body.reorder-drag-active {
transition: transform 0.18s ease;
}
body.metadata-drag-active {
user-select: none; user-select: none;
cursor: grabbing; cursor: grabbing;
} }
body.metadata-drag-active * { body.reorder-drag-active * {
cursor: grabbing !important; cursor: grabbing !important;
} }
+20 -118
View File
@@ -639,90 +639,40 @@
display: inline; display: inline;
} }
/* Create folder drop zone */ /* Create folder inline row: rendered inside the tree at the creation
.sidebar-create-folder-zone { location, styled like a regular node row with a full-width input */
position: absolute; .sidebar-create-folder-row {
bottom: 16px; padding-top: 4px;
left: 16px; padding-bottom: 4px;
right: 16px; cursor: default;
padding: 16px; }
border: 2px dashed oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4);
border-radius: var(--border-radius-xs); .sidebar-tree-node-content.sidebar-create-folder-row:hover,
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08); .sidebar-node-content.sidebar-create-folder-row:hover {
background: transparent;
color: var(--text-color);
}
.sidebar-create-folder-spacer {
opacity: 0; opacity: 0;
transform: translateY(10px);
transition: var(--transition-base);
pointer-events: none; pointer-events: none;
z-index: 10;
} }
.sidebar-create-folder-zone.active { .sidebar-create-folder-row .sidebar-tree-folder-icon,
opacity: 1; .sidebar-create-folder-row .sidebar-folder-icon {
transform: translateY(0);
}
.sidebar-create-folder-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--lora-accent); color: var(--lora-accent);
font-size: 0.85em; opacity: 0.9;
text-align: center;
}
.sidebar-create-folder-content i {
font-size: 1.5em;
opacity: 0.8;
}
/* Create folder input container */
.sidebar-create-folder-input-container {
/* Sticky footer inside the scroll container: always visible at the
bottom of the viewport regardless of tree scroll position */
position: sticky;
bottom: 8px;
margin: 8px 16px 0;
padding: 12px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
box-shadow: var(--shadow-lg);
z-index: 20;
animation: slideUp 0.2s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.sidebar-create-folder-input-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-create-folder-input-wrapper > i {
color: var(--lora-accent);
font-size: 1em;
} }
.sidebar-create-folder-input { .sidebar-create-folder-input {
flex: 1; flex: 1;
min-width: 0; /* allow the input to shrink below its intrinsic width */ min-width: 0; /* allow the input to shrink below its intrinsic width */
padding: 6px 10px; padding: 4px 8px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
background: var(--bg-color); background: var(--bg-color);
color: var(--text-color); color: var(--text-color);
font-size: 0.85em; font-size: 1em;
outline: none; outline: none;
transition: var(--transition-base); transition: var(--transition-base);
} }
@@ -732,49 +682,6 @@
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15); box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
} }
.sidebar-create-folder-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: var(--transition-base);
background: transparent;
color: var(--text-muted);
}
.sidebar-create-folder-btn:hover,
.sidebar-create-folder-btn:focus-visible {
background: var(--lora-surface);
color: var(--text-color);
outline: none;
}
.sidebar-create-folder-confirm:hover,
.sidebar-create-folder-confirm:focus-visible {
background: oklch(from var(--success-color) l c h / 0.15);
color: var(--success-color);
outline: none;
}
.sidebar-create-folder-cancel:hover,
.sidebar-create-folder-cancel:focus-visible {
background: oklch(from var(--error-color) l c h / 0.15);
color: var(--error-color);
outline: none;
}
.sidebar-create-folder-hint {
margin-top: 6px;
font-size: 0.75em;
color: var(--text-muted);
text-align: center;
opacity: 0.8;
}
/* Dragging state for sidebar */ /* Dragging state for sidebar */
.folder-sidebar.dragging-active { .folder-sidebar.dragging-active {
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.5); border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.5);
@@ -786,11 +693,6 @@
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02); background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02);
} }
/* Tree container positioning for create folder elements */
.sidebar-tree-container {
position: relative;
}
/* Folder context menu - positioned relative to sidebar */ /* Folder context menu - positioned relative to sidebar */
#sidebarFolderContextMenu { #sidebarFolderContextMenu {
z-index: var(--z-modal, 1002); z-index: var(--z-modal, 1002);
+1
View File
@@ -18,6 +18,7 @@
@import 'components/modal/example-access-modal.css'; @import 'components/modal/example-access-modal.css';
@import 'components/modal/support-modal.css'; @import 'components/modal/support-modal.css';
@import 'components/modal/download-modal.css'; @import 'components/modal/download-modal.css';
@import 'components/modal/directory-picker-modal.css';
@import 'components/toast.css'; @import 'components/toast.css';
@import 'components/loading.css'; @import 'components/loading.css';
@import 'components/menu.css'; @import 'components/menu.css';
+5
View File
@@ -84,6 +84,8 @@ export function getApiEndpoints(modelType) {
moveModel: `/api/lm/${modelType}/move_model`, moveModel: `/api/lm/${modelType}/move_model`,
moveBulk: `/api/lm/${modelType}/move_models_bulk`, moveBulk: `/api/lm/${modelType}/move_models_bulk`,
createFolder: `/api/lm/${modelType}/create-folder`, createFolder: `/api/lm/${modelType}/create-folder`,
deleteFolder: `/api/lm/${modelType}/delete-folder`,
renameFolder: `/api/lm/${modelType}/rename-folder`,
// CivitAI integration // CivitAI integration
fetchCivitai: `/api/lm/${modelType}/fetch-civitai`, fetchCivitai: `/api/lm/${modelType}/fetch-civitai`,
@@ -120,6 +122,9 @@ export function getApiEndpoints(modelType) {
autoOrganize: `/api/lm/${modelType}/auto-organize`, autoOrganize: `/api/lm/${modelType}/auto-organize`,
autoOrganizeProgress: `/api/lm/${modelType}/auto-organize-progress`, autoOrganizeProgress: `/api/lm/${modelType}/auto-organize-progress`,
// Filename template operations
applyFilenameTemplate: `/api/lm/${modelType}/apply-filename-template`,
// Model-specific endpoints (will be merged with specific configs) // Model-specific endpoints (will be merged with specific configs)
specific: {} specific: {}
}; };
+194
View File
@@ -1330,6 +1330,71 @@ export class BaseModelApiClient {
return result; return result;
} }
/**
* Delete a model-free folder inside the library roots.
*
* Only model-free folders can be removed; the backend answers with a 409
* `not_empty`/`busy` conflict otherwise. Those codes are attached to the
* thrown Error (`code`, `manifest`) so callers can explain the refusal
* instead of showing a bare message.
*
* @param {string} folderPath Absolute business path of the folder
* @param {{dryRun?: boolean}} [options]
*/
async deleteFolder(folderPath, options = {}) {
const { dryRun = false } = options || {};
const response = await fetch(this.apiConfig.endpoints.deleteFolder, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ folder_path: folderPath, dry_run: dryRun })
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) {
const error = new Error(result.error || `Failed to delete folder`);
error.code = result.code || null;
error.manifest = result.manifest || null;
throw error;
}
return result;
}
/**
* Rename a folder inside the library roots.
*
* Works on folders that hold models too the backend re-keys the affected
* cache records instead of cascading. A name collision or a staged delete
* inside the subtree surfaces as a 409 conflict, attached to the thrown
* Error as `code`.
*
* @param {string} folderPath Absolute business path of the folder
* @param {string} newName New leaf name (a single path segment)
*/
async renameFolder(folderPath, newName) {
const response = await fetch(this.apiConfig.endpoints.renameFolder, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ folder_path: folderPath, new_name: newName })
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) {
const error = new Error(result.error || `Failed to rename folder`);
error.code = result.code || null;
throw error;
}
return result;
}
async fetchUnifiedFolderTree(options = {}) { async fetchUnifiedFolderTree(options = {}) {
try { try {
const { includeEmpty = false } = options; const { includeEmpty = false } = options;
@@ -2110,6 +2175,135 @@ export class BaseModelApiClient {
}); });
} }
/**
* Apply the configured download filename template to models, renaming their files
* @param {Array} filePaths - Optional array of file paths to rename. If not provided, applies to all models.
* @returns {Promise} - Promise that resolves when the operation is complete
*/
async applyFilenameTemplate(filePaths = null) {
let ws = null;
await state.loadingManager.showWithProgress(async (loading) => {
loading.showCancelButton(() => this.cancelTask());
try {
// Connect to WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
const operationComplete = new Promise((resolve, reject) => {
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type !== 'filename_template_progress') return;
switch (data.status) {
case 'started':
loading.setProgress(0);
const operationType = data.operation_type === 'bulk' ? 'selected models' : 'all models';
loading.setStatus(translate('loras.bulkOperations.filenameTemplateProgress.starting', { type: operationType }, `Applying filename template to ${operationType}...`));
break;
case 'processing':
const percent = data.total > 0 ? ((data.processed / data.total) * 90).toFixed(1) : 0;
loading.setProgress(percent);
loading.setStatus(
translate('loras.bulkOperations.filenameTemplateProgress.processing', {
processed: data.processed,
total: data.total,
success: data.success,
failures: data.failures,
skipped: data.skipped
}, `Processing (${data.processed}/${data.total}) - ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`)
);
break;
case 'completed':
loading.setProgress(100);
loading.setStatus(
translate('loras.bulkOperations.filenameTemplateProgress.completed', {
success: data.success,
skipped: data.skipped,
failures: data.failures,
total: data.total
}, `Completed: ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`)
);
setTimeout(() => {
resolve(data);
}, 1500);
break;
case 'cancelled':
loading.setStatus(translate('toast.api.operationCancelled', {}, 'Operation cancelled by user'));
resolve(data);
break;
case 'error':
loading.setStatus(translate('loras.bulkOperations.filenameTemplateProgress.error', { error: data.error }, `Error: ${data.error}`));
reject(new Error(data.error));
break;
}
};
ws.onerror = (error) => {
console.error('WebSocket error during filename template apply:', error);
reject(new Error('Connection error'));
};
});
// Start the filename template operation
const endpoint = this.apiConfig.endpoints.applyFilenameTemplate;
const requestBody = {};
if (filePaths) {
requestBody.file_paths = filePaths;
}
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to start filename template operation');
}
// Wait for the operation to complete via WebSocket
const result = await operationComplete;
// Show appropriate success message based on results
if (result.status === 'cancelled') {
showToast('toast.api.operationCancelledPartial', { success: result.success, total: result.total }, 'info');
} else if (result.failures === 0) {
showToast('toast.loras.filenameTemplateSuccess', {
count: result.success,
type: result.operation_type === 'bulk' ? 'selected models' : 'all models'
}, 'success');
} else {
showToast('toast.loras.filenameTemplatePartialSuccess', {
success: result.success,
failures: result.failures,
total: result.total
}, 'warning');
}
} catch (error) {
console.error('Error applying filename template:', error);
showToast('toast.loras.filenameTemplateFailed', { error: error.message }, 'error');
throw error;
} finally {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
}
}, {
initialMessage: translate('loras.bulkOperations.filenameTemplateProgress.initializing', {}, 'Initializing filename template apply...'),
completionMessage: translate('loras.bulkOperations.filenameTemplateProgress.complete', {}, 'Filename template apply complete')
});
}
async stopExampleImages() { async stopExampleImages() {
try { try {
const response = await fetch('/api/lm/stop-example-images', { const response = await fetch('/api/lm/stop-example-images', {
@@ -0,0 +1,206 @@
import { translate } from '../utils/i18nHelpers.js';
/**
* Reusable directory picker modal backed by POST /api/lm/browse-directory.
* Self-managed (NOT registered with ModalManager): it stacks above the
* settings modal, so ModalManager's "close current modal on open" behavior
* would kill the modal underneath.
*/
class DirectoryPickerModal {
constructor() {
this.isOpen = false;
this.currentPath = '';
this.parentPath = null;
this.onSelect = null;
this.elements = {};
this._bindings = [];
}
open({ initialPath = '', onSelect } = {}) {
this._cacheElements();
if (!this.elements.modal) {
console.warn('DirectoryPickerModal: #directoryPickerModal not found in DOM');
return;
}
this._unbindEvents();
this.onSelect = typeof onSelect === 'function' ? onSelect : null;
this.currentPath = '';
this.parentPath = null;
this._clearError();
this.elements.folderList.innerHTML = '';
this.elements.currentPathEl.textContent = '';
this.elements.upBtn.disabled = true;
this.elements.pathInput.value = initialPath || '';
this._bindEvents();
document.body.classList.add('modal-open');
this.elements.modal.style.display = 'block';
this.isOpen = true;
// An empty path lets the server pick its default (user home).
this.loadDirectory(initialPath || '');
}
close() {
if (!this.isOpen) return;
this.isOpen = false;
this._unbindEvents();
if (this.elements.modal) {
this.elements.modal.style.display = 'none';
}
this.onSelect = null;
// Keep body.modal-open: the settings modal underneath may still be open.
}
async loadDirectory(path) {
try {
const response = await fetch('/api/lm/browse-directory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
});
const data = await response.json();
if (data.success) {
this._clearError();
this._renderDirectory(data);
} else {
this._showError(data.error || translate('settings.directoryPicker.loadError', {}, 'Failed to load directory'));
}
} catch (error) {
console.error('Error loading directory:', error);
this._showError(translate('settings.directoryPicker.loadError', {}, 'Failed to load directory'));
}
}
_cacheElements() {
const modal = document.getElementById('directoryPickerModal');
this.elements = {
modal,
closeBtn: document.getElementById('directoryPickerCloseBtn'),
pathInput: document.getElementById('directoryPickerPathInput'),
goBtn: document.getElementById('directoryPickerGoBtn'),
upBtn: document.getElementById('directoryPickerUpBtn'),
currentPathEl: document.getElementById('directoryPickerCurrentPath'),
folderList: document.getElementById('directoryPickerFolderList'),
errorEl: document.getElementById('directoryPickerError'),
selectBtn: document.getElementById('directoryPickerSelectBtn')
};
}
_bind(target, type, handler, options) {
target.addEventListener(type, handler, options);
this._bindings.push([target, type, handler, options]);
}
_bindEvents() {
const { modal, closeBtn, pathInput, goBtn, upBtn, selectBtn } = this.elements;
this._bind(closeBtn, 'click', () => this.close());
this._bind(goBtn, 'click', () => this.loadDirectory(pathInput.value.trim()));
this._bind(pathInput, 'keydown', (event) => {
if (event.key === 'Enter') {
this.loadDirectory(pathInput.value.trim());
}
});
this._bind(upBtn, 'click', () => {
// Server-provided parent_path: Windows paths cannot be derived client-side.
if (this.parentPath) {
this.loadDirectory(this.parentPath);
}
});
this._bind(selectBtn, 'click', () => this._selectCurrent());
// Capture phase + stopPropagation so an ESC here never reaches the
// settings modal's own ESC handler underneath.
this._bind(document, 'keydown', (event) => {
if (event.key === 'Escape') {
event.stopPropagation();
this.close();
}
}, true);
// Backdrop click (the .modal element itself, not its content).
this._bind(modal, 'click', (event) => {
if (event.target === modal) {
this.close();
}
});
}
_unbindEvents() {
for (const [target, type, handler, options] of this._bindings) {
target.removeEventListener(type, handler, options);
}
this._bindings = [];
}
_renderDirectory(data) {
this.currentPath = data.current_path || '';
this.parentPath = data.parent_path || null;
this.elements.currentPathEl.textContent = this.currentPath;
this.elements.pathInput.value = this.currentPath;
this.elements.upBtn.disabled = !this.parentPath;
const folderList = this.elements.folderList;
folderList.innerHTML = '';
const directories = data.directories || [];
if (directories.length === 0) {
const empty = document.createElement('div');
empty.className = 'directory-picker-empty';
empty.textContent = translate('settings.directoryPicker.emptyFolder', {}, 'This folder is empty');
folderList.appendChild(empty);
return;
}
directories.forEach((entry) => {
folderList.appendChild(this._createFolderItem(entry));
});
}
// Each entry is { name, path, is_parent }; the server supplies the full
// child path, so navigation never joins path segments client-side.
_createFolderItem(entry) {
const item = document.createElement('div');
item.className = 'folder-item';
item.innerHTML = `
<i class="fas fa-folder"></i>
<span class="item-name">${this._escapeHtml(entry.name)}</span>
`;
item.addEventListener('click', () => {
this.loadDirectory(entry.path);
});
return item;
}
_selectCurrent() {
if (!this.currentPath) return;
if (this.onSelect) {
this.onSelect(this.currentPath);
}
this.close();
}
_showError(message) {
this.elements.errorEl.textContent = message;
this.elements.errorEl.style.display = 'block';
}
_clearError() {
this.elements.errorEl.textContent = '';
this.elements.errorEl.style.display = 'none';
}
_escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
export const directoryPickerModal = new DirectoryPickerModal();
export { DirectoryPickerModal };
+32
View File
@@ -494,6 +494,7 @@ class RecipeModal {
this.syncGenerationParams(hydratedRecipe.gen_params); this.syncGenerationParams(hydratedRecipe.gen_params);
this.syncResourcesSection(hydratedRecipe); this.syncResourcesSection(hydratedRecipe);
this.syncHeaderActions(); this.syncHeaderActions();
this.syncBaseModelBadge();
this.syncMetaFooter(); this.syncMetaFooter();
// Show the modal // Show the modal
@@ -520,6 +521,32 @@ class RecipeModal {
} }
} }
/**
* Render the recipe-level base model badge in the header tags row.
* Unlike the width-constrained card overlay (which abbreviates), the
* modal has room for the full base model name matching the model
* modal's info grid and this modal's resource rows. Falls back to a
* dimmed "Unknown" instead of hiding so the header layout does not
* shift when hydration fills the value in.
*/
syncBaseModelBadge() {
const badge = document.getElementById('recipeBaseModelBadge');
if (!badge) {
return;
}
const rawLabel = (this.currentRecipe?.base_model || '').trim();
const unknownLabel = translate('recipes.modal.metadata.unknown', {}, 'Unknown');
const baseModelLabel = rawLabel || unknownLabel;
const fieldLabel = translate('recipes.modal.metadata.baseModel', {}, 'Base Model');
badge.textContent = baseModelLabel;
badge.title = `${fieldLabel}: ${baseModelLabel}`;
badge.setAttribute('aria-label', badge.title);
badge.classList.toggle('is-unknown', !rawLabel);
badge.hidden = false;
}
/** /**
* Render the meta footer: clickable file location (opens the recipe JSON * Render the meta footer: clickable file location (opens the recipe JSON
* in the OS file manager) plus the truncated recipe ID with copy button. * in the OS file manager) plus the truncated recipe ID with copy button.
@@ -661,6 +688,10 @@ class RecipeModal {
nextRecipe.has_workflow = fullRecipe.has_workflow; nextRecipe.has_workflow = fullRecipe.has_workflow;
} }
if (fullRecipe.base_model !== undefined) {
nextRecipe.base_model = fullRecipe.base_model;
}
if (fullRecipe.checkpoint !== undefined) { if (fullRecipe.checkpoint !== undefined) {
nextRecipe.checkpoint = fullRecipe.checkpoint; nextRecipe.checkpoint = fullRecipe.checkpoint;
} else { } else {
@@ -718,6 +749,7 @@ class RecipeModal {
this.updateSourceUrlDisplay(this.currentRecipe.source_path || ''); this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
} }
this.syncHeaderActions(); this.syncHeaderActions();
this.syncBaseModelBadge();
this.syncMetaFooter(); this.syncMetaFooter();
} }
File diff suppressed because it is too large Load Diff
+41 -196
View File
@@ -7,6 +7,12 @@ import { getModelApiClient } from '../../api/modelApiFactory.js';
import { translate } from '../../utils/i18nHelpers.js'; import { translate } from '../../utils/i18nHelpers.js';
import { getPriorityTagSuggestions } from '../../utils/priorityTagHelpers.js'; import { getPriorityTagSuggestions } from '../../utils/priorityTagHelpers.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { enablePointerSort } from './pointerSort.js';
import {
refreshReorderState,
renderReorderHandle,
renderReorderHint,
} from './reorderSupport.js';
const MODEL_TYPE_SUGGESTION_KEY_MAP = { const MODEL_TYPE_SUGGESTION_KEY_MAP = {
loras: 'lora', loras: 'lora',
@@ -18,16 +24,22 @@ const MODEL_TYPE_SUGGESTION_KEY_MAP = {
}; };
const METADATA_ITEM_SELECTOR = '.metadata-item'; const METADATA_ITEM_SELECTOR = '.metadata-item';
const METADATA_ITEMS_CONTAINER_SELECTOR = '.metadata-items'; const METADATA_ITEMS_CONTAINER_SELECTOR = '.metadata-items';
const METADATA_ITEM_DRAGGING_CLASS = 'metadata-item-dragging';
const METADATA_ITEM_PLACEHOLDER_CLASS = 'metadata-item-placeholder'; /**
const METADATA_ITEMS_SORTING_CLASS = 'metadata-items-sorting'; * Tag items have no click action of their own, so the whole chip stays
const BODY_DRAGGING_CLASS = 'metadata-drag-active'; * draggable (handleSelector is null); the small threshold keeps a click on the
* grip from starting a drag (it just focuses the grip). Touch users drag by the
* grip, which is the element that opts out of scrolling via touch-action.
*/
const TAG_SORT_CONFIG = {
itemSelector: METADATA_ITEM_SELECTOR,
dragThreshold: 5,
};
let activeModelTypeKey = ''; let activeModelTypeKey = '';
let priorityTagSuggestions = []; let priorityTagSuggestions = [];
let priorityTagSuggestionsLoaded = false; let priorityTagSuggestionsLoaded = false;
let priorityTagSuggestionsPromise = null; let priorityTagSuggestionsPromise = null;
let activeTagDragState = null;
// Configurable options for tag editing (set by setupTagEditMode) // Configurable options for tag editing (set by setupTagEditMode)
let tagEditOptions = { let tagEditOptions = {
@@ -423,6 +435,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
<div class="metadata-items"> <div class="metadata-items">
${currentTags.map(tag => ` ${currentTags.map(tag => `
<div class="metadata-item" data-tag="${tag}"> <div class="metadata-item" data-tag="${tag}">
${renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
<span class="metadata-item-content">${tag}</span> <span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn"> <button class="metadata-delete-btn">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
@@ -431,6 +444,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
`).join('')} `).join('')}
</div> </div>
<div class="metadata-edit-controls"> <div class="metadata-edit-controls">
${renderReorderHint(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
<button class="save-tags-btn" title="Save changes"> <button class="save-tags-btn" title="Save changes">
<i class="fas fa-save"></i> Save <i class="fas fa-save"></i> Save
</button> </button>
@@ -543,8 +557,11 @@ function setupDeleteButtons() {
btn.addEventListener('click', function(e) { btn.addEventListener('click', function(e) {
e.stopPropagation(); e.stopPropagation();
const tag = this.closest('.metadata-item'); const tag = this.closest('.metadata-item');
const scope = tag?.closest('.model-tags-container');
tag.remove(); tag.remove();
refreshTagReorderState(scope);
// Update status of items in the suggestion dropdown // Update status of items in the suggestion dropdown
updateSuggestionsDropdown(); updateSuggestionsDropdown();
}); });
@@ -563,204 +580,31 @@ function setupTagDragAndDrop(scopeContainer) {
return; return;
} }
container.querySelectorAll(METADATA_ITEM_SELECTOR).forEach((item) => { const scope = container.closest('.model-tags-container') || container;
item.removeAttribute('draggable');
if (item.classList.contains(METADATA_ITEM_PLACEHOLDER_CLASS)) {
return;
}
if (item.dataset.pointerDragInit === 'true') {
return;
}
item.addEventListener('pointerdown', handleTagPointerDown); enablePointerSort(container, {
item.dataset.pointerDragInit = 'true'; ...TAG_SORT_CONFIG,
onSorted: () => {
updateSuggestionsDropdown();
refreshTagReorderState(scope);
},
}); });
refreshTagReorderState(scope);
} }
function handleTagPointerDown(event) { /**
if (event.button !== 0) { * Refresh the "sortable" flag (and therefore the grip + hint) of a tags section
return; * @param {Element} [tagsSection] - The .model-tags-container element
} */
function refreshTagReorderState(tagsSection) {
if (event.target.closest('.metadata-delete-btn')) { refreshReorderState({
return; container: tagsSection?.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR),
} scope: tagsSection || undefined,
itemSelector: METADATA_ITEM_SELECTOR,
const item = event.currentTarget;
const container = item?.closest(METADATA_ITEMS_CONTAINER_SELECTOR);
if (!item || !container) {
return;
}
event.preventDefault();
startPointerDrag({ item, container, startEvent: event });
}
function startPointerDrag({ item, container, startEvent }) {
if (activeTagDragState) {
finishPointerDrag();
}
const itemRect = item.getBoundingClientRect();
const placeholder = document.createElement('div');
placeholder.className = `metadata-item ${METADATA_ITEM_PLACEHOLDER_CLASS}`;
placeholder.style.width = `${itemRect.width}px`;
placeholder.style.height = `${itemRect.height}px`;
container.insertBefore(placeholder, item);
item.classList.add(METADATA_ITEM_DRAGGING_CLASS);
item.style.width = `${itemRect.width}px`;
item.style.height = `${itemRect.height}px`;
item.style.position = 'fixed';
item.style.left = `${itemRect.left}px`;
item.style.top = `${itemRect.top}px`;
item.style.pointerEvents = 'none';
item.style.zIndex = '1000';
container.classList.add(METADATA_ITEMS_SORTING_CLASS);
if (document.body) {
document.body.classList.add(BODY_DRAGGING_CLASS);
}
const dragState = {
container,
item,
placeholder,
offsetX: startEvent.clientX - itemRect.left,
offsetY: startEvent.clientY - itemRect.top,
lastKnownPointer: { x: startEvent.clientX, y: startEvent.clientY },
rafId: null,
};
activeTagDragState = dragState;
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
document.addEventListener('pointercancel', handlePointerUp);
}
function handlePointerMove(event) {
if (!activeTagDragState) {
return;
}
activeTagDragState.lastKnownPointer = { x: event.clientX, y: event.clientY };
if (activeTagDragState.rafId !== null) {
return;
}
activeTagDragState.rafId = requestAnimationFrame(() => {
if (!activeTagDragState) {
return;
}
activeTagDragState.rafId = null;
updateDraggingItemPosition();
updatePlaceholderPosition();
}); });
} }
function handlePointerUp() {
finishPointerDrag();
}
function updateDraggingItemPosition() {
if (!activeTagDragState) {
return;
}
const { item, offsetX, offsetY, lastKnownPointer } = activeTagDragState;
const left = lastKnownPointer.x - offsetX;
const top = lastKnownPointer.y - offsetY;
item.style.left = `${left}px`;
item.style.top = `${top}px`;
}
function updatePlaceholderPosition() {
if (!activeTagDragState) {
return;
}
const { container, placeholder, item, lastKnownPointer } = activeTagDragState;
const siblings = Array.from(
container.querySelectorAll(
`${METADATA_ITEM_SELECTOR}:not(.${METADATA_ITEM_PLACEHOLDER_CLASS})`
)
).filter((element) => element !== item);
let insertAfter = null;
for (const sibling of siblings) {
const rect = sibling.getBoundingClientRect();
if (lastKnownPointer.y < rect.top) {
container.insertBefore(placeholder, sibling);
return;
}
if (lastKnownPointer.y <= rect.bottom) {
if (lastKnownPointer.x < rect.left + rect.width / 2) {
container.insertBefore(placeholder, sibling);
return;
}
insertAfter = sibling;
continue;
}
insertAfter = sibling;
}
if (!insertAfter) {
container.insertBefore(placeholder, container.firstElementChild);
return;
}
container.insertBefore(placeholder, insertAfter.nextSibling);
}
function finishPointerDrag() {
if (!activeTagDragState) {
return;
}
const { container, item, placeholder, rafId } = activeTagDragState;
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
document.removeEventListener('pointercancel', handlePointerUp);
container.classList.remove(METADATA_ITEMS_SORTING_CLASS);
if (document.body) {
document.body.classList.remove(BODY_DRAGGING_CLASS);
}
if (rafId !== null) {
cancelAnimationFrame(rafId);
activeTagDragState.rafId = null;
updateDraggingItemPosition();
updatePlaceholderPosition();
}
if (placeholder && placeholder.parentNode === container) {
container.insertBefore(item, placeholder);
container.removeChild(placeholder);
}
item.classList.remove(METADATA_ITEM_DRAGGING_CLASS);
item.style.position = '';
item.style.width = '';
item.style.height = '';
item.style.left = '';
item.style.top = '';
item.style.pointerEvents = '';
item.style.zIndex = '';
activeTagDragState = null;
updateSuggestionsDropdown();
}
/** /**
* Add a new tag * Add a new tag
* @param {string} tag - Tag to add * @param {string} tag - Tag to add
@@ -799,6 +643,7 @@ function addNewTag(tag, scopeElement = null) {
newTag.className = 'metadata-item'; newTag.className = 'metadata-item';
newTag.dataset.tag = tag; newTag.dataset.tag = tag;
newTag.innerHTML = ` newTag.innerHTML = `
${renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
<span class="metadata-item-content">${tag}</span> <span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn"> <button class="metadata-delete-btn">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
+105 -1
View File
@@ -7,10 +7,35 @@ import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js'; import { translate } from '../../utils/i18nHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js'; import { getModelApiClient } from '../../api/modelApiFactory.js';
import { escapeAttribute, escapeHtml } from './utils.js'; import { escapeAttribute, escapeHtml } from './utils.js';
import {
enablePointerSort,
disablePointerSort,
} from './pointerSort.js';
import {
refreshReorderState,
renderReorderHandle,
renderReorderHint,
} from './reorderSupport.js';
const MAX_WORDS_PER_TRIGGER_GROUP = 500; const MAX_WORDS_PER_TRIGGER_GROUP = 500;
const MAX_TRIGGER_WORD_GROUPS = 100; const MAX_TRIGGER_WORD_GROUPS = 100;
const TRIGGER_WORD_CLICK_DELAY_MS = 220; const TRIGGER_WORD_CLICK_DELAY_MS = 220;
const TRIGGER_WORD_DRAG_HANDLE_SELECTOR = '.reorder-handle';
/**
* Drag-to-reorder configuration for trigger word tags.
* Handlers are installed when entering edit mode and removed again on exit, so
* display mode keeps its click-to-copy / double-click-to-edit behaviour.
* The item body is click-to-edit here, so only the grip starts a drag, and the
* small threshold keeps a click on the grip from lifting the tag.
*/
const TRIGGER_WORD_DRAG_CONFIG = {
itemSelector: '.trigger-word-tag',
handleSelector: TRIGGER_WORD_DRAG_HANDLE_SELECTOR,
ignoreSelector: '.metadata-delete-btn, .trigger-word-edit-input',
blockedItemSelector: '.is-editing',
dragThreshold: 5,
};
/** /**
* Fetch trained words for a model * Fetch trained words for a model
@@ -182,6 +207,16 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
return dropdown; return dropdown;
} }
/**
* Render the drag handle of a trigger word tag.
* The handle is always in the DOM but only visible (and clickable) in edit mode,
* so switching modes never has to rebuild the tag markup.
* @returns {string} Handle markup
*/
function renderTriggerWordDragHandle() {
return renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'));
}
/** /**
* Render trigger words * Render trigger words
* @param {Array} words - Array of trigger words * @param {Array} words - Array of trigger words
@@ -203,6 +238,7 @@ export function renderTriggerWords(words, filePath) {
<div class="trigger-words-tags" style="display:none;"></div> <div class="trigger-words-tags" style="display:none;"></div>
</div> </div>
<div class="metadata-edit-controls" style="display:none;"> <div class="metadata-edit-controls" style="display:none;">
${renderReorderHint(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}"> <button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
<i class="fas fa-save"></i> ${translate('common.actions.save')} <i class="fas fa-save"></i> ${translate('common.actions.save')}
</button> </button>
@@ -228,6 +264,7 @@ export function renderTriggerWords(words, filePath) {
const escapedAttr = escapeAttribute(word); const escapedAttr = escapeAttribute(word);
return ` return `
<div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyOrEditWord')}"> <div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyOrEditWord')}">
${renderTriggerWordDragHandle()}
<span class="trigger-word-content">${escapedWord}</span> <span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy"> <span class="trigger-word-copy">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
@@ -240,6 +277,7 @@ export function renderTriggerWords(words, filePath) {
</div> </div>
</div> </div>
<div class="metadata-edit-controls" style="display:none;"> <div class="metadata-edit-controls" style="display:none;">
${renderReorderHint(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}"> <button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
<i class="fas fa-save"></i> ${translate('common.actions.save')} <i class="fas fa-save"></i> ${translate('common.actions.save')}
</button> </button>
@@ -316,6 +354,10 @@ export function setupTriggerWordsEditMode() {
} }
}); });
// Enable drag-to-reorder (grip handle) for the current words
enableTriggerWordSort(triggerWordsSection);
refreshTriggerWordHandleLabels(triggerWordsSection);
// Load trained words and display dropdown when entering edit mode // Load trained words and display dropdown when entering edit mode
// Add loading indicator // Add loading indicator
const loadingIndicator = document.createElement('div'); const loadingIndicator = document.createElement('div');
@@ -379,6 +421,10 @@ export function setupTriggerWordsEditMode() {
if (tagsContainer) tagsContainer.style.display = 'none'; if (tagsContainer) tagsContainer.style.display = 'none';
} }
// Leaving edit mode: tags are no longer reorderable
disableTriggerWordSort(triggerWordsSection);
refreshTriggerWordHandleLabels(triggerWordsSection);
// Remove dropdown if present // Remove dropdown if present
const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown'); const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown');
if (dropdown) dropdown.remove(); if (dropdown) dropdown.remove();
@@ -433,8 +479,13 @@ export function setupTriggerWordsEditMode() {
function deleteTriggerWord(e) { function deleteTriggerWord(e) {
e.stopPropagation(); e.stopPropagation();
const tag = this.closest('.trigger-word-tag'); const tag = this.closest('.trigger-word-tag');
const section = tag?.closest('.trigger-words');
tag.remove(); tag.remove();
if (section) {
refreshTriggerWordHandleLabels(section);
}
// Update status of items in the trained words dropdown // Update status of items in the trained words dropdown
updateTrainedWordsDropdown(); updateTrainedWordsDropdown();
} }
@@ -493,6 +544,47 @@ function restoreOriginalTriggerWords(section, originalWords) {
}); });
} }
/**
* Refresh the "sortable" flag (and therefore the grip + hint) of a section.
* Reordering is drag-only and only offered while editing: the tag body itself
* is click-to-edit, so the grip must not appear in display mode.
* @param {HTMLElement} section - The .trigger-words section
*/
function refreshTriggerWordHandleLabels(section) {
refreshReorderState({
container: section.querySelector('.trigger-words-tags'),
scope: section,
itemSelector: TRIGGER_WORD_DRAG_CONFIG.itemSelector,
isActive: () => section.classList.contains('edit-mode'),
});
}
/**
* Enable drag-to-reorder for the tags of a section (edit mode only)
* @param {HTMLElement} section - The .trigger-words section
*/
function enableTriggerWordSort(section) {
const tagsContainer = section.querySelector('.trigger-words-tags');
if (!tagsContainer) return;
enablePointerSort(tagsContainer, {
...TRIGGER_WORD_DRAG_CONFIG,
onSorted: () => refreshTriggerWordHandleLabels(section),
});
}
/**
* Remove drag-to-reorder handlers when leaving edit mode
* @param {HTMLElement} section - The .trigger-words section
*/
function disableTriggerWordSort(section) {
const tagsContainer = section.querySelector('.trigger-words-tags');
if (!tagsContainer) return;
disablePointerSort(tagsContainer, TRIGGER_WORD_DRAG_CONFIG);
refreshTriggerWordHandleLabels(section);
}
/** /**
* Create a trigger word tag element * Create a trigger word tag element
* @param {string} word - Trigger word * @param {string} word - Trigger word
@@ -507,6 +599,7 @@ function createTriggerWordTag(word, isEditMode = false) {
const escapedWord = escapeHtml(word); const escapedWord = escapeHtml(word);
tag.innerHTML = ` tag.innerHTML = `
${renderTriggerWordDragHandle()}
<span class="trigger-word-content">${escapedWord}</span> <span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy" style="${isEditMode ? 'display:none;' : ''}"> <span class="trigger-word-copy" style="${isEditMode ? 'display:none;' : ''}">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
@@ -637,7 +730,7 @@ function validateTriggerWord(word, tagsContainer, currentTag = null) {
* @param {Event} e - Click event * @param {Event} e - Click event
*/ */
function startEditTriggerWord(e) { function startEditTriggerWord(e) {
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input')) return; if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input') || e.target.closest(TRIGGER_WORD_DRAG_HANDLE_SELECTOR)) return;
const tag = this.closest('.trigger-word-tag'); const tag = this.closest('.trigger-word-tag');
const section = tag?.closest('.trigger-words'); const section = tag?.closest('.trigger-words');
@@ -684,6 +777,11 @@ function startEditTriggerWord(e) {
tag.classList.remove('is-editing'); tag.classList.remove('is-editing');
tag.style.removeProperty('--trigger-word-edit-width'); tag.style.removeProperty('--trigger-word-edit-width');
tag.style.removeProperty('--trigger-word-edit-height'); tag.style.removeProperty('--trigger-word-edit-height');
if (section) {
refreshTriggerWordHandleLabels(section);
}
updateTrainedWordsDropdown(); updateTrainedWordsDropdown();
}; };
@@ -763,6 +861,12 @@ function addNewTriggerWord(word) {
const newTag = createTriggerWordTag(word, triggerWordsSection.classList.contains('edit-mode')); const newTag = createTriggerWordTag(word, triggerWordsSection.classList.contains('edit-mode'));
tagsContainer.appendChild(newTag); tagsContainer.appendChild(newTag);
if (triggerWordsSection.classList.contains('edit-mode')) {
// Wire the freshly added tag for reordering too
enableTriggerWordSort(triggerWordsSection);
refreshTriggerWordHandleLabels(triggerWordsSection);
}
// Update status of items in the trained words dropdown // Update status of items in the trained words dropdown
updateTrainedWordsDropdown(); updateTrainedWordsDropdown();
} }
+367
View File
@@ -0,0 +1,367 @@
/**
* pointerSort.js
* Shared pointer-based drag-and-drop sorting for wrapped item lists
* (model tags, trigger words, ...).
*
* The engine lifts the dragged item into a fixed-position "ghost", leaves a
* correctly sized placeholder behind, and moves that placeholder around based
* on the pointer position. Because the dragged node is re-inserted where the
* placeholder ended up, the resulting DOM order *is* the new sort order the
* save path simply reads the items in DOM order.
*/
const DEFAULT_OPTIONS = {
// Selector of the sortable items inside the container.
itemSelector: '.metadata-item',
// When set, a drag can only start from inside this element (a handle).
// When null the whole item is draggable.
handleSelector: null,
// Elements inside an item that must never start a drag.
ignoreSelector: '.metadata-delete-btn',
// Items matching this selector cannot be dragged (e.g. while being edited).
blockedItemSelector: null,
draggingClass: 'reorder-dragging',
placeholderClass: 'reorder-placeholder',
containerSortingClass: 'reorder-sorting',
// Added to <body> while dragging to disable text selection globally.
bodySortingClass: 'reorder-drag-active',
// Pointer travel (px) required before a drag starts. 0 = start on pointerdown.
dragThreshold: 0,
// Called after a successful drop with (item, container).
onSorted: null,
};
// Marks a container whose items are actually sortable, so styles can offer the
// grab affordance only where dragging really works.
const CONTAINER_ENABLED_CLASS = 'pointer-sort-enabled';
let activeDragState = null;
let pendingDragState = null;
function resolveConfig(options = {}) {
return { ...DEFAULT_OPTIONS, ...options };
}
function itemInitKey(config) {
// Any option that changes how a pointerdown is interpreted is part of the
// key, so re-enabling a container with new options replaces the handler
// instead of silently keeping the old one.
return [
config.itemSelector,
config.handleSelector || '',
config.ignoreSelector || '',
config.blockedItemSelector || '',
config.dragThreshold,
].join('|');
}
/**
* Make the items of a container draggable within it.
* Safe to call repeatedly (e.g. after adding an item): already-configured items
* are skipped, and newly added items get wired up.
* @param {HTMLElement} container - Element holding the sortable items
* @param {Object} [options] - See DEFAULT_OPTIONS
*/
export function enablePointerSort(container, options = {}) {
if (!container) return;
const config = resolveConfig(options);
const initKey = itemInitKey(config);
container.__pointerSortConfig = config;
container.classList.add(CONTAINER_ENABLED_CLASS);
container.querySelectorAll(config.itemSelector).forEach((item) => {
item.removeAttribute('draggable');
if (item.classList.contains(config.placeholderClass)) return;
if (item.__pointerSortKey === initKey) return;
if (item.__pointerSortHandler) {
item.removeEventListener('pointerdown', item.__pointerSortHandler);
}
const handler = (event) => handlePointerDown(event, item, container, config);
item.addEventListener('pointerdown', handler);
item.__pointerSortKey = initKey;
item.__pointerSortHandler = handler;
});
}
/**
* Remove drag handlers previously installed by enablePointerSort().
* @param {HTMLElement} container - Element holding the sortable items
* @param {Object} [options] - Used when the container has no stored config
*/
export function disablePointerSort(container, options = {}) {
if (!container) return;
const config = resolveConfig(container.__pointerSortConfig || options);
container.querySelectorAll(config.itemSelector).forEach((item) => {
if (item.__pointerSortHandler) {
item.removeEventListener('pointerdown', item.__pointerSortHandler);
}
delete item.__pointerSortHandler;
delete item.__pointerSortKey;
});
delete container.__pointerSortConfig;
container.classList.remove(CONTAINER_ENABLED_CLASS);
cancelPendingDrag(container);
if (activeDragState && activeDragState.container === container) {
finishPointerDrag();
}
}
function handlePointerDown(event, item, container, config) {
if (activeDragState || pendingDragState) return;
if (typeof event.button === 'number' && event.button !== 0) return;
if (config.ignoreSelector && event.target.closest(config.ignoreSelector)) return;
if (config.handleSelector && !event.target.closest(config.handleSelector)) return;
if (config.blockedItemSelector && item.matches(config.blockedItemSelector)) return;
if (item.classList.contains(config.placeholderClass)) return;
if (config.dragThreshold > 0) {
startPendingDrag({ item, container, config, startEvent: event });
return;
}
// Prevent the browser's native text selection / image drag from kicking in.
event.preventDefault();
startPointerDrag({ item, container, config, startEvent: event });
}
function startPendingDrag({ item, container, config, startEvent }) {
const state = {
item,
container,
config,
startX: startEvent.clientX,
startY: startEvent.clientY,
};
state.onMove = (event) => {
const dx = event.clientX - state.startX;
const dy = event.clientY - state.startY;
if (Math.hypot(dx, dy) < config.dragThreshold) return;
cleanupPendingDrag();
event.preventDefault();
clearTextSelection();
startPointerDrag({ item, container, config, startEvent: event });
};
state.onUp = () => cleanupPendingDrag();
pendingDragState = state;
document.addEventListener('pointermove', state.onMove);
document.addEventListener('pointerup', state.onUp);
document.addEventListener('pointercancel', state.onUp);
}
function cleanupPendingDrag() {
if (!pendingDragState) return;
const { onMove, onUp } = pendingDragState;
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
pendingDragState = null;
}
function cancelPendingDrag(container) {
if (pendingDragState && (!container || pendingDragState.container === container)) {
cleanupPendingDrag();
}
}
function clearTextSelection() {
if (typeof window === 'undefined' || !window.getSelection) return;
const selection = window.getSelection();
if (selection && selection.removeAllRanges) selection.removeAllRanges();
}
function startPointerDrag({ item, container, config, startEvent }) {
if (activeDragState) finishPointerDrag();
const itemRect = item.getBoundingClientRect();
const placeholder = document.createElement('div');
const placeholderClasses = Array.from(item.classList).filter(
(name) => name !== config.draggingClass && name !== config.placeholderClass,
);
placeholderClasses.push(config.placeholderClass);
placeholder.className = placeholderClasses.join(' ');
placeholder.style.width = `${itemRect.width}px`;
placeholder.style.height = `${itemRect.height}px`;
container.insertBefore(placeholder, item);
item.classList.add(config.draggingClass);
item.style.width = `${itemRect.width}px`;
item.style.height = `${itemRect.height}px`;
item.style.position = 'fixed';
item.style.left = `${itemRect.left}px`;
item.style.top = `${itemRect.top}px`;
item.style.pointerEvents = 'none';
item.style.zIndex = '1000';
container.classList.add(config.containerSortingClass);
if (config.bodySortingClass && document.body) {
document.body.classList.add(config.bodySortingClass);
}
// Swallow the click generated by this pointer sequence so dropping an item
// never triggers its own click handler (copy-to-clipboard, inline editing).
// Scoped to the dragged container so unrelated clicks are never affected.
const swallowClick = (event) => {
if (event.target !== container && !container.contains(event.target)) {
return;
}
event.preventDefault();
event.stopPropagation();
document.removeEventListener('click', swallowClick, true);
};
document.addEventListener('click', swallowClick, true);
activeDragState = {
container,
item,
placeholder,
config,
offsetX: startEvent.clientX - itemRect.left,
offsetY: startEvent.clientY - itemRect.top,
lastKnownPointer: { x: startEvent.clientX, y: startEvent.clientY },
rafId: null,
swallowClick,
};
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
document.addEventListener('pointercancel', handlePointerUp);
}
function handlePointerMove(event) {
if (!activeDragState) return;
activeDragState.lastKnownPointer = { x: event.clientX, y: event.clientY };
if (activeDragState.rafId !== null) return;
activeDragState.rafId = requestAnimationFrame(() => {
if (!activeDragState) return;
activeDragState.rafId = null;
updateDraggingItemPosition();
updatePlaceholderPosition();
});
}
function handlePointerUp() {
finishPointerDrag();
}
function updateDraggingItemPosition() {
if (!activeDragState) return;
const { item, offsetX, offsetY, lastKnownPointer } = activeDragState;
const left = lastKnownPointer.x - offsetX;
const top = lastKnownPointer.y - offsetY;
item.style.left = `${left}px`;
item.style.top = `${top}px`;
}
function updatePlaceholderPosition() {
if (!activeDragState) return;
const { container, placeholder, item, config, lastKnownPointer } = activeDragState;
const siblings = Array.from(
container.querySelectorAll(
`${config.itemSelector}:not(.${config.placeholderClass})`,
),
).filter((element) => element !== item);
let insertAfter = null;
for (const sibling of siblings) {
const rect = sibling.getBoundingClientRect();
if (lastKnownPointer.y < rect.top) {
container.insertBefore(placeholder, sibling);
return;
}
if (lastKnownPointer.y <= rect.bottom) {
if (lastKnownPointer.x < rect.left + rect.width / 2) {
container.insertBefore(placeholder, sibling);
return;
}
insertAfter = sibling;
continue;
}
insertAfter = sibling;
}
if (!insertAfter) {
container.insertBefore(placeholder, container.firstElementChild);
return;
}
container.insertBefore(placeholder, insertAfter.nextSibling);
}
function finishPointerDrag() {
if (!activeDragState) return;
const { container, item, placeholder, config, rafId, swallowClick } = activeDragState;
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
document.removeEventListener('pointercancel', handlePointerUp);
container.classList.remove(config.containerSortingClass);
if (config.bodySortingClass && document.body) {
document.body.classList.remove(config.bodySortingClass);
}
if (rafId !== null) {
cancelAnimationFrame(rafId);
activeDragState.rafId = null;
}
// Always settle the placeholder from the last known pointer: the drop must
// reflect the final pointer position even when no animation frame ran
// (fast drags, or drags that started from the threshold-crossing move).
updateDraggingItemPosition();
updatePlaceholderPosition();
if (placeholder && placeholder.parentNode === container) {
container.insertBefore(item, placeholder);
container.removeChild(placeholder);
}
item.classList.remove(config.draggingClass);
item.style.position = '';
item.style.width = '';
item.style.height = '';
item.style.left = '';
item.style.top = '';
item.style.pointerEvents = '';
item.style.zIndex = '';
activeDragState = null;
if (typeof config.onSorted === 'function') {
config.onSorted(item, container);
}
cleanupSwallowClick(swallowClick);
}
/**
* The click that follows a drop is dispatched right after pointerup, so the
* guard has to survive until the next macrotask.
* @param {Function} handler - Capture-phase click handler to remove
*/
function cleanupSwallowClick(handler) {
if (!handler) return;
setTimeout(() => document.removeEventListener('click', handler, true), 0);
}
@@ -0,0 +1,64 @@
/**
* reorderSupport.js
* Shared drag affordance for chip lists sorted with pointerSort.
*
* The drag gesture itself lives in pointerSort.js; this module owns the parts
* every sortable list needs on top of it:
* - the `` grip markup,
* - the "sortable" flag that reveals the grip only when reordering is possible.
*
* Convention used by both callers: a list always shows the grip while it is
* sortable. Whether the item *body* is draggable as well depends on the item:
* - body has no click action (model/recipe tags) -> whole item is draggable,
* - body is click-to-edit (trigger words) -> only the grip starts a drag.
*
* Reordering is deliberately pointer-only: a keyboard shortcut would have to
* fight the browser's own Alt + Arrow handling and the modal's arrow-key
* navigation, so the grip is a plain decorative affordance rather than a
* focusable control.
*/
import { escapeAttribute, escapeHtml } from './utils.js';
const SORTABLE_CLASS = 'has-sortable-words';
/**
* Render the shared reorder grip
* @param {string} label - Tooltip text
* @returns {string} Handle markup
*/
export function renderReorderHandle(label) {
const safeLabel = escapeAttribute(label || '');
return `<span class="reorder-handle" aria-hidden="true" title="${safeLabel}"><i class="fas fa-grip-vertical"></i></span>`;
}
/**
* Render the shared reorder hint shown in an edit controls row
* @param {string} label - Hint text
* @returns {string} Hint markup
*/
export function renderReorderHint(label) {
return `<span class="reorder-hint"><i class="fas fa-grip-vertical"></i> ${escapeHtml(label || '')}</span>`;
}
/**
* Show or hide the grip and hint of a list.
* They are only offered while the list is editable and holds more than one
* item, so the UI never shows an affordance that cannot do anything.
* @param {Object} options - Options
* @param {HTMLElement} options.container - Element holding the sortable items
* @param {HTMLElement} [options.scope] - Element that receives the sortable flag
* @param {string} options.itemSelector - Selector of the sortable items
* @param {Function} [options.isActive] - Whether reordering is currently allowed
*/
export function refreshReorderState({
container,
scope = container,
itemSelector,
isActive = () => true,
}) {
if (!container) return;
const items = container.querySelectorAll(itemSelector);
scope.classList.toggle(SORTABLE_CLASS, isActive() && items.length > 1);
}
+126 -30
View File
@@ -31,6 +31,9 @@ class BannerService {
this.banners = new Map(); this.banners = new Map();
this.container = null; this.container = null;
this.initialized = false; this.initialized = false;
// Only one banner is rendered at a time; this index selects which of
// the active (non-dismissed) banners is currently displayed.
this.currentBannerIndex = 0;
this.recentHistory = this.loadBannerHistory(); this.recentHistory = this.loadBannerHistory();
this.bannerHistoryViewedAt = this.loadBannerHistoryViewedAt(); this.bannerHistoryViewedAt = this.loadBannerHistoryViewedAt();
@@ -122,11 +125,21 @@ class BannerService {
registerBanner(id, bannerConfig) { registerBanner(id, bannerConfig) {
this.banners.set(id, bannerConfig); this.banners.set(id, bannerConfig);
// If already initialized, render the banner immediately if (!this.initialized || !this.container || this.isBannerDismissed(id)) {
if (this.initialized && !this.isBannerDismissed(id) && this.container) { return;
this.renderBanner(bannerConfig);
this.updateContainerVisibility();
} }
// Preempt the currently displayed banner only when the new one has a
// strictly higher priority (i.e. sorts earlier).
const activeBanners = this.getSortedActiveBanners();
const displayedId = this.container.querySelector('.banner-item')
?.getAttribute('data-banner-id');
const newIndex = activeBanners.findIndex(banner => banner.id === id);
const displayedIndex = activeBanners.findIndex(banner => banner.id === displayedId);
if (displayedIndex === -1 || (newIndex !== -1 && newIndex < displayedIndex)) {
this.currentBannerIndex = Math.max(newIndex, 0);
}
this.renderCurrentBanner();
} }
/** /**
@@ -167,8 +180,7 @@ class BannerService {
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards'; bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
setTimeout(() => { setTimeout(() => {
bannerElement.remove(); this.renderCurrentBanner();
this.updateContainerVisibility();
}, 300); }, 300);
} }
@@ -193,28 +205,87 @@ class BannerService {
} }
} }
/**
* Get active (non-dismissed) banners sorted by priority, highest first
* @returns {Object[]}
*/
getSortedActiveBanners() {
return Array.from(this.banners.values())
.filter(banner => !this.isBannerDismissed(banner.id))
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
}
/** /**
* Show all active (non-dismissed) banners * Show all active (non-dismissed) banners
*/ */
async showActiveBanners() { async showActiveBanners() {
if (!this.container) return; if (!this.container) return;
const activeBanners = Array.from(this.banners.values()) this.currentBannerIndex = 0;
.filter(banner => !this.isBannerDismissed(banner.id)) this.renderCurrentBanner();
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
activeBanners.forEach(banner => {
this.renderBanner(banner);
});
this.updateContainerVisibility();
} }
/** /**
* Render a banner to the DOM * Render the currently selected banner into the container. Only one
* @param {Object} banner - Banner configuration * banner is visible at a time; a pager lets the user cycle through the
* remaining active banners.
*/ */
renderBanner(banner) { renderCurrentBanner() {
if (!this.container) return;
const activeBanners = this.getSortedActiveBanners();
this.container.innerHTML = '';
if (activeBanners.length === 0) {
this.currentBannerIndex = 0;
this.updateContainerVisibility();
return;
}
if (this.currentBannerIndex >= activeBanners.length) {
this.currentBannerIndex = activeBanners.length - 1;
}
if (this.currentBannerIndex < 0) {
this.currentBannerIndex = 0;
}
// Record every active banner once so dismissed/cycled-away banners
// remain reachable through the notification center history.
activeBanners.forEach(banner => this.recordBannerAppearance(banner));
const banner = activeBanners[this.currentBannerIndex];
const bannerElement = this.buildBannerElement(banner, activeBanners.length);
this.container.appendChild(bannerElement);
this.updateContainerVisibility();
// Call onRegister callback if provided
if (typeof banner.onRegister === 'function') {
banner.onRegister(bannerElement);
}
}
/**
* Advance the displayed banner by offset, wrapping around
* @param {number} offset - +1 for next, -1 for previous
*/
showAdjacentBanner(offset) {
const activeBanners = this.getSortedActiveBanners();
if (activeBanners.length < 2) return;
this.currentBannerIndex =
(this.currentBannerIndex + offset + activeBanners.length) % activeBanners.length;
this.renderCurrentBanner();
}
/**
* Build a banner DOM element
* @param {Object} banner - Banner configuration
* @param {number} totalCount - Total number of active banners
* @returns {HTMLElement}
*/
buildBannerElement(banner, totalCount) {
const bannerElement = document.createElement('div'); const bannerElement = document.createElement('div');
bannerElement.className = 'banner-item'; bannerElement.className = 'banner-item';
bannerElement.setAttribute('data-banner-id', banner.id); bannerElement.setAttribute('data-banner-id', banner.id);
@@ -235,6 +306,29 @@ class BannerService {
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button>` : ''; </button>` : '';
let pagerHtml = '';
if (totalCount > 1) {
const previousLabel = translate('banners.pager.previous', {}, 'Previous message');
const nextLabel = translate('banners.pager.next', {}, 'Next message');
const positionLabel = translate('banners.pager.position', {
current: this.currentBannerIndex + 1,
total: totalCount
}, `Message ${this.currentBannerIndex + 1} of ${totalCount}`);
pagerHtml = `
<div class="banner-pager">
<button type="button" class="banner-pager-btn" data-pager="prev"
aria-label="${previousLabel}" title="${previousLabel}">
<i class="fas fa-chevron-left"></i>
</button>
<span class="banner-pager-indicator" aria-label="${positionLabel}">${this.currentBannerIndex + 1} / ${totalCount}</span>
<button type="button" class="banner-pager-btn" data-pager="next"
aria-label="${nextLabel}" title="${nextLabel}">
<i class="fas fa-chevron-right"></i>
</button>
</div>`;
}
bannerElement.innerHTML = ` bannerElement.innerHTML = `
<div class="banner-content"> <div class="banner-content">
<div class="banner-text"> <div class="banner-text">
@@ -244,18 +338,19 @@ class BannerService {
<div class="banner-actions"> <div class="banner-actions">
${actionsHtml} ${actionsHtml}
</div> </div>
${pagerHtml}
</div> </div>
${dismissButtonHtml} ${dismissButtonHtml}
`; `;
this.container.appendChild(bannerElement); bannerElement.querySelectorAll('.banner-pager-btn').forEach(button => {
button.addEventListener('click', (event) => {
event.preventDefault();
this.showAdjacentBanner(button.getAttribute('data-pager') === 'next' ? 1 : -1);
});
});
this.recordBannerAppearance(banner); return bannerElement;
// Call onRegister callback if provided
if (typeof banner.onRegister === 'function') {
banner.onRegister(bannerElement);
}
} }
/** /**
@@ -458,17 +553,18 @@ class BannerService {
* @param {string} bannerId - Banner ID to remove * @param {string} bannerId - Banner ID to remove
*/ */
removeBannerElement(bannerId) { removeBannerElement(bannerId) {
// Also remove from banners map
this.banners.delete(bannerId);
const bannerElement = document.querySelector(`[data-banner-id="${bannerId}"]`); const bannerElement = document.querySelector(`[data-banner-id="${bannerId}"]`);
if (bannerElement) { if (bannerElement) {
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards'; bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
setTimeout(() => { setTimeout(() => {
bannerElement.remove(); this.renderCurrentBanner();
this.updateContainerVisibility();
}, 300); }, 300);
} else {
this.renderCurrentBanner();
} }
// Also remove from banners map
this.banners.delete(bannerId);
} }
prepareCommunitySupportBanner() { prepareCommunitySupportBanner() {
+30
View File
@@ -340,6 +340,27 @@ export class DownloadManager {
// ---- External repository download flow (Hugging Face / ModelScope) ---- // ---- External repository download flow (Hugging Face / ModelScope) ----
/**
* Report a post-transfer stage frame to the progress UI.
*
* The backend keeps working after the last byte lands it indexes the
* file and reads the model site's API and announces those stages with
* `status: 'metadata'`. Without them the bar sits at 100% showing "0 B/s"
* and the download looks stuck. The stage and platform are machine
* readable so LoadingManager can localise the wording.
*
* @returns {boolean} `true` when the frame was a stage frame.
*/
_applyMetadataStage(data, updateProgress, completed, name) {
if (data?.status !== 'metadata') return false;
updateProgress(100, completed, name, {}, {
phase: 'metadata',
stage: data.stage || '',
platform: data.platform || '',
});
return true;
}
/** Rendering group key: the same repo on two sites is two groups. */ /** Rendering group key: the same repo on two sites is two groups. */
_externalGroupKey(item) { _externalGroupKey(item) {
return `${item.source}:${item.repo || 'unknown'}`; return `${item.source}:${item.repo || 'unknown'}`;
@@ -1708,6 +1729,12 @@ export class DownloadManager {
cancelled = true; cancelled = true;
return; return;
} }
// Indexing / site metadata: the transfer is over but the
// backend is still working, so say so instead of
// leaving the bar frozen at 100%.
if (this._applyMetadataStage(data, updateProgress, snapshotCompleted, filename)) {
return;
}
if (data.status === 'progress') { if (data.status === 'progress') {
const metrics = { const metrics = {
bytesDownloaded: data.bytes_downloaded, bytesDownloaded: data.bytes_downloaded,
@@ -2331,6 +2358,9 @@ export class DownloadManager {
const snapshotCompleted = completedDownloads; const snapshotCompleted = completedDownloads;
wsHf.onmessage = (event) => { wsHf.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (this._applyMetadataStage(data, updateProgress, snapshotCompleted, name)) {
return;
}
if (data.status === 'progress') { if (data.status === 'progress') {
const metrics = { const metrics = {
bytesDownloaded: data.bytes_downloaded, bytesDownloaded: data.bytes_downloaded,
+79 -8
View File
@@ -1,5 +1,6 @@
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { formatFileSize } from '../utils/formatters.js'; import { formatFileSize } from '../utils/formatters.js';
import { getModelSource } from '../utils/modelSourceHelpers.js';
// Loading management // Loading management
export class LoadingManager { export class LoadingManager {
@@ -278,6 +279,35 @@ export class LoadingManager {
} }
}; };
/**
* Describe a post-transfer stage in the status line.
*
* The byte counter stops as soon as the last byte lands, but the
* backend still hashes the file and reads the model site's API. Naming
* that work is what stops the bar looking frozen at 100%.
*/
const describeMetadataStage = (stage, platform) => {
if (stage === 'indexing') {
return translate(
'modals.download.progress.indexingFile',
{},
'Reading model file...'
);
}
const label = getModelSource(platform)?.label || platform || '';
return label
? translate(
'modals.download.progress.fetchingSourceMetadata',
{ source: label },
`Fetching metadata from ${label}...`
)
: translate(
'modals.download.progress.fetchingMetadata',
{},
'Fetching metadata...'
);
};
// Initialize transfer stats with empty data // Initialize transfer stats with empty data
updateTransferStats(); updateTransferStats();
@@ -285,19 +315,62 @@ export class LoadingManager {
this.loadingContent.appendChild(this.cancelButton); this.loadingContent.appendChild(this.cancelButton);
} }
// Return update function /**
return (currentProgress, currentIndex = 0, currentName = '', metrics = {}) => { * Update the progress UI.
*
* @param {number} currentProgress Percentage of the current item.
* @param {number} [currentIndex] Items finished so far.
* @param {string} [currentName] File being processed.
* @param {object} [metrics] Byte counters; only meaningful while
* transferring.
* @param {object} [phase] `{ phase: 'metadata', stage, platform }` once
* the transfer has finished, so the UI can show what is still running
* instead of a 0 B/s speed.
*/
return (
currentProgress,
currentIndex = 0,
currentName = '',
metrics = {},
phase = null
) => {
const isMetadata = phase?.phase === 'metadata';
// Update current item progress // Update current item progress
currentItemProgress.style.width = `${currentProgress}%`; currentItemProgress.style.width = `${currentProgress}%`;
currentItemPercent.textContent = `${Math.floor(currentProgress)}%`; currentItemPercent.textContent = `${Math.floor(currentProgress)}%`;
currentItemProgress.classList.toggle('is-indeterminate', isMetadata);
// Update current item label if name provided // Update current item label if name provided
if (currentName) { if (currentName) {
currentItemLabel.textContent = translate( currentItemLabel.textContent = isMetadata
'modals.download.progress.downloading', ? translate(
{ name: currentName }, 'modals.download.progress.metadata',
`Downloading: ${currentName}` { name: currentName },
`Metadata: ${currentName}`
)
: translate(
'modals.download.progress.downloading',
{ name: currentName },
`Downloading: ${currentName}`
);
}
// No bytes are moving any more, so report the stage instead of a
// rate that has dropped to zero.
if (isMetadata) {
updateTransferStats({ bytesDownloaded: metrics.bytesDownloaded, totalBytes: metrics.totalBytes });
const stageText = describeMetadataStage(phase.stage, phase.platform);
speedDetail.textContent = stageText;
// Keep the batch position visible; the status line is the one
// place a caller also writes to.
this.setStatus(
totalItems > 1
? `${Math.min(currentIndex + 1, totalItems)}/${totalItems}: ${stageText}`
: stageText
); );
} else {
updateTransferStats(metrics);
} }
// Update overall label if multiple items // Update overall label if multiple items
@@ -311,8 +384,6 @@ export class LoadingManager {
// Single item, just update main progress // Single item, just update main progress
this.setProgress(currentProgress); this.setProgress(currentProgress);
} }
updateTransferStats(metrics);
}; };
} }
+13
View File
@@ -243,6 +243,18 @@ export class ModalManager {
}); });
} }
// Add deleteFolderModal registration
const deleteFolderModal = document.getElementById('deleteFolderModal');
if (deleteFolderModal) {
this.registerModal('deleteFolderModal', {
element: deleteFolderModal,
onClose: () => {
this.getModal('deleteFolderModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add helpModal registration // Add helpModal registration
const helpModal = document.getElementById('helpModal'); const helpModal = document.getElementById('helpModal');
if (helpModal) { if (helpModal) {
@@ -441,6 +453,7 @@ export class ModalManager {
id === "clearCacheModal" || id === "clearCacheModal" ||
id === "bulkDeleteModal" || id === "bulkDeleteModal" ||
id === "checkUpdatesConfirmModal" || id === "checkUpdatesConfirmModal" ||
id === "deleteFolderModal" ||
id === "resolveFilenameConflictsModal" id === "resolveFilenameConflictsModal"
) { ) {
modal.element.classList.add("show"); modal.element.classList.add("show");
File diff suppressed because it is too large Load Diff
+59 -4
View File
@@ -1,14 +1,16 @@
import { appCore } from './core.js'; import { appCore } from './core.js';
import { showToast } from './utils/uiHelpers.js'; import { showToast } from './utils/uiHelpers.js';
import { enableOtherModels, openOtherModelsSettings } from './utils/otherModels.js'; import { enableOtherModels, openOtherModelsSettings, openModelPathsSettings } from './utils/otherModels.js';
/** /**
* Other Models is an opt-in feature. While it is disabled this page renders an * Other Models is an opt-in feature. While it is disabled this page renders an
* empty state whose button turns the feature on; the backend then rebuilds the * empty state whose button turns the feature on; the backend then rebuilds the
* other-model roots and starts scanning, so a reload lands on the real page. * other-model roots and starts scanning, so a reload lands on the real page.
* *
* The same module backs the "enabled but no folders found" state, where the * The same module backs the "enabled but no folders found" state: ComfyUI
* only useful action is jumping to Settings instead of enabling anything. * mode points to the Settings page's Library section, while standalone mode
* points to the standalone-only Model Paths section (which edits the primary
* folder_paths) and still offers the settings.json location as a fallback.
*/ */
async function handleEnableClick() { async function handleEnableClick() {
const button = document.getElementById('enableOtherModelsBtn'); const button = document.getElementById('enableOtherModelsBtn');
@@ -32,6 +34,49 @@ function handleOpenSettingsClick(event) {
openOtherModelsSettings(); openOtherModelsSettings();
} }
/**
* Open Settings on the Model Paths section for the standalone "no folders
* found" state, so the missing folders can be added directly.
*/
function handleOpenModelPathsSettingsClick(event) {
event.preventDefault();
openModelPathsSettings();
}
/**
* Open the settings.json location from the standalone no-folders state,
* offered as a fallback next to the Model Paths settings button.
*/
async function handleOpenSettingsFolderClick() {
const button = document.getElementById('openSettingsFolderBtn');
if (!button || button.disabled) return;
button.disabled = true;
try {
const response = await fetch('/api/lm/settings/open-location', { method: 'POST' });
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
throw new Error(data.error || `HTTP ${response.status}`);
}
if (data.mode === 'clipboard' && data.path) {
try {
await navigator.clipboard.writeText(data.path);
showToast('settings.openSettingsFileLocation.copied', { path: data.path }, 'success');
} catch (clipboardError) {
console.warn('Clipboard API not available:', clipboardError);
showToast('settings.openSettingsFileLocation.clipboardFallback', { path: data.path }, 'info');
}
} else {
showToast('settings.openSettingsFileLocation.success', {}, 'success');
}
} catch (error) {
console.error('Failed to open settings location:', error);
showToast('settings.openSettingsFileLocation.failed', {}, 'error');
} finally {
button.disabled = false;
}
}
async function initializeOtherDisabledPage() { async function initializeOtherDisabledPage() {
// appCore.initialize() wires the shared header (theme, settings modal, // appCore.initialize() wires the shared header (theme, settings modal,
// language) so this page is not a dead end. // language) so this page is not a dead end.
@@ -46,8 +91,18 @@ async function initializeOtherDisabledPage() {
if (settingsButton) { if (settingsButton) {
settingsButton.addEventListener('click', handleOpenSettingsClick); settingsButton.addEventListener('click', handleOpenSettingsClick);
} }
const modelPathsButton = document.getElementById('openModelPathsSettingsBtn');
if (modelPathsButton) {
modelPathsButton.addEventListener('click', handleOpenModelPathsSettingsClick);
}
const settingsFolderButton = document.getElementById('openSettingsFolderBtn');
if (settingsFolderButton) {
settingsFolderButton.addEventListener('click', handleOpenSettingsFolderClick);
}
} }
document.addEventListener('DOMContentLoaded', initializeOtherDisabledPage); document.addEventListener('DOMContentLoaded', initializeOtherDisabledPage);
export { handleEnableClick as enableOtherModels, initializeOtherDisabledPage }; export { handleEnableClick as enableOtherModels, handleOpenSettingsFolderClick, initializeOtherDisabledPage };
+9 -1
View File
@@ -1,7 +1,7 @@
// Create the new hierarchical state structure // Create the new hierarchical state structure
import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js'; import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js';
import { MODEL_TYPES } from '../api/apiConfig.js'; import { MODEL_TYPES } from '../api/apiConfig.js';
import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js'; import { DEFAULT_PATH_TEMPLATES, DEFAULT_FILENAME_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
const DEFAULT_SETTINGS_BASE = Object.freeze({ const DEFAULT_SETTINGS_BASE = Object.freeze({
civitai_api_key: '', civitai_api_key: '',
@@ -30,6 +30,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
recipes_path: '', recipes_path: '',
base_model_path_mappings: {}, base_model_path_mappings: {},
download_path_templates: {}, download_path_templates: {},
download_filename_templates: {},
example_images_path: '', example_images_path: '',
example_images_open_mode: 'system', example_images_open_mode: 'system',
example_images_local_root: '', example_images_local_root: '',
@@ -74,9 +75,16 @@ export function createDefaultSettings() {
...DEFAULT_SETTINGS_BASE, ...DEFAULT_SETTINGS_BASE,
base_model_path_mappings: {}, base_model_path_mappings: {},
download_path_templates: { ...DEFAULT_PATH_TEMPLATES }, download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
download_filename_templates: { ...DEFAULT_FILENAME_TEMPLATES },
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
default_other_roots: {}, default_other_roots: {},
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'], enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'],
// Standalone-only fields populated by GET /api/lm/settings; in plugin
// mode the backend omits folder_paths/folder_path_schema and these
// defaults apply.
standalone_mode: false,
folder_paths: {},
folder_path_schema: [],
}; };
} }
+19
View File
@@ -360,6 +360,25 @@ export const DEFAULT_PATH_TEMPLATES = {
other: '' other: ''
}; };
// Valid placeholders for download filename templates (opt-in rename of
// downloaded safetensors; the result is a filename stem, no path separators)
export const FILENAME_TEMPLATE_PLACEHOLDERS = [
'{model_name}',
'{version_name}',
'{base_model}',
'{author}',
'{first_tag}',
'{hash_short}',
'{original_name}'
];
// Default filename templates per model type; empty string keeps the original filename
export const DEFAULT_FILENAME_TEMPLATES = {
lora: '',
checkpoint: '',
embedding: ''
};
// Model type labels for UI // Model type labels for UI
export const MODEL_TYPE_LABELS = { export const MODEL_TYPE_LABELS = {
lora: 'LoRA Models', lora: 'LoRA Models',
+20
View File
@@ -49,6 +49,26 @@ export const MODEL_SOURCES = [
filePage: (id, filename) => filePage: (id, filename) =>
`https://modelscope.cn/models/${id}/file/view/master/${filename}`, `https://modelscope.cn/models/${id}/file/view/master/${filename}`,
}, },
{
// A separate catalogue from `modelscope.cn`, not an alias: a repository
// published on one is routinely absent from the other, so the host is part
// of the model's identity. Mirrors ModelScopeIntlSource in the backend.
platform: 'modelscope-ai',
label: 'ModelScope (International)',
groupPrefix: 'msai',
supportsEnrichment: true,
supportsDownload: true,
defaultRevision: 'master',
defaultSubdir: 'modelscope-ai',
exampleUrl: 'https://www.modelscope.ai/models/user/repo',
placeholder: 'https://www.modelscope.ai/models/user/repo',
pattern: /^https?:\/\/(?:www\.)?modelscope\.ai\/models\/([^/?#\s]+\/[^/?#\s]+)/i,
filePattern:
/^https?:\/\/(?:www\.)?modelscope\.ai\/models\/([^/?#\s]+\/[^/?#\s]+)\/resolve\/([^/?#\s]+)\/(.+)$/i,
canonical: (id) => `https://www.modelscope.ai/models/${id}`,
filePage: (id, filename) =>
`https://www.modelscope.ai/models/${id}/file/view/master/${filename}`,
},
{ {
platform: 'tensorart', platform: 'tensorart',
label: 'TensorArt', label: 'TensorArt',
+16
View File
@@ -50,3 +50,19 @@ export function openOtherModelsSettings() {
}); });
}, 100); }, 100);
} }
/**
* Open the settings modal on the standalone-only Model Paths section, where
* primary folder_paths are edited. The section only exists in standalone mode,
* so the nav item lookup simply no-ops elsewhere.
*/
export function openModelPathsSettings() {
const modalManager = window.modalManager;
if (modalManager && typeof modalManager.showModal === 'function') {
modalManager.showModal('settingsModal');
}
window.setTimeout(() => {
document.querySelector('.settings-nav-item[data-section="modelPaths"]')?.click();
}, 100);
}
+15 -2
View File
@@ -205,12 +205,24 @@
</div> </div>
<!-- Sidebar Folder Context Menu --> <!-- Sidebar Folder Context Menu -->
<!-- Order: the content action (update check) first, then the folder operations
as one group, then the destructive action behind its own divider. The
dividers are collapsed by SidebarManager when a group is hidden on the
current page (recipes keep only the update check). -->
<div id="sidebarFolderContextMenu" class="context-menu"> <div id="sidebarFolderContextMenu" class="context-menu">
<div class="context-menu-item" data-action="check-folder-updates">
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="create-subfolder"> <div class="context-menu-item" data-action="create-subfolder">
<i class="fas fa-folder-plus"></i> <span>{{ t('sidebar.newSubfolder') }}</span> <i class="fas fa-folder-plus"></i> <span>{{ t('sidebar.newSubfolder') }}</span>
</div> </div>
<div class="context-menu-item" data-action="check-folder-updates"> <div class="context-menu-item" data-action="rename-folder">
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span> <i class="fas fa-i-cursor"></i> <span>{{ t('sidebar.renameFolder') }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item delete-item" data-action="delete-folder">
<i class="fas fa-trash"></i> <span>{{ t('sidebar.deleteFolder') }}</span>
</div> </div>
</div> </div>
@@ -231,6 +243,7 @@
</div> </div>
<div class="context-menu-item" data-action="toggle-empty-folders"> <div class="context-menu-item" data-action="toggle-empty-folders">
<i class="fas fa-folder-open"></i> <span>{{ t('sidebar.showEmptyFolders') }}</span> <i class="fas fa-folder-open"></i> <span>{{ t('sidebar.showEmptyFolders') }}</span>
<span id="sidebarEmptyFoldersCount" class="context-menu-count"></span>
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i> <i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
</div> </div>
</div> </div>
+1
View File
@@ -14,3 +14,4 @@
{% include 'components/modals/move_modal.html' %} {% include 'components/modals/move_modal.html' %}
{% include 'components/modals/bulk_add_tags_modal.html' %} {% include 'components/modals/bulk_add_tags_modal.html' %}
{% include 'components/modals/bulk_base_model_modal.html' %} {% include 'components/modals/bulk_base_model_modal.html' %}
{% include 'components/modals/directory_picker_modal.html' %}
@@ -82,6 +82,35 @@
</div> </div>
</div> </div>
<!-- Filename Template Apply/Revert Confirmation Modal
Self-managed by SettingsManager (NOT registered with ModalManager): it
stacks above the settings modal, like the directory picker. -->
<div id="filenameTemplateConfirmModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<h2 data-role="title"></h2>
<p class="delete-message" data-role="message"></p>
<div class="modal-actions">
<button class="cancel-btn" data-action="cancel-filename-template">{{ t('common.actions.cancel') }}</button>
<button class="primary-btn" data-action="confirm-filename-template"></button>
</div>
</div>
</div>
<!-- Sidebar Folder Delete Confirmation Modal -->
<!-- Shared by two states: 'confirm' (model-free folder) and 'blocked' (the
subtree still holds models, so a cascade delete is refused). -->
<div id="deleteFolderModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<h2 data-role="title">{{ t('sidebar.deleteFolderModal.title') }}</h2>
<p class="delete-message" data-role="message">{{ t('sidebar.deleteFolderModal.message') }}</p>
<div class="delete-model-info" data-role="info"></div>
<div class="modal-actions">
<button class="cancel-btn" data-action="cancel-delete-folder">{{ t('common.actions.cancel') }}</button>
<button class="delete-btn" data-action="confirm-delete-folder">{{ t('sidebar.deleteFolderModal.confirm') }}</button>
</div>
</div>
</div>
<!-- Bulk Download Missing LoRAs Confirmation Modal --> <!-- Bulk Download Missing LoRAs Confirmation Modal -->
<div id="bulkDownloadMissingLorasModal" class="modal"> <div id="bulkDownloadMissingLorasModal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -0,0 +1,32 @@
<!-- Directory Picker Modal (self-managed by DirectoryPickerModal.js, stacked above the settings modal) -->
<div id="directoryPickerModal" class="modal directory-picker-modal" style="display: none;">
<div class="modal-content directory-picker-content">
<button class="close" id="directoryPickerCloseBtn">&times;</button>
<h3>{{ t('settings.directoryPicker.title') }}</h3>
<div class="directory-picker-path-row">
<input type="text" id="directoryPickerPathInput" placeholder="{{ t('settings.directoryPicker.pathPlaceholder') }}" autocomplete="off">
<button class="secondary-btn" id="directoryPickerGoBtn">
<i class="fas fa-arrow-right"></i> {{ t('settings.directoryPicker.go') }}
</button>
</div>
<div class="directory-browser" id="directoryPickerBrowser">
<div class="browser-header">
<button class="back-btn" id="directoryPickerUpBtn" title="{{ t('settings.directoryPicker.goUp') }}" disabled>
<i class="fas fa-arrow-up"></i>
</button>
<div class="current-path" id="directoryPickerCurrentPath"></div>
</div>
<div class="browser-content">
<div class="folder-list" id="directoryPickerFolderList"></div>
<div class="directory-picker-error" id="directoryPickerError" style="display: none;"></div>
</div>
<div class="browser-footer">
<button class="primary-btn" id="directoryPickerSelectBtn">
<i class="fas fa-check"></i> {{ t('settings.directoryPicker.selectFolder') }}
</button>
</div>
</div>
</div>
</div>
@@ -16,6 +16,7 @@
<div id="hfSupportedSources"> <div id="hfSupportedSources">
<strong>https://huggingface.co/user/repo</strong><br> <strong>https://huggingface.co/user/repo</strong><br>
<strong>https://modelscope.cn/models/user/repo</strong><br> <strong>https://modelscope.cn/models/user/repo</strong><br>
<strong>https://www.modelscope.ai/models/user/repo</strong><br>
<strong>https://tensor.art/models/827823520299086029</strong> <strong>https://tensor.art/models/827823520299086029</strong>
</div> </div>
{{ t('modals.linkModelSource.enrichNote') }} {{ t('modals.linkModelSource.enrichNote') }}
@@ -1,15 +1,4 @@
{% import 'components/modals/settings/_macros.html' as sm with context %} {% import 'components/modals/settings/_macros.html' as sm with context %}
{% set template_preset_options = [
('', 'settings.downloadPathTemplates.templateOptions.flatStructure'),
('{base_model}', 'settings.downloadPathTemplates.templateOptions.byBaseModel'),
('{author}', 'settings.downloadPathTemplates.templateOptions.byAuthor'),
('{first_tag}', 'settings.downloadPathTemplates.templateOptions.byFirstTag'),
('{base_model}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelFirstTag'),
('{base_model}/{author}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthor'),
('{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.authorFirstTag'),
('{base_model}/{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthorFirstTag'),
('custom', 'settings.downloadPathTemplates.templateOptions.customTemplate'),
] %}
<!-- Section 3: Library --> <!-- Section 3: Library -->
<div id="section-library" class="settings-section" data-section="library"> <div id="section-library" class="settings-section" data-section="library">
<!-- Folder Settings --> <!-- Folder Settings -->
@@ -187,192 +176,6 @@
</div> </div>
</div> </div>
<!-- Download Path Templates -->
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>
{{ t('settings.downloadPathTemplates.title') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.help') }}"></i>
</h4>
</div>
<div class="setting-item">
<div class="input-help">
<div class="placeholder-info">
<strong>{{ t('settings.downloadPathTemplates.availablePlaceholders') }}</strong>
<span class="placeholder-tag">{base_model}</span>
<span class="placeholder-tag">{author}</span>
<span class="placeholder-tag">{first_tag}</span>
<span class="placeholder-tag">{model_name}</span>
<span class="placeholder-tag">{version_name}</span>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="loraTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.lora') }}</label>
</div>
<div class="setting-control select-control">
<select id="loraTemplatePreset" onchange="settingsManager.updateTemplatePreset('lora', this.value)">
{% for value, option_label in template_preset_options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="template-custom-row" id="loraCustomRow" style="display: none;">
<input type="text" id="loraCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
<div class="template-validation" id="loraValidation"></div>
</div>
<div class="template-preview" id="loraPreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="checkpointTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.checkpoint') }}</label>
</div>
<div class="setting-control select-control">
<select id="checkpointTemplatePreset" onchange="settingsManager.updateTemplatePreset('checkpoint', this.value)">
{% for value, option_label in template_preset_options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="template-custom-row" id="checkpointCustomRow" style="display: none;">
<input type="text" id="checkpointCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
<div class="template-validation" id="checkpointValidation"></div>
</div>
<div class="template-preview" id="checkpointPreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="embeddingTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.embedding') }}</label>
</div>
<div class="setting-control select-control">
<select id="embeddingTemplatePreset" onchange="settingsManager.updateTemplatePreset('embedding', this.value)">
{% for value, option_label in template_preset_options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="template-custom-row" id="embeddingCustomRow" style="display: none;">
<input type="text" id="embeddingCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
<div class="template-validation" id="embeddingValidation"></div>
</div>
<div class="template-preview" id="embeddingPreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>
{{ t('settings.downloadPathTemplates.baseModelPathMappings') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.baseModelPathMappingsHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
<i class="fas fa-plus"></i>
<span>{{ t('settings.downloadPathTemplates.addMapping') }}</span>
</button>
</div>
</div>
<div class="mappings-container">
<div id="baseModelMappingsContainer">
</div>
</div>
</div>
{{ sm.setting_toggle('skipPreviouslyDownloadedModelVersions', 'skip_previously_downloaded_model_versions', 'settings.skipPreviouslyDownloadedModelVersions.label', 'settings.skipPreviouslyDownloadedModelVersions.help') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadSkipBaseModelsToggle">
{{ t('settings.downloadSkipBaseModels.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadSkipBaseModels.help') }}"></i>
</label>
</div>
<div class="setting-control">
<button
type="button"
id="downloadSkipBaseModelsToggle"
class="secondary-btn base-model-skip-toggle"
aria-expanded="false"
>
<span id="downloadSkipBaseModelsSummary">{{ t('settings.downloadSkipBaseModels.summary.none') }}</span>
<span class="base-model-skip-toggle-label">{{ t('settings.downloadSkipBaseModels.actions.edit') }}</span>
</button>
</div>
</div>
<div id="downloadSkipBaseModelsPanel" class="base-model-skip-panel" hidden>
<div class="base-model-skip-toolbar">
<input
type="text"
id="downloadSkipBaseModelsSearch"
class="base-model-skip-search"
placeholder="{{ t('settings.downloadSkipBaseModels.searchPlaceholder') }}"
/>
<button type="button" class="text-btn base-model-skip-clear" id="downloadSkipBaseModelsClear">
{{ t('settings.downloadSkipBaseModels.actions.clear') }}
</button>
</div>
<div id="downloadSkipBaseModelsContainer" class="base-model-skip-list"></div>
<div id="downloadSkipBaseModelsEmpty" class="base-model-skip-empty" hidden>
{{ t('settings.downloadSkipBaseModels.empty') }}
</div>
</div>
<div class="settings-input-error-message" id="downloadSkipBaseModelsError"></div>
</div>
<!-- Priority Tags -->
<div class="setting-item priority-tags-item">
<div class="setting-row priority-tags-header-row">
<div class="setting-info priority-tags-header">
<label>
{{ t('settings.priorityTags.title') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.priorityTags.description') }}"></i>
</label>
<a class="settings-action-link priority-tags-help-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide" target="_blank" rel="noopener" aria-label="{{ t('settings.priorityTags.helpLinkLabel') }}" title="{{ t('settings.priorityTags.helpLinkLabel') }}">
<i class="fas fa-question-circle" aria-hidden="true"></i>
</a>
</div>
</div>
<div class="priority-tags-tabs">
<input type="radio" id="priority-tags-tab-lora" name="priority-tags-tab" class="priority-tags-tab-input" checked>
<input type="radio" id="priority-tags-tab-checkpoint" name="priority-tags-tab" class="priority-tags-tab-input">
<input type="radio" id="priority-tags-tab-embedding" name="priority-tags-tab" class="priority-tags-tab-input">
<div class="priority-tags-tablist">
<label class="priority-tags-tab-label" for="priority-tags-tab-lora" id="priority-tags-tab-lora-label">{{ t('settings.priorityTags.modelTypes.lora') }}</label>
<label class="priority-tags-tab-label" for="priority-tags-tab-checkpoint" id="priority-tags-tab-checkpoint-label">{{ t('settings.priorityTags.modelTypes.checkpoint') }}</label>
<label class="priority-tags-tab-label" for="priority-tags-tab-embedding" id="priority-tags-tab-embedding-label">{{ t('settings.priorityTags.modelTypes.embedding') }}</label>
</div>
<div class="priority-tags-panels">
<div class="priority-tags-panel" id="priority-tags-panel-lora">
<textarea id="loraPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="loraPriorityTagsError"></div>
</div>
<div class="priority-tags-panel" id="priority-tags-panel-checkpoint">
<textarea id="checkpointPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="checkpointPriorityTagsError"></div>
</div>
<div class="priority-tags-panel" id="priority-tags-panel-embedding">
<textarea id="embeddingPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="embeddingPriorityTagsError"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Version Scope --> <!-- Version Scope -->
<div class="settings-subsection"> <div class="settings-subsection">
{{ sm.subsection_header('settings.sections.versionScope') }} {{ sm.subsection_header('settings.sections.versionScope') }}
@@ -463,25 +266,6 @@
</div> </div>
</div> </div>
<!-- Auto-organize -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.autoOrganize') }}
<!-- Auto-organize Exclusions -->
<div class="setting-item auto-organize-exclusions-item">
<div class="setting-row">
<div class="setting-info">
<label for="autoOrganizeExclusions">
{{ t('settings.autoOrganizeExclusions.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.autoOrganizeExclusions.help') }}"></i>
</label>
</div>
</div>
<textarea id="autoOrganizeExclusions" class="priority-tags-input auto-organize-exclusions-input" placeholder="{{ t('settings.autoOrganizeExclusions.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="autoOrganizeExclusionsError"></div>
</div>
</div>
<!-- Metadata --> <!-- Metadata -->
<div class="settings-subsection"> <div class="settings-subsection">
{{ sm.subsection_header('settings.sections.metadata') }} {{ sm.subsection_header('settings.sections.metadata') }}
@@ -0,0 +1,295 @@
{% import 'components/modals/settings/_macros.html' as sm with context %}
{% set template_preset_options = [
('', 'settings.downloadPathTemplates.templateOptions.flatStructure'),
('{base_model}', 'settings.downloadPathTemplates.templateOptions.byBaseModel'),
('{author}', 'settings.downloadPathTemplates.templateOptions.byAuthor'),
('{first_tag}', 'settings.downloadPathTemplates.templateOptions.byFirstTag'),
('{base_model}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelFirstTag'),
('{base_model}/{author}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthor'),
('{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.authorFirstTag'),
('{base_model}/{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthorFirstTag'),
('custom', 'settings.downloadPathTemplates.templateOptions.customTemplate'),
] %}
<!-- Section 4: Organization -->
<div id="section-organization" class="settings-section" data-section="organization">
<!-- Download Path Templates -->
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>
{{ t('settings.downloadPathTemplates.title') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.help') }}"></i>
</h4>
</div>
<div class="setting-item">
<div class="input-help">
<div class="placeholder-info">
<strong>{{ t('settings.downloadPathTemplates.availablePlaceholders') }}</strong>
<span class="placeholder-tag">{base_model}</span>
<span class="placeholder-tag">{author}</span>
<span class="placeholder-tag">{first_tag}</span>
<span class="placeholder-tag">{model_name}</span>
<span class="placeholder-tag">{version_name}</span>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="loraTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.lora') }}</label>
</div>
<div class="setting-control select-control">
<select id="loraTemplatePreset" onchange="settingsManager.updateTemplatePreset('lora', this.value)">
{% for value, option_label in template_preset_options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="template-custom-row" id="loraCustomRow" style="display: none;">
<input type="text" id="loraCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
<div class="template-validation" id="loraValidation"></div>
</div>
<div class="template-preview" id="loraPreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="checkpointTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.checkpoint') }}</label>
</div>
<div class="setting-control select-control">
<select id="checkpointTemplatePreset" onchange="settingsManager.updateTemplatePreset('checkpoint', this.value)">
{% for value, option_label in template_preset_options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="template-custom-row" id="checkpointCustomRow" style="display: none;">
<input type="text" id="checkpointCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
<div class="template-validation" id="checkpointValidation"></div>
</div>
<div class="template-preview" id="checkpointPreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="embeddingTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.embedding') }}</label>
</div>
<div class="setting-control select-control">
<select id="embeddingTemplatePreset" onchange="settingsManager.updateTemplatePreset('embedding', this.value)">
{% for value, option_label in template_preset_options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="template-custom-row" id="embeddingCustomRow" style="display: none;">
<input type="text" id="embeddingCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
<div class="template-validation" id="embeddingValidation"></div>
</div>
<div class="template-preview" id="embeddingPreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>
{{ t('settings.downloadPathTemplates.baseModelPathMappings') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.baseModelPathMappingsHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
<i class="fas fa-plus"></i>
<span>{{ t('settings.downloadPathTemplates.addMapping') }}</span>
</button>
</div>
</div>
<div class="mappings-container">
<div id="baseModelMappingsContainer">
</div>
</div>
</div>
{{ sm.setting_toggle('skipPreviouslyDownloadedModelVersions', 'skip_previously_downloaded_model_versions', 'settings.skipPreviouslyDownloadedModelVersions.label', 'settings.skipPreviouslyDownloadedModelVersions.help') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadSkipBaseModelsToggle">
{{ t('settings.downloadSkipBaseModels.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadSkipBaseModels.help') }}"></i>
</label>
</div>
<div class="setting-control">
<button
type="button"
id="downloadSkipBaseModelsToggle"
class="secondary-btn base-model-skip-toggle"
aria-expanded="false"
>
<span id="downloadSkipBaseModelsSummary">{{ t('settings.downloadSkipBaseModels.summary.none') }}</span>
<span class="base-model-skip-toggle-label">{{ t('settings.downloadSkipBaseModels.actions.edit') }}</span>
</button>
</div>
</div>
<div id="downloadSkipBaseModelsPanel" class="base-model-skip-panel" hidden>
<div class="base-model-skip-toolbar">
<input
type="text"
id="downloadSkipBaseModelsSearch"
class="base-model-skip-search"
placeholder="{{ t('settings.downloadSkipBaseModels.searchPlaceholder') }}"
/>
<button type="button" class="text-btn base-model-skip-clear" id="downloadSkipBaseModelsClear">
{{ t('settings.downloadSkipBaseModels.actions.clear') }}
</button>
</div>
<div id="downloadSkipBaseModelsContainer" class="base-model-skip-list"></div>
<div id="downloadSkipBaseModelsEmpty" class="base-model-skip-empty" hidden>
{{ t('settings.downloadSkipBaseModels.empty') }}
</div>
</div>
<div class="settings-input-error-message" id="downloadSkipBaseModelsError"></div>
</div>
<!-- Priority Tags -->
<div class="setting-item priority-tags-item">
<div class="setting-row priority-tags-header-row">
<div class="setting-info priority-tags-header">
<label>
{{ t('settings.priorityTags.title') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.priorityTags.description') }}"></i>
</label>
<a class="settings-action-link priority-tags-help-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide" target="_blank" rel="noopener" aria-label="{{ t('settings.priorityTags.helpLinkLabel') }}" title="{{ t('settings.priorityTags.helpLinkLabel') }}">
<i class="fas fa-question-circle" aria-hidden="true"></i>
</a>
</div>
</div>
<div class="priority-tags-tabs">
<input type="radio" id="priority-tags-tab-lora" name="priority-tags-tab" class="priority-tags-tab-input" checked>
<input type="radio" id="priority-tags-tab-checkpoint" name="priority-tags-tab" class="priority-tags-tab-input">
<input type="radio" id="priority-tags-tab-embedding" name="priority-tags-tab" class="priority-tags-tab-input">
<div class="priority-tags-tablist">
<label class="priority-tags-tab-label" for="priority-tags-tab-lora" id="priority-tags-tab-lora-label">{{ t('settings.priorityTags.modelTypes.lora') }}</label>
<label class="priority-tags-tab-label" for="priority-tags-tab-checkpoint" id="priority-tags-tab-checkpoint-label">{{ t('settings.priorityTags.modelTypes.checkpoint') }}</label>
<label class="priority-tags-tab-label" for="priority-tags-tab-embedding" id="priority-tags-tab-embedding-label">{{ t('settings.priorityTags.modelTypes.embedding') }}</label>
</div>
<div class="priority-tags-panels">
<div class="priority-tags-panel" id="priority-tags-panel-lora">
<textarea id="loraPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="loraPriorityTagsError"></div>
</div>
<div class="priority-tags-panel" id="priority-tags-panel-checkpoint">
<textarea id="checkpointPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="checkpointPriorityTagsError"></div>
</div>
<div class="priority-tags-panel" id="priority-tags-panel-embedding">
<textarea id="embeddingPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="embeddingPriorityTagsError"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Filename Templates -->
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>
{{ t('settings.filenameTemplates.title') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.filenameTemplates.help') }}"></i>
</h4>
</div>
<div class="setting-item">
<div class="input-help">
<div class="placeholder-info">
<strong>{{ t('settings.filenameTemplates.availablePlaceholders') }}</strong>
<span class="placeholder-tag">{model_name}</span>
<span class="placeholder-tag">{version_name}</span>
<span class="placeholder-tag">{base_model}</span>
<span class="placeholder-tag">{author}</span>
<span class="placeholder-tag">{first_tag}</span>
<span class="placeholder-tag">{hash_short}</span>
<span class="placeholder-tag">{original_name}</span>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="loraFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.lora') }}</label>
</div>
<div class="setting-control">
<button type="button" id="loraApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('lora')">
{{ t('settings.filenameTemplates.applyButton') }}
</button>
</div>
</div>
<input type="text" id="loraFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
<div class="template-validation" id="loraFilenameValidation"></div>
<div class="template-preview" id="loraFilenamePreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="checkpointFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.checkpoint') }}</label>
</div>
<div class="setting-control">
<button type="button" id="checkpointApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('checkpoint')">
{{ t('settings.filenameTemplates.applyButton') }}
</button>
</div>
</div>
<input type="text" id="checkpointFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
<div class="template-validation" id="checkpointFilenameValidation"></div>
<div class="template-preview" id="checkpointFilenamePreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="embeddingFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.embedding') }}</label>
</div>
<div class="setting-control">
<button type="button" id="embeddingApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('embedding')">
{{ t('settings.filenameTemplates.applyButton') }}
</button>
</div>
</div>
<input type="text" id="embeddingFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
<div class="template-validation" id="embeddingFilenameValidation"></div>
<div class="template-preview" id="embeddingFilenamePreview"></div>
</div>
<div class="setting-item">
<div class="input-help">{{ t('settings.filenameTemplates.applyHelp') }}</div>
</div>
</div>
<!-- Auto-organize -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.autoOrganize') }}
<!-- Auto-organize Exclusions -->
<div class="setting-item auto-organize-exclusions-item">
<div class="setting-row">
<div class="setting-info">
<label for="autoOrganizeExclusions">
{{ t('settings.autoOrganizeExclusions.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.autoOrganizeExclusions.help') }}"></i>
</label>
</div>
</div>
<textarea id="autoOrganizeExclusions" class="priority-tags-input auto-organize-exclusions-input" placeholder="{{ t('settings.autoOrganizeExclusions.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="autoOrganizeExclusionsError"></div>
</div>
</div>
</div>
@@ -36,6 +36,7 @@
<button type="button" class="settings-nav-item active" data-section="general">{{ t('settings.nav.general') }}</button> <button type="button" class="settings-nav-item active" data-section="general">{{ t('settings.nav.general') }}</button>
<button type="button" class="settings-nav-item" data-section="interface">{{ t('settings.nav.interface') }}</button> <button type="button" class="settings-nav-item" data-section="interface">{{ t('settings.nav.interface') }}</button>
<button type="button" class="settings-nav-item" data-section="library">{{ t('settings.nav.library') }}</button> <button type="button" class="settings-nav-item" data-section="library">{{ t('settings.nav.library') }}</button>
<button type="button" class="settings-nav-item" data-section="organization">{{ t('settings.nav.organization') }}</button>
</li> </li>
</ul> </ul>
</nav> </nav>
@@ -46,6 +47,7 @@
{% include 'components/modals/settings/general.html' %} {% include 'components/modals/settings/general.html' %}
{% include 'components/modals/settings/interface.html' %} {% include 'components/modals/settings/interface.html' %}
{% include 'components/modals/settings/library.html' %} {% include 'components/modals/settings/library.html' %}
{% include 'components/modals/settings/organization.html' %}
</div> </div>
</div> </div>
</div> </div>
+7 -2
View File
@@ -26,8 +26,13 @@
<i class="fas fa-trash" aria-hidden="true"></i> <i class="fas fa-trash" aria-hidden="true"></i>
</button> </button>
</div> </div>
<!-- Recipe Tags Container (rendered by renderCompactTags) --> <!-- Tags row: the base model badge is an independent sibling of the
<div id="recipeTagsContainer"></div> tags container so renderCompactTags re-renders and tag edit mode
never touch it. Badge is populated by RecipeModal.syncBaseModelBadge(). -->
<div class="recipe-tags-row">
<span id="recipeBaseModelBadge" class="base-model-label recipe-base-model-badge" hidden></span>
<div id="recipeTagsContainer"></div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">

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