Compare commits

...

32 Commits

Author SHA1 Message Date
Will Miao 16b0bdf70a chore(release): bump version to v1.2.3 2026-09-17 09:12:34 +08:00
Will Miao e09fe5888b refactor(reorder): drop the Alt + Arrow shortcut, keep drag only
The reorder shortcut cannot be made reliable in this UI. `Alt + Arrow` is
the browser's tab-history / back-forward gesture on several platforms,
and the modal already binds bare `ArrowLeft`/`ArrowRight` to model
navigation, so the binding either did nothing — a keypress with nothing
focused never reaches a listener on the tag list — or fought the browser.
An affordance that occasionally navigates the page away is worse than
having no keyboard path at all, so drop it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Consume the context in the post-processor:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tests: backend 2815 passed; frontend 1130 JS + 91 Vue passed; pytest
tests/i18n and a Jinja compile pass over templates/. The nine locales carry
[TODO: Translate] for the new strings, completed in the next commit.
2026-09-14 07:24:08 +08:00
114 changed files with 16661 additions and 2109 deletions
+246 -235
View File
@@ -7,190 +7,199 @@
],
"allSupporters": [
"Takkan",
"2018cfh",
"megakirbs",
"Brennok",
"Charles Blakemore",
"2018cfh",
"Rob Williams",
"Insomnia Art Designs",
"Charles Blakemore",
"Arlecchino Shion",
"Insomnia Art Designs",
"Mozzel",
"Gingko Biloba",
"stone9k",
"Kiba",
"onesecondinosaur",
"Skalabananen",
"Sterilized",
"Polymorphic Indeterminate",
"Liam MacDougal",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"Rosenthal",
"ClockDaemon",
"Francisco Tatis",
"Tobi_Swagg",
"SG",
"jmack",
"Andrew Wilson",
"Greybush",
"Ricky Carter",
"JongWon Han",
"VantAI",
"レプサイ",
"Michael Wong",
"Illrigger",
"Tom Corrigan",
"JackieWang",
"FreelancerZ",
"Mozzel",
"fnkylove",
"Lilleman",
"Robert Stacey",
"PM",
"Marc Whiffen",
"Dogwalkerbr",
"Birdy",
"Kiba",
"quarz",
"$MetaSamsara",
"jean jahren",
"Reno Lam",
"Aleksander Wujczyk",
"AM Kuro",
"JSST",
"sig",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"J\\B/ 8r0wns0n",
"Snaggwort",
"Anthony+Rizzo",
"W+K+White",
"ClockDaemon",
"Baekdoosixt",
"Jonathan Ross",
"KD",
"Omnidex",
"Nolife_M",
"Melville Parrish",
"daniel dove",
"Lustre",
"Tyler Trebuchon",
"Release Cabrakan",
"SG",
"JW Sin",
"Alex",
"carozzz",
"Marlon Daniels",
"James Dooley",
"zenbound",
"Buzzard",
"jmack",
"Adam Shaw",
"Mark Corneglio",
"RedrockVP",
"James Todd",
"Wicked Choices by ASLPro3D",
"FinalyFree",
"Fyf",
"レプサイ",
"Timmy",
"Johnny",
"Tak",
"Lisster",
"Michael Wong",
"Big Red",
"whudunit",
"Tom Corrigan",
"JackieWang",
"fnkylove",
"Luc Job",
"corde",
"Yushio",
"Vik71it",
"Bishoujoker",
"Echo",
"Lilleman",
"Robert Stacey",
"PM",
"Todd Keck",
"Briton Heilbrun",
"wildnut",
"Edgar Tejeda",
"Sterilized",
"BadassArabianMofo",
"Dogwalkerbr",
"quarz",
"MiraiKuriyamaSy",
"Pascal Dahle",
"Greg",
"jean jahren",
"AM Kuro",
"JSST",
"Akira HentAI",
"otaku fra",
"lmsupporter",
"andrew.tappan",
"wackop",
"Phil",
"Greenmoustache",
"Carl G.",
"wfpearl",
"jeaness",
"Dsperado",
"Baekdoosixt",
"Jack B Nimble",
"Melville Parrish",
"daniel dove",
"Lustre",
"JW Sin",
"Alex",
"bh",
"Marlon Daniels",
"Jwk0205",
"Starkselle",
"Olive",
"Aaron Bleuer",
"LacesOut!",
"greebles",
"SarcasticHashtag",
"Wicked Choices by ASLPro3D",
"Some Guy Named Barry",
"M Postkasse",
"Jacob Hoehler",
"FinalyFree",
"Matt Wenzel",
"Weasyl",
"Lex Song",
"Cory Paza",
"Tak",
"Gonzalo Andre Allendes Lopez",
"Big Red",
"Serge Bekenkamp",
"AIJimmy",
"Luc Job",
"Philip Hempel",
"corde",
"Bishoujoker",
"dan",
"aai",
"wildnut",
"Ran C",
"ViperC",
"itismyelement",
"Sangheili460",
"MagnaInsomnia",
"Karl P.",
"Akira HentAI",
"MiraiKuriyamaSy",
"LarsesFPC",
"otaku fra",
"andrew.tappan",
"Weird_With_A_Beard",
"N/A",
"The Spawn",
"graysock",
"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",
"AIGooner",
"Luc",
"ProtonPrince",
"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",
"Hasturkun",
"Jon Sandman",
@@ -201,39 +210,38 @@
"wundershark",
"mr_dinosaur",
"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",
"Ranzitho",
"Gus",
"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",
"Tr4shP4nda",
"Gamalonia",
@@ -248,37 +256,41 @@
"Kland",
"Hailshem",
"Naomi Hale Danchi",
"epicgamer0020690",
"Joshua Porrata",
"Andrew",
"Brian M",
"sanborondon",
"Robert Wegemund",
"Littlehuggy",
"Brian Buie",
"Thought2Form",
"jcay015",
"RAIDiation",
"Erik Lopez",
"Mateo Curić",
"Eris3D",
"Sadlip",
"Gooohokrbe",
"m",
"OldBones",
"Pierce McBride",
"Zach Gonser",
"Mikko Hemilä",
"Jacob McDaniel",
"Jamie Ogletree",
"a _",
"James Coleman",
"Temikus",
"Artokun",
"Michael Taylor",
"Martial",
"Emil Andersson",
"Ouro Boros",
"Atilla Berke Pekduyar",
"Decx _",
"Yuji Kaneko",
"Rops Alot",
"Sam",
"Penfore",
"Gordon Cole",
"Ace Ventura",
"AbstractAss",
"David LaVallee",
"ken",
"epicgamer0020690",
"Joshua Porrata",
"Crocket",
"keemun",
"SuBu",
"RedPIXel",
@@ -297,15 +309,19 @@
"KitKatM",
"socrasteeze",
"MudkipMedkitz",
"deanbrian",
"Alex Wortman",
"Cody",
"emadsultan",
"InformedViewz",
"Bubbafett",
"leaf",
"Adam Rinehart",
"gzmzmvp",
"takyamtom",
"Andrew",
"Robert Wegemund",
"Littlehuggy",
"Aberr",
"Gregory Kozhemiak",
"Brian Buie",
"aezin",
"Sadlip",
"Eric Whitney",
"Joey Callahan",
"Ivan Tadic",
@@ -315,17 +331,12 @@
"Elliot E",
"Morgandel",
"Theerat Jiramate",
"Jacob McDaniel",
"X",
"SloanSteddyAI",
"Temikus",
"Artokun",
"Michael Taylor",
"Steven Owens",
"hexxish",
"Derek Baker",
"Atilla Berke Pekduyar",
"NICHOLAS BAXLEY",
"Decx _",
"Ed Wang",
"Saya",
"Xeeosat",
@@ -333,18 +344,10 @@
"四糸凜音",
"esthe",
"FrxzenSnxw",
"Crocket",
"chriphost",
"ResidentDeviant",
"deanbrian",
"Alex Wortman",
"Cody",
"emadsultan",
"InformedViewz",
"Bubbafett",
"leaf",
"Ginnie",
"Skyfire83",
"Adam Rinehart",
"Pitpe11",
"IamAyam",
"TheD1rtyD03",
@@ -356,17 +359,25 @@
"SpringBootisTrash",
"carsten",
"ikok",
"quantenmecha",
"Jason+Nash",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"Duk3+Rand0m",
"Nathen+Choi",
"T",
"D",
"David Schenck",
"Wolfe7D1",
"Aberr",
"Andrew Marshall",
"Taylor Funk",
"elleshar666",
"Gerald Welly",
"Tee Gee",
"ACTUALLY_the_Real_Willem_Dafoe",
"Михал Михалыч",
"tarek helmi",
"Kauffy",
"Max Marklund",
@@ -376,13 +387,15 @@
"Vane Holzer",
"psytrax",
"Cyrus Fett",
"hexxish",
"lh qwe",
"conner",
"Xenon Xue",
"Michael Anthony Scott",
"notedfakes",
"Princess Bright Eyes",
"Michael Scott",
"Solixer",
"Jimmy Borup",
"Wes Sims",
"Donor4115",
"Filippo Ferrari",
@@ -393,11 +406,19 @@
"momokai",
"몽타주",
"kudari",
"Whitepinetrader",
"OrganicArtifact",
"Ginnie",
"Raku",
"CHKeeho80",
"nanana",
"Alex",
"Karru",
"ChaChanoKo",
"ghoulars",
"null",
"Beau",
"redcarrot",
"powerbot99",
"Fthehappy",
"J",
"Jeff+Kesemeyer",
@@ -407,39 +428,32 @@
"Doug+Rintoul",
"Noor",
"Yorunai",
"D",
"quantenmecha",
"Jason+Nash",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"Duk3+Rand0m",
"Richard",
"奚明 刘",
"준희 김",
"りん あめ",
"Михал Михалыч",
"Matt",
"Tomohiro Baba",
"Noora",
"Frogmilk",
"SPJ",
"Kor",
"Bryan Rutkowski",
"Noah",
"Xenon Xue",
"TenaciousD",
"Dmitry Ryzhov",
"DarkSunset",
"Edward Ten Eyck",
"Steam Steam",
"CryptoTraderJK",
"Davaitamin",
"Solixer",
"Pete Pain",
"Nathan",
"Jimmy Borup",
"tedcor",
"RHopkirk",
"jinksta187",
"Fotek Design",
"Maxim",
"Manu Thetug",
"Lyavph",
"Nihongasuki",
@@ -450,8 +464,14 @@
"starbugx",
"dc7431",
"Inversity",
"Whitepinetrader",
"Vir",
"Sildoren",
"Darv",
"Seon+Song",
"2turbo",
"Dmitry+Viznesenskiy",
"tanjin90",
"sternenkrieger",
"Pascalou",
"Patrick+Bryan",
"lighthawke",
@@ -468,23 +488,17 @@
"Bob+Barker",
"Dark_Pest",
"Eldithor",
"Alex",
"Karru",
"ChaChanoKo",
"ghoulars",
"redcarrot",
"null",
"Beau",
"powerbot99",
"Ko-fi+Supporter",
"lrdchs2",
"Tú Nguyễn Lý Hoàng",
"shira1011",
"Kalli Core",
"Ben D",
"Draven T",
"marioandluigi",
"G",
"Ronan Delevacq",
"Leslie Andrew Ridings",
"Aquatic Coffee",
"Dave Abraham",
"Joaquin Hierrezuelo",
@@ -492,25 +506,27 @@
"StudOx Tech",
"yves.poezevara",
"Jarrid Lee",
"Kor",
"Poophead27 Blyat",
"Joseph Hanson",
"John Rednoulf",
"Focuschannel",
"Boba Smith",
"matt",
"somethingtosay8",
"ivistorm",
"Anthony Faxlandez",
"Sauv",
"TenaciousD",
"Ted Cart",
"Sage Himeros",
"Zeeble",
"Pat Hen",
"Pete Pain",
"Draconach",
"Tigon",
"ItsGeneralButtNaked",
"Jordan Shaw",
"RHopkirk",
"g unit",
"Maxim",
"Dkom22",
"Marcos Tortosa Carmona",
"Distortik",
"JC",
"Prompt Pirate",
@@ -518,11 +534,22 @@
"Marcus thronico",
"zenobeus",
"ryoma",
"dg",
"Stryker",
"smart.edge5178",
"Menard",
"SomeDude",
"raf8osz",
"Gold_miner_ego",
"bakeliteboy",
"TequiTequi",
"Homero+Banda",
"Nick",
"Monix",
"Trolinka",
"PredragR",
"Clauzmak",
"Nerick",
"SundayRage",
"matter",
"SRCRCOSS",
@@ -539,13 +566,6 @@
"Mobius2020",
"ExLightSaber",
"YaboiRay",
"Sildoren",
"Darv",
"Seon+Song",
"2turbo",
"Dmitry+Viznesenskiy",
"tanjin90",
"sternenkrieger",
"boston666",
"cocona",
"Obsidian.Studios",
@@ -553,52 +573,53 @@
"Aquaneo",
"blikkies",
"JBsuede",
"shira1011",
"Wolf and Fox Legends",
"ゼクス、六",
"Neko Desco",
"Vinarus",
"Josh Snyder",
"Shock Shockor",
"Goldwaters",
"Leslie Andrew Ridings",
"Zude",
"Poophead27 Blyat",
"Room Light",
"Kyler",
"Justin Blaylock",
"aRtFuL_DodGeR",
"Snorklebort",
"TheFusion",
"MR.Bear",
"matt",
"somethingtosay8",
"3zS4QNQ4",
"Terminuz",
"Matt M.",
"Ivan Imes",
"J M",
"Steven",
"Borte",
"Sage Himeros",
"yyuvuvu",
"Billy Gladky",
"Nomki",
"Probis",
"Jack Lawfield",
"SkibidiRizzler",
"Maxon - Plans",
"Kalle Björk",
"ItsGeneralButtNaked",
"Karlanx",
"operationancut",
"Nacho Ferrando",
"Marcos Tortosa Carmona",
"Dkom22",
"Youguang",
"andrewzpong",
"BossGame",
"lrdchs",
"Tree Tagger",
"Janik",
"AIVORY3D",
"Kevinj",
"Mitchell Robson",
"dg",
"POPPIN",
"meatyalien",
"Tony+V",
"draganjankovic1975dj528",
"kinz",
"YoruHime",
"Mark+Staaf",
"Michael+Fürmann",
@@ -611,17 +632,7 @@
"thomasand01",
"Shiba+Sama",
"Celestial+Kitten",
"TequiTequi",
"Homero+Banda",
"bakeliteboy",
"Nick",
"Gold_miner_ego",
"IshouI;_;",
"Monix",
"Trolinka",
"PredragR",
"Clauzmak",
"Nerick",
"SAVEagleBasement",
"Adam+Spreer",
"BillyBoy84",
@@ -629,18 +640,17 @@
"Welkor",
"dubious1one",
"Brandon Thomas",
"Dustin Hendel",
"moranqianlong",
"Wolf and Fox Legends",
"ゼクス、六",
"Liberation",
"Ninja Tom",
"75marc",
"Elemnt",
"Bradley Turner",
"swra",
"JollRodrigo",
"Oliverfish",
"uruksayshi",
"Room Light",
"Patryk Serious",
"nk8",
"Kyron Mahan",
@@ -648,17 +658,18 @@
"Nimhloth",
"TBitz33",
"Anonym dkjglfleeoeldldldlkf",
"Tsani Prodanov",
"Ezokewn",
"SendingRavens",
"J M",
"Slacks",
"Glenn Hoetker",
"JackJohnnyJim",
"Khánh Đặng",
"Michael Hicks",
"Homero Banda",
"Michael Docherty",
"yyuvuvu",
"Nomki",
"MadGod",
"GhostyGhost",
"Paul Hartsuyker",
"elitassj",
"Never_M",
@@ -667,6 +678,7 @@
"Andrew Wilkinson",
"David",
"floeki75pad",
"TheJohnes",
"deadwishd",
"shinonomeiro",
"Snille",
@@ -675,7 +687,6 @@
"xybrightsummer",
"jreedatchison",
"PhilW",
"Janik",
"Cruel",
"MRBlack",
"Kiyoe",
@@ -685,6 +696,15 @@
"Scott",
"Muratoraccio",
"D",
"Daevalus",
"Milky+Mai",
"Krash",
"PP",
"thababydjac",
"belligerencebk",
"tortor",
"Peter",
"T",
"zipzorpp",
"Anton",
"actual",
@@ -706,11 +726,7 @@
"plonk",
"Anvil+Girl",
"Kotetsu",
"meatyalien",
"Tony+V",
"draganjankovic1975dj528",
"miduzza",
"kinz",
"Somebody",
"てぃんてぃんひーろー",
"you+halo9",
@@ -727,12 +743,12 @@
"4IXplr0r3r",
"hayden",
"ahoystan",
"Civitaier",
"BakunyuuWaifu",
"edk",
"Dustin Hendel",
"Joey Leto",
"Anagra Nouma",
"tafapayo",
"Bradley Turner",
"ja s",
"Doug Mason",
"scoreswazey",
@@ -747,8 +763,8 @@
"David Murcko",
"Justin Defer",
"Ben Brogger",
"Tsani Prodanov",
"Jack Dole",
"dsffsdfsdfsdfsdfsdf",
"V Bj",
"Rj Joplin",
"Kurt",
@@ -757,15 +773,13 @@
"Taylor Dominy",
"Faith",
"Bouya shaka",
"Michael Hicks",
"Maso",
"MadGod",
"Kevin Wallace",
"GhostyGhost",
"ChicRic",
"Bastard-Sama",
"mercur",
"Sunny",
"Somebody",
"inusanorthcape",
"Kane Sturzebecher",
"Yavizu3d",
@@ -776,7 +790,6 @@
"Evgeniya Smolentseva",
"Raf Stahelin",
"Вячеслав Маринин",
"TheJohnes",
"Cola Matthew",
"OniNoKen",
"Iain Wisely",
@@ -819,6 +832,12 @@
"SelfishMedic",
"adderleighn",
"EnragedAntelope",
"mcmalt",
"cesasol",
"Null",
"fdfac",
"Eli",
"Somebody",
"8/4",
"ivan.morgado.siles",
"SEI",
@@ -830,16 +849,7 @@
"gdfgfdgfds",
"Benjamin+Doerr",
"D",
"Daevalus",
"MilkyMai",
"Krash",
"PP",
"babydjac",
"belligerencebk",
"tortor",
"Cryphius",
"Peter+Timothy+Stover",
"Joel+Magnusson",
"Connor+Hall",
"Macho+Grump",
"Morcoddd",
@@ -879,13 +889,11 @@
"proto merp",
"_ G3n",
"Donovan Jenkins",
"Civitaier",
"Hans Meier",
"jboul",
"Michael Eid",
"Super Sigma Reborne",
"Veloce",
"Joey Leto",
"Bob barker",
"Michael Rivera",
"karim ben brik",
@@ -916,6 +924,7 @@
"DrB",
"wknight",
"Moneymaker412K",
"Jacid",
"unkeiknown",
"Towelie",
"Alex Ross",
@@ -926,10 +935,12 @@
"john Greene",
"jimyjomson",
"JaeHyun Jang",
"sbone",
"BigBoss",
"Chase Kwon",
"Bob Ling",
"Inyoshu",
"nick Meadows",
"Chad Barnes",
"redlines3",
"Adam Gardner",
@@ -944,6 +955,7 @@
"Somebody",
"Somebody",
"Somebody",
"Somebody",
"CoffeeMage",
"Ken+Suzuki",
"hannibal",
@@ -954,8 +966,7 @@
"L C",
"Dude",
"Somebody",
"Somebody",
"CK"
],
"totalCount": 954
"totalCount": 965
}
+124 -8
View File
@@ -62,27 +62,143 @@ Environment variable overrides: `LLM_API_KEY`, `LLM_MODEL`, `LLM_API_BASE`, `LLM
### enrich_hf_metadata
Enriches HuggingFace-downloaded models with metadata extracted by an LLM from the HF model card.
Enriches models linked to an external model site with metadata extracted by an LLM from the site's model card (README).
**Entry point**: Right-click context menu → "Enrich Metadata (Agent)"
**Entry point**: Right-click context menu → "Enrich Metadata with AI"
**Supported model sources**:
| Platform | Link | AI enrichment | Direct download |
| --- | --- | --- | --- |
| Hugging Face | yes | yes | yes |
| ModelScope (`modelscope.cn`) | yes | yes | yes |
| ModelScope International (`modelscope.ai`) | yes | yes | yes |
| TensorArt | yes | no (see below) | no |
`modelscope.cn` and `modelscope.ai` are **separate catalogues, not mirrors** — a
repository published on one is routinely absent from the other — so each is
registered as its own source (`ModelScopeSource` / `ModelScopeIntlSource` in
`py/services/model_sources/modelscope.py`). The host therefore decides which
API and CDN a model resolves against, and the two deployments get separate
version groups (`ms:` / `msai:`) and default download directories. Keep the two
tables in `modelSourceHelpers.js` and `registry.py` in step when adding a site.
TensorArt is link-only: `tensor.art` sits behind a Cloudflare managed challenge and its internal API requires session authorization, so the backend cannot read its model pages. Linking still stores the canonical page URL and the "View on TensorArt" link works.
**What it does**:
1. Reads the model's `.metadata.json` to get the `hf_url`
2. Fetches the README.md from the HuggingFace repository
3. Sends the README + local metadata to the LLM for structured extraction
1. Reads the model's `.metadata.json` to get the source (`source_platform` + `source_url`, or the legacy `hf_url`)
2. Fetches the model card through the provider in `py/services/model_sources/` — the README via `fetch_model_card()`, plus any extras the site keeps outside it via `fetch_model_card_context()`
3. Sends the README + site-provided extras + local metadata to the LLM for structured extraction
4. Writes extracted fields to `.metadata.json`:
- `base_model` — only if current value is empty
- `trainedWords` — trigger words (LoRA only, if none exist)
- `modelDescription`concise summary (if none exists)
- `modelDescription`the site's author description (if any) followed by the README rendered as HTML
- `tags` — merged with existing tags, deduplicated
- `civitai.images` — example images
- `metadata_source` — audit trail: `agent:enrich_hf_metadata`
- `llm_enriched_at` — ISO timestamp
5. Downloads and optimizes preview image (if LLM found one in the README)
5. Downloads and optimizes a preview image, using the per-file example image the
site publishes when the README has none
6. Updates the scanner cache
7. Broadcasts WebSocket progress events
#### Site-provided card extras (`fetch_model_card_context`)
A model card is not always just `README.md`. ModelScope keeps the author's
summary (`Description`), the site-curated tags (`OfficialTags`), and — per
published version — the model filenames together with that file's example
images (`MuseInfo.versions[].coverImages`) and trigger words in its
model-detail API. AIGC repositories there often ship an auto-generated
boilerplate README and put everything useful in `Description`, so reading only
the README yields almost nothing.
Providers opt in by overriding `ModelSource.fetch_model_card_context()`, which
returns a `ModelCardContext`. The wanted file is identified by its sha256 when
the caller knows it (the scanner already records one) and by **basename**
otherwise, so each checkpoint in a collection repo gets its own images — and
keeps getting them after the user renames the weights, which is the only
identifier a rename cannot invalidate. Sites with no such extras inherit an
empty context, and the pipeline behaves exactly as before.
The README and the repository metadata describe the whole repository, not one
file, so `execute_skill()` creates a `ModelSourceCache` for the duration of a
run and passes it down. Enriching the eight checkpoints of one ModelScope
repository costs two HTTP requests instead of sixteen; only the per-file
selection is redone for each file. Nothing is cached across runs, and download
URLs never go through it.
#### Deterministic data is applied whether or not an LLM is configured
`AgentService._load_source_card()` runs for every source-backed enrichment, and
the post-processor applies what it returns before the LLM output is merged. A
user with **no** provider configured therefore still gets the author summary,
the example images, the preview, the site-curated tags, the trigger words and
the README rendered as the model description.
The LLM is always consulted when one is configured — invoking **Enrich Metadata
with AI** must call the provider every time, and the site data is never treated
as a reason to skip it. The deterministic values act as fallbacks that fill
gaps the LLM leaves behind:
| Field | Deterministic source | LLM role |
| --- | --- | --- |
| `model_name` | site display name (`Name`), written only while the value is still the file stem | — |
| `modelDescription` | author summary + README as HTML | — |
| `civitai.name` | the matched version's label (`modelVersion.showName`) | — |
| `civitai.images` | site example images, then README images | — |
| `preview_url` | first available example image | may propose one from the README |
| `tags` | site-curated tags, always merged in | proposes additional content tags |
| `civitai.description` | author summary | richer 1-2 sentence summary wins |
| `base_model` | site hints resolved against the canonical vocabulary (`py/services/agent/base_model_resolver.py`) | mapping it is the LLM's job; the resolver only fills in when the LLM returns nothing |
| `trainedWords` | per-file site trigger words, then YAML `instance_prompt` | primary extraction |
| `usage_tips` | regex over an explicitly stated strength range | primary extraction |
| `notes` | — | LLM-only |
Models with no source, an unknown source, or a source without model-card access (TensorArt) are skipped with an explicit reason and counted in the run summary.
**Model types**: LoRA, Checkpoint, Embedding
### Download-time hydration
The same deterministic mapping runs automatically when a model is downloaded
from a model source, so a ModelScope or Hugging Face download lands with the
populated card a CivitAI download produces instead of a bare filename and
hash. Nothing needs to be triggered by hand and no provider is called.
`py/services/model_sources/hydration.py` owns this path:
* `_save_source_metadata()` in `py/routes/handlers/model_source_handlers.py`
creates the sidecar (hash, source link, scanner-cache entry) and then calls
`hydrate_from_source()`. It also runs for a file that was already on disk, so
models downloaded before this existed get topped up on the next attempt.
* Metadata is created through the **owning scanner**
(`scanner._create_default_metadata()`) rather than
`MetadataManager.create_default_metadata()`, so the per-type lazy-hash rule
applies: `CheckpointScanner` and `OtherScanner` store
`hash_status="pending"` with an empty `sha256` for their multi-GB files, and
the generic helper would read a 10 GB checkpoint end to end inside the
download request. Hydration copes with the empty hash — `_matching_versions()`
falls back to the repository basename, which the download just wrote.
* Hydration reuses `PostProcessor` with an empty `llm_output`, so the two paths
cannot drift apart. It reports `metadata_source = "source:<platform>"` rather
than the skill's `agent:enrich_hf_metadata`, and — because no provider ran —
it does not stamp `llm_enriched_at`.
* `model_name` is only written while it still equals the file stem: once a user
renames a model, that choice is kept.
* Only a model whose stored `source_platform`/`source_url` match the repository
being downloaded is updated; a local file that merely shares a name must not
receive another model's card.
* The README and repository payload describe the *repository*, so a short-lived
process-wide `ModelSourceCache` (`shared_source_cache`, 300 s, 32 entries)
keeps a batch over one repository to two HTTP requests.
* Every failure — unreachable site, changed payload shape, broken post-processor
— is logged and swallowed. Metadata hydration can never fail a download.
* Neither stage advances the byte counter, so both are announced to the
progress UI (`_report_phase()``{"status": "metadata", "stage": ...}`) as
they start. Without that the bar sits at 100% reporting `0 B/s` for several
seconds and the download looks stuck. `stage` and `platform` are
machine-readable; the wording is localised in `LoadingManager`.
## Adding a New Skill
### 1. Create the skill directory
@@ -129,7 +245,7 @@ Use `{{variable}}` placeholders that will be replaced with data from the `prepar
```markdown
You are an expert assistant...
Model URL: {{hf_url}}
Model URL: {{source_url}}
README content:
{{readme_content}}
+91 -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
`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).
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
@@ -33,6 +33,29 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
> in the same pass. The `folder_paths` JSON snippet shown in that state lives in
> `templates/other.html`, **not** in the locale files, so it is never translated — only the
> surrounding prose is. Terminology added in §2.
>
> **Status (2026-09, model sources):** models can now be linked to ModelScope and TensorArt
> alongside Hugging Face, which added 15 keys (`modelCard.actions.viewOnSource`,
> `loras.contextMenu.linkModelSource`, `modals.linkModelSource.*`,
> `modals.model.versions.sourceGroupInfo`, `toast.contextMenu.enrichNeedsSource`,
> `toast.contextMenu.enrichUnsupportedSource`) and refreshed the two `enrichHfAgent` labels,
> which had hardcoded "HF" for a button that now also enriches ModelScope models. The
> `modals.linkModelSource.urlPlaceholder` value stays byte-identical to `en.json` (it is a URL,
> the §6 exception). Terminology in §2, "Model source feature".
>
> **Status (2026-09, folder sidebar):** the model-root sidebar gained on-disk folder management
> (create / rename / delete folders, show empty folders, tree vs list view) plus its `...`
> view-options menu, adding 35 `sidebar.*` keys. Those were the only `[TODO: Translate]`
> placeholders left behind by the feature series, and all 35 are now translated in all 9
> locales, so the "no remaining placeholders" claim above holds again. Terminology in §2,
> "Folder sidebar feature".
>
> **Status (2026-09, chip reordering):** model tags and trigger words now share one drag/`⠿`
> grip reorder affordance, which added the single `common.reorder.dragHandle` key (it lives
> under `common` because both editors render it). All 9 locales are translated (renderings in
> §2, "Chip reordering"). Reordering is pointer-only by design: an `Alt + Arrow` shortcut was
> prototyped and removed because it collided with the browser's Alt + Arrow handling and the
> modal's arrow-key navigation.
---
@@ -292,6 +315,73 @@ physically exist:
`settings.json` and `ComfyUI` stay verbatim in every locale; "reload this page" / "restart
LoRA Manager" reuse each locale's existing restart wording (`settings.extraFolderPaths.*`).
### Model source feature (Hugging Face / ModelScope / TensorArt)
A model file can be linked to the page of an external model site. **Hugging Face**,
**ModelScope** and **TensorArt** are brand names and stay Latin in every locale (R3); the
generic nouns around them are translated:
| Term | Rendering |
|---|---|
| model source | zh-CN 模型来源 · zh-TW 模型來源 · ja モデルソース · ko 모델 소스 · fr source de modèle · de Modellquelle · es fuente de modelo · ru источник модели · he מקור מודל |
| model page | zh-CN 模型页面 · zh-TW 模型頁面 · ja モデルページ · ko 모델 페이지 · fr page du modèle · de Modellseite · es página del modelo · ru страница модели · he עמוד המודל |
| model card | zh-CN 模型卡 · zh-TW 模型卡 · ja モデルカード · ko 모델 카드 · fr fiche de modèle · de Modellkarte · es ficha de modelo · ru карточка модели · he כרטיס מודל |
| AI enrichment (noun) | reuse the existing pair per locale: zh-CN 增强 · zh-TW 增強 · ja 補完 · ko 보강 · fr enrichissement (par IA) · de Anreicherung (KI-) · es enriquecimiento (con IA) · ru обогащение (с помощью ИИ) · he העשרה (AI) |
`modelCard.actions.viewOnSource` ("View on {source}") follows each locale's existing
`viewOnHuggingFace` pattern — de `Auf … ansehen`, ru `Открыть …`, he `צפייה ב-…`,
ja `… で見る`, ko `…에서 보기`, zh `在 … 查看`, fr `Voir sur …`, es `Ver en …`. `{source}` is
replaced at runtime with the untranslated platform name, so the brand never appears inside the
translated text.
`modals.linkModelSource.enrichNote` states the rule that only sites exposing a readable model
card can be enriched and names TensorArt as the current exception. Keep the parenthetical
exception in sync if another link-only source is ever added — the sentence is deliberately
phrased as a rule, not as an apology for one site.
The context-menu and bulk-operation enrichment entry points read **"Enrich Metadata with AI"**
in `en`, not "Enrich HF Metadata": they cover ModelScope as well, so no locale may reintroduce
an `HF` qualifier in `loras.contextMenu.enrichHfAgent` / `loras.bulkOperations.enrichHfAgent`
(the key names keep the historical `Hf`; only the values changed).
### Folder sidebar feature (create / rename / delete folders, empty folders, view options)
The model-root sidebar manages on-disk folders. "Folder" reuses the noun already fixed in §2
(the `folder key` row); the rest is new surface:
| Term | Rendering |
|---|---|
| folder | zh-CN 文件夹 · zh-TW 資料夾 · ja フォルダ · ko 폴더 · fr dossier · de Ordner · es carpeta · ru папка · he תיקייה |
| model root (as in "no model root is configured") | zh-CN 模型根目录 · zh-TW 模型根目錄 · ja モデルルート · ko 모델 루트 · fr racine de modèle · de Modell-Stammverzeichnis · es raíz de modelo · ru корневая папка моделей · he שורש מודלים — note `sidebar.modelRoot` alone is the shorter 根目录 / 根目錄 / ルート / 루트 / Racine / Stammverzeichnis / Raíz / Корень / שורש |
| tree view / list view | zh-CN 树形视图 / 列表视图 · zh-TW 樹狀檢視 / 清單檢視 · ja ツリー表示 / リスト表示 · ko 트리 보기 / 목록 보기 · fr Vue arborescente / Vue liste · de Baumansicht / Listenansicht · es Vista de árbol / Vista de lista · ru Дерево / Список · he תצוגת עץ / תצוגת רשימה |
| sidebar | reuse each locale's `sidebar.hideOnThisPage` noun: zh-CN 侧边栏 · zh-TW 側邊欄 · ja サイドバー · ko 사이드바 · fr barre latérale · de Seitenleiste · es barra lateral · ru боковая панель · he סרגל צד |
Deleting a folder **never cascades over model files** — the backend refuses it and
`sidebar.deleteFolderModal.notEmptyMessage` states the rule in every locale, so keep that
clause (and its `—`) when the copy is edited. The `{name}` / `{count}` / `{message}` tokens in
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
### 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)
+74 -23
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"reorder": {
"dragHandle": "Zum Neuordnen ziehen"
},
"actions": {
"save": "Speichern",
"cancel": "Abbrechen",
@@ -139,6 +142,7 @@
"viewOnCivitai": "Auf CivitAI anzeigen",
"notAvailableFromCivitai": "Nicht auf CivitAI verfügbar",
"viewOnHuggingFace": "Auf Hugging Face ansehen",
"viewOnSource": "Auf {source} ansehen",
"sendToWorkflow": "An ComfyUI senden (Klick: Anhängen, Shift+Klick: Ersetzen)",
"copyLoRASyntax": "LoRA-Syntax kopieren",
"checkpointNameCopied": "Checkpoint-Name kopiert",
@@ -867,14 +871,14 @@
"complete": "Automatische Organisation abgeschlossen",
"error": "Fehler: {error}"
},
"enrichHfAgent": "HF-Metadaten mit KI anreichern"
"enrichHfAgent": "Metadaten mit KI anreichern"
},
"contextMenu": {
"refreshMetadata": "CivitAI-Daten aktualisieren",
"checkUpdates": "Updates prüfen",
"linkModel": "Modell verknüpfen",
"linkCivitai": "Mit CivitAI neu verknüpfen",
"linkHuggingFace": "Mit HuggingFace verknüpfen",
"linkModelSource": "Mit Modellquelle verknüpfen",
"copySyntax": "LoRA-Syntax kopieren",
"copyFilename": "Modell-Dateiname kopieren",
"copyRecipeSyntax": "Rezept-Syntax kopieren",
@@ -896,7 +900,7 @@
"viewAllLoras": "Alle LoRAs anzeigen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
"deleteRecipe": "Rezept löschen",
"enrichHfAgent": "HF-Metadaten mit KI anreichern"
"enrichHfAgent": "Metadaten mit KI anreichern"
}
},
"recipes": {
@@ -914,7 +918,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "Basismodell",
"unknown": "Unbekannt"
},
"actions": {
"openFileLocation": "Dateispeicherort öffnen",
@@ -1245,28 +1251,63 @@
"sidebar": {
"modelRoot": "Stammverzeichnis",
"collapseAll": "Alle Ordner einklappen",
"collapseAllDisabled": "In der Listenansicht nicht verfügbar",
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
"showSidebar": "Seitenleiste anzeigen",
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
"switchToListView": "Zur Listenansicht wechseln",
"switchToTreeView": "Zur Baumansicht wechseln",
"viewOptions": "Ansichtsoptionen",
"treeView": "Baumansicht",
"listView": "Listenansicht",
"recursiveOn": "Unterordner einbeziehen",
"recursiveOff": "Nur aktueller Ordner",
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
"createFolder": "Neuer Ordner",
"newSubfolder": "Neuer Unterordner",
"showEmptyFolders": "Leere Ordner anzeigen",
"createFolderResult": {
"success": "Ordner \"{name}\" erstellt",
"failed": "Ordner konnte nicht erstellt werden: {message}",
"unsupported": "Das Erstellen von Ordnern wird auf dieser Seite nicht unterstützt",
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
},
"deleteFolder": "Ordner löschen",
"deleteFolderModal": {
"title": "Ordner löschen?",
"message": "Der Ordner und sein gesamter Inhalt werden endgültig vom Datenträger gelöscht.",
"folderLabel": "Ordner",
"emptyNote": "Dieser Ordner enthält keine Modelle. Alle anderen darin enthaltenen Dateien werden ebenfalls gelöscht.",
"notEmptyTitle": "Ordner ist nicht leer",
"notEmptyMessage": "Dieser Ordner enthält noch Modelle. Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
"confirm": "Ordner löschen"
},
"deleteFolderResult": {
"success": "Ordner \"{name}\" gelöscht",
"successWithFiles": "Ordner \"{name}\" sowie {count} weitere(s) Element(e) gelöscht",
"restored": "Ordner wiederhergestellt",
"failed": "Ordner konnte nicht gelöscht werden: {message}",
"notEmpty": "Dieser Ordner enthält noch Modelle. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
"unsupported": "Das Löschen von Ordnern wird auf dieser Seite nicht unterstützt",
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
},
"renameFolder": "Ordner umbenennen",
"renameFolderResult": {
"success": "Ordner umbenannt in \"{name}\"",
"failed": "Ordner konnte nicht umbenannt werden: {message}",
"targetExists": "Ein Ordner mit diesem Namen ist hier bereits vorhanden",
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
"unsupported": "Das Umbenennen von Ordnern wird auf dieser Seite nicht unterstützt",
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
},
"dragDrop": {
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
"moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.",
"createFolderHint": "Loslassen, um einen neuen Ordner zu erstellen",
"newFolderName": "Neuer Ordnername",
"folderNameHint": "Eingabetaste zum Bestätigen, Escape zum Abbrechen",
"emptyFolderName": "Bitte geben Sie einen Ordnernamen ein",
"invalidFolderName": "Ordnername enthält ungültige Zeichen",
"noDragState": "Kein ausstehender Ziehvorgang gefunden"
},
"empty": {
"noFolders": "Keine Ordner gefunden",
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
"createHint": "Klicken Sie oben auf „Neuer Ordner“, um Ordner zu erstellen"
},
"folderUpdateCheck": {
"label": "Auf Updates in diesem Ordner prüfen",
@@ -1394,9 +1435,9 @@
"download": {
"title": "Modell von URL herunterladen",
"titleWithType": "{type} von URL herunterladen",
"civitaiUrl": "CivitAI URL:",
"civitaiUrl": "Modell-URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Geben Sie eine CivitAI-, CivArchive- oder Hugging Face-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
"urlHint": "Geben Sie eine CivitAI-, CivArchive-, Hugging Face- oder ModelScope-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
"selectHfFiles": "Datei(en) zum Herunterladen aus diesem Repository auswählen:",
"selectAll": "Alle auswählen",
"fetchingRepoFiles": "Repository-Dateien werden abgerufen...",
@@ -1429,9 +1470,9 @@
"inLibrary": "In Bibliothek"
},
"errors": {
"invalidUrl": "Ungültiges CivitAI URL-Format",
"invalidUrl": "Ungültiges Modell-URL-Format",
"noVersions": "Keine Versionen für dieses Modell verfügbar",
"mixedSources": "CivitAI- und Hugging Face-URLs können nicht in derselben Charge gemischt werden.",
"mixedSources": "CivitAI- und Hugging Face-/ModelScope-URLs können nicht in derselben Charge gemischt werden.",
"noModelFiles": "In diesem Repository wurden keine Modelldateien gefunden."
},
"status": {
@@ -1445,6 +1486,10 @@
"progress": {
"currentFile": "Aktuelle Datei:",
"downloading": "Wird heruntergeladen: {name}",
"metadata": "Metadaten: {name}",
"indexingFile": "Modelldatei wird gelesen...",
"fetchingSourceMetadata": "Metadaten werden von {source} abgerufen...",
"fetchingMetadata": "Metadaten werden abgerufen...",
"transferred": "Heruntergeladen: {downloaded} / {total}",
"transferredSimple": "Heruntergeladen: {downloaded}",
"transferredUnknown": "Heruntergeladen: --",
@@ -1596,12 +1641,16 @@
"pathPlaceholder": "Ordnerpfad eingeben oder aus Baum unten auswählen...",
"root": "Stammverzeichnis"
},
"linkHuggingFace": {
"title": "Mit HuggingFace verknüpfen",
"infoText": "Fügen Sie die HuggingFace-Repository-URL ein, um dieses Modell zuzuordnen. Dies ermöglicht die KI-gestützte Metadatenanreicherung.",
"urlLabel": "HuggingFace-Repository-URL:",
"linkModelSource": {
"title": "Mit Modellquelle verknüpfen",
"infoText": "Fügen Sie die URL der Modellseite ein, um dieses Modell seiner Quelle zuzuordnen. Die Verknüpfung ermöglicht die KI-gestützte Metadatenanreicherung für Modelle von Hugging Face und ModelScope.",
"urlLabel": "URL der Modellseite:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "Geben Sie die vollständige URL des HuggingFace-Repositorys ein.",
"helpText": "Geben Sie die vollständige URL der Modellseite ein. Unterstützte Websites:",
"enrichNote": "Die KI-Anreicherung benötigt eine lesbare Modellkarte. Websites, die keine bereitstellen (derzeit TensorArt), können nur verknüpft werden.",
"urlRequired": "Bitte geben Sie die URL der Modellseite ein.",
"invalidUrl": "Nicht unterstützte URL. Unterstützte Websites: Hugging Face, ModelScope, TensorArt.",
"linking": "Modellquelle wird verknüpft...",
"confirmAction": "Speichern & Verknüpfen"
},
"relinkCivitai": {
@@ -1847,7 +1896,7 @@
"empty": "Noch keine Versionshistorie für dieses Modell vorhanden.",
"error": "Versionen konnten nicht geladen werden.",
"missingModelId": "Für dieses Modell ist keine CivitAI-Model-ID vorhanden.",
"hfGroupInfo": "Dies ist eine HuggingFace-Modellgruppe. Öffnen Sie die Bibliothek, um alle Versionen im Raster zu sehen.",
"sourceGroupInfo": "Dies ist eine {source}-Modellgruppe. Öffnen Sie die Bibliothek, um alle Versionen im Raster zu sehen.",
"confirm": {
"delete": "Diese Version aus Ihrer Bibliothek löschen?"
},
@@ -2483,7 +2532,9 @@
"linkCivArchSuccess": "Modell erfolgreich über CivitArchive neu verknüpft",
"fetchMetadataFirst": "Bitte rufen Sie zuerst Metadaten von CivitAI ab",
"noCivitaiInfo": "Keine CivitAI-Informationen verfügbar",
"missingHash": "Modell-Hash nicht verfügbar"
"missingHash": "Modell-Hash nicht verfügbar",
"enrichNeedsSource": "Verknüpfen Sie dieses Modell zuerst mit einer Modellquelle (Modell verknüpfen → Mit Modellquelle verknüpfen)",
"enrichUnsupportedSource": "Die KI-Anreicherung ist für {source}-Modelle nicht verfügbar"
},
"exampleImages": {
"pathUpdated": "Beispielbilder-Pfad erfolgreich aktualisiert",
+75 -24
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "Cancel",
"confirm": "Confirm",
"reorder": {
"dragHandle": "Drag to reorder"
},
"actions": {
"save": "Save",
"cancel": "Cancel",
@@ -139,6 +142,7 @@
"viewOnCivitai": "View on CivitAI",
"notAvailableFromCivitai": "Not available from CivitAI",
"viewOnHuggingFace": "View on Hugging Face",
"viewOnSource": "View on {source}",
"sendToWorkflow": "Send to ComfyUI (Click: Append, Shift+Click: Replace)",
"copyLoRASyntax": "Copy LoRA Syntax",
"checkpointNameCopied": "Checkpoint name copied",
@@ -867,14 +871,14 @@
"complete": "Auto-organize complete",
"error": "Error: {error}"
},
"enrichHfAgent": "Enrich HF Metadata (AI)"
"enrichHfAgent": "Enrich Metadata with AI"
},
"contextMenu": {
"refreshMetadata": "Refresh CivitAI Data",
"checkUpdates": "Check Updates",
"linkModel": "Link Model",
"linkCivitai": "Link to CivitAI",
"linkHuggingFace": "Link to HuggingFace",
"linkModelSource": "Link to Model Source",
"copySyntax": "Copy LoRA Syntax",
"copyFilename": "Copy Model Filename",
"copyRecipeSyntax": "Copy Recipe Syntax",
@@ -896,7 +900,7 @@
"viewAllLoras": "View All LoRAs",
"downloadMissingLoras": "Download Missing LoRAs",
"deleteRecipe": "Delete Recipe",
"enrichHfAgent": "Enrich HF Metadata (AI)"
"enrichHfAgent": "Enrich Metadata with AI"
}
},
"recipes": {
@@ -914,7 +918,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "Base Model",
"unknown": "Unknown"
},
"actions": {
"openFileLocation": "Open File Location",
@@ -1245,28 +1251,63 @@
"sidebar": {
"modelRoot": "Root",
"collapseAll": "Collapse All Folders",
"collapseAllDisabled": "Not available in list view",
"hideOnThisPage": "Hide sidebar on this page",
"showSidebar": "Show sidebar",
"sidebarHiddenNotification": "Folder sidebar hidden on {page} page",
"switchToListView": "Switch to List View",
"switchToTreeView": "Switch to Tree View",
"viewOptions": "View options",
"treeView": "Tree view",
"listView": "List view",
"recursiveOn": "Include subfolders",
"recursiveOff": "Current folder only",
"recursiveUnavailable": "Recursive search is available in tree view only",
"collapseAllDisabled": "Not available in list view",
"createFolder": "New folder",
"newSubfolder": "New subfolder",
"showEmptyFolders": "Show empty folders",
"createFolderResult": {
"success": "Folder \"{name}\" created",
"failed": "Failed to create folder: {message}",
"unsupported": "Folder creation is not supported on this page",
"noRoot": "No model root is configured"
},
"deleteFolder": "Delete folder",
"deleteFolderModal": {
"title": "Delete folder?",
"message": "The folder and everything inside it will be permanently removed from disk.",
"folderLabel": "Folder",
"emptyNote": "This folder contains no models. Any other files it holds will be deleted too.",
"notEmptyTitle": "Folder is not empty",
"notEmptyMessage": "This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
"confirm": "Delete folder"
},
"deleteFolderResult": {
"success": "Folder \"{name}\" deleted",
"successWithFiles": "Folder \"{name}\" deleted along with {count} other item(s)",
"restored": "Folder restored",
"failed": "Failed to delete folder: {message}",
"notEmpty": "This folder still contains models. Refresh the sidebar and try again.",
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
"unsupported": "Folder deletion is not supported on this page",
"noRoot": "No model root is configured"
},
"renameFolder": "Rename folder",
"renameFolderResult": {
"success": "Folder renamed to \"{name}\"",
"failed": "Failed to rename folder: {message}",
"targetExists": "A folder with that name already exists here",
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
"unsupported": "Folder renaming is not supported on this page",
"noRoot": "No model root is configured"
},
"dragDrop": {
"unableToResolveRoot": "Unable to determine destination path for move.",
"moveUnsupported": "Move is not supported for this item.",
"createFolderHint": "Release to create new folder",
"newFolderName": "New folder name",
"folderNameHint": "Press Enter to confirm, Escape to cancel",
"emptyFolderName": "Please enter a folder name",
"invalidFolderName": "Folder name contains invalid characters",
"noDragState": "No pending drag operation found"
},
"empty": {
"noFolders": "No folders found",
"dragHint": "Drag items here to create folders"
"createHint": "Click the New Folder button above to create folders"
},
"folderUpdateCheck": {
"label": "Check for updates in this folder",
@@ -1394,9 +1435,9 @@
"download": {
"title": "Download Model from URL",
"titleWithType": "Download {type} from URL",
"civitaiUrl": "CivitAI URL(s):",
"civitaiUrl": "Model URL(s):",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Enter one CivitAI, CivArchive, or Hugging Face URL per line. Supports multiple URLs for batch download.",
"urlHint": "Enter one CivitAI, CivArchive, Hugging Face, or ModelScope URL per line. Supports multiple URLs for batch download.",
"selectHfFiles": "Select file(s) to download from this repository:",
"selectAll": "Select All",
"fetchingRepoFiles": "Fetching repository files...",
@@ -1429,9 +1470,9 @@
"inLibrary": "In Library"
},
"errors": {
"invalidUrl": "Invalid CivitAI URL format",
"invalidUrl": "Invalid model URL format",
"noVersions": "No versions available for this model",
"mixedSources": "Cannot mix CivitAI and Hugging Face URLs in the same batch.",
"mixedSources": "Cannot mix CivitAI and Hugging Face / ModelScope URLs in the same batch.",
"noModelFiles": "No model files found in this repository."
},
"status": {
@@ -1445,6 +1486,10 @@
"progress": {
"currentFile": "Current file:",
"downloading": "Downloading: {name}",
"metadata": "Metadata: {name}",
"indexingFile": "Reading model file...",
"fetchingSourceMetadata": "Fetching metadata from {source}...",
"fetchingMetadata": "Fetching metadata...",
"transferred": "Transferred: {downloaded} / {total}",
"transferredSimple": "Transferred: {downloaded}",
"transferredUnknown": "Transferred: --",
@@ -1596,12 +1641,16 @@
"pathPlaceholder": "Type folder path or select from tree below...",
"root": "Root"
},
"linkHuggingFace": {
"title": "Link to HuggingFace",
"infoText": "Paste the HuggingFace repository URL to associate this model with its source. This enables AI-powered metadata enrichment.",
"urlLabel": "HuggingFace Repository URL:",
"linkModelSource": {
"title": "Link to Model Source",
"infoText": "Paste the model page URL to associate this model with its source. Linking enables AI-powered metadata enrichment for Hugging Face and ModelScope models.",
"urlLabel": "Model Page URL:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "Enter the full URL of the HuggingFace repository.",
"helpText": "Enter the full URL of the model page. Supported sites:",
"enrichNote": "AI enrichment needs a readable model card. Sites that don't expose one (currently TensorArt) can only be linked.",
"urlRequired": "Please enter a model page URL.",
"invalidUrl": "Unsupported URL. Supported sites: Hugging Face, ModelScope, TensorArt.",
"linking": "Linking model source...",
"confirmAction": "Save & Link"
},
"relinkCivitai": {
@@ -1847,7 +1896,7 @@
"empty": "No version history available for this model yet.",
"error": "Failed to load versions.",
"missingModelId": "This model is missing a CivitAI model id.",
"hfGroupInfo": "This is a HuggingFace model group. Open the library to see all versions in the grid.",
"sourceGroupInfo": "This is a {source} model group. Open the library to see all versions in the grid.",
"confirm": {
"delete": "Delete this version from your library?"
},
@@ -2478,12 +2527,14 @@
"contentRatingFailed": "Failed to set content rating: {message}",
"relinkSuccess": "Model successfully re-linked to CivitAI",
"relinkFailed": "Error: {message}",
"linkHfSuccess": "Model successfully linked to HuggingFace",
"linkHfSuccess": "Model successfully linked to its model source",
"linkHfFailed": "Error: {message}",
"linkCivArchSuccess": "Model successfully re-linked via CivitArchive",
"fetchMetadataFirst": "Please fetch metadata from CivitAI first",
"noCivitaiInfo": "No CivitAI information available",
"missingHash": "Model hash not available"
"missingHash": "Model hash not available",
"enrichNeedsSource": "Link this model to a model source first (Link Model → Link to Model Source)",
"enrichUnsupportedSource": "AI enrichment is not available for {source} models"
},
"exampleImages": {
"pathUpdated": "Example images path updated successfully",
+74 -23
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "Cancelar",
"confirm": "Confirmar",
"reorder": {
"dragHandle": "Arrastra para reordenar"
},
"actions": {
"save": "Guardar",
"cancel": "Cancelar",
@@ -139,6 +142,7 @@
"viewOnCivitai": "Ver en CivitAI",
"notAvailableFromCivitai": "No disponible en CivitAI",
"viewOnHuggingFace": "Ver en Hugging Face",
"viewOnSource": "Ver en {source}",
"sendToWorkflow": "Enviar a ComfyUI (Clic: Añadir, Shift+Clic: Reemplazar)",
"copyLoRASyntax": "Copiar sintaxis de LoRA",
"checkpointNameCopied": "Nombre del checkpoint copiado",
@@ -867,14 +871,14 @@
"complete": "Auto-organización completada",
"error": "Error: {error}"
},
"enrichHfAgent": "Enriquecer metadatos HF (IA)"
"enrichHfAgent": "Enriquecer metadatos con IA"
},
"contextMenu": {
"refreshMetadata": "Actualizar datos de CivitAI",
"checkUpdates": "Comprobar actualizaciones",
"linkModel": "Vincular modelo",
"linkCivitai": "Re-vincular a CivitAI",
"linkHuggingFace": "Vincular a HuggingFace",
"linkModelSource": "Vincular a una fuente de modelo",
"copySyntax": "Copiar sintaxis de LoRA",
"copyFilename": "Copiar nombre de archivo del modelo",
"copyRecipeSyntax": "Copiar sintaxis de receta",
@@ -896,7 +900,7 @@
"viewAllLoras": "Ver todos los LoRAs",
"downloadMissingLoras": "Descargar LoRAs faltantes",
"deleteRecipe": "Eliminar receta",
"enrichHfAgent": "Enriquecer metadatos HF (IA)"
"enrichHfAgent": "Enriquecer metadatos con IA"
}
},
"recipes": {
@@ -914,7 +918,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "Modelo base",
"unknown": "Desconocido"
},
"actions": {
"openFileLocation": "Abrir ubicación del archivo",
@@ -1245,28 +1251,63 @@
"sidebar": {
"modelRoot": "Raíz",
"collapseAll": "Colapsar todas las carpetas",
"collapseAllDisabled": "No disponible en la vista de lista",
"hideOnThisPage": "Ocultar barra lateral en esta página",
"showSidebar": "Mostrar barra lateral",
"sidebarHiddenNotification": "Barra lateral oculta en la página {page}",
"switchToListView": "Cambiar a vista de lista",
"switchToTreeView": "Cambiar a vista de árbol",
"viewOptions": "Opciones de vista",
"treeView": "Vista de árbol",
"listView": "Vista de lista",
"recursiveOn": "Incluir subcarpetas",
"recursiveOff": "Solo carpeta actual",
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
"collapseAllDisabled": "No disponible en vista de lista",
"createFolder": "Nueva carpeta",
"newSubfolder": "Nueva subcarpeta",
"showEmptyFolders": "Mostrar carpetas vacías",
"createFolderResult": {
"success": "Carpeta \"{name}\" creada",
"failed": "Error al crear la carpeta: {message}",
"unsupported": "La creación de carpetas no es compatible con esta página",
"noRoot": "No hay ninguna raíz de modelo configurada"
},
"deleteFolder": "Eliminar carpeta",
"deleteFolderModal": {
"title": "¿Eliminar carpeta?",
"message": "La carpeta y todo su contenido se eliminarán permanentemente del disco.",
"folderLabel": "Carpeta",
"emptyNote": "Esta carpeta no contiene modelos. Los demás archivos que contenga también se eliminarán.",
"notEmptyTitle": "La carpeta no está vacía",
"notEmptyMessage": "Esta carpeta aún contiene modelos. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
"confirm": "Eliminar carpeta"
},
"deleteFolderResult": {
"success": "Carpeta \"{name}\" eliminada",
"successWithFiles": "Carpeta \"{name}\" eliminada junto con {count} elemento(s) más",
"restored": "Carpeta restaurada",
"failed": "Error al eliminar la carpeta: {message}",
"notEmpty": "Esta carpeta aún contiene modelos. Actualiza la barra lateral e inténtalo de nuevo.",
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
"unsupported": "La eliminación de carpetas no es compatible con esta página",
"noRoot": "No hay ninguna raíz de modelo configurada"
},
"renameFolder": "Cambiar nombre de la carpeta",
"renameFolderResult": {
"success": "Carpeta renombrada a \"{name}\"",
"failed": "Error al cambiar el nombre de la carpeta: {message}",
"targetExists": "Ya existe una carpeta con ese nombre aquí",
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
"unsupported": "El cambio de nombre de carpetas no es compatible con esta página",
"noRoot": "No hay ninguna raíz de modelo configurada"
},
"dragDrop": {
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
"moveUnsupported": "El movimiento no es compatible con este elemento.",
"createFolderHint": "Suelta para crear una nueva carpeta",
"newFolderName": "Nombre de la nueva carpeta",
"folderNameHint": "Presiona Enter para confirmar, Escape para cancelar",
"emptyFolderName": "Por favor, introduce un nombre de carpeta",
"invalidFolderName": "El nombre de la carpeta contiene caracteres no válidos",
"noDragState": "No se encontró ninguna operación de arrastre pendiente"
},
"empty": {
"noFolders": "No se encontraron carpetas",
"dragHint": "Arrastra elementos aquí para crear carpetas"
"createHint": "Haz clic en el botón Nueva carpeta de arriba para crear carpetas"
},
"folderUpdateCheck": {
"label": "Buscar actualizaciones en esta carpeta",
@@ -1394,9 +1435,9 @@
"download": {
"title": "Descargar modelo desde URL",
"titleWithType": "Descargar {type} desde URL",
"civitaiUrl": "URL de CivitAI:",
"civitaiUrl": "URL del modelo:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Ingrese una URL de CivitAI, CivArchive o Hugging Face por línea. Admite múltiples URLs para descarga por lotes.",
"urlHint": "Ingrese una URL de CivitAI, CivArchive, Hugging Face o ModelScope por línea. Admite múltiples URLs para descarga por lotes.",
"selectHfFiles": "Seleccione el/los archivo(s) para descargar de este repositorio:",
"selectAll": "Seleccionar todo",
"fetchingRepoFiles": "Obteniendo archivos del repositorio...",
@@ -1429,9 +1470,9 @@
"inLibrary": "En la biblioteca"
},
"errors": {
"invalidUrl": "Formato de URL de CivitAI inválido",
"invalidUrl": "Formato de URL de modelo inválido",
"noVersions": "No hay versiones disponibles para este modelo",
"mixedSources": "No se pueden mezclar URL de CivitAI y Hugging Face en el mismo lote.",
"mixedSources": "No se pueden mezclar URL de CivitAI y Hugging Face / ModelScope en el mismo lote.",
"noModelFiles": "No se encontraron archivos de modelo en este repositorio."
},
"status": {
@@ -1445,6 +1486,10 @@
"progress": {
"currentFile": "Archivo actual:",
"downloading": "Descargando: {name}",
"metadata": "Metadatos: {name}",
"indexingFile": "Leyendo el archivo de modelo...",
"fetchingSourceMetadata": "Obteniendo metadatos de {source}...",
"fetchingMetadata": "Obteniendo metadatos...",
"transferred": "Descargado: {downloaded} / {total}",
"transferredSimple": "Descargado: {downloaded}",
"transferredUnknown": "Descargado: --",
@@ -1596,12 +1641,16 @@
"pathPlaceholder": "Escribe la ruta de la carpeta o selecciona del árbol de abajo...",
"root": "Raíz"
},
"linkHuggingFace": {
"title": "Vincular a HuggingFace",
"infoText": "Pegue la URL del repositorio de HuggingFace para asociar este modelo. Esto permite el enriquecimiento de metadatos con IA.",
"urlLabel": "URL del repositorio de HuggingFace:",
"linkModelSource": {
"title": "Vincular a una fuente de modelo",
"infoText": "Pegue la URL de la página del modelo para asociar este modelo con su fuente. La vinculación permite el enriquecimiento de metadatos con IA para modelos de Hugging Face y ModelScope.",
"urlLabel": "URL de la página del modelo:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "Ingrese la URL completa del repositorio de HuggingFace.",
"helpText": "Ingrese la URL completa de la página del modelo. Sitios soportados:",
"enrichNote": "El enriquecimiento con IA necesita una ficha de modelo legible. Los sitios que no la exponen (actualmente TensorArt) solo se pueden vincular.",
"urlRequired": "Ingrese la URL de la página del modelo.",
"invalidUrl": "URL no soportada. Sitios soportados: Hugging Face, ModelScope, TensorArt.",
"linking": "Vinculando la fuente del modelo...",
"confirmAction": "Guardar y vincular"
},
"relinkCivitai": {
@@ -1847,7 +1896,7 @@
"empty": "Aún no hay historial de versiones para este modelo.",
"error": "No se pudieron cargar las versiones.",
"missingModelId": "Este modelo no tiene un ID de modelo de CivitAI.",
"hfGroupInfo": "Este es un grupo de modelos de HuggingFace. Abra la biblioteca para ver todas las versiones en la cuadrícula.",
"sourceGroupInfo": "Este es un grupo de modelos de {source}. Abra la biblioteca para ver todas las versiones en la cuadrícula.",
"confirm": {
"delete": "¿Eliminar esta versión de tu biblioteca?"
},
@@ -2483,7 +2532,9 @@
"linkCivArchSuccess": "Modelo re-vinculado exitosamente mediante CivitArchive",
"fetchMetadataFirst": "Por favor obtén metadatos de CivitAI primero",
"noCivitaiInfo": "No hay información de CivitAI disponible",
"missingHash": "Hash del modelo no disponible"
"missingHash": "Hash del modelo no disponible",
"enrichNeedsSource": "Vincule este modelo a una fuente de modelo primero (Vincular modelo → Vincular a una fuente de modelo)",
"enrichUnsupportedSource": "El enriquecimiento con IA no está disponible para modelos de {source}"
},
"exampleImages": {
"pathUpdated": "Ruta de imágenes de ejemplo actualizada exitosamente",
+74 -23
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "Annuler",
"confirm": "Confirmer",
"reorder": {
"dragHandle": "Glisser pour réordonner"
},
"actions": {
"save": "Enregistrer",
"cancel": "Annuler",
@@ -139,6 +142,7 @@
"viewOnCivitai": "Voir sur CivitAI",
"notAvailableFromCivitai": "Non disponible sur CivitAI",
"viewOnHuggingFace": "Voir sur Hugging Face",
"viewOnSource": "Voir sur {source}",
"sendToWorkflow": "Envoyer vers ComfyUI (Clic: Ajouter, Maj+Clic: Remplacer)",
"copyLoRASyntax": "Copier la syntaxe LoRA",
"checkpointNameCopied": "Nom du checkpoint copié",
@@ -867,14 +871,14 @@
"complete": "Auto-organisation terminée",
"error": "Erreur : {error}"
},
"enrichHfAgent": "Enrichir les métadonnées HF (IA)"
"enrichHfAgent": "Enrichir les métadonnées avec l'IA"
},
"contextMenu": {
"refreshMetadata": "Actualiser les données CivitAI",
"checkUpdates": "Vérifier les mises à jour",
"linkModel": "Lier le modèle",
"linkCivitai": "Relier à nouveau à CivitAI",
"linkHuggingFace": "Lier à HuggingFace",
"linkModelSource": "Lier à une source de modèle",
"copySyntax": "Copier la syntaxe LoRA",
"copyFilename": "Copier le nom de fichier du modèle",
"copyRecipeSyntax": "Copier la syntaxe de la recipe",
@@ -896,7 +900,7 @@
"viewAllLoras": "Voir tous les LoRAs",
"downloadMissingLoras": "Télécharger les LoRAs manquants",
"deleteRecipe": "Supprimer la recipe",
"enrichHfAgent": "Enrichir les métadonnées HF (IA)"
"enrichHfAgent": "Enrichir les métadonnées avec l'IA"
}
},
"recipes": {
@@ -914,7 +918,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "Modèle de base",
"unknown": "Inconnu"
},
"actions": {
"openFileLocation": "Ouvrir lemplacement du fichier",
@@ -1245,28 +1251,63 @@
"sidebar": {
"modelRoot": "Racine",
"collapseAll": "Réduire tous les dossiers",
"collapseAllDisabled": "Non disponible en vue liste",
"hideOnThisPage": "Masquer la barre latérale sur cette page",
"showSidebar": "Afficher la barre latérale",
"sidebarHiddenNotification": "Barre latérale masquée sur la page {page}",
"switchToListView": "Passer en vue liste",
"switchToTreeView": "Passer en vue arborescence",
"viewOptions": "Options daffichage",
"treeView": "Vue arborescente",
"listView": "Vue liste",
"recursiveOn": "Inclure les sous-dossiers",
"recursiveOff": "Dossier actuel uniquement",
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
"collapseAllDisabled": "Non disponible en vue liste",
"createFolder": "Nouveau dossier",
"newSubfolder": "Nouveau sous-dossier",
"showEmptyFolders": "Afficher les dossiers vides",
"createFolderResult": {
"success": "Dossier \"{name}\" créé",
"failed": "Échec de la création du dossier : {message}",
"unsupported": "La création de dossiers nest pas prise en charge sur cette page",
"noRoot": "Aucune racine de modèle nest configurée"
},
"deleteFolder": "Supprimer le dossier",
"deleteFolderModal": {
"title": "Supprimer le dossier ?",
"message": "Le dossier et tout son contenu seront définitivement supprimés du disque.",
"folderLabel": "Dossier",
"emptyNote": "Ce dossier ne contient aucun modèle. Les autres fichiers quil contient seront également supprimés.",
"notEmptyTitle": "Le dossier nest pas vide",
"notEmptyMessage": "Ce dossier contient encore des modèles. Supprimez-les ou déplacez-les dabord — la suppression dun dossier nentraîne jamais celle des fichiers de modèles.",
"confirm": "Supprimer le dossier"
},
"deleteFolderResult": {
"success": "Dossier \"{name}\" supprimé",
"successWithFiles": "Dossier \"{name}\" supprimé, ainsi que {count} autre(s) élément(s)",
"restored": "Dossier restauré",
"failed": "Échec de la suppression du dossier : {message}",
"notEmpty": "Ce dossier contient encore des modèles. Actualisez la barre latérale et réessayez.",
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre dannulation.",
"unsupported": "La suppression de dossiers nest pas prise en charge sur cette page",
"noRoot": "Aucune racine de modèle nest configurée"
},
"renameFolder": "Renommer le dossier",
"renameFolderResult": {
"success": "Dossier renommé en \"{name}\"",
"failed": "Échec du renommage du dossier : {message}",
"targetExists": "Un dossier portant ce nom existe déjà ici",
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre dannulation.",
"unsupported": "Le renommage de dossiers nest pas pris en charge sur cette page",
"noRoot": "Aucune racine de modèle nest configurée"
},
"dragDrop": {
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
"moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.",
"createFolderHint": "Relâcher pour créer un nouveau dossier",
"newFolderName": "Nom du nouveau dossier",
"folderNameHint": "Appuyez sur Entrée pour confirmer, Échap pour annuler",
"emptyFolderName": "Veuillez saisir un nom de dossier",
"invalidFolderName": "Le nom du dossier contient des caractères invalides",
"noDragState": "Aucune opération de glissement en attente trouvée"
},
"empty": {
"noFolders": "Aucun dossier trouvé",
"dragHint": "Faites glisser des éléments ici pour créer des dossiers"
"createHint": "Cliquez sur le bouton Nouveau dossier ci-dessus pour créer des dossiers"
},
"folderUpdateCheck": {
"label": "Vérifier les mises à jour dans ce dossier",
@@ -1394,9 +1435,9 @@
"download": {
"title": "Télécharger un modèle depuis une URL",
"titleWithType": "Télécharger {type} depuis une URL",
"civitaiUrl": "URL CivitAI :",
"civitaiUrl": "URL du modèle :",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Entrez une URL CivitAI, CivArchive ou Hugging Face par ligne. Prend en charge plusieurs URL pour le téléchargement par lot.",
"urlHint": "Entrez une URL CivitAI, CivArchive, Hugging Face ou ModelScope par ligne. Prend en charge plusieurs URL pour le téléchargement par lot.",
"selectHfFiles": "Sélectionnez le(s) fichier(s) à télécharger depuis ce dépôt :",
"selectAll": "Tout sélectionner",
"fetchingRepoFiles": "Récupération des fichiers du dépôt...",
@@ -1429,9 +1470,9 @@
"inLibrary": "Dans la bibliothèque"
},
"errors": {
"invalidUrl": "Format d'URL CivitAI invalide",
"invalidUrl": "Format d'URL de modèle invalide",
"noVersions": "Aucune version disponible pour ce modèle",
"mixedSources": "Impossible de mélanger les URL CivitAI et Hugging Face dans le même lot.",
"mixedSources": "Impossible de mélanger les URL CivitAI et Hugging Face / ModelScope dans le même lot.",
"noModelFiles": "Aucun fichier de modèle trouvé dans ce dépôt."
},
"status": {
@@ -1445,6 +1486,10 @@
"progress": {
"currentFile": "Fichier actuel :",
"downloading": "Téléchargement : {name}",
"metadata": "Métadonnées : {name}",
"indexingFile": "Lecture du fichier de modèle...",
"fetchingSourceMetadata": "Récupération des métadonnées depuis {source}...",
"fetchingMetadata": "Récupération des métadonnées...",
"transferred": "Téléchargé : {downloaded} / {total}",
"transferredSimple": "Téléchargé : {downloaded}",
"transferredUnknown": "Téléchargé : --",
@@ -1596,12 +1641,16 @@
"pathPlaceholder": "Tapez le chemin du dossier ou sélectionnez dans l'arbre ci-dessous...",
"root": "Racine"
},
"linkHuggingFace": {
"title": "Lier à HuggingFace",
"infoText": "Collez l'URL du dépôt HuggingFace pour associer ce modèle à sa source. Cela permet l'enrichissement des métadonnées par IA.",
"urlLabel": "URL du dépôt HuggingFace :",
"linkModelSource": {
"title": "Lier à une source de modèle",
"infoText": "Collez l'URL de la page du modèle pour associer ce modèle à sa source. La liaison permet l'enrichissement des métadonnées par IA pour les modèles Hugging Face et ModelScope.",
"urlLabel": "URL de la page du modèle :",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "Entrez l'URL complète du dépôt HuggingFace.",
"helpText": "Entrez l'URL complète de la page du modèle. Sites pris en charge :",
"enrichNote": "L'enrichissement par IA nécessite une fiche de modèle lisible. Les sites qui n'en exposent pas (actuellement TensorArt) ne peuvent être que liés.",
"urlRequired": "Veuillez saisir l'URL de la page du modèle.",
"invalidUrl": "URL non prise en charge. Sites pris en charge : Hugging Face, ModelScope, TensorArt.",
"linking": "Liaison de la source du modèle...",
"confirmAction": "Enregistrer & lier"
},
"relinkCivitai": {
@@ -1847,7 +1896,7 @@
"empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.",
"error": "Échec du chargement des versions.",
"missingModelId": "Ce modèle ne possède pas d'identifiant de modèle CivitAI.",
"hfGroupInfo": "Ceci est un groupe de modèles HuggingFace. Ouvrez la bibliothèque pour voir toutes les versions dans la grille.",
"sourceGroupInfo": "Ceci est un groupe de modèles {source}. Ouvrez la bibliothèque pour voir toutes les versions dans la grille.",
"confirm": {
"delete": "Supprimer cette version de votre bibliothèque ?"
},
@@ -2483,7 +2532,9 @@
"linkCivArchSuccess": "Modèle relié via CivitArchive avec succès",
"fetchMetadataFirst": "Veuillez d'abord récupérer les métadonnées depuis CivitAI",
"noCivitaiInfo": "Aucune information CivitAI disponible",
"missingHash": "Hash du modèle non disponible"
"missingHash": "Hash du modèle non disponible",
"enrichNeedsSource": "Liez d'abord ce modèle à une source de modèle (Lier le modèle → Lier à une source de modèle)",
"enrichUnsupportedSource": "L'enrichissement par IA n'est pas disponible pour les modèles {source}"
},
"exampleImages": {
"pathUpdated": "Chemin des images d'exemple mis à jour avec succès",
+74 -23
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "ביטול",
"confirm": "אישור",
"reorder": {
"dragHandle": "גרור כדי לשנות סדר"
},
"actions": {
"save": "שמירה",
"cancel": "ביטול",
@@ -139,6 +142,7 @@
"viewOnCivitai": "הצג ב-CivitAI",
"notAvailableFromCivitai": "לא זמין מ-CivitAI",
"viewOnHuggingFace": "צפייה ב-Hugging Face",
"viewOnSource": "צפייה ב-{source}",
"sendToWorkflow": "שלח ל-ComfyUI (לחיצה: הוסף, Shift+לחיצה: החלף)",
"copyLoRASyntax": "העתק תחביר LoRA",
"checkpointNameCopied": "שם Checkpoint הועתק",
@@ -867,14 +871,14 @@
"complete": "ארגון אוטומטי הושלם",
"error": "שגיאה: {error}"
},
"enrichHfAgent": "העשרת HF מטא-נתונים (AI)"
"enrichHfAgent": "העשרת מטא-נתונים ב-AI"
},
"contextMenu": {
"refreshMetadata": "רענן נתוני CivitAI",
"checkUpdates": "בדוק עדכונים",
"linkModel": "קישור מודל",
"linkCivitai": "קשר מחדש ל-CivitAI",
"linkHuggingFace": "קישור ל-HuggingFace",
"linkModelSource": "קישור למקור מודל",
"copySyntax": "העתק תחביר LoRA",
"copyFilename": "העתק שם קובץ מודל",
"copyRecipeSyntax": "העתק תחביר מתכון",
@@ -896,7 +900,7 @@
"viewAllLoras": "הצג את כל ה-LoRAs",
"downloadMissingLoras": "הורד LoRAs חסרים",
"deleteRecipe": "מחק מתכון",
"enrichHfAgent": "העשרת HF מטא-נתונים (AI)"
"enrichHfAgent": "העשרת מטא-נתונים ב-AI"
}
},
"recipes": {
@@ -914,7 +918,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "מודל בסיס",
"unknown": "לא ידוע"
},
"actions": {
"openFileLocation": "פתח מיקום קובץ",
@@ -1245,28 +1251,63 @@
"sidebar": {
"modelRoot": "שורש",
"collapseAll": "כווץ את כל התיקיות",
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
"hideOnThisPage": "הסתר סרגל צד בדף זה",
"showSidebar": "הצג סרגל צד",
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
"switchToListView": "עבור לתצוגת רשימה",
"switchToTreeView": "תצוגת עץ",
"viewOptions": "אפשרויות תצוגה",
"treeView": "תצוגת עץ",
"listView": "תצוגת רשימה",
"recursiveOn": "כלול תיקיות משנה",
"recursiveOff": "רק התיקייה הנוכחית",
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
"createFolder": "תיקייה חדשה",
"newSubfolder": "תיקיית משנה חדשה",
"showEmptyFolders": "הצג תיקיות ריקות",
"createFolderResult": {
"success": "התיקייה \"{name}\" נוצרה",
"failed": "יצירת התיקייה נכשלה: {message}",
"unsupported": "יצירת תיקיות אינה נתמכת בדף זה",
"noRoot": "לא הוגדר שורש מודלים"
},
"deleteFolder": "מחק תיקייה",
"deleteFolderModal": {
"title": "למחוק את התיקייה?",
"message": "התיקייה וכל תוכנה יימחקו לצמיתות מהדיסק.",
"folderLabel": "תיקייה",
"emptyNote": "אין מודלים בתיקייה זו. קבצים אחרים שבה יימחקו גם הם.",
"notEmptyTitle": "התיקייה אינה ריקה",
"notEmptyMessage": "בתיקייה זו עדיין יש מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"confirm": "מחק תיקייה"
},
"deleteFolderResult": {
"success": "התיקייה \"{name}\" נמחקה",
"successWithFiles": "התיקייה \"{name}\" נמחקה יחד עם {count} פריטים נוספים",
"restored": "התיקייה שוחזרה",
"failed": "מחיקת התיקייה נכשלה: {message}",
"notEmpty": "בתיקייה זו עדיין יש מודלים. רענן את סרגל הצד ונסה שוב.",
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
"noRoot": "לא הוגדר שורש מודלים"
},
"renameFolder": "שנה שם תיקייה",
"renameFolderResult": {
"success": "שם התיקייה שונה ל-\"{name}\"",
"failed": "שינוי שם התיקייה נכשל: {message}",
"targetExists": "תיקייה בשם זה כבר קיימת כאן",
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
"unsupported": "שינוי שם תיקיות אינו נתמך בדף זה",
"noRoot": "לא הוגדר שורש מודלים"
},
"dragDrop": {
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
"moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
"createFolderHint": "שחרר כדי ליצור תיקייה חדשה",
"newFolderName": "שם תיקייה חדשה",
"folderNameHint": "הקש Enter לאישור, Escape לביטול",
"emptyFolderName": "אנא הזן שם תיקייה",
"invalidFolderName": "שם התיקייה מכיל תווים לא חוקיים",
"noDragState": "לא נמצאה פעולת גרירה ממתינה"
},
"empty": {
"noFolders": "לא נמצאו תיקיות",
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
"createHint": "לחץ על כפתור תיקייה חדשה למעלה כדי ליצור תיקיות"
},
"folderUpdateCheck": {
"label": "בדוק עדכונים בתיקייה זו",
@@ -1394,9 +1435,9 @@
"download": {
"title": "הורד מודל מכתובת URL",
"titleWithType": "הורד {type} מכתובת URL",
"civitaiUrl": "כתובת URL של CivitAI:",
"civitaiUrl": "כתובת URL של מודל:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "יש להזין כתובת URL אחת של CivitAI, CivArchive או Hugging Face בכל שורה. תומך במספר כתובות URL להורדה בקבוצה.",
"urlHint": "יש להזין כתובת URL אחת של CivitAI, CivArchive, Hugging Face או ModelScope בכל שורה. תומך במספר כתובות URL להורדה בקבוצה.",
"selectHfFiles": "בחר קבצים להורדה ממאגר זה:",
"selectAll": "בחר הכל",
"fetchingRepoFiles": "מביא קבצים מהמאגר...",
@@ -1429,9 +1470,9 @@
"inLibrary": "בספרייה"
},
"errors": {
"invalidUrl": "פורמט URL של CivitAI לא חוקי",
"invalidUrl": "פורמט URL של מודל לא חוקי",
"noVersions": "אין גרסאות זמינות למודל זה",
"mixedSources": "לא ניתן לערבב כתובות URL של CivitAI ו-Hugging Face באותה קבוצה.",
"mixedSources": "לא ניתן לערבב כתובות URL של CivitAI ו-Hugging Face / ModelScope באותה קבוצה.",
"noModelFiles": "לא נמצאו קבצי מודל במאגר זה."
},
"status": {
@@ -1445,6 +1486,10 @@
"progress": {
"currentFile": "הקובץ הנוכחי:",
"downloading": "מוריד: {name}",
"metadata": "מטא-נתונים: {name}",
"indexingFile": "קורא קובץ מודל...",
"fetchingSourceMetadata": "מביא מטא-נתונים מ-{source}...",
"fetchingMetadata": "מביא מטא-נתונים...",
"transferred": "הורד: {downloaded} / {total}",
"transferredSimple": "הורד: {downloaded}",
"transferredUnknown": "הורד: --",
@@ -1596,12 +1641,16 @@
"pathPlaceholder": "הקלד נתיב תיקייה או בחר מהעץ למטה...",
"root": "שורש"
},
"linkHuggingFace": {
"title": "קישור ל-HuggingFace",
"infoText": "הדבק את כתובת ה-URL של מאגר HuggingFace כדי לשייך מודל זה למקורו. פעולה זו מאפשרת העשרת מטא-נתונים באמצעות AI.",
"urlLabel": "כתובת URL של מאגר HuggingFace:",
"linkModelSource": {
"title": "קישור למקור מודל",
"infoText": "הדבק את כתובת ה-URL של עמוד המודל כדי לשייך מודל זה למקורו. הקישור מאפשר העשרת מטא-נתונים באמצעות AI עבור מודלים של Hugging Face ו-ModelScope.",
"urlLabel": "כתובת URL של עמוד המודל:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "הזן את כתובת ה-URL המלאה של מאגר HuggingFace.",
"helpText": "הזן את כתובת ה-URL המלאה של עמוד המודל. אתרים נתמכים:",
"enrichNote": "העשרת AI דורשת כרטיס מודל קריא. אתרים שאינם חושפים אותו (נכון להיום TensorArt) ניתנים לקישור בלבד.",
"urlRequired": "הזן כתובת URL של עמוד המודל.",
"invalidUrl": "כתובת URL לא נתמכת. אתרים נתמכים: Hugging Face, ModelScope, TensorArt.",
"linking": "מקשר את מקור המודל...",
"confirmAction": "שמור וקשר"
},
"relinkCivitai": {
@@ -1847,7 +1896,7 @@
"empty": "אין עדיין היסטוריית גרסאות למודל זה.",
"error": "טעינת הגרסאות נכשלה.",
"missingModelId": "למודל זה אין מזהה מודל של CivitAI.",
"hfGroupInfo": "זוהי קבוצת מודלים של HuggingFace. פתח את הספרייה כדי לראות את כל הגרסאות ברשת.",
"sourceGroupInfo": "זוהי קבוצת מודלים של {source}. פתח את הספרייה כדי לראות את כל הגרסאות ברשת.",
"confirm": {
"delete": "למחוק גרסה זו מהספרייה שלך?"
},
@@ -2483,7 +2532,9 @@
"linkCivArchSuccess": "המודל קושר מחדש דרך CivitArchive בהצלחה",
"fetchMetadataFirst": "אנא אחזר מטא-נתונים מ-CivitAI תחילה",
"noCivitaiInfo": "אין מידע מ-CivitAI זמין",
"missingHash": "ה-hash של המודל אינו זמין"
"missingHash": "ה-hash של המודל אינו זמין",
"enrichNeedsSource": "קשר מודל זה למקור מודל תחילה (קישור מודל → קישור למקור מודל)",
"enrichUnsupportedSource": "העשרת AI אינה זמינה עבור מודלים של {source}"
},
"exampleImages": {
"pathUpdated": "נתיב תמונות הדוגמה עודכן בהצלחה",
+74 -23
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "キャンセル",
"confirm": "確認",
"reorder": {
"dragHandle": "ドラッグして並べ替え"
},
"actions": {
"save": "保存",
"cancel": "キャンセル",
@@ -139,6 +142,7 @@
"viewOnCivitai": "CivitAIで表示",
"notAvailableFromCivitai": "CivitAIでは利用できません",
"viewOnHuggingFace": "Hugging Face で見る",
"viewOnSource": "{source} で見る",
"sendToWorkflow": "ComfyUIに送信(クリック:追加、Shift+クリック:置換)",
"copyLoRASyntax": "LoRA構文をコピー",
"checkpointNameCopied": "Checkpointの名前をコピーしました",
@@ -867,14 +871,14 @@
"complete": "自動整理が完了しました",
"error": "エラー:{error}"
},
"enrichHfAgent": "HF メタデータをAIで補完"
"enrichHfAgent": "メタデータをAIで補完"
},
"contextMenu": {
"refreshMetadata": "CivitAIデータを更新",
"checkUpdates": "更新確認",
"linkModel": "モデルをリンク",
"linkCivitai": "CivitAI にリンク",
"linkHuggingFace": "HuggingFace にリンク",
"linkModelSource": "モデルソースにリンク",
"copySyntax": "LoRA構文をコピー",
"copyFilename": "モデルファイル名をコピー",
"copyRecipeSyntax": "レシピ構文をコピー",
@@ -896,7 +900,7 @@
"viewAllLoras": "すべてのLoRAを表示",
"downloadMissingLoras": "不足しているLoRAをダウンロード",
"deleteRecipe": "レシピを削除",
"enrichHfAgent": "HF メタデータをAIで補完"
"enrichHfAgent": "メタデータをAIで補完"
}
},
"recipes": {
@@ -914,7 +918,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "ベースモデル",
"unknown": "不明"
},
"actions": {
"openFileLocation": "ファイルの場所を開く",
@@ -1245,28 +1251,63 @@
"sidebar": {
"modelRoot": "ルート",
"collapseAll": "すべてのフォルダを折りたたむ",
"collapseAllDisabled": "リスト表示では利用できません",
"hideOnThisPage": "このページでサイドバーを非表示",
"showSidebar": "サイドバーを表示",
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
"switchToListView": "リストビューに切り替え",
"switchToTreeView": "ツリー表示に切り替え",
"viewOptions": "表示オプション",
"treeView": "ツリー表示",
"listView": "リスト表示",
"recursiveOn": "サブフォルダーを含める",
"recursiveOff": "現在のフォルダーのみ",
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
"collapseAllDisabled": "リストビューでは利用できません",
"createFolder": "新規フォルダ",
"newSubfolder": "新規サブフォルダ",
"showEmptyFolders": "空のフォルダを表示",
"createFolderResult": {
"success": "フォルダ \"{name}\" を作成しました",
"failed": "フォルダの作成に失敗しました: {message}",
"unsupported": "このページではフォルダを作成できません",
"noRoot": "モデルルートが設定されていません"
},
"deleteFolder": "フォルダを削除",
"deleteFolderModal": {
"title": "フォルダを削除しますか?",
"message": "フォルダとその内容はすべてディスクから完全に削除されます。",
"folderLabel": "フォルダ",
"emptyNote": "このフォルダにはモデルがありません。他のファイルもすべて削除されます。",
"notEmptyTitle": "フォルダが空ではありません",
"notEmptyMessage": "このフォルダにはまだモデルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"confirm": "フォルダを削除"
},
"deleteFolderResult": {
"success": "フォルダ \"{name}\" を削除しました",
"successWithFiles": "フォルダ \"{name}\" を削除し、他に {count} 件の項目も削除しました",
"restored": "フォルダを復元しました",
"failed": "フォルダの削除に失敗しました: {message}",
"notEmpty": "このフォルダにはまだモデルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダを削除できません",
"noRoot": "モデルルートが設定されていません"
},
"renameFolder": "フォルダ名を変更",
"renameFolderResult": {
"success": "フォルダ名を \"{name}\" に変更しました",
"failed": "フォルダ名の変更に失敗しました: {message}",
"targetExists": "同じ名前のフォルダが既に存在します",
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダ名を変更できません",
"noRoot": "モデルルートが設定されていません"
},
"dragDrop": {
"unableToResolveRoot": "移動先のパスを特定できません。",
"moveUnsupported": "この項目の移動はサポートされていません。",
"createFolderHint": "放して新しいフォルダを作成",
"newFolderName": "新しいフォルダ名",
"folderNameHint": "Enterで確定、Escでキャンセル",
"emptyFolderName": "フォルダ名を入力してください",
"invalidFolderName": "フォルダ名に無効な文字が含まれています",
"noDragState": "保留中のドラッグ操作が見つかりません"
},
"empty": {
"noFolders": "フォルダが見つかりません",
"dragHint": "ここへアイテムをドラッグしてフォルダを作成ます"
"createHint": "上部の新規フォルダボタンからフォルダを作成できます"
},
"folderUpdateCheck": {
"label": "このフォルダのアップデートを確認",
@@ -1394,9 +1435,9 @@
"download": {
"title": "URLからモデルをダウンロード",
"titleWithType": "URLから{type}をダウンロード",
"civitaiUrl": "CivitAI URL",
"civitaiUrl": "モデル URL",
"placeholder": "https://civitai.com/models/...",
"urlHint": "1行に1つのCivitAI、CivArchive、またはHugging Face URLを入力してください。複数のURLを一括ダウンロードできます。",
"urlHint": "1行に1つのCivitAI、CivArchive、Hugging Face、またはModelScope URLを入力してください。複数のURLを一括ダウンロードできます。",
"selectHfFiles": "このリポジトリからダウンロードするファイルを選択してください:",
"selectAll": "すべて選択",
"fetchingRepoFiles": "リポジトリのファイルを取得中...",
@@ -1429,9 +1470,9 @@
"inLibrary": "ライブラリ内"
},
"errors": {
"invalidUrl": "無効なCivitAI URL形式",
"invalidUrl": "無効なモデル URL 形式",
"noVersions": "このモデルの利用可能なバージョンがありません",
"mixedSources": "同じバッチ内でCivitAIとHugging FaceのURLを混在させることはできません。",
"mixedSources": "同じバッチ内でCivitAIとHugging Face / ModelScopeのURLを混在させることはできません。",
"noModelFiles": "このリポジトリにモデルファイルが見つかりませんでした。"
},
"status": {
@@ -1445,6 +1486,10 @@
"progress": {
"currentFile": "現在のファイル:",
"downloading": "ダウンロード中: {name}",
"metadata": "メタデータ: {name}",
"indexingFile": "モデルファイルを読み込み中...",
"fetchingSourceMetadata": "{source} からメタデータを取得中...",
"fetchingMetadata": "メタデータを取得中...",
"transferred": "ダウンロード済み: {downloaded} / {total}",
"transferredSimple": "ダウンロード済み: {downloaded}",
"transferredUnknown": "ダウンロード済み: --",
@@ -1596,12 +1641,16 @@
"pathPlaceholder": "フォルダパスを入力するか、下のツリーから選択...",
"root": "ルート"
},
"linkHuggingFace": {
"title": "HuggingFace にリンク",
"infoText": "HuggingFace リポジトリの URL を貼り付けてモデルを関連付けます。AI によるメタデータ補完が有効になります。",
"urlLabel": "HuggingFace リポジトリ URL",
"linkModelSource": {
"title": "モデルソースにリンク",
"infoText": "モデルページの URL を貼り付けて、このモデルをソースに関連付けます。リンクすると、Hugging Face と ModelScope のモデルで AI によるメタデータ補完が有効になります。",
"urlLabel": "モデルページ URL",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "完全な HuggingFace リポジトリ URL を入力してください。",
"helpText": "完全なモデルページ URL を入力してください。対応サイト:",
"enrichNote": "AI 補完には読み取り可能なモデルカードが必要です。モデルカードを公開していないサイト(現在は TensorArt)はリンクのみ可能です。",
"urlRequired": "モデルページの URL を入力してください。",
"invalidUrl": "サポートされていない URL です。対応サイト:Hugging Face、ModelScope、TensorArt。",
"linking": "モデルソースをリンクしています...",
"confirmAction": "保存&リンク"
},
"relinkCivitai": {
@@ -1847,7 +1896,7 @@
"empty": "このモデルにはまだバージョン履歴がありません。",
"error": "バージョンの読み込みに失敗しました。",
"missingModelId": "このモデルにはCivitAIのモデルIDがありません。",
"hfGroupInfo": "これは HuggingFace モデルグループです。ライブラリを開いてグリッドですべてのバージョンを表示してください。",
"sourceGroupInfo": "これは {source} モデルグループです。ライブラリを開いてグリッドですべてのバージョンを表示してください。",
"confirm": {
"delete": "このバージョンをライブラリから削除しますか?"
},
@@ -2483,7 +2532,9 @@
"linkCivArchSuccess": "モデルがCivitArchive経由で正常に再リンクされました",
"fetchMetadataFirst": "最初にCivitAIからメタデータを取得してください",
"noCivitaiInfo": "CivitAI情報が利用できません",
"missingHash": "モデルハッシュが利用できません"
"missingHash": "モデルハッシュが利用できません",
"enrichNeedsSource": "まずこのモデルをモデルソースにリンクしてください(モデルをリンク → モデルソースにリンク)",
"enrichUnsupportedSource": "{source} モデルでは AI 補完を利用できません"
},
"exampleImages": {
"pathUpdated": "例画像パスが正常に更新されました",
+74 -23
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "취소",
"confirm": "확인",
"reorder": {
"dragHandle": "드래그하여 순서 변경"
},
"actions": {
"save": "저장",
"cancel": "취소",
@@ -139,6 +142,7 @@
"viewOnCivitai": "CivitAI에서 보기",
"notAvailableFromCivitai": "CivitAI에서 사용할 수 없음",
"viewOnHuggingFace": "Hugging Face에서 보기",
"viewOnSource": "{source}에서 보기",
"sendToWorkflow": "ComfyUI로 전송 (클릭: 추가, Shift+클릭: 교체)",
"copyLoRASyntax": "LoRA 문법 복사",
"checkpointNameCopied": "Checkpoint 이름 복사됨",
@@ -867,14 +871,14 @@
"complete": "자동 정리 완료",
"error": "오류: {error}"
},
"enrichHfAgent": "HF AI로 메타데이터 보강"
"enrichHfAgent": "AI로 메타데이터 보강"
},
"contextMenu": {
"refreshMetadata": "CivitAI 데이터 새로고침",
"checkUpdates": "업데이트 확인",
"linkModel": "모델 연결",
"linkCivitai": "CivitAI에 연결",
"linkHuggingFace": "HuggingFace에 연결",
"linkModelSource": "모델 소스에 연결",
"copySyntax": "LoRA 문법 복사",
"copyFilename": "모델 파일명 복사",
"copyRecipeSyntax": "레시피 문법 복사",
@@ -896,7 +900,7 @@
"viewAllLoras": "모든 LoRA 보기",
"downloadMissingLoras": "누락된 LoRA 다운로드",
"deleteRecipe": "레시피 삭제",
"enrichHfAgent": "HF AI로 메타데이터 보강"
"enrichHfAgent": "AI로 메타데이터 보강"
}
},
"recipes": {
@@ -914,7 +918,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "베이스 모델",
"unknown": "알 수 없음"
},
"actions": {
"openFileLocation": "파일 위치 열기",
@@ -1245,28 +1251,63 @@
"sidebar": {
"modelRoot": "루트",
"collapseAll": "모든 폴더 접기",
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
"hideOnThisPage": "이 페이지에서 사이드바 숨기기",
"showSidebar": "사이드바 표시",
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
"switchToListView": "목록 보기로 전환",
"switchToTreeView": "트리 보기로 전환",
"viewOptions": "보기 옵션",
"treeView": "트리 보기",
"listView": "목록 보기",
"recursiveOn": "하위 폴더 포함",
"recursiveOff": "현재 폴더",
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
"createFolder": " 폴더",
"newSubfolder": "새 하위 폴더",
"showEmptyFolders": "빈 폴더 표시",
"createFolderResult": {
"success": "\"{name}\" 폴더를 생성했습니다",
"failed": "폴더 생성 실패: {message}",
"unsupported": "이 페이지에서는 폴더를 만들 수 없습니다",
"noRoot": "모델 루트가 설정되지 않았습니다"
},
"deleteFolder": "폴더 삭제",
"deleteFolderModal": {
"title": "폴더를 삭제할까요?",
"message": "폴더와 그 안의 모든 내용이 디스크에서 영구적으로 삭제됩니다.",
"folderLabel": "폴더",
"emptyNote": "이 폴더에는 모델이 없습니다. 폴더 안의 다른 파일도 함께 삭제됩니다.",
"notEmptyTitle": "폴더가 비어 있지 않습니다",
"notEmptyMessage": "이 폴더에는 아직 모델이 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"confirm": "폴더 삭제"
},
"deleteFolderResult": {
"success": "\"{name}\" 폴더를 삭제했습니다",
"successWithFiles": "\"{name}\" 폴더와 {count}개 항목을 함께 삭제했습니다",
"restored": "폴더를 복원했습니다",
"failed": "폴더 삭제 실패: {message}",
"notEmpty": "이 폴더에는 아직 모델이 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
"noRoot": "모델 루트가 설정되지 않았습니다"
},
"renameFolder": "폴더 이름 바꾸기",
"renameFolderResult": {
"success": "폴더 이름을 \"{name}\"(으)로 변경했습니다",
"failed": "폴더 이름 바꾸기 실패: {message}",
"targetExists": "같은 이름의 폴더가 이미 있습니다",
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
"unsupported": "이 페이지에서는 폴더 이름을 바꿀 수 없습니다",
"noRoot": "모델 루트가 설정되지 않았습니다"
},
"dragDrop": {
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
"moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
"createFolderHint": "놓아서 새 폴더 만들기",
"newFolderName": "새 폴더 이름",
"folderNameHint": "Enter를 눌러 확인, Escape를 눌러 취소",
"emptyFolderName": "폴더 이름을 입력하세요",
"invalidFolderName": "폴더 이름에 잘못된 문자가 포함되어 있습니다",
"noDragState": "보류 중인 드래그 작업을 찾을 수 없습니다"
},
"empty": {
"noFolders": "폴더를 찾을 수 없습니다",
"dragHint": "항목을 여기로 드래그하여 폴더를 만니다"
"createHint": "위의 새 폴더 버튼을 클릭하여 폴더를 만들 수 있습니다"
},
"folderUpdateCheck": {
"label": "이 폴더의 업데이트 확인",
@@ -1394,9 +1435,9 @@
"download": {
"title": "URL에서 모델 다운로드",
"titleWithType": "URL에서 {type} 다운로드",
"civitaiUrl": "CivitAI URL:",
"civitaiUrl": "모델 URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "한 줄에 하나의 CivitAI, CivArchive 또는 Hugging Face URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
"urlHint": "한 줄에 하나의 CivitAI, CivArchive, Hugging Face 또는 ModelScope URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
"selectHfFiles": "이 저장소에서 다운로드할 파일을 선택하세요:",
"selectAll": "모두 선택",
"fetchingRepoFiles": "저장소 파일을 가져오는 중...",
@@ -1429,9 +1470,9 @@
"inLibrary": "라이브러리에 있음"
},
"errors": {
"invalidUrl": "잘못된 CivitAI URL 형식",
"invalidUrl": "잘못된 모델 URL 형식",
"noVersions": "이 모델에 사용 가능한 버전이 없습니다",
"mixedSources": "동일한 배치에서 CivitAI와 Hugging Face URL을 혼합할 수 없습니다.",
"mixedSources": "동일한 배치에서 CivitAI와 Hugging Face / ModelScope URL을 혼합할 수 없습니다.",
"noModelFiles": "이 저장소에서 모델 파일을 찾을 수 없습니다."
},
"status": {
@@ -1445,6 +1486,10 @@
"progress": {
"currentFile": "현재 파일:",
"downloading": "다운로드 중: {name}",
"metadata": "메타데이터: {name}",
"indexingFile": "모델 파일 읽는 중...",
"fetchingSourceMetadata": "{source}에서 메타데이터 가져오는 중...",
"fetchingMetadata": "메타데이터 가져오는 중...",
"transferred": "다운로드됨: {downloaded} / {total}",
"transferredSimple": "다운로드됨: {downloaded}",
"transferredUnknown": "다운로드됨: --",
@@ -1596,12 +1641,16 @@
"pathPlaceholder": "폴더 경로를 입력하거나 아래 트리에서 선택하세요...",
"root": "루트"
},
"linkHuggingFace": {
"title": "HuggingFace에 연결",
"infoText": "HuggingFace 저장소 URL을 붙여넣어 모델을 연결합니다. AI 메타데이터 보강 기능을 사용할 수 있습니다.",
"urlLabel": "HuggingFace 저장소 URL",
"linkModelSource": {
"title": "모델 소스에 연결",
"infoText": "모델 페이지 URL을 붙여넣어 모델을 소스에 연결합니다. 연결하면 Hugging Face 및 ModelScope 모델에 AI 메타데이터 보강을 사용할 수 있습니다.",
"urlLabel": "모델 페이지 URL:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "전체 HuggingFace 저장소 URL을 입력하세요.",
"helpText": "전체 모델 페이지 URL을 입력하세요. 지원 사이트:",
"enrichNote": "AI 보강에는 읽을 수 있는 모델 카드가 필요합니다. 모델 카드를 제공하지 않는 사이트(현재 TensorArt)는 연결만 가능합니다.",
"urlRequired": "모델 페이지 URL을 입력하세요.",
"invalidUrl": "지원되지 않는 URL입니다. 지원 사이트: Hugging Face, ModelScope, TensorArt.",
"linking": "모델 소스를 연결하는 중...",
"confirmAction": "저장 및 연결"
},
"relinkCivitai": {
@@ -1847,7 +1896,7 @@
"empty": "이 모델에는 아직 버전 기록이 없습니다.",
"error": "버전을 불러오지 못했습니다.",
"missingModelId": "이 모델에는 CivitAI 모델 ID가 없습니다.",
"hfGroupInfo": "HuggingFace 모델 그룹입니다. 라이브러리를 열어 그리드에서 모든 버전을 확인하세요.",
"sourceGroupInfo": "{source} 모델 그룹입니다. 라이브러리를 열어 그리드에서 모든 버전을 확인하세요.",
"confirm": {
"delete": "이 버전을 라이브러리에서 삭제하시겠습니까?"
},
@@ -2483,7 +2532,9 @@
"linkCivArchSuccess": "모델이 CivitArchive을 통해 성공적으로 다시 연결되었습니다",
"fetchMetadataFirst": "먼저 CivitAI에서 메타데이터를 가져와주세요",
"noCivitaiInfo": "사용 가능한 CivitAI 정보가 없습니다",
"missingHash": "모델 해시를 사용할 수 없습니다"
"missingHash": "모델 해시를 사용할 수 없습니다",
"enrichNeedsSource": "먼저 이 모델을 모델 소스에 연결하세요 (모델 연결 → 모델 소스에 연결)",
"enrichUnsupportedSource": "{source} 모델에서는 AI 보강을 사용할 수 없습니다"
},
"exampleImages": {
"pathUpdated": "예시 이미지 경로가 성공적으로 업데이트되었습니다",
+74 -23
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "Отмена",
"confirm": "Подтвердить",
"reorder": {
"dragHandle": "Перетащите, чтобы изменить порядок"
},
"actions": {
"save": "Сохранить",
"cancel": "Отмена",
@@ -139,6 +142,7 @@
"viewOnCivitai": "Посмотреть на CivitAI",
"notAvailableFromCivitai": "Недоступно на CivitAI",
"viewOnHuggingFace": "Открыть Hugging Face",
"viewOnSource": "Открыть {source}",
"sendToWorkflow": "Отправить в ComfyUI (Клик: Добавить, Shift+Клик: Заменить)",
"copyLoRASyntax": "Копировать синтаксис LoRA",
"checkpointNameCopied": "Имя checkpoint скопировано",
@@ -867,14 +871,14 @@
"complete": "Автоматическая организация завершена",
"error": "Ошибка: {error}"
},
"enrichHfAgent": "Обогатить HF метаданные (ИИ)"
"enrichHfAgent": "Обогатить метаданные с помощью ИИ"
},
"contextMenu": {
"refreshMetadata": "Обновить данные CivitAI",
"checkUpdates": "Проверить обновления",
"linkModel": "Связать модель",
"linkCivitai": "Пересвязать с CivitAI",
"linkHuggingFace": "Связать с HuggingFace",
"linkModelSource": "Связать с источником модели",
"copySyntax": "Копировать синтаксис LoRA",
"copyFilename": "Копировать имя файла модели",
"copyRecipeSyntax": "Копировать синтаксис рецепта",
@@ -896,7 +900,7 @@
"viewAllLoras": "Посмотреть все LoRAs",
"downloadMissingLoras": "Загрузить отсутствующие LoRAs",
"deleteRecipe": "Удалить рецепт",
"enrichHfAgent": "Обогатить HF метаданные (ИИ)"
"enrichHfAgent": "Обогатить метаданные с помощью ИИ"
}
},
"recipes": {
@@ -914,7 +918,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "Базовая модель",
"unknown": "Неизвестно"
},
"actions": {
"openFileLocation": "Открыть расположение файла",
@@ -1245,28 +1251,63 @@
"sidebar": {
"modelRoot": "Корень",
"collapseAll": "Свернуть все папки",
"collapseAllDisabled": "Недоступно в виде списка",
"hideOnThisPage": "Скрыть боковую панель на этой странице",
"showSidebar": "Показать боковую панель",
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
"switchToListView": "Переключить на вид списка",
"switchToTreeView": "Переключить на древовидный вид",
"viewOptions": "Параметры отображения",
"treeView": "Дерево",
"listView": "Список",
"recursiveOn": "Включать вложенные папки",
"recursiveOff": "Только текущая папка",
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
"collapseAllDisabled": "Недоступно в виде списка",
"createFolder": "Новая папка",
"newSubfolder": "Новая вложенная папка",
"showEmptyFolders": "Показывать пустые папки",
"createFolderResult": {
"success": "Папка \"{name}\" создана",
"failed": "Не удалось создать папку: {message}",
"unsupported": "Создание папок не поддерживается на этой странице",
"noRoot": "Корневая папка моделей не настроена"
},
"deleteFolder": "Удалить папку",
"deleteFolderModal": {
"title": "Удалить папку?",
"message": "Папка и всё её содержимое будут безвозвратно удалены с диска.",
"folderLabel": "Папка",
"emptyNote": "В этой папке нет моделей. Остальные файлы в ней тоже будут удалены.",
"notEmptyTitle": "Папка не пуста",
"notEmptyMessage": "В этой папке ещё есть модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
"confirm": "Удалить папку"
},
"deleteFolderResult": {
"success": "Папка \"{name}\" удалена",
"successWithFiles": "Папка \"{name}\" удалена вместе с ещё {count} элемент(ами)",
"restored": "Папка восстановлена",
"failed": "Не удалось удалить папку: {message}",
"notEmpty": "В этой папке ещё есть модели. Обновите боковую панель и повторите попытку.",
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
"unsupported": "Удаление папок не поддерживается на этой странице",
"noRoot": "Корневая папка моделей не настроена"
},
"renameFolder": "Переименовать папку",
"renameFolderResult": {
"success": "Папка переименована в \"{name}\"",
"failed": "Не удалось переименовать папку: {message}",
"targetExists": "Папка с таким именем уже существует здесь",
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
"unsupported": "Переименование папок не поддерживается на этой странице",
"noRoot": "Корневая папка моделей не настроена"
},
"dragDrop": {
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
"moveUnsupported": "Перемещение этого элемента не поддерживается.",
"createFolderHint": "Отпустите, чтобы создать новую папку",
"newFolderName": "Имя новой папки",
"folderNameHint": "Нажмите Enter для подтверждения, Escape для отмены",
"emptyFolderName": "Пожалуйста, введите имя папки",
"invalidFolderName": "Имя папки содержит недопустимые символы",
"noDragState": "Ожидающая операция перетаскивания не найдена"
},
"empty": {
"noFolders": "Папки не найдены",
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
"createHint": "Нажмите кнопку «Новая папка» вверху, чтобы создать папки"
},
"folderUpdateCheck": {
"label": "Проверить обновления в этой папке",
@@ -1394,9 +1435,9 @@
"download": {
"title": "Скачать модель по URL",
"titleWithType": "Скачать {type} по URL",
"civitaiUrl": "CivitAI URL:",
"civitaiUrl": "URL модели:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Введите один URL CivitAI, CivArchive или Hugging Face в каждой строке. Поддерживает несколько URL для пакетной загрузки.",
"urlHint": "Введите один URL CivitAI, CivArchive, Hugging Face или ModelScope в каждой строке. Поддерживает несколько URL для пакетной загрузки.",
"selectHfFiles": "Выберите файл(ы) для загрузки из этого репозитория:",
"selectAll": "Выбрать все",
"fetchingRepoFiles": "Получение файлов репозитория...",
@@ -1429,9 +1470,9 @@
"inLibrary": "В библиотеке"
},
"errors": {
"invalidUrl": "Неверный формат URL CivitAI",
"invalidUrl": "Неверный формат URL модели",
"noVersions": "Нет доступных версий для этой модели",
"mixedSources": "Нельзя смешивать URL-адреса CivitAI и Hugging Face в одном пакете.",
"mixedSources": "Нельзя смешивать URL-адреса CivitAI и Hugging Face / ModelScope в одном пакете.",
"noModelFiles": "В этом репозитории не найдено файлов моделей."
},
"status": {
@@ -1445,6 +1486,10 @@
"progress": {
"currentFile": "Текущий файл:",
"downloading": "Скачивается: {name}",
"metadata": "Метаданные: {name}",
"indexingFile": "Чтение файла модели...",
"fetchingSourceMetadata": "Получение метаданных из {source}...",
"fetchingMetadata": "Получение метаданных...",
"transferred": "Скачано: {downloaded} / {total}",
"transferredSimple": "Скачано: {downloaded}",
"transferredUnknown": "Скачано: --",
@@ -1596,12 +1641,16 @@
"pathPlaceholder": "Введите путь к папке или выберите из дерева ниже...",
"root": "Корень"
},
"linkHuggingFace": {
"title": "Связать с HuggingFace",
"infoText": "Вставьте URL репозитория HuggingFace, чтобы связать эту модель с её источником. Это позволит обогащать метаданные с помощью ИИ.",
"urlLabel": "URL репозитория HuggingFace:",
"linkModelSource": {
"title": "Связать с источником модели",
"infoText": "Вставьте URL страницы модели, чтобы связать эту модель с её источником. Связывание включает обогащение метаданных с помощью ИИ для моделей Hugging Face и ModelScope.",
"urlLabel": "URL страницы модели:",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "Введите полный URL репозитория HuggingFace.",
"helpText": "Введите полный URL страницы модели. Поддерживаемые сайты:",
"enrichNote": "Для обогащения с помощью ИИ нужна читаемая карточка модели. Сайты, которые её не предоставляют (сейчас TensorArt), можно только связать.",
"urlRequired": "Введите URL страницы модели.",
"invalidUrl": "Неподдерживаемый URL. Поддерживаемые сайты: Hugging Face, ModelScope, TensorArt.",
"linking": "Связывание с источником модели...",
"confirmAction": "Сохранить и связать"
},
"relinkCivitai": {
@@ -1847,7 +1896,7 @@
"empty": "Для этой модели пока нет истории версий.",
"error": "Не удалось загрузить версии.",
"missingModelId": "У этой модели отсутствует идентификатор модели CivitAI.",
"hfGroupInfo": "Это группа моделей HuggingFace. Откройте библиотеку, чтобы увидеть все версии в сетке.",
"sourceGroupInfo": "Это группа моделей {source}. Откройте библиотеку, чтобы увидеть все версии в сетке.",
"confirm": {
"delete": "Удалить эту версию из библиотеки?"
},
@@ -2483,7 +2532,9 @@
"linkCivArchSuccess": "Модель успешно пересвязана через CivitArchive",
"fetchMetadataFirst": "Пожалуйста, сначала получите метаданные с CivitAI",
"noCivitaiInfo": "Информация CivitAI недоступна",
"missingHash": "Хеш модели недоступен"
"missingHash": "Хеш модели недоступен",
"enrichNeedsSource": "Сначала свяжите эту модель с источником модели (Связать модель → Связать с источником модели)",
"enrichUnsupportedSource": "Обогащение с помощью ИИ недоступно для моделей {source}"
},
"exampleImages": {
"pathUpdated": "Путь к примерам изображений успешно обновлен",
+74 -23
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "取消",
"confirm": "确认",
"reorder": {
"dragHandle": "拖拽以调整顺序"
},
"actions": {
"save": "保存",
"cancel": "取消",
@@ -139,6 +142,7 @@
"viewOnCivitai": "在 CivitAI 查看",
"notAvailableFromCivitai": "CivitAI 上不可用",
"viewOnHuggingFace": "在 Hugging Face 查看",
"viewOnSource": "在 {source} 查看",
"sendToWorkflow": "发送到 ComfyUI(点击:追加,Shift+点击:替换)",
"copyLoRASyntax": "复制 LoRA 语法",
"checkpointNameCopied": "Checkpoint 名称已复制",
@@ -867,14 +871,14 @@
"complete": "自动整理已完成",
"error": "错误:{error}"
},
"enrichHfAgent": "AI HF 元数据增强"
"enrichHfAgent": "AI 元数据增强"
},
"contextMenu": {
"refreshMetadata": "刷新 CivitAI 数据",
"checkUpdates": "检查更新",
"linkModel": "链接模型",
"linkCivitai": "链接到 CivitAI",
"linkHuggingFace": "链接到 HuggingFace",
"linkModelSource": "链接到模型来源",
"copySyntax": "复制 LoRA 语法",
"copyFilename": "复制模型文件名",
"copyRecipeSyntax": "复制配方语法",
@@ -896,7 +900,7 @@
"viewAllLoras": "查看所有 LoRA",
"downloadMissingLoras": "下载缺失的 LoRA",
"deleteRecipe": "删除配方",
"enrichHfAgent": "AI HF 元数据增强"
"enrichHfAgent": "AI 元数据增强"
}
},
"recipes": {
@@ -914,7 +918,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "基础模型",
"unknown": "未知"
},
"actions": {
"openFileLocation": "打开文件位置",
@@ -1245,28 +1251,63 @@
"sidebar": {
"modelRoot": "根目录",
"collapseAll": "折叠所有文件夹",
"collapseAllDisabled": "列表视图下不可用",
"hideOnThisPage": "隐藏此页面侧边栏",
"showSidebar": "显示侧边栏",
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
"switchToListView": "切换到列表视图",
"switchToTreeView": "切换到树状视图",
"viewOptions": "视图选项",
"treeView": "树形视图",
"listView": "列表视图",
"recursiveOn": "包含子文件夹",
"recursiveOff": "仅当前文件夹",
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
"collapseAllDisabled": "列表视图下不可用",
"createFolder": "新建文件夹",
"newSubfolder": "新建子文件夹",
"showEmptyFolders": "显示空文件夹",
"createFolderResult": {
"success": "已创建文件夹 \"{name}\"",
"failed": "创建文件夹失败: {message}",
"unsupported": "此页面不支持创建文件夹",
"noRoot": "未配置模型根目录"
},
"deleteFolder": "删除文件夹",
"deleteFolderModal": {
"title": "删除文件夹?",
"message": "该文件夹及其中所有内容都将从磁盘上永久删除。",
"folderLabel": "文件夹",
"emptyNote": "该文件夹中没有模型,其中的其他文件也会一并删除。",
"notEmptyTitle": "文件夹不为空",
"notEmptyMessage": "该文件夹中仍有模型,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
"confirm": "删除文件夹"
},
"deleteFolderResult": {
"success": "已删除文件夹 \"{name}\"",
"successWithFiles": "已删除文件夹 \"{name}\",同时删除了另外 {count} 项内容",
"restored": "文件夹已恢复",
"failed": "删除文件夹失败: {message}",
"notEmpty": "该文件夹中仍有模型。请刷新侧边栏后重试。",
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
"unsupported": "此页面不支持删除文件夹",
"noRoot": "未配置模型根目录"
},
"renameFolder": "重命名文件夹",
"renameFolderResult": {
"success": "文件夹已重命名为 \"{name}\"",
"failed": "重命名文件夹失败: {message}",
"targetExists": "此处已存在同名文件夹",
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
"unsupported": "此页面不支持重命名文件夹",
"noRoot": "未配置模型根目录"
},
"dragDrop": {
"unableToResolveRoot": "无法确定移动的目标路径。",
"moveUnsupported": "此条目不支持移动。",
"createFolderHint": "释放以创建新文件夹",
"newFolderName": "新文件夹名称",
"folderNameHint": "按 Enter 确认,Escape 取消",
"emptyFolderName": "请输入文件夹名称",
"invalidFolderName": "文件夹名称包含无效字符",
"noDragState": "未找到待处理的拖放操作"
},
"empty": {
"noFolders": "未找到文件夹",
"dragHint": "拖拽项目到此处以创建文件夹"
"createHint": "点击上方的新建文件夹按钮即可创建文件夹"
},
"folderUpdateCheck": {
"label": "检查此文件夹的更新",
@@ -1394,9 +1435,9 @@
"download": {
"title": "从 URL 下载模型",
"titleWithType": "从 URL 下载 {type}",
"civitaiUrl": "CivitAI URL:",
"civitaiUrl": "模型 URL",
"placeholder": "https://civitai.com/models/...",
"urlHint": "每行输入一个 CivitAI、CivArchiveHugging Face URL。支持批量下载多个 URL。",
"urlHint": "每行输入一个 CivitAI、CivArchiveHugging Face 或 ModelScope URL。支持批量下载多个 URL。",
"selectHfFiles": "选择从此仓库下载的文件:",
"selectAll": "全选",
"fetchingRepoFiles": "正在获取仓库文件...",
@@ -1429,9 +1470,9 @@
"inLibrary": "已在库中"
},
"errors": {
"invalidUrl": "无效的 CivitAI URL 格式",
"invalidUrl": "无效的模型 URL 格式",
"noVersions": "此模型没有可用版本",
"mixedSources": "无法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
"mixedSources": "无法在同一批次中混合使用 CivitAI 和 Hugging Face / ModelScope URL。",
"noModelFiles": "在此仓库中未找到模型文件。"
},
"status": {
@@ -1445,6 +1486,10 @@
"progress": {
"currentFile": "当前文件:",
"downloading": "下载中:{name}",
"metadata": "元数据:{name}",
"indexingFile": "正在读取模型文件...",
"fetchingSourceMetadata": "正在从 {source} 获取元数据...",
"fetchingMetadata": "正在获取元数据...",
"transferred": "已下载:{downloaded} / {total}",
"transferredSimple": "已下载:{downloaded}",
"transferredUnknown": "已下载:--",
@@ -1596,12 +1641,16 @@
"pathPlaceholder": "输入文件夹路径或从下方树中选择...",
"root": "根目录"
},
"linkHuggingFace": {
"title": "链接到 HuggingFace",
"infoText": "粘贴 HuggingFace 仓库 URL 以关联此模型。关联后可启用 AI 元数据增强功能。",
"urlLabel": "HuggingFace 仓库 URL",
"linkModelSource": {
"title": "链接到模型来源",
"infoText": "粘贴模型页面 URL 以关联此模型与其来源。关联后可对 Hugging Face 和 ModelScope 模型启用 AI 元数据增强。",
"urlLabel": "模型页面 URL",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "请输入完整的 HuggingFace 仓库 URL。",
"helpText": "请输入完整的模型页面 URL。支持的站点:",
"enrichNote": "AI 增强需要可读取的模型卡。未提供模型卡的站点(目前为 TensorArt)只能建立链接。",
"urlRequired": "请输入模型页面 URL。",
"invalidUrl": "URL 不受支持。支持的站点:Hugging Face、ModelScope、TensorArt。",
"linking": "正在链接模型来源...",
"confirmAction": "保存并链接"
},
"relinkCivitai": {
@@ -1847,7 +1896,7 @@
"empty": "该模型还没有版本历史。",
"error": "加载版本失败。",
"missingModelId": "该模型缺少 CivitAI 模型 ID。",
"hfGroupInfo": "这是一个 HuggingFace 模型组。打开库页面即可在网格中查看所有版本。",
"sourceGroupInfo": "这是一个 {source} 模型组。打开库页面即可在网格中查看所有版本。",
"confirm": {
"delete": "从库中删除此版本?"
},
@@ -2483,7 +2532,9 @@
"linkCivArchSuccess": "模型已成功通过 CivitArchive 重新关联",
"fetchMetadataFirst": "请先从 CivitAI 获取元数据",
"noCivitaiInfo": "无 CivitAI 信息",
"missingHash": "模型哈希不可用"
"missingHash": "模型哈希不可用",
"enrichNeedsSource": "请先将此模型链接到模型来源(链接模型 → 链接到模型来源)",
"enrichUnsupportedSource": "{source} 模型不支持 AI 增强"
},
"exampleImages": {
"pathUpdated": "示例图片路径更新成功",
+74 -23
View File
@@ -2,6 +2,9 @@
"common": {
"cancel": "取消",
"confirm": "確認",
"reorder": {
"dragHandle": "拖曳以調整順序"
},
"actions": {
"save": "儲存",
"cancel": "取消",
@@ -139,6 +142,7 @@
"viewOnCivitai": "在 CivitAI 查看",
"notAvailableFromCivitai": "CivitAI 不提供",
"viewOnHuggingFace": "在 Hugging Face 查看",
"viewOnSource": "在 {source} 查看",
"sendToWorkflow": "傳送到 ComfyUI(點擊:附加,Shift+點擊:取代)",
"copyLoRASyntax": "複製 LoRA 語法",
"checkpointNameCopied": "Checkpoint 名稱已複製",
@@ -867,14 +871,14 @@
"complete": "自動整理完成",
"error": "錯誤:{error}"
},
"enrichHfAgent": "AI HF 中繼資料增強"
"enrichHfAgent": "AI 中繼資料增強"
},
"contextMenu": {
"refreshMetadata": "刷新 CivitAI 資料",
"checkUpdates": "檢查更新",
"linkModel": "連結模型",
"linkCivitai": "連結到 CivitAI",
"linkHuggingFace": "連結到 HuggingFace",
"linkModelSource": "連結到模型來源",
"copySyntax": "複製 LoRA 語法",
"copyFilename": "複製模型檔名",
"copyRecipeSyntax": "複製配方語法",
@@ -896,7 +900,7 @@
"viewAllLoras": "檢視全部 LoRA",
"downloadMissingLoras": "下載缺少的 LoRA",
"deleteRecipe": "刪除配方",
"enrichHfAgent": "AI HF 中繼資料增強"
"enrichHfAgent": "AI 中繼資料增強"
}
},
"recipes": {
@@ -914,7 +918,9 @@
},
"modal": {
"metadata": {
"id": "ID"
"id": "ID",
"baseModel": "基礎模型",
"unknown": "未知"
},
"actions": {
"openFileLocation": "開啟檔案位置",
@@ -1245,28 +1251,63 @@
"sidebar": {
"modelRoot": "根目錄",
"collapseAll": "全部摺疊資料夾",
"collapseAllDisabled": "清單檢視下無法使用",
"hideOnThisPage": "隱藏此頁面側邊欄",
"showSidebar": "顯示側邊欄",
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
"switchToListView": "切換至列表檢視",
"switchToTreeView": "切換到樹狀檢視",
"viewOptions": "檢視選項",
"treeView": "樹狀檢視",
"listView": "清單檢視",
"recursiveOn": "包含子資料夾",
"recursiveOff": "僅目前資料夾",
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
"collapseAllDisabled": "列表檢視下不可用",
"createFolder": "新增資料夾",
"newSubfolder": "新增子資料夾",
"showEmptyFolders": "顯示空資料夾",
"createFolderResult": {
"success": "已建立資料夾 \"{name}\"",
"failed": "建立資料夾失敗: {message}",
"unsupported": "此頁面不支援建立資料夾",
"noRoot": "未設定模型根目錄"
},
"deleteFolder": "刪除資料夾",
"deleteFolderModal": {
"title": "刪除資料夾?",
"message": "該資料夾及其中的所有內容都將從磁碟上永久刪除。",
"folderLabel": "資料夾",
"emptyNote": "該資料夾中沒有模型,其中的其他檔案也會一併刪除。",
"notEmptyTitle": "資料夾不是空的",
"notEmptyMessage": "該資料夾中仍有模型,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
"confirm": "刪除資料夾"
},
"deleteFolderResult": {
"success": "已刪除資料夾 \"{name}\"",
"successWithFiles": "已刪除資料夾 \"{name}\",同時刪除了另外 {count} 項內容",
"restored": "資料夾已還原",
"failed": "刪除資料夾失敗: {message}",
"notEmpty": "該資料夾中仍有模型。請重新整理側邊欄後再試。",
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
"unsupported": "此頁面不支援刪除資料夾",
"noRoot": "未設定模型根目錄"
},
"renameFolder": "重新命名資料夾",
"renameFolderResult": {
"success": "資料夾已重新命名為 \"{name}\"",
"failed": "重新命名資料夾失敗: {message}",
"targetExists": "此處已存在同名資料夾",
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
"unsupported": "此頁面不支援重新命名資料夾",
"noRoot": "未設定模型根目錄"
},
"dragDrop": {
"unableToResolveRoot": "無法確定移動的目標路徑。",
"moveUnsupported": "此項目不支援移動。",
"createFolderHint": "放開以建立新資料夾",
"newFolderName": "新資料夾名稱",
"folderNameHint": "按 Enter 確認,Escape 取消",
"emptyFolderName": "請輸入資料夾名稱",
"invalidFolderName": "資料夾名稱包含無效字元",
"noDragState": "未找到待處理的拖放操作"
},
"empty": {
"noFolders": "未找到資料夾",
"dragHint": "將項目拖到此處以建立資料夾"
"createHint": "點擊上方的新增資料夾按鈕即可建立資料夾"
},
"folderUpdateCheck": {
"label": "檢查此資料夾的更新",
@@ -1394,9 +1435,9 @@
"download": {
"title": "從網址下載模型",
"titleWithType": "從網址下載 {type}",
"civitaiUrl": "CivitAI 網址:",
"civitaiUrl": "模型網址:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "每行輸入一個 CivitAI、CivArchiveHugging Face URL。支援批量下載多個 URL。",
"urlHint": "每行輸入一個 CivitAI、CivArchiveHugging Face 或 ModelScope URL。支援批量下載多個 URL。",
"selectHfFiles": "選擇從此倉庫下載的檔案:",
"selectAll": "全選",
"fetchingRepoFiles": "正在獲取倉庫檔案...",
@@ -1429,9 +1470,9 @@
"inLibrary": "已在庫中"
},
"errors": {
"invalidUrl": "CivitAI 網址格式無效",
"invalidUrl": "模型網址格式無效",
"noVersions": "此模型無可用版本",
"mixedSources": "無法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
"mixedSources": "無法在同一批次中混合使用 CivitAI 和 Hugging Face / ModelScope URL。",
"noModelFiles": "在此倉庫中未找到模型檔案。"
},
"status": {
@@ -1445,6 +1486,10 @@
"progress": {
"currentFile": "目前檔案:",
"downloading": "下載中:{name}",
"metadata": "中繼資料:{name}",
"indexingFile": "正在讀取模型檔案...",
"fetchingSourceMetadata": "正在從 {source} 取得中繼資料...",
"fetchingMetadata": "正在取得中繼資料...",
"transferred": "已下載:{downloaded} / {total}",
"transferredSimple": "已下載:{downloaded}",
"transferredUnknown": "已下載:--",
@@ -1596,12 +1641,16 @@
"pathPlaceholder": "輸入資料夾路徑或從下方樹狀結構選擇...",
"root": "根目錄"
},
"linkHuggingFace": {
"title": "連結到 HuggingFace",
"infoText": "貼上 HuggingFace 倉庫 URL 以關聯此模型。關聯後可啟用 AI 中繼資料增強功能。",
"urlLabel": "HuggingFace 倉庫 URL",
"linkModelSource": {
"title": "連結到模型來源",
"infoText": "貼上模型頁面 URL 以關聯此模型與其來源。關聯後可對 Hugging Face 和 ModelScope 模型啟用 AI 中繼資料增強。",
"urlLabel": "模型頁面 URL",
"urlPlaceholder": "https://huggingface.co/user/repo",
"helpText": "請輸入完整的 HuggingFace 倉庫 URL。",
"helpText": "請輸入完整的模型頁面 URL。支援的站點:",
"enrichNote": "AI 增強需要可讀取的模型卡。未提供模型卡的站點(目前為 TensorArt)只能建立連結。",
"urlRequired": "請輸入模型頁面 URL。",
"invalidUrl": "URL 不受支援。支援的站點:Hugging Face、ModelScope、TensorArt。",
"linking": "正在連結模型來源...",
"confirmAction": "儲存並連結"
},
"relinkCivitai": {
@@ -1847,7 +1896,7 @@
"empty": "此模型尚無版本歷史。",
"error": "載入版本失敗。",
"missingModelId": "此模型缺少 CivitAI 模型 ID。",
"hfGroupInfo": "這是一個 HuggingFace 模型組。打開庫頁面即可在網格中查看所有版本。",
"sourceGroupInfo": "這是一個 {source} 模型組。打開庫頁面即可在網格中查看所有版本。",
"confirm": {
"delete": "要從庫中刪除此版本嗎?"
},
@@ -2483,7 +2532,9 @@
"linkCivArchSuccess": "模型已成功透過 CivitArchive 重新連結",
"fetchMetadataFirst": "請先從 CivitAI 取得 metadata",
"noCivitaiInfo": "無 CivitAI 資訊",
"missingHash": "模型雜湊不可用"
"missingHash": "模型雜湊不可用",
"enrichNeedsSource": "請先將此模型連結到模型來源(連結模型 → 連結到模型來源)",
"enrichUnsupportedSource": "{source} 模型不支援 AI 增強"
},
"exampleImages": {
"pathUpdated": "範例圖片路徑已更新",
-7
View File
@@ -472,12 +472,5 @@ class LoraManager:
scanner.cancel_task()
logger.debug("LoRA Manager: Cancelled %s", name)
# Close shared aiohttp sessions to avoid "Unclosed client session" warnings
try:
from py.routes.handlers.hf_handlers import close_hf_api_session
await close_hf_api_session()
except Exception as exc:
logger.debug("Error closing HF API session: %s", exc)
except Exception as e:
logger.error(f"Error during cleanup: {e}", exc_info=True)
+2 -2
View File
@@ -78,7 +78,7 @@ class CheckpointLoaderLM:
# Filter only checkpoint type (not diffusion_model) and format names
names = []
for item in cache.raw_data:
for item in list(cache.raw_data):
if item.get("sub_type") == "checkpoint":
file_path = item.get("file_path", "")
# Only offer models that still exist on disk so ComfyUI
@@ -126,7 +126,7 @@ class CheckpointLoaderLM:
cache = await scanner.get_cached_data()
base_models = set()
for item in cache.raw_data:
for item in list(cache.raw_data):
if item.get("sub_type") != "checkpoint":
continue
base_model = item.get("base_model")
+1 -1
View File
@@ -601,7 +601,7 @@ class SaveImageLM:
os.path.basename(name),
os.path.splitext(os.path.basename(name))[0],
]
for model in getattr(cache, "raw_data", []):
for model in list(getattr(cache, "raw_data", [])):
file_name = model.get("file_name")
if file_name in candidates:
return model
+2 -2
View File
@@ -93,7 +93,7 @@ class UNETLoaderLM:
# Filter only diffusion_model type and format names
names = []
for item in cache.raw_data:
for item in list(cache.raw_data):
if item.get("sub_type") == "diffusion_model":
file_path = item.get("file_path", "")
# Only offer models that still exist on disk so ComfyUI
@@ -141,7 +141,7 @@ class UNETLoaderLM:
cache = await scanner.get_cached_data()
base_models = set()
for item in cache.raw_data:
for item in list(cache.raw_data):
if item.get("sub_type") != "diffusion_model":
continue
base_model = item.get("base_model")
+1 -1
View File
@@ -156,7 +156,7 @@ def _find_missing_loras(names: list[str]) -> list[str]:
lookup = {}
basename_candidates = {}
for item in cache.raw_data:
for item in list(cache.raw_data):
file_path = item.get("file_path")
if not file_path:
continue
+10 -6
View File
@@ -55,7 +55,7 @@ from ...utils.constants import (
VALID_LORA_TYPES,
VALID_OTHER_CIVITAI_TYPES,
)
from .hf_handlers import HfHandler
from .model_source_handlers import ModelSourceHandler
from .agent_handlers import AgentHandler
from .download_routing_handlers import DownloadRoutingHandler
from .model_handlers import ModelCivitaiHandler
@@ -4001,7 +4001,7 @@ class MiscHandlerSet:
doctor: DoctorHandler,
example_workflows: ExampleWorkflowsHandler,
base_model: BaseModelHandlerSet,
hf_handler: Any = None,
model_source_handler: Any = None,
agent_handler: Any = None,
download_routing: Any = None,
) -> None:
@@ -4022,7 +4022,7 @@ class MiscHandlerSet:
self.doctor = doctor
self.example_workflows = example_workflows
self.base_model = base_model
self.hf_handler = hf_handler
self.model_source_handler = model_source_handler
self.agent_handler = agent_handler
self.download_routing = download_routing
@@ -4076,9 +4076,13 @@ class MiscHandlerSet:
"get_example_workflows": self.example_workflows.get_example_workflows,
"get_example_workflow": self.example_workflows.get_example_workflow,
# Hugging Face handlers
"get_hf_repo_files": self.hf_handler.get_hf_repo_files,
"download_hf_model": self.hf_handler.download_hf_model,
"set_hf_url": self.hf_handler.set_hf_url,
# External model sources (Hugging Face / ModelScope)
"list_model_source_files": self.model_source_handler.list_model_source_files,
"download_model_source": self.model_source_handler.download_model_source,
"get_hf_repo_files": self.model_source_handler.list_model_source_files,
"download_hf_model": self.model_source_handler.download_model_source,
"set_hf_url": self.model_source_handler.set_hf_url,
"get_model_sources": self.model_source_handler.get_model_sources,
# Agent skill handlers
"get_agent_skills": self.agent_handler.get_agent_skills,
"execute_agent_skill": self.agent_handler.execute_agent_skill,
+92
View File
@@ -1910,6 +1910,11 @@ class ModelDownloadHandler:
response_payload["status"] = status
if "message" in progress_data:
response_payload["message"] = progress_data["message"]
# Post-transfer stage (indexing / source metadata); polling
# consumers need it to tell "working" from "stuck".
for field in ("stage", "platform"):
if field in progress_data:
response_payload[field] = progress_data[field]
elif status is None and "message" in progress_data:
response_payload["message"] = progress_data["message"]
@@ -2479,6 +2484,90 @@ class ModelMoveHandler:
self._move_service = move_service
self._logger = logger
async def create_folder(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
try:
folder_path = data.get("folder_path")
if not folder_path:
return web.json_response(
{"success": False, "error": "Folder path is required"}, status=400
)
result = await self._move_service.create_folder(folder_path)
status = 200 if result.get("success") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error creating folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def delete_folder(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
try:
folder_path = data.get("folder_path")
if not folder_path:
return web.json_response(
{"success": False, "error": "Folder path is required"}, status=400
)
dry_run = bool(data.get("dry_run"))
result = await self._move_service.delete_folder(
folder_path, dry_run=dry_run
)
if result.get("success"):
if not dry_run:
_broadcast_models_changed()
return web.json_response(result, status=200)
# "not_empty" / "busy" are conflicts between the tree the client
# rendered and the on-disk truth; everything else is a bad request.
code = result.get("code")
status = 409 if code in ("not_empty", "busy") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error deleting folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def rename_folder(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
try:
folder_path = data.get("folder_path")
new_name = data.get("new_name")
if not folder_path:
return web.json_response(
{"success": False, "error": "Folder path is required"}, status=400
)
if not new_name:
return web.json_response(
{"success": False, "error": "New folder name is required"}, status=400
)
result = await self._move_service.rename_folder(folder_path, new_name)
if result.get("success"):
if result.get("renamed"):
_broadcast_models_changed()
return web.json_response(result, status=200)
# A name collision or a staged delete inside the subtree is a
# conflict with the state the client rendered, not a bad request.
code = result.get("code")
status = 409 if code in ("target_exists", "busy") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error renaming folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def move_model(self, request: web.Request) -> web.Response:
try:
data = await request.json()
@@ -3429,6 +3518,9 @@ class ModelHandlerSet:
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,
"move_model": self.move.move_model,
"move_models_bulk": self.move.move_models_bulk,
"create_folder": self.move.create_folder,
"delete_folder": self.move.delete_folder,
"rename_folder": self.move.rename_folder,
"auto_organize_models": self.auto_organize.auto_organize_models,
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
"get_model_notes": self.query.get_model_notes,
@@ -1,8 +1,13 @@
"""Handlers for Hugging Face model listing and download.
"""Handlers for external model sources: linking, file listing and downloads.
Minimal MVP implementation uses direct HTTP to the HF API for file
listing and the project's existing aiohttp-based Downloader for
downloading. No huggingface_hub dependency required.
Covers every site registered in :mod:`py.services.model_sources`. The module
was Hugging Face only (``hf_handlers.py`` / ``HfHandler``) until ModelScope
downloads were added; the per-site differences now live in the providers, so
this file has no platform branches beyond the capability lookups.
The historical route paths (``/api/lm/set-hf-url``, ``/api/lm/hf-repo-files``,
``/api/lm/download-hf-model``) are still registered as aliases of the generic
handlers, so existing callers keep working.
"""
from __future__ import annotations
@@ -10,10 +15,8 @@ from __future__ import annotations
import json
import logging
import os
import re
from typing import Any
import aiohttp
from aiohttp import web
from ...config import config
@@ -22,10 +25,19 @@ from ...services.downloader import (
get_downloader,
)
from ...services.aria2_downloader import Aria2Downloader
from ...services.model_sources import (
ModelSourceError,
SourceRef,
detect_source,
get_download_source,
hydrate_from_source,
is_valid_source_id,
list_sources,
normalize_metadata_source,
)
from ...services.settings_manager import get_settings_manager
from ...services.service_registry import ServiceRegistry
from ...services.websocket_manager import ws_manager
from ...utils.constants import MODEL_FILE_EXTENSIONS
from ...utils.metadata_manager import MetadataManager
from ...utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
@@ -34,28 +46,6 @@ logger = logging.getLogger(__name__)
_DEFAULT_MODEL_CLASS = LoraMetadata
_DEFAULT_SCANNER_GETTER = "get_lora_scanner"
# Shared aiohttp session for HF API calls (created on first use)
_hf_api_session: aiohttp.ClientSession | None = None
async def _get_hf_api_session() -> aiohttp.ClientSession:
"""Get or create the shared aiohttp session for HF API calls."""
global _hf_api_session # needed because we reassign the module-level name
if _hf_api_session is None or _hf_api_session.closed:
_hf_api_session = aiohttp.ClientSession(
headers={"User-Agent": "ComfyUI-LoRA-Manager/1.0"},
timeout=aiohttp.ClientTimeout(total=30),
)
return _hf_api_session
async def close_hf_api_session() -> None:
"""Close the shared HF API session, if it was ever created."""
global _hf_api_session
if _hf_api_session is not None and not _hf_api_session.closed:
await _hf_api_session.close()
_hf_api_session = None
def _infer_model_type(model_root: str) -> tuple[Any, str]:
"""Determine model class and scanner by matching ``model_root`` against the
@@ -96,35 +86,96 @@ def _infer_model_type(model_root: str) -> tuple[Any, str]:
return _DEFAULT_MODEL_CLASS, _DEFAULT_SCANNER_GETTER
async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
async def _report_phase(
download_id: str | None, stage: str, platform: str = ""
) -> None:
"""Tell the progress UI which post-transfer stage is running.
A download's byte counter stops the moment the last byte lands, but the
backend still has to index the file and read the model site's API. Without
this the bar sits at 100% reporting "0 B/s" and the download looks stuck for
several seconds. *stage* is machine-readable the UI localises it and
*platform* lets it name the site the metadata comes from.
"""
if not download_id:
return
try:
await ws_manager.broadcast_download_progress(
download_id,
{
"status": "metadata",
"stage": stage,
"platform": platform,
"progress": 100,
},
)
except Exception as exc: # pragma: no cover - progress must never be fatal
logger.debug("Failed to report the '%s' phase: %s", stage, exc)
async def _save_source_metadata(
dest_path: str, ref: SourceRef, model_root: str, *, download_id: str | None = None
) -> None:
"""Create a proper .metadata.json and add the model to the scanner cache.
Uses ``MetadataManager.create_default_metadata()`` which computes the
SHA256 hash, extracts safetensors header metadata (base_model), and
produces a fully-populated ``LoraMetadata`` (or ``CheckpointMetadata`` /
``EmbeddingMetadata``) object. We then overlay HF-specific fields and
register the model in the in-memory scanner cache so it appears
immediately without a full filesystem walk.
The metadata is created through the owning scanner rather than
``MetadataManager.create_default_metadata()``, because that is the only
factory that knows when hashing must be deferred: ``CheckpointScanner`` and
``OtherScanner`` deliberately record ``hash_status="pending"`` with an empty
``sha256`` for their multi-GB files, and the generic helper would read a
10 GB checkpoint end to end *inside the download request*. Scanners for the
small types delegate straight back to it, so nothing changes for them.
The external-source fields are then overlaid and the model is registered in
the in-memory scanner cache so it appears immediately without a full
filesystem walk.
Finally the site's own published metadata is applied (see
:func:`~py.services.model_sources.hydration.hydrate_from_source`), so a
ModelScope or Hugging Face download lands with the same populated model
card a CivitAI download produces instead of a bare filename and hash.
Both post-transfer stages are reported through *download_id* when the UI is
watching one, because neither advances the byte counter.
"""
try:
hf_url = f"https://huggingface.co/{repo}"
model_class, scanner_getter_name = _infer_model_type(model_root)
# 1. Create proper metadata (computes SHA256, reads safetensors headers)
metadata = await MetadataManager.create_default_metadata(
dest_path, model_class=model_class
)
scanner = None
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
if scanner_getter is not None:
scanner = await scanner_getter()
# 1. Create proper metadata (reads safetensors headers; hashes only for
# the model types whose scanner does not defer it)
await _report_phase(download_id, "indexing", ref.platform)
create_metadata = getattr(scanner, "_create_default_metadata", None)
if create_metadata is not None:
metadata = await create_metadata(dest_path)
else:
metadata = await MetadataManager.create_default_metadata(
dest_path, model_class=model_class
)
if metadata is None:
logger.warning("create_default_metadata returned None for %s", dest_path)
return
# 2. Overlay HF-specific fields
metadata._unknown_fields["hf_url"] = hf_url
metadata.from_civitai = False # HF models are not from CivitAI
# 2. Overlay the external-source fields (`hf_url` is written by
# normalisation for Hugging Face only)
fields = metadata._unknown_fields
fields["source_url"] = ref.url
fields["source_platform"] = ref.platform
if ref.platform == "huggingface":
fields["hf_url"] = ref.url
metadata.from_civitai = False # externally-sourced models are not from CivitAI
# 3. Save metadata atomically
await MetadataManager.save_metadata(dest_path, metadata)
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
logger.info(
"Saved %s metadata (source=%s, hash_status=%s) for %s",
ref.platform, ref.url, getattr(metadata, "hash_status", "?"), dest_path,
)
# 4. Determine relative folder path for cache
# model_root is an absolute path; dest_path is under it
@@ -134,17 +185,19 @@ async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
folder = rel.replace(os.sep, "/") if rel != "." else ""
# 5. Add to scanner cache (same as CivitAI's _execute_download does)
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
if scanner_getter is not None:
scanner = await scanner_getter()
if scanner is not None:
metadata_dict = metadata.to_dict()
metadata_dict["hf_url"] = hf_url
await scanner.add_model_to_cache(metadata_dict, folder)
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
if scanner is not None:
metadata_dict = normalize_metadata_source(metadata.to_dict())
await scanner.add_model_to_cache(metadata_dict, folder)
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
# 6. Top up from the site's public API. Runs last so the scanner-cache
# refresh it performs lands on the entry created above. It never
# raises and never fails the download.
await _report_phase(download_id, "source", ref.platform)
await hydrate_from_source(dest_path, ref=ref)
except Exception as exc:
logger.warning("Failed to save HF metadata for %s: %s", dest_path, exc)
logger.warning("Failed to save source metadata for %s: %s", dest_path, exc)
def _find_matching_root(dest_dir: str) -> str | None:
@@ -186,30 +239,87 @@ async def _add_to_scanner_cache(dest_path: str, metadata: dict[str, Any]) -> Non
await scanner.update_single_model_cache(dest_path, dest_path, metadata)
class HfHandler:
"""Handle Hugging Face model browsing and download."""
def _unsupported_platform_error(platform: str) -> web.Response:
supported = ", ".join(source.label for source in list_sources() if source.supports_download)
return web.json_response(
{"error": f"'{platform}' does not support downloads. Supported: {supported}"},
status=400,
)
class ModelSourceHandler:
"""Handle external model browsing, linking and downloads."""
async def get_model_sources(self, request: web.Request) -> web.Response:
"""List the external model sites the UI can link a model to.
Used by the "Link Model" dialog to validate URLs client-side, to
explain which sites support AI metadata enrichment, and to pick the
right download endpoint/revision.
"""
return web.json_response([
{
"platform": source.platform,
"label": source.label,
"supports_enrichment": source.supports_enrichment,
"supports_download": source.supports_download,
"default_revision": source.default_revision,
"example_url": source.canonical_url(
"user/repo" if source.platform != "tensorart" else "827823520299086029"
),
}
for source in list_sources()
])
async def set_hf_url(self, request: web.Request) -> web.Response:
"""Link a model file to its page on an external model site.
Accepts ``source_url`` (preferred) or the legacy ``hf_url`` / ``url``
payload key. Every registered site is recognised and the platform is
stored alongside the canonical URL. TensorArt models can be linked and
browsed, but not AI-enriched.
The route path keeps its historical ``set-hf-url`` name.
"""
try:
payload: dict[str, Any] = await request.json()
except json.JSONDecodeError:
return web.json_response({"success": False, "error": "Invalid JSON"}, status=400)
file_path = (payload.get("file_path") or "").strip()
hf_url = (payload.get("hf_url") or "").strip()
raw_url = (
payload.get("source_url")
or payload.get("hf_url")
or payload.get("url")
or ""
)
source_url = raw_url.strip() if isinstance(raw_url, str) else ""
if not file_path or not hf_url:
return web.json_response(
{"success": False, "error": "Missing required fields: 'file_path' and 'hf_url'"},
status=400,
)
m = re.match(r"^https?://huggingface\.co/([^/]+/[^/]+)/?$", hf_url)
if not m:
if not file_path or not source_url:
return web.json_response(
{
"success": False,
"error": "Invalid HuggingFace URL. Expected format: https://huggingface.co/user/repo",
"error": "Missing required fields: 'file_path' and 'source_url'",
},
status=400,
)
ref = detect_source(source_url, strict=True)
if ref is None:
return web.json_response(
{
"success": False,
"error": (
"Unsupported model URL. Supported formats: "
+ ", ".join(
f"{s.label} ({s.canonical_url('user/repo')})"
if s.platform != "tensorart"
else f"{s.label} (https://tensor.art/models/<id>)"
for s in list_sources()
)
),
},
status=400,
)
@@ -225,110 +335,120 @@ class HfHandler:
return web.json_response(
{
"success": False,
"error": "File is not within any configured model directory. Cannot link to HuggingFace.",
"error": "File is not within any configured model directory. Cannot link to a model source.",
},
status=400,
)
try:
existing = await MetadataManager.load_metadata_payload(file_path)
if existing.get("hf_url") == hf_url:
already_linked = (
(existing.get("source_url") or "").strip() == ref.url
and (existing.get("source_platform") or "").strip().lower()
== ref.platform
) or (
not existing.get("source_url")
and ref.platform == "huggingface"
and (existing.get("hf_url") or "").strip() == ref.url
)
if already_linked:
return web.json_response({
"success": True,
"message": "hf_url already set",
"hf_url": hf_url,
"message": "source_url already set",
"source_url": ref.url,
"source_platform": ref.platform,
"hf_url": ref.url if ref.platform == "huggingface" else "",
})
existing["hf_url"] = hf_url
existing["source_url"] = ref.url
existing["source_platform"] = ref.platform
if ref.platform == "huggingface":
existing["hf_url"] = ref.url
else:
existing.pop("hf_url", None)
normalize_metadata_source(existing)
# NOTE: deliberately do NOT touch `from_civitai` here. It records
# where the metadata came from, and the UI must show the CivitAI
# link whenever CivitAI data is present — linking HuggingFace must
# not hide it (#1094). HF provenance is tracked via `hf_url`.
# link whenever CivitAI data is present — linking an external
# source must not hide it (#1094). Source provenance is tracked
# via `source_platform` / `source_url`.
await MetadataManager.save_metadata(file_path, existing)
await _add_to_scanner_cache(file_path, existing)
logger.info("Set hf_url=%s for %s", hf_url, file_path)
logger.info(
"Linked %s to %s source (%s)", file_path, ref.platform, ref.url
)
return web.json_response({
"success": True,
"message": f"hf_url set to {hf_url}",
"hf_url": hf_url,
"message": f"Linked to {ref.url}",
"source_url": ref.url,
"source_platform": ref.platform,
"hf_url": existing.get("hf_url", ""),
})
except Exception as exc:
logger.error("Failed to set hf_url for %s: %s", file_path, exc)
logger.error("Failed to link %s to a model source: %s", file_path, exc)
return web.json_response(
{"success": False, "error": str(exc)},
status=500,
)
async def get_hf_repo_files(self, request: web.Request) -> web.Response:
"""List model-weight files from a HF repo with real file sizes.
async def list_model_source_files(self, request: web.Request) -> web.Response:
"""List the downloadable weight files of an external repository.
Uses the HF tree API endpoint which returns accurate file sizes
(including LFS-tracked files), unlike the model info endpoint.
Query params: ``platform``, ``repo`` (``owner/name``), ``revision``
(optional; each site has its own default branch).
Returns a JSON array of ``{"filename", "size"}``, largest first
the same shape the Hugging Face endpoint has always returned.
"""
repo = request.query.get("repo", "").strip()
if not repo or "/" not in repo:
platform = (request.query.get("platform") or "").strip()
repo = (request.query.get("repo") or "").strip()
revision = (request.query.get("revision") or "").strip()
source = get_download_source(platform)
if source is None:
return _unsupported_platform_error(platform)
if not is_valid_source_id(repo):
return web.json_response(
{"error": "Missing or invalid 'repo' parameter (expected user/repo)"},
{"error": "Missing or invalid 'repo' parameter (expected owner/name)"},
status=400,
)
url = f"https://huggingface.co/api/models/{repo}/tree/main"
try:
session = await _get_hf_api_session()
async with session.get(url) as resp:
if resp.status == 404:
return web.json_response(
{"error": f"Repo '{repo}' not found"}, status=404
)
if resp.status != 200:
text = await resp.text()
return web.json_response(
{"error": f"HF API error {resp.status}: {text[:200]}"},
status=resp.status,
)
tree: list[dict[str, Any]] = await resp.json()
files = await source.list_files(repo, revision)
except ModelSourceError as exc:
return web.json_response({"error": str(exc)}, status=exc.status)
except Exception as exc:
logger.error("Failed to fetch HF repo files: %s", exc)
logger.error("Failed to list %s files in %s: %s", platform, repo, exc)
return web.json_response({"error": str(exc)}, status=502)
files: list[dict[str, Any]] = []
for entry in tree:
path: str = entry.get("path", "")
ext = os.path.splitext(path)[1].lower()
if ext not in MODEL_FILE_EXTENSIONS:
continue
size = entry.get("size", 0) or 0
if size == 0 and "lfs" in entry:
size = entry["lfs"].get("size", 0) or 0
files.append({
"filename": path,
"size": size,
})
files.sort(key=lambda f: f["size"], reverse=True)
return web.json_response(files)
async def download_hf_model(self, request: web.Request) -> web.Response:
"""Download a single file from Hugging Face into the model directory.
async def download_model_source(self, request: web.Request) -> web.Response:
"""Download a single file from an external repository.
POST JSON body::
{
"repo": "dx8152/Flux2-Klein-9B-Consistency",
"filename": "Flux2-Klein-9B-consistency-V2.safetensors",
"revision": "main",
"platform": "modelscope",
"repo": "owner/name",
"filename": "subdir/model.safetensors",
"revision": "master",
"model_root": "loras",
"relative_path": "",
"use_default_paths": false,
"download_id": "optional-batch-id"
}
``platform`` defaults to ``huggingface`` when omitted, which keeps the
legacy ``/api/lm/download-hf-model`` payload working unchanged.
If ``download_id`` is provided, real-time progress (bytes, speed,
percentage) is broadcast via the WebSocket progress system, matching
the CivitAI download experience.
percentage) is broadcast via the WebSocket progress system.
Respects the ``download_backend`` setting (``aria2`` or ``default``).
"""
@@ -337,30 +457,33 @@ class HfHandler:
except json.JSONDecodeError:
return web.json_response({"error": "Invalid JSON"}, status=400)
platform = (payload.get("platform") or "huggingface").strip()
repo = (payload.get("repo") or "").strip()
filename = (payload.get("filename") or "").strip()
revision = (payload.get("revision") or "main").strip()
revision = (payload.get("revision") or "").strip()
model_root = (payload.get("model_root") or "").strip()
relative_path = (payload.get("relative_path") or "").strip()
use_default_paths = bool(payload.get("use_default_paths", False))
download_id: str | None = payload.get("download_id")
logger.info(
"download_hf_model: repo=%s file=%s root=%s download_id=%s",
repo, filename, model_root, download_id,
"download_model_source: platform=%s repo=%s file=%s root=%s download_id=%s",
platform, repo, filename, model_root, download_id,
)
source = get_download_source(platform)
if source is None:
return _unsupported_platform_error(platform)
if not repo or not filename:
return web.json_response(
{"error": "Missing required fields: 'repo' and 'filename'"}, status=400
)
# Validate repo format — must be user/repo_name
if repo.count("/") != 1 or not re.match(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", repo):
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
author, repo_name = repo.split("/", 1)
if ".." in (author, repo_name) or "." in (author, repo_name):
# `owner/name` only; the components become path segments below.
if not is_valid_source_id(repo):
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
owner, repo_name = repo.split("/", 1)
# Validate filename — must not contain path traversal
if ".." in filename:
@@ -379,40 +502,47 @@ class HfHandler:
# unnecessary when the frontend sends the path from its own dropdown
# (populated from scanner roots). Using the "business path" directly
# keeps dest_path consistent with scanner roots so that later folder
# derivation (in _save_hf_metadata) works correctly.
# derivation (in _save_source_metadata) works correctly.
if os.path.isabs(model_root):
base_dir = os.path.normpath(model_root)
else:
base_dir = os.path.normpath(os.path.join(os.getcwd(), "models", model_root))
if use_default_paths:
target_dir = os.path.join(base_dir, "huggingface", author, repo_name)
target_dir = os.path.join(base_dir, source.default_subdir, owner, repo_name)
elif relative_path:
target_dir = os.path.join(base_dir, relative_path)
else:
target_dir = base_dir
# Strip HF repo subdirectory — "diffusion_models/xxx.safetensors"
# is an HF repo convention, not meaningful for local storage.
# Strip the repository sub-directory — "diffusion_models/xxx.safetensors"
# is a repository convention, not meaningful for local storage.
file_base = os.path.basename(filename)
os.makedirs(target_dir, exist_ok=True)
dest_path = os.path.join(target_dir, file_base)
# Built per request: sites that redirect to a CDN hand out a
# time-limited token in the redirect, so the URL must never be cached.
resolve_url = source.file_download_url(repo, filename, revision)
ref = SourceRef(
platform=source.platform, source_id=repo, url=source.canonical_url(repo)
)
# Check if already exists (simple skip)
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
logger.info("download_hf_model: file already exists, skipping — %s", dest_path)
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,
})
# Build HF resolve URL
resolve_url = (
f"https://huggingface.co/{repo}/resolve/{revision}/{filename}"
)
# Set up progress callback if download_id is provided
progress_callback = None
if download_id:
@@ -452,28 +582,29 @@ class HfHandler:
if download_backend == "aria2":
aria2 = await Aria2Downloader.get_instance()
aid = download_id or f"hf_{repo}_{filename}"
aid = download_id or f"{source.platform}_{repo}_{filename}"
try:
hf_success, hf_result = await aria2.download_file(
ok, result = await aria2.download_file(
url=resolve_url,
save_path=dest_path,
download_id=aid,
progress_callback=progress_callback,
)
if hf_success:
await _save_hf_metadata(dest_path, repo, model_root)
if ok:
await _save_source_metadata(
dest_path, ref, model_root, download_id=download_id
)
return web.json_response({
"success": True,
"message": f"Downloaded to {dest_path}",
"path": dest_path,
})
else:
return web.json_response(
{"success": False, "error": hf_result or "aria2 download failed"},
status=500,
)
return web.json_response(
{"success": False, "error": result or "aria2 download failed"},
status=500,
)
except Exception as exc:
logger.error("HF download (aria2) failed: %s", exc)
logger.error("%s download (aria2) failed: %s", platform, exc)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
@@ -489,19 +620,20 @@ class HfHandler:
progress_callback=progress_callback,
)
if success:
await _save_hf_metadata(dest_path, repo, model_root)
await _save_source_metadata(
dest_path, ref, model_root, download_id=download_id
)
return web.json_response({
"success": True,
"message": f"Downloaded to {result}",
"path": result,
})
else:
return web.json_response(
{"success": False, "error": result or "Download failed"},
status=500,
)
return web.json_response(
{"success": False, "error": result or "Download failed"},
status=500,
)
except Exception as exc:
logger.error("HF download failed: %s", exc)
logger.error("%s download failed: %s", platform, exc)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
+12 -1
View File
@@ -99,7 +99,11 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"GET", "/api/lm/delete-model-version", "delete_model_version"
),
# Hugging Face model endpoints
# External model source endpoints (Hugging Face / ModelScope).
# The hf-* paths are the historical names, kept as aliases.
RouteDefinition(
"GET", "/api/lm/model-source-files", "list_model_source_files"
),
RouteDefinition(
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
),
@@ -107,12 +111,19 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"POST", "/api/lm/download/routing", "get_download_routing"
),
RouteDefinition(
"POST", "/api/lm/download-model-source", "download_model_source"
),
RouteDefinition(
"POST", "/api/lm/download-hf-model", "download_hf_model"
),
RouteDefinition(
"POST", "/api/lm/set-hf-url", "set_hf_url"
),
# Supported external model sites (Hugging Face / ModelScope / TensorArt)
RouteDefinition(
"GET", "/api/lm/model-sources", "get_model_sources"
),
# Agent skill endpoints
RouteDefinition(
"GET", "/api/lm/agent/skills", "get_agent_skills"
+3 -3
View File
@@ -39,7 +39,7 @@ from .handlers.misc_handlers import (
build_service_registry_adapter,
)
from .handlers.base_model_handlers import BaseModelHandlerSet
from .handlers.hf_handlers import HfHandler
from .handlers.model_source_handlers import ModelSourceHandler
from .handlers.agent_handlers import AgentHandler
from .handlers.download_routing_handlers import DownloadRoutingHandler
from .misc_route_registrar import MiscRouteRegistrar
@@ -139,7 +139,7 @@ class MiscRoutes:
doctor = DoctorHandler(settings_service=self._settings)
example_workflows = ExampleWorkflowsHandler()
base_model = BaseModelHandlerSet()
hf_handler = HfHandler()
model_source_handler = ModelSourceHandler()
agent_handler = AgentHandler()
download_routing = DownloadRoutingHandler()
@@ -161,7 +161,7 @@ class MiscRoutes:
doctor=doctor,
example_workflows=example_workflows,
base_model=base_model,
hf_handler=hf_handler,
model_source_handler=model_source_handler,
agent_handler=agent_handler,
download_routing=download_routing,
)
+3
View File
@@ -40,6 +40,9 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/{prefix}/verify-duplicates", "verify_duplicates"),
RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"),
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"),
RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"),
RouteDefinition("POST", "/api/lm/{prefix}/rename-folder", "rename_folder"),
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition(
+212 -48
View File
@@ -19,16 +19,21 @@ from __future__ import annotations
import asyncio
import json
import logging
import os
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
import aiohttp
import os
from ...config import config
from ..llm_service import LLMService
from ..model_sources import (
ModelCardContext,
ModelSourceCache,
get_source,
resolve_source_ref,
source_label,
)
from ..model_sources.hydration import load_model_card, resolve_site_base_model
from ..websocket_manager import ws_manager
from .post_processor import PostProcessor
from .skill_registry import SkillRegistry
@@ -255,6 +260,11 @@ class AgentService:
llm = await self._ensure_llm()
llm_configured = llm.is_configured() if skill.llm_required else True
# A collection repository holds many model files under one source id;
# this memo keeps the README and the repository metadata from being
# re-fetched once per file. It lives for this run only.
source_cache = ModelSourceCache()
for model_path in model_paths:
model_filename = os.path.basename(model_path)
logger.info(
@@ -267,24 +277,50 @@ class AgentService:
from ...metadata_ops import read_metadata
metadata = await read_metadata(model_path)
# Fast-fail: enrich_hf_metadata requires hf_url to have HF README context
if skill_name == "enrich_hf_metadata" and not metadata.get("hf_url", ""):
logger.info(
"[%s] SKIP %s — no hf_url in metadata",
skill_name, model_filename,
)
skipped_count += 1
skip_model = True
# Fast-fail: enrich_hf_metadata needs an external model source
# that exposes an accessible model card.
if skill_name == "enrich_hf_metadata":
skip_reason = self._enrichment_skip_reason(metadata)
if skip_reason:
logger.info(
"[%s] SKIP %s%s",
skill_name, model_filename, skip_reason,
)
skipped_count += 1
skip_model = True
if not skip_model:
prompt_vars: Dict[str, Any] = {"model_path": model_path}
if skill.llm_required and llm_configured:
prompt_vars = await self._build_prompt_context(
skill_name, model_path, metadata, registry, llm,
# The site's own data is deterministic and must land whether
# or not an LLM is available: a user without a key still gets
# the author summary, the example images and the tags.
source_vars, source_context = await self._load_source_card(
model_path, metadata, cache=source_cache,
)
resolved_base_model = ""
if skill_name == "enrich_hf_metadata" and not (
metadata.get("base_model") or ""
).strip():
resolved_base_model = await self._resolve_site_base_model(
source_context,
)
llm_response: Optional[Dict[str, Any]] = None
if skill.llm_required and llm_configured:
if skill.llm_required and not llm_configured:
# Without a provider the deterministic model-source data
# still lands; the LLM-only fields simply stay untouched.
logger.info(
"[%s] No LLM configured for %s — applying %s data only",
skill_name, model_filename,
"model-source"
if not source_context.is_empty()
else "README",
)
elif skill.llm_required:
prompt_vars = await self._build_prompt_context(
skill_name, model_path, metadata, registry, llm,
source_vars=source_vars,
source_context=source_context,
)
prompt_template = registry.load_prompt(skill_name)
rendered = _render_prompt(prompt_template, prompt_vars)
llm_response = await llm.chat_completion_json(
@@ -307,7 +343,9 @@ class AgentService:
model_path=model_path,
llm_output=llm_response or {},
metadata=metadata,
readme_content=prompt_vars.get("readme_content_full", ""),
readme_content=source_vars.get("readme_content_full", ""),
source_context=source_context,
resolved_base_model=resolved_base_model,
)
if model_result.get("success", True):
@@ -358,6 +396,28 @@ class AgentService:
# Base model grouping (keeps the prompt compact)
# ------------------------------------------------------------------
@staticmethod
def _enrichment_skip_reason(metadata: Dict[str, Any]) -> str:
"""Return why ``enrich_hf_metadata`` cannot run, or ``""`` if it can.
Distinguishes the three cases the user can act on: no source linked,
a source we don't know, and a known source whose model card is not
reachable from the backend (TensorArt).
"""
ref = resolve_source_ref(metadata)
if ref is None:
return "no model source linked (source_url missing)"
source = get_source(ref.platform)
if source is None:
return f"unsupported model source platform '{ref.platform}'"
if not source.supports_enrichment:
return (
f"{source.label} does not expose a model card to the backend; "
"AI metadata enrichment is not available for this source"
)
return ""
@staticmethod
def _format_base_models(models: List[str]) -> str:
"""Format the base model list as a flat, one-per-line list.
@@ -368,6 +428,82 @@ class AgentService:
"""
return "\n".join(f"- {m}" for m in models)
async def _load_source_card(
self,
model_path: str,
metadata: Dict[str, Any],
*,
cache: Optional[ModelSourceCache] = None,
) -> tuple[Dict[str, Any], ModelCardContext]:
"""Fetch the model card and site-published extras for one model.
Runs for every source-backed enrichment regardless of LLM
availability, because everything it returns is deterministic data that
should be applied even without a configured provider.
*cache* is the per-run memo created by :meth:`execute_skill`. The
README is repository-wide, so it is fetched once per source id; only
successful reads are memoised, leaving a transient failure to be
retried for the next file.
"""
variables: Dict[str, Any] = {
"asset_base_url": "",
"source_description": "",
"source_base_model": "",
"source_official_tags": "",
"source_example_images": "",
"source_trigger_words": "",
"readme_content": "(README not available)",
"readme_content_full": "",
}
ref = resolve_source_ref(metadata)
source = get_source(ref.platform) if ref is not None else None
if ref is None or source is None or not source.supports_enrichment:
return variables, ModelCardContext()
raw_basename = os.path.splitext(os.path.basename(model_path))[0]
variables["asset_base_url"] = source.asset_base_url(ref.source_id)
readme = await load_model_card(source, ref.source_id, cache)
# Sites such as ModelScope keep part of the model card outside the
# README (author summary, curated tags, per-file example images). The
# recorded hash identifies the file even after the user renames it.
card_context = await source.fetch_model_card_context(
ref.source_id,
os.path.basename(model_path),
sha256=(metadata.get("sha256") or "").strip(),
cache=cache,
)
variables["source_description"] = card_context.description
variables["source_base_model"] = card_context.base_model
variables["source_official_tags"] = "\n".join(
f"- {tag}" for tag in card_context.official_tags
)
variables["source_example_images"] = "\n".join(
f"- {url}" for url in card_context.example_images
)
variables["source_trigger_words"] = ", ".join(card_context.trigger_words)
# Trim README to the section relevant to this model file
# (collection repos often have multiple models in one README).
if readme and raw_basename:
trimmed = extract_relevant_section(readme, raw_basename)
cleaned = clean_readme_for_llm(trimmed) if trimmed else ""
else:
cleaned = clean_readme_for_llm(readme) if readme else ""
variables["readme_content"] = cleaned if cleaned else "(README not available)"
variables["readme_content_full"] = readme or ""
return variables, card_context
async def _resolve_site_base_model(self, source_context: ModelCardContext) -> str:
"""Resolve the site's base-model hints to a canonical name, or ``""``."""
return await resolve_site_base_model(source_context)
async def _build_prompt_context(
self,
skill_name: str,
@@ -375,19 +511,45 @@ class AgentService:
metadata: Dict[str, Any],
registry: SkillRegistry,
llm: Any,
*,
source_vars: Optional[Dict[str, Any]] = None,
source_context: Optional[ModelCardContext] = None,
) -> Dict[str, Any]:
"""Gather variables for the skill's prompt template.
Reads metadata, fetches the HF README (if applicable), lists available
Reads metadata, fetches the model card (unless a pre-fetched
*source_vars* / *source_context* pair is supplied), lists available
base models, loads user priority tags, and returns a dict that maps to
``{{variable}}`` placeholders in ``prompt.md``.
"""
from ...metadata_ops import identify_model_type, list_base_models
from ..settings_manager import SettingsManager
if source_vars is None or source_context is None:
source_vars, source_context = await self._load_source_card(
model_path, metadata,
)
context: Dict[str, Any] = {
"model_path": model_path,
"model_basename": "",
# Canonical external-source variables
"source_url": "",
"source_id": "",
"source_platform": "",
"source_label": "",
"asset_base_url": "",
# Site-provided card extras (see ModelSource.fetch_model_card_context)
"source_description": "",
"source_base_model": "",
"source_official_tags": "",
"source_example_images": "",
"source_trigger_words": "",
# Carrier for the structured context handed to the post-processor;
# never rendered into the prompt.
"source_context": ModelCardContext(),
# Legacy Hugging Face aliases (kept so older prompt templates and
# third-party skills keep rendering)
"hf_url": "",
"repo": "",
"readme_content": "",
@@ -411,21 +573,29 @@ class AgentService:
"size": metadata.get("size", 0),
}
hf_url = metadata.get("hf_url", "")
context["hf_url"] = hf_url
repo = self._extract_repo_from_url(hf_url) if hf_url else ""
context["repo"] = repo or ""
if repo:
readme = await self._fetch_readme(repo)
# Trim README to the section relevant to this model file
# (collection repos often have multiple models in one README).
if readme and raw_basename:
trimmed = extract_relevant_section(readme, raw_basename)
cleaned = clean_readme_for_llm(trimmed) if trimmed else ""
else:
cleaned = clean_readme_for_llm(readme) if readme else ""
context["readme_content"] = cleaned if cleaned else "(README not available)"
context["readme_content_full"] = readme or ""
ref = resolve_source_ref(metadata)
if ref is not None:
context["source_url"] = ref.url
context["source_id"] = ref.source_id
context["source_platform"] = ref.platform
context["source_label"] = source_label(ref.platform, ref.platform)
if ref.platform == "huggingface":
context["hf_url"] = ref.url
context["repo"] = ref.source_id
source = get_source(ref.platform) if ref is not None else None
if ref is not None and source is not None and source.supports_enrichment:
# Values fetched once by _load_source_card and shared with the
# post-processor, so the network is not hit twice per model.
context["asset_base_url"] = source_vars["asset_base_url"]
context["source_context"] = source_context
context["source_description"] = source_vars["source_description"]
context["source_base_model"] = source_vars["source_base_model"]
context["source_official_tags"] = source_vars["source_official_tags"]
context["source_example_images"] = source_vars["source_example_images"]
context["source_trigger_words"] = source_vars["source_trigger_words"]
context["readme_content"] = source_vars["readme_content"]
context["readme_content_full"] = source_vars["readme_content_full"]
try:
raw_models = await list_base_models()
@@ -458,20 +628,14 @@ class AgentService:
@staticmethod
async def _fetch_readme(repo: str) -> str:
"""Fetch README.md from HuggingFace (tries ``main``, then ``master``)."""
async with aiohttp.ClientSession(
headers={"User-Agent": "ComfyUI-LoRA-Manager/1.0"},
timeout=aiohttp.ClientTimeout(total=30),
) as session:
for branch in ("main", "master"):
url = f"https://huggingface.co/{repo}/raw/{branch}/README.md"
try:
async with session.get(url) as resp:
if resp.status == 200:
return await resp.text()
except Exception as exc:
logger.debug("Failed to fetch README from %s: %s", url, exc)
return ""
"""Fetch a Hugging Face README (tries ``main``, then ``master``).
Kept for backward compatibility; new code should go through the
model-source registry so every supported site works.
"""
from ..model_sources import HuggingFaceSource
return await HuggingFaceSource().fetch_model_card(repo)
async def _emit_progress(
self,
+94
View File
@@ -0,0 +1,94 @@
"""Map a site-reported base model onto this system's canonical vocabulary.
Model sites name base models in their own terms: ModelScope publishes
``krea/Krea-2-Turbo`` and ``KREA_2_TURBO`` where this system expects the
canonical ``Krea 2``. Turning one into the other is normally the LLM's job;
this module resolves the cases that can be decided safely so the canonical
field is still populated when the LLM returns nothing usable for it.
The resolver is deliberately strict, because a wrong base model written with
apparent authority is worse than no value at all:
* it only ever returns a name that is already present in *known_names*;
* matching is on the normalised form (lowercased, non-alphanumerics removed),
so separators and casing are ignored but nothing is inferred;
* a bounded set of published variant suffixes may be stripped, and only when
the remainder still matches a known name exactly.
Anything it cannot decide returns ``""``, and the caller falls back to the LLM.
"""
from __future__ import annotations
import re
from typing import Iterable, Sequence
#: Variant suffixes sites append to a base-model *family* name. Stripping one
#: is only attempted when the remainder matches a known name exactly, so an
#: unrecognised suffix can never produce a bogus match.
_VARIANT_SUFFIXES: tuple[str, ...] = (
"turbo",
"schnell",
"lightning",
"dev",
"beta",
"alpha",
)
_NON_ALNUM = re.compile(r"[^a-z0-9]+")
def _normalize(value: str) -> str:
"""Return the comparison form of *value*.
Lowercases and drops every non-alphanumeric character, so ``KREA_2``,
``Krea 2``, ``krea-2`` and ``krea.2`` all collapse to ``krea2``.
"""
return _NON_ALNUM.sub("", (value or "").lower())
def resolve_base_model(
hints: Iterable[str], known_names: Sequence[str]
) -> str:
"""Return the canonical base model that *hints* refers to, or ``""``.
Args:
hints: Site-reported names, best first (e.g. an architecture enum
before a link-style repository id).
known_names: The canonical vocabulary; only these are ever returned.
Returns:
One of *known_names*, or ``""`` when nothing matches exactly.
"""
normalized: dict[str, str] = {}
for name in known_names:
key = _normalize(name)
if key and key not in normalized:
normalized[key] = name
if not normalized:
return ""
ordered = [hint for hint in hints if hint]
# 1. Exact normalised match — the unambiguous case.
for hint in ordered:
candidate = _normalize(hint)
if candidate in normalized:
return normalized[candidate]
# 2. Drop one published variant suffix and retry exactly.
for hint in ordered:
candidate = _normalize(hint)
for suffix in _VARIANT_SUFFIXES:
if not candidate.endswith(suffix) or candidate == suffix:
continue
stem = candidate[: -len(suffix)]
if stem in normalized:
return normalized[stem]
return ""
__all__ = ["resolve_base_model"]
+316 -78
View File
@@ -10,12 +10,16 @@ refresh cache). All actual I/O is delegated to :mod:`~py.metadata_ops`.
from __future__ import annotations
import html
import json
import logging
import os
import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from typing import TYPE_CHECKING, Any, Dict, List, Optional
if TYPE_CHECKING: # pragma: no cover - typing only
from ..model_sources import ModelCardContext
logger = logging.getLogger(__name__)
@@ -42,6 +46,9 @@ class PostProcessor:
llm_output: Dict[str, Any],
metadata: Dict[str, Any],
readme_content: str = "",
source_context: Optional["ModelCardContext"] = None,
resolved_base_model: str = "",
metadata_source: str = "agent:enrich_hf_metadata",
) -> Dict[str, Any]:
"""Route *llm_output* to the correct skill post-processor.
@@ -49,12 +56,26 @@ class PostProcessor:
that is converted to HTML and stored as ``modelDescription`` for
the description tab.
*source_context* carries the extras the model site publishes outside
the README (author description, per-file example images, trigger
words). It is ``None`` for callers that have none.
*resolved_base_model* is the canonical base-model name the site's own
hints resolve to, used when the LLM did not supply one (which is the
normal case when the LLM was skipped).
*metadata_source* records who produced the metadata. The AI skill
keeps its historical value; the deterministic download-time hydration
passes its own so the two remain distinguishable. ``llm_enriched_at``
is only stamped when *llm_output* actually carries a provider answer.
Returns a dict with keys ``success`` (bool), ``updated_fields`` (list),
``preview_downloaded`` (bool), and ``errors`` (list).
"""
if skill_name == "enrich_hf_metadata":
return await self._process_enrich_hf_metadata(
model_path, llm_output, metadata, readme_content,
model_path, llm_output, metadata, readme_content, source_context,
resolved_base_model, metadata_source,
)
return {
"success": False,
@@ -72,12 +93,16 @@ class PostProcessor:
llm_output: Dict[str, Any],
metadata: Dict[str, Any],
readme_content: str = "",
source_context: Optional["ModelCardContext"] = None,
resolved_base_model: str = "",
metadata_source: str = "agent:enrich_hf_metadata",
) -> Dict[str, Any]:
from ...metadata_ops import (
apply_metadata_updates,
download_preview,
refresh_cache,
)
from ..model_sources import get_source, has_external_source, resolve_source_ref
from .skills.enrich_hf_metadata.readme_processor import (
convert_readme_to_html,
extract_gallery_images,
@@ -85,27 +110,49 @@ class PostProcessor:
extract_relevant_section,
extract_simple_markdown_images,
extract_html_img_tags,
extract_repo_from_hf_url,
)
updated_fields: List[str] = []
preview_downloaded = False
# -- Determine whether this is an HF-sourced model -----------------
# Key off `hf_url` directly: `from_civitai` records provenance and can
# be true for a model that is also linked to HuggingFace (both sources
# coexist, see #1094), so it must not gate HF enrichment.
is_hf_model = bool(metadata.get("hf_url", ""))
# -- Determine whether this is an externally-sourced model ---------
# Key off the source fields directly: `from_civitai` records provenance
# and can be true for a model that is also linked to an external site
# (both sources coexist, see #1094), so it must not gate enrichment.
is_source_model = has_external_source(metadata)
source_ref = resolve_source_ref(metadata)
source = get_source(source_ref.platform) if source_ref else None
source_id = source_ref.source_id if source_ref else ""
asset_base_url = (
source.asset_base_url(source_id)
if source is not None and source_id
else None
)
# -- Collect updates -----------------------------------------------
updates: Dict[str, Any] = {}
# base_model
# base_model — the LLM's mapping wins; when it returned nothing usable,
# fall back to the canonical name the site's own hints resolve to.
new_base = (llm_output.get("base_model") or "").strip()
if not new_base:
new_base = (resolved_base_model or "").strip()
current_base = metadata.get("base_model", "") or ""
if new_base and self._should_overwrite(current_base, is_hf_model):
if new_base and self._should_overwrite(current_base, is_source_model):
updates["base_model"] = new_base
# model_name — the site's own display name, so a source download never
# shows up under its local filename. Written only while the name is
# still the untouched file stem: once a user renames a model that
# choice is theirs to keep.
site_name = ((source_context.model_name if source_context else "") or "").strip()
if is_source_model and site_name:
current_name = (metadata.get("model_name") or "").strip()
file_stem = (metadata.get("file_name") or "").strip()
if not current_name or current_name == file_stem:
updates["model_name"] = site_name
# trigger words → civitai.trainedWords
new_triggers = llm_output.get("trigger_words", [])
trigger_words_empty = True
@@ -113,45 +160,71 @@ class PostProcessor:
cleaned = [t.strip() for t in new_triggers if t.strip()]
cleaned = [t for t in cleaned if t.lower() not in ("none", "null", "n/a")]
trigger_words_empty = not cleaned
current_civitai = metadata.get("civitai") or {}
current_triggers = current_civitai.get("trainedWords") or []
if self._should_overwrite_list(current_triggers, is_hf_model):
trig_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
trig_civitai.update(updates["civitai"])
trig_civitai["trainedWords"] = cleaned
updates["civitai"] = trig_civitai
current_triggers = (metadata.get("civitai") or {}).get("trainedWords") or []
if self._should_overwrite_list(current_triggers, is_source_model):
self._merge_civitai(updates, metadata, trainedWords=cleaned)
# modelDescription — from raw README content (converted to HTML)
if readme_content and is_hf_model:
converted = convert_readme_to_html(readme_content)
if converted:
updates["modelDescription"] = converted
# modelDescription — the author's own summary (when the site keeps one
# outside the README, e.g. ModelScope's ``Description``) followed by the
# README converted to HTML.
site_description = (
(source_context.description if source_context else "") or ""
).strip()
if is_source_model and (site_description or readme_content):
parts: List[str] = []
if site_description:
parts.append(f"<p>{html.escape(site_description)}</p>")
if readme_content:
converted = convert_readme_to_html(readme_content)
if converted:
parts.append(converted)
if parts:
updates["modelDescription"] = "\n".join(parts)
# short_description → civitai.description (for "About this version")
# short_description → civitai.description (for "About this version").
# Falls back to the site's author summary, which for ModelScope AIGC
# models is frequently the only human-written text available.
short_desc = (llm_output.get("short_description") or "").strip()
if short_desc and is_hf_model:
current_civitai = metadata.get("civitai") or {}
desc_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
desc_civitai.update(updates["civitai"])
desc_civitai["description"] = short_desc
updates["civitai"] = desc_civitai
if not short_desc:
short_desc = site_description
if short_desc and is_source_model:
self._merge_civitai(updates, metadata, description=short_desc)
# The version label completes the card the way a CivitAI download does:
# the UI renders `civitai.name` as the version chip. It is per file,
# so a collection repository shows that checkpoint's own label.
site_version = (
(source_context.version_name if source_context else "") or ""
).strip()
if is_source_model and site_version:
self._merge_civitai(updates, metadata, name=site_version)
# gallery images → civitai.images (site example images, YAML frontmatter
# widget entries, and Sample Gallery markdown tables in the README body)
rec_width = llm_output.get("recommended_width") or 0
rec_height = llm_output.get("recommended_height") or 0
# Example images the site publishes for *this* file. They are matched
# by filename, so they are the most precise preview source available
# and the only one for repositories whose README carries no images.
site_images: List[Dict[str, Any]] = []
if is_source_model and source_context is not None:
site_images = [
_example_image(url, rec_width, rec_height)
for url in source_context.example_images
if url
]
# gallery images → civitai.images (from YAML frontmatter widget entries
# and Sample Gallery markdown tables in the README body)
gallery_images: List[Dict[str, Any]] = []
if readme_content and is_hf_model:
hf_url = metadata.get("hf_url", "") or ""
repo = extract_repo_from_hf_url(hf_url)
if repo:
rec_w = llm_output.get("recommended_width") or 0
rec_h = llm_output.get("recommended_height") or 0
if (readme_content or site_images) and is_source_model:
repo = source_id
readme_images: List[Dict[str, Any]] = []
if readme_content and repo:
# 1. Widget images (YAML frontmatter)
gallery = extract_gallery_images(
readme_content, repo,
default_width=rec_w, default_height=rec_h,
default_width=rec_width, default_height=rec_height,
base_url=asset_base_url,
)
# 2. Sample Gallery table images (markdown body), deduplicated
@@ -159,7 +232,8 @@ class PostProcessor:
table_images = extract_gallery_table_images(
readme_content, repo,
existing_urls=existing_urls,
default_width=rec_w, default_height=rec_h,
default_width=rec_width, default_height=rec_height,
base_url=asset_base_url,
)
existing_urls.update(img["url"] for img in table_images if img.get("url"))
@@ -167,7 +241,8 @@ class PostProcessor:
simple_images = extract_simple_markdown_images(
readme_content, repo,
existing_urls=existing_urls,
default_width=rec_w, default_height=rec_h,
default_width=rec_width, default_height=rec_height,
base_url=asset_base_url,
)
existing_urls.update(img["url"] for img in simple_images if img.get("url"))
@@ -175,54 +250,71 @@ class PostProcessor:
html_images = extract_html_img_tags(
readme_content, repo,
existing_urls=existing_urls,
default_width=rec_w, default_height=rec_h,
default_width=rec_width, default_height=rec_height,
base_url=asset_base_url,
)
all_images = gallery + table_images + simple_images + html_images
if all_images:
gallery_images = all_images
current_civitai = metadata.get("civitai") or {}
gallery_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
gallery_civitai.update(updates["civitai"])
gallery_civitai["images"] = all_images
updates["civitai"] = gallery_civitai
readme_images = gallery + table_images + simple_images + html_images
# tags
# Site images come first so the preview fallback below prefers an
# image that is known to belong to this exact file.
all_images = _dedupe_images(site_images + readme_images)
if all_images:
gallery_images = all_images
self._merge_civitai(updates, metadata, images=all_images)
# tags — the site's curated tags are authoritative content vocabulary, so
# they are kept alongside whatever the LLM proposed (the LLM is skipped
# entirely when the site data is complete, which is why this cannot rely
# on ``llm_output`` alone).
new_tags = llm_output.get("tags", [])
if isinstance(new_tags, list) and new_tags:
candidate_tags: List[str] = []
if is_source_model and source_context is not None:
candidate_tags.extend(source_context.official_tags)
if isinstance(new_tags, list):
candidate_tags.extend(
tag for tag in new_tags if tag not in candidate_tags
)
if candidate_tags:
existing_tags = metadata.get("tags") or []
merged = self._merge_tags(existing_tags, new_tags)
if len(merged) > len(existing_tags) or is_hf_model:
merged = self._merge_tags(existing_tags, candidate_tags)
if len(merged) > len(existing_tags) or is_source_model:
updates["tags"] = merged
# metadata_source & llm_enriched_at (always set)
updates["metadata_source"] = "agent:enrich_hf_metadata"
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat()
# metadata_source is recorded for provenance; llm_enriched_at only means
# something when a provider actually answered, so the deterministic
# download-time hydration does not claim an enrichment that never ran.
updates["metadata_source"] = metadata_source
if llm_output:
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat()
# Store LLM confidence in metadata so it's accessible for evaluation
# LLM confidence, stored for the enrichment evaluation harness. The key
# must NOT start with an underscore: `BaseModelMetadata.from_dict()`
# deliberately drops underscore-prefixed keys so they never round-trip,
# which silently erased this field on the next metadata write.
raw_confidence = (llm_output.get("confidence") or "").strip()
if raw_confidence:
updates["_llm_confidence"] = raw_confidence
updates["llm_confidence"] = raw_confidence
# Fallback: extract instance_prompt from YAML frontmatter when the LLM
# returned empty trigger words but the README has instance_prompt.
# Fallback: use the trigger words the site records for this exact file,
# then the README's YAML `instance_prompt`, when the LLM returned none.
if trigger_words_empty:
instance_prompt = _extract_yaml_instance_prompt(readme_content)
if instance_prompt:
current_civitai = metadata.get("civitai") or {}
trig_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
trig_civitai.update(updates["civitai"])
trig_civitai["trainedWords"] = [instance_prompt]
updates["civitai"] = trig_civitai
site_triggers = (
list(source_context.trigger_words) if source_context else []
)
if not site_triggers:
instance_prompt = _extract_yaml_instance_prompt(readme_content)
if instance_prompt:
site_triggers = [instance_prompt]
if site_triggers:
self._merge_civitai(updates, metadata, trainedWords=site_triggers)
preview_remote_url = (llm_output.get("preview_url") or "").strip()
# Fallback: if the LLM couldn't find a preview image in the cleaned
# README, find the first gallery image from the *model-specific
# section* of the README (not the repo-wide first image, which
# belongs to a different model in collection repos).
if not preview_remote_url and readme_content and is_hf_model:
if not preview_remote_url and readme_content and is_source_model:
model_basename = os.path.splitext(os.path.basename(model_path))[0]
relevant_section = extract_relevant_section(
readme_content, model_basename,
@@ -248,8 +340,12 @@ class PostProcessor:
if new_notes:
updates["notes"] = new_notes
# usage_tips — JSON string (e.g. {"strength_min":0.85,"strength_max":1.4})
# usage_tips — JSON string (e.g. {"strength_min":0.85,"strength_max":1.4}).
# When the LLM returned nothing, recover an explicitly stated strength
# range from the author summary so the value is not lost.
raw_tips = (llm_output.get("usage_tips") or "").strip()
if not raw_tips or raw_tips == "{}":
raw_tips = _extract_usage_tips(site_description)
if raw_tips and raw_tips != "{}":
try:
json.loads(raw_tips)
@@ -279,16 +375,35 @@ class PostProcessor:
# ------------------------------------------------------------------
@staticmethod
def _should_overwrite(current_value: str, is_hf_model: bool) -> bool:
def _should_overwrite(current_value: str, is_source_model: bool) -> bool:
"""Return ``True`` when a scalar field should be overwritten."""
return is_hf_model or not current_value or current_value.lower() in (
return is_source_model or not current_value or current_value.lower() in (
"", "unknown",
)
@staticmethod
def _should_overwrite_list(current_list: List[str], is_hf_model: bool) -> bool:
def _merge_civitai(
updates: Dict[str, Any], metadata: Dict[str, Any], **fields: Any
) -> None:
"""Layer *fields* onto the ``civitai`` block being assembled.
Description, version label, trigger words and gallery images all live
in the same dict and are contributed by separate branches, so each one
starts from what is already on disk and then applies whatever an
earlier branch queued in *updates*.
"""
merged = dict(metadata.get("civitai") or {})
queued = updates.get("civitai")
if isinstance(queued, dict):
merged.update(queued)
merged.update(fields)
updates["civitai"] = merged
@staticmethod
def _should_overwrite_list(current_list: List[str], is_source_model: bool) -> bool:
"""Return ``True`` when a list field should be overwritten."""
return is_hf_model or not current_list
return is_source_model or not current_list
@staticmethod
def _merge_tags(existing: List[str], new: List[str]) -> List[str]:
@@ -312,6 +427,129 @@ class PostProcessor:
# ------------------------------------------------------------------
#: Separator between a label and its value. Published model cards routinely
#: wrap the numbers in markdown emphasis or quotes (``strength: **0.85 - 1.4**``,
#: ``CLIP 强度「0.5」``), so those are absorbed rather than treated as a break.
_EMPHASIS = "[\"'\u201c\u201d\u300c\u300d*_`\\s]*"
#: An explicitly stated strength/weight range, e.g. ``权重0.5-1.2``,
#: ``强度 0.8 ~ 1.2``, ``strength: **0.85 - 1.4**``.
_RANGE_DASH = "(?:-|\u2010|\u2011|\u2012|\u2013|\u2014|\uff0d|~|\uff5e|\u81f3|\u5230|to)"
_STRENGTH_RANGE_RE = re.compile(
"(?:\u6743\u91cd|\u5f3a\u5ea6|strength|weight)" + _EMPHASIS + "[:\uff1a]?" + _EMPHASIS
+ r"(\d+(?:\.\d+)?)" + _EMPHASIS + _RANGE_DASH + _EMPHASIS
+ r"(\d+(?:\.\d+)?)",
re.IGNORECASE,
)
#: A single strength/weight value, e.g. ``strength: 0.6``, ``权重 0.8``.
_STRENGTH_VALUE_RE = re.compile(
"(?:\u6743\u91cd|\u5f3a\u5ea6|strength|weight)" + _EMPHASIS + "[:\uff1a]?" + _EMPHASIS
+ r"(\d+(?:\.\d+)?)",
re.IGNORECASE,
)
#: ``clip strength: 0.5`` / ``CLIP 强度 0.5``.
_CLIP_STRENGTH_RE = re.compile(
"clip" + _EMPHASIS + "(?:\u5f3a\u5ea6|strength)" + _EMPHASIS + "[:\uff1a]?" + _EMPHASIS
+ r"(\d+(?:\.\d+)?)",
re.IGNORECASE,
)
#: ``clip skip: 2`` / ``CLIP 跳过 2``.
_CLIP_SKIP_RE = re.compile(
"clip" + _EMPHASIS + "(?:skip|\u8df3\u8fc7)" + _EMPHASIS + "[:\uff1a]?" + _EMPHASIS
+ r"(\d+)",
re.IGNORECASE,
)
def _extract_usage_tips(text: str) -> str:
"""Extract stated strength/CLIP recommendations from prose.
This is the deterministic counterpart to the LLM's ``usage_tips`` output,
used when the LLM was skipped. It only recognises explicitly written
values it never infers a range and returns ``""`` when it finds none.
Returns:
A JSON string matching the skill's ``usage_tips`` schema, or ``""``.
"""
if not text:
return ""
tips: Dict[str, Any] = {}
# CLIP strength is resolved first and then blanked out, so the generic
# strength patterns cannot mistake `CLIP 强度 0.5` for the LoRA strength.
text_for_strength = text
clip_strength = _CLIP_STRENGTH_RE.search(text_for_strength)
if clip_strength:
tips["clip_strength"] = float(clip_strength.group(1))
text_for_strength = (
text_for_strength[: clip_strength.start()]
+ " "
+ text_for_strength[clip_strength.end() :]
)
range_match = _STRENGTH_RANGE_RE.search(text_for_strength)
if range_match:
low = float(range_match.group(1))
high = float(range_match.group(2))
if low > high:
low, high = high, low
tips["strength_min"] = low
tips["strength_max"] = high
tips["strength_range"] = f"{low:g}-{high:g}"
else:
value_match = _STRENGTH_VALUE_RE.search(text_for_strength)
if value_match:
tips["strength"] = float(value_match.group(1))
clip_skip = _CLIP_SKIP_RE.search(text)
if clip_skip:
tips["clip_skip"] = int(clip_skip.group(1))
if not tips:
return ""
return json.dumps(tips, ensure_ascii=False)
def _example_image(url: str, width: int, height: int) -> Dict[str, Any]:
"""Build a ``civitai.images`` entry for a site-provided example image.
The site publishes no prompt alongside these images, so the entry carries
empty prompt metadata and the LLM's recommended dimensions when it found
any (falling back to the same 512px placeholder the README extractors use).
"""
return {
"url": url,
"type": "image",
"nsfwLevel": 0,
"width": width or 512,
"height": height or 512,
"meta": {"prompt": "", "negativePrompt": ""},
"hasMeta": False,
"hasPositivePrompt": False,
}
def _dedupe_images(images: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Drop later entries that repeat an earlier image URL, keeping order."""
seen: set[str] = set()
unique: List[Dict[str, Any]] = []
for image in images:
url = image.get("url") or ""
if not url or url in seen:
continue
seen.add(url)
unique.append(image)
return unique
def _extract_yaml_instance_prompt(readme_content: str) -> str:
"""Extract ``instance_prompt`` from the YAML frontmatter of a HF README.
@@ -1,20 +1,23 @@
---
name: enrich_hf_metadata
title: "Enrich Metadata from HuggingFace"
title: "Enrich Metadata from Model Card"
description: >
Parse the HuggingFace model card via LLM to extract description, trigger
words, base model, tags, and preview image URL.
Parse the model card (README) from HuggingFace, ModelScope, or any other
supported model site via LLM to extract description, trigger words, base
model, tags, and preview image URL.
llm_required: true
---
You are an expert assistant for AI image generation models. Your task is to extract structured metadata from a HuggingFace model card (README.md).
You are an expert assistant for AI image generation models. Your task is to extract structured metadata from a model card (README).
## Model Information
- **Repository**: {{hf_url}}
- **Source site**: {{source_label}} ({{source_platform}})
- **Model page**: {{source_url}}
- **Model file path**: {{model_path}}
- **Model filename**: {{model_basename}}
- **Repository ID**: {{repo}}
- **Repository ID**: {{source_id}}
- **Repository raw-file base URL**: {{asset_base_url}}
## Current Metadata (may be incomplete)
@@ -22,6 +25,34 @@ You are an expert assistant for AI image generation models. Your task is to extr
{{current_metadata}}
```
## Site-Provided Metadata (any field may be empty)
The model site publishes the following **alongside** the README. It is
first-hand information recorded by the site itself, so it outranks anything
you would otherwise guess:
- **Author description**: {{source_description}}
- **Base model reported by the site**: {{source_base_model}}
- **Trigger words recorded for this file**: {{source_trigger_words}}
- **Site-curated tags**:
{{source_official_tags}}
- **Example image URLs for this file**:
{{source_example_images}}
Use it as follows:
- A weight or strength range stated in the **author description** belongs in
``usage_tips`` (and in ``notes``); do not leave ``usage_tips`` empty when the
description states one.
- When the author description exists, base ``short_description`` on it rather
than on the README, which on some sites is auto-generated boilerplate.
- Treat the **site-curated tags** as strong signals for ``tags``: they are
already a curated content vocabulary, so prefer them over invented words.
- Treat the **base model reported by the site** as a strong hint for
``base_model``, but still map it to the EXACT canonical name from the
available base-model list.
- Use the **example image URLs** when the README contains no usable image.
## User Priority Tags Reference
The user has configured the following list of **meaningful tag categories** for this model type (`{{model_type}}`):
@@ -39,7 +70,7 @@ name listed — do not invent aliases or modify variant suffixes.
{{base_models}}
## HuggingFace README Content
## Model Card Content
```
{{readme_content}}
@@ -52,10 +83,11 @@ Extract the following information from the README content above:
### base_model
The base model this model was trained on. Use EXACTLY one of the names from the **Available Base Models** list above. Do not invent new names or use aliases.
Check the YAML frontmatter for ``base_model:`` first. If the frontmatter has no ``base_model:``, look at the **model filename** (``{{model_basename}}``), YAML ``tags:``, README title and first paragraph for clues — the base model family is often embedded in the name
Check the **base model reported by the site** (above) and the YAML frontmatter ``base_model:`` first. If neither yields a match, look at the **model filename** (``{{model_basename}}``), YAML ``tags:``, README title and first paragraph for clues — the base model family is often embedded in the name
### trigger_words
The trigger words or activation prompts needed to use this LoRA. Look for:
- The **trigger words recorded for this file** in the site-provided metadata (most authoritative)
- `instance_prompt:` in the YAML frontmatter
- Phrases like "trigger word:", "trigger:", "use this prompt:", "activation prompt:"
- In collection repos: the trigger section **specific to this model file** (look near matching download links or anchor IDs)
@@ -63,12 +95,13 @@ The trigger words or activation prompts needed to use this LoRA. Look for:
Return as an array of strings. If none found, return an empty array `[]`. **Never** return `["None"]` or any placeholder value — a truly empty list means no trigger words exist.
### short_description
A concise 1-2 sentence summary of what this model does. Extract from the "Model description" section or the first paragraph. For collection repos, focus on the **specific model version** matching `{{model_basename}}`, not the repo as a whole. Return empty string if the README is too minimal.
A concise 1-2 sentence summary of what this model does. For collection repos, focus on the **specific model version** matching `{{model_basename}}`, not the repo as a whole. Prefer the **author description** from the site-provided metadata when it is present; otherwise extract from the "Model description" section or the first paragraph. Return empty string if the available content is too minimal.
### tags
3-8 relevant tags for categorizing this model. **Quality over quantity.**
Sources to consider:
- The **site-curated tags** from the site-provided metadata (these are already filtered content tags — prefer them)
- The YAML frontmatter `tags:` list (filter out technical ones — see below)
- The subject, style, character, or concept the model represents
- The model filename itself may give clues (e.g. "pokemon", "anime", "pixelart")
@@ -79,7 +112,9 @@ Sources to consider:
2. **Cross-reference against the priority_tags reference.** Only include a tag if it meaningfully describes what the model actually creates (subject, style, character type) and is semantically close to one of the priority_tags. If none of the README's tags match meaningful categories, prefer returning a smaller set or an empty array over including low-value tags.
3. **All lowercase, no spaces, no hyphens** (use single words like `"photorealistic"`, `"anime"`, `"character"`).
3. **All lowercase, and keep each tag's own wording.** Prefer the spelling already used by the site, the frontmatter, or the author — including hyphenated and multi-word tags such as `"sci-fi"`, `"semi-realistic"`, `"character-enhancement"` or `"art style"`. Do **not** strip separators or invent a single-word variant of a tag you are already including (e.g. do not emit both `"character-enhancement"` and `"character"`). When a tag is written in another script (e.g. Chinese), likewise keep it verbatim instead of translating it.
4. **Never invent a tag** that neither the site-provided metadata, the YAML frontmatter, nor the README text supports.
Return empty array if no meaningful content tags remain after filtering.
@@ -92,13 +127,13 @@ The URL of the most suitable preview image from the README. Look for:
- The YAML frontmatter `widget:` section (which often has `output.url` fields)
- In collection repos: the sample images listed **under the section** for this specific model version
- Generic `![alt](url)` in the body
Choose the first image that appears to be a generation example (not a logo or diagram). Construct the absolute URL as `https://huggingface.co/{{repo}}/resolve/main/{filename}`. If no suitable image is found, return an empty string.
Choose the first image that appears to be a generation example (not a logo or diagram). Construct the absolute URL from the repository raw-file base URL (`{{asset_base_url}}`) plus the relative path. If the README has no suitable image, fall back to the site-provided **example image URLs** for this file. If nothing is available, return an empty string.
### notes
A plain-text summary of the model card's key practical usage information. Combine trigger words, style modifiers, recommended parameters (steps, CFG, resolution, sampler), and any setup tips into a readable paragraph. For collection repos, focus on the **specific model version** matching `{{model_basename}}`. Return empty string if the README has no useful usage info.
A plain-text summary of the model card's key practical usage information. Combine trigger words, style modifiers, recommended parameters (steps, CFG, resolution, sampler), and any setup tips into a readable paragraph. For collection repos, focus on the **specific model version** matching `{{model_basename}}`. Include the **author description** from the site-provided metadata when it is present. Return empty string if there is no useful usage info.
### usage_tips
A JSON string with structured usage recommendations. Extract from the README any explicit ranges or recommended values (e.g. "Set LoRA strength: **0.85 - 1.4**", "CLIP strength: 0.5"). Possible fields (include only those you can determine):
A JSON string with structured usage recommendations. Extract from the **author description** (site-provided metadata) and the README any explicit ranges or recommended values (e.g. "Set LoRA strength: **0.85 - 1.4**", "CLIP strength: 0.5", "权重0.5-1.2"). Possible fields (include only those you can determine):
```json
{
@@ -121,7 +156,7 @@ Your confidence level in the extracted data:
## Important: Handling Collection Repos (multiple model files)
Many HuggingFace repos contain **multiple model files** in a single repository
Many model repositories contain **multiple model files** in a single repository
(e.g. a "LoRA collection" with different styles/characters in separate files).
The model file currently being enriched is: **`{{model_basename}}`**
@@ -1,8 +1,15 @@
"""HF README processing for the ``enrich_hf_metadata`` skill.
"""Model card (README) processing for the ``enrich_hf_metadata`` skill.
Provides README cleaning for LLM injection, gallery/image extraction from
multiple formats (YAML widget, markdown, HTML ``<img>``, gallery tables),
and section-based README trimming for collection repos.
The extractors default to Hugging Face asset URLs, but every one of them
accepts an explicit ``base_url`` so the same parsing works for any model
source (ModelScope, ...). See :mod:`py.services.model_sources`.
This module deliberately has no package-relative imports: it is also loaded
standalone by the README-processing test harness.
"""
from __future__ import annotations
@@ -15,12 +22,25 @@ from typing import Any, List, Tuple
_REPO_URL_PATTERN = re.compile(r"https?://huggingface\.co/([^/]+/[^/]+)")
def resolve_asset_base_url(repo: str, base_url: str | None = None) -> str:
"""Return the base URL used to resolve repository-relative assets.
Falls back to the historical Hugging Face layout when *base_url* is not
supplied, so existing callers keep their behaviour.
"""
if base_url:
return base_url.rstrip("/")
return f"https://huggingface.co/{repo}/resolve/main"
def extract_simple_markdown_images(
markdown_text: str,
repo: str,
existing_urls: set[str] | None = None,
default_width: int = 512,
default_height: int = 512,
base_url: str | None = None,
) -> list[dict[str, Any]]:
"""Extract standalone markdown images from the README body.
@@ -32,10 +52,10 @@ def extract_simple_markdown_images(
Returns a list of dicts in the same ``civitai.images`` format as
:func:`extract_gallery_images`.
"""
if not markdown_text or not repo:
if not markdown_text or not (repo or base_url):
return []
base_url = f"https://huggingface.co/{repo}/resolve/main"
base_url = resolve_asset_base_url(repo, base_url)
images: list[dict[str, Any]] = []
seen_urls: set[str] = set(existing_urls) if existing_urls else set()
@@ -89,20 +109,21 @@ def extract_html_img_tags(
existing_urls: set[str] | None = None,
default_width: int = 512,
default_height: int = 512,
base_url: str | None = None,
) -> list[dict[str, Any]]:
"""Extract image URLs from HTML ``<img src=\"...\">`` tags in the README.
Many HF collection repos (e.g. ``deadman44/Z-Image_LoRA``) use raw HTML
``<img>`` tags exclusively for their sample images, with no markdown
``![]()`` equivalents. This function finds those tags and constructs
resolvable HF URLs.
resolvable URLs.
Returns a list of dicts in the ``civitai.images`` format.
"""
if not markdown_text or not repo:
if not markdown_text or not (repo or base_url):
return []
base_url = f"https://huggingface.co/{repo}/resolve/main"
base_url = resolve_asset_base_url(repo, base_url)
images: list[dict[str, Any]] = []
seen_urls: set[str] = set(existing_urls) if existing_urls else set()
@@ -166,7 +187,7 @@ def extract_html_img_tags(
def extract_repo_from_hf_url(hf_url: str) -> str:
"""Extract ``user/repo`` from a HuggingFace URL."""
m = _REPO_URL_PATTERN.match(hf_url)
m = _REPO_URL_PATTERN.match(hf_url or "")
return m.group(1) if m else ""
@@ -175,21 +196,23 @@ def extract_gallery_images(
repo: str,
default_width: int = 512,
default_height: int = 512,
base_url: str | None = None,
) -> List[dict[str, Any]]:
"""Extract widget/gallery images from the YAML frontmatter of a HF README.
"""Extract widget/gallery images from the YAML frontmatter of a README.
Args:
markdown_text: Raw README content.
repo: HF repo identifier (``user/repo``).
repo: Repository identifier (``user/repo``).
default_width: Fallback width when the README provides no dimension.
default_height: Fallback height when the README provides no dimension.
base_url: Overrides the asset base URL (defaults to Hugging Face).
Returns a list of dicts compatible with the ``civitai.images`` metadata
format, each containing ``url`` (absolute HF URL), ``meta.prompt``,
format, each containing ``url`` (absolute), ``meta.prompt``,
``width``, ``height``, and ``type``. Returns an empty list when no
widget entries are found or when *repo* is empty.
"""
if not markdown_text or not repo:
if not markdown_text or not (repo or base_url):
return []
frontmatter = _extract_frontmatter(markdown_text)
@@ -197,7 +220,7 @@ def extract_gallery_images(
return []
images: List[dict[str, Any]] = []
base_url = f"https://huggingface.co/{repo}/resolve/main"
base_url = resolve_asset_base_url(repo, base_url)
w = default_width or 512
h = default_height or 512
@@ -279,10 +302,11 @@ def extract_gallery_table_images(
existing_urls: set[str] | None = None,
default_width: int = 512,
default_height: int = 512,
base_url: str | None = None,
) -> list[dict[str, Any]]:
"""Extract images from ``| Preview | Prompt |`` markdown gallery tables.
Many HF READMEs include a sample-gallery table in the body (outside
Many READMEs include a sample-gallery table in the body (outside
the YAML frontmatter) that shows generation examples with their
prompts. This function parses those tables and merges results with
the widget-sourced images from :func:`extract_gallery_images`.
@@ -291,10 +315,10 @@ def extract_gallery_table_images(
:func:`extract_gallery_images`. Already-seen URLs (from *existing_urls*)
are skipped.
"""
if not markdown_text or not repo:
if not markdown_text or not (repo or base_url):
return []
base_url = f"https://huggingface.co/{repo}/resolve/main"
base_url = resolve_asset_base_url(repo, base_url)
images: list[dict[str, Any]] = []
seen_urls: set[str] = set(existing_urls) if existing_urls else set()
lines = markdown_text.split("\n")
@@ -368,12 +392,18 @@ def _extract_frontmatter(text: str) -> str:
def convert_readme_to_html(markdown_text: str | None) -> str:
"""Convert HF README markdown to sanitised HTML."""
"""Convert HF README markdown to sanitised HTML.
Site-generated placeholder notices are dropped here too, so a repository
whose author wrote nothing does not store the download instructions as its
model description; the result is an empty string in that case.
"""
if not markdown_text:
return ""
text = markdown_text
text = _strip_frontmatter(text)
text = _strip_generated_card_boilerplate(text)
text = _strip_gallery(text)
text = _strip_badge_images(text)
text = _strip_html_comments(text)
@@ -420,6 +450,59 @@ _MASSIVE_LIST_LINE_MIN_LEN = 150
#: Minimum consecutive enumeration lines to trigger massive-list stripping.
_MASSIVE_LIST_THRESHOLD = 8
#: Substrings identifying text a *site* generated to fill a model card whose
#: author wrote nothing, as opposed to the author's own content. ModelScope
#: renders such a card as a placeholder notice, a block of SDK/git download
#: instructions, and a closing invitation to improve the card.
#:
#: Matched as substrings rather than whole headings because the notices are
#: prose, and because non-Latin scripts are not space-delimited — the notice
#: continues with a full-width period, so the ``title == kw`` style matching
#: used for :data:`_BOILERPLATE_HEADERS` would never fire.
_GENERATED_CARD_MARKERS: tuple[str, ...] = (
"当前模型的贡献者未提供更加详细的模型介绍",
"您可以通过如下",
"如果您是本模型的贡献者",
)
def _strip_generated_card_boilerplate(text: str) -> str:
"""Remove the notices a site generates to fill an empty model card.
A repository whose uploader wrote no README still gets a card: ModelScope
answers with "the contributor provided no further description", the SDK
and git download commands, and an invitation to complete the card. None
of it describes the model, yet it was landing in both the LLM prompt and
the stored description.
A notice that is a heading takes its whole section with it, so the
download block goes too; a stand-alone notice line is dropped on its own.
Content the author added later under a heading of equal or higher
level is kept, so an improved card is not thrown away.
"""
lines = text.split("\n")
out: list[str] = []
skip_until_level: int | None = None
for line in lines:
level = _heading_level(line)
if any(marker in line for marker in _GENERATED_CARD_MARKERS):
if level > 0:
skip_until_level = level
continue
if skip_until_level is not None:
if level > 0 and level <= skip_until_level:
skip_until_level = None
else:
continue
out.append(line)
return "\n".join(out)
def clean_readme_for_llm(markdown_text: str | None, max_length: int = 6000) -> str:
"""Clean a HF README for injection into an LLM metadata-extraction prompt.
@@ -429,6 +512,8 @@ def clean_readme_for_llm(markdown_text: str | None, max_length: int = 6000) -> s
* ``widget:`` YAML block (example prompts + output URLs)
* ``<Gallery />`` tags and wrappers
* Site-generated placeholder notices for a card the author never wrote
(see :func:`_strip_generated_card_boilerplate`)
* Fenced code blocks (Python / bash / bibtex / yaml)
* Standalone ``![...](...)`` image lines and ``<img>`` tags
* Training-parameter tables
@@ -454,6 +539,7 @@ def clean_readme_for_llm(markdown_text: str | None, max_length: int = 6000) -> s
# Order matters — broader strips first, then finer ones.
text = _strip_gallery(text)
text = _strip_widget_section(text)
text = _strip_generated_card_boilerplate(text)
text = _strip_fenced_code_blocks(text)
text = _strip_standalone_images(text)
text = _strip_training_tables(text)
+16 -12
View File
@@ -21,6 +21,7 @@ from .model_query import (
resolve_sub_type,
)
from .settings_manager import get_settings_manager
from .model_sources import source_group_key
from ..utils.civitai_utils import build_civitai_model_page_url
logger = logging.getLogger(__name__)
@@ -742,29 +743,32 @@ class BaseModelService(ABC):
@staticmethod
def _extract_hf_group_key(item: Dict[str, Any]) -> Optional[str]:
"""Extract `hf:{owner}/{repo}` from item's ``hf_url``, or None."""
hf_url = item.get("hf_url") if isinstance(item, dict) else None
if not hf_url or not isinstance(hf_url, str):
return None
m = re.match(
r"https?://huggingface\.co/([^/]+/[^/]+)", hf_url.strip()
)
if not m:
return None
return f"hf:{m.group(1)}"
key = BaseModelService._extract_source_group_key(item)
return key if key and key.startswith("hf:") else None
@staticmethod
def _extract_source_group_key(item: Dict[str, Any]) -> Optional[str]:
"""Return the external-source group key for *item*, or None.
Hugging Face keeps the historical ``hf:{owner}/{repo}`` shape; other
platforms use their own short prefix (``ms:`` / ``ta:``).
"""
return source_group_key(item)
@staticmethod
def _extract_group_key(item: Dict[str, Any]) -> Union[int, str, None]:
"""Return the group identity key: CivitAI modelId (int) or HF repo (str).
"""Return the group identity key.
Preference order:
1. CivitAI ``modelId`` (int)
2. HF repo identity ``hf:{owner}/{repo}`` (str)
2. External model source identity, e.g. ``hf:{owner}/{repo}``,
``ms:{owner}/{repo}``, ``ta:{model_id}`` (str)
3. ``None`` (no known grouping source)
"""
mid = BaseModelService._extract_model_id(item)
if mid is not None:
return mid
return BaseModelService._extract_hf_group_key(item)
return BaseModelService._extract_source_group_key(item)
@staticmethod
def _extract_model_id(item: Dict[str, Any]) -> Optional[int]:
+2
View File
@@ -67,6 +67,8 @@ class CheckpointService(BaseModelService):
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
"version_count": model_data.get("version_count"),
"source_platform": model_data.get("source_platform", ""),
"source_url": model_data.get("source_url", ""),
"hf_url": model_data.get("hf_url", ""),
}
+2
View File
@@ -67,6 +67,8 @@ class EmbeddingService(BaseModelService):
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
"version_count": model_data.get("version_count"),
"source_platform": model_data.get("source_platform", ""),
"source_url": model_data.get("source_url", ""),
"hf_url": model_data.get("hf_url", ""),
}
+58 -34
View File
@@ -267,6 +267,16 @@ _PROVIDER_DEFAULTS: Dict[str, str] = {
# Request timeout for LLM calls (seconds)
_LLM_TIMEOUT = aiohttp.ClientTimeout(total=120)
# Providers that do NOT implement ``response_format: {"type": "json_schema"}``
# and reject it with HTTP 400. For these the weaker, widely supported
# ``json_object`` mode is used instead (the prompt already specifies the
# expected JSON shape, and ``_try_salvage_json`` repairs imperfect output).
# DeepSeek answers a json_schema request with
# ``{"error":{"message":"This response_format type is unavailable now"}}``.
# LM Studio and some other local OpenAI-compatible servers reject
# ``json_object`` but accept ``json_schema``, so they are not listed here.
_JSON_OBJECT_ONLY_PROVIDERS = frozenset({"deepseek"})
class LLMService:
"""Centralized LLM API client.
@@ -614,47 +624,61 @@ class LLMService:
if effective_max is None:
effective_max = 4096
# Use json_schema (not json_object) for broader provider compatibility:
# LM Studio and some other OpenAI-compatible servers reject
# json_object but accept json_schema. {"type": "object"} is
# functionally equivalent — it accepts any JSON object without
# constraining specific fields.
response_format = {
# Structured-output format. ``json_schema`` is preferred because LM
# Studio and other local OpenAI-compatible servers reject
# ``json_object`` but accept ``json_schema``; ``{"type": "object"}``
# accepts any JSON object without constraining specific fields, so the
# two modes are functionally equivalent here. Providers known to
# reject json_schema (see _JSON_OBJECT_ONLY_PROVIDERS) get
# ``json_object`` instead.
schema_format: Dict[str, Any] = {
"type": "json_schema",
"json_schema": {
"name": "metadata",
"schema": {"type": "object"},
},
}
json_object_format: Dict[str, Any] = {"type": "json_object"}
try:
result = await self.chat_completion(
messages=messages,
model=model,
temperature=temperature,
response_format=response_format,
max_tokens=effective_max,
)
except LLMResponseError as e:
# Only fall back when the provider rejects the response_format
# type value (e.g. "'response_format.type' must be..."). Avoid
# catching unrelated 400 errors whose body happens to mention
# "response_format" (e.g. "model does not support
# response_format restrictions on this endpoint").
if "'response_format.type'" not in str(e).lower():
raise
logger.info(
"Provider rejected response_format, retrying without it. "
"Falling back to prompt-only JSON mode. Error: %s",
e,
)
result = await self.chat_completion(
messages=messages,
model=model,
temperature=temperature,
response_format=None,
max_tokens=effective_max,
)
if self._get_config()["provider"] in _JSON_OBJECT_ONLY_PROVIDERS:
format_chain: List[Optional[Dict[str, Any]]] = [
json_object_format,
None,
]
else:
format_chain = [schema_format, json_object_format, None]
result: Optional[Dict[str, Any]] = None
for index, fmt in enumerate(format_chain):
try:
result = await self.chat_completion(
messages=messages,
model=model,
temperature=temperature,
response_format=fmt,
max_tokens=effective_max,
)
break
except LLMResponseError as e:
message = str(e).lower()
if index + 1 >= len(format_chain):
raise
# Only downgrade when the failure is about ``response_format``.
# Everything else (auth, unknown model, rate limits) must
# surface unchanged. Matching on the bare parameter name also
# covers variants such as DeepSeek's "This response_format
# type is unavailable now" without swallowing unrelated 400s.
if "response_format" not in message:
raise
logger.info(
"Provider rejected response_format=%s, retrying with %s. "
"Error: %s",
(fmt or {}).get("type", "none"),
(format_chain[index + 1] or {}).get("type", "none"),
e,
)
assert result is not None # non-empty chain always sets or raises
content = result.get("content", "") or ""
if not content:
+2
View File
@@ -79,6 +79,8 @@ class LoraService(BaseModelService):
),
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
"version_count": model_data.get("version_count"),
"source_platform": model_data.get("source_platform", ""),
"source_url": model_data.get("source_url", ""),
"hf_url": model_data.get("hf_url", ""),
}
+30 -5
View File
@@ -14,10 +14,33 @@ from ..utils.model_utils import determine_base_model
from ..utils.models import autov3_from_civitai_files
from .connectivity_guard import OFFLINE_FRIENDLY_MESSAGE, is_expected_offline_error
from .errors import RateLimitError
from .model_sources import has_external_source
logger = logging.getLogger(__name__)
def _merge_ordered_unique(existing: Iterable[str], new: Iterable[str]) -> list[str]:
"""Concatenate two word lists, dropping duplicates without reordering.
Trigger word order is meaningful: the sequence stored in
``civitai.trainedWords`` is the order used when building prompts, and users
can reorder it in the UI. A plain ``set`` union used to shuffle that order on
every metadata refresh, so existing words are kept first (in their saved
order) and newly discovered ones are appended.
"""
merged: list[str] = []
seen: set[str] = set()
for word in list(existing) + list(new):
if word in seen:
continue
seen.add(word)
merged.append(word)
return merged
class MetadataProviderProtocol(Protocol):
"""Subset of metadata provider interface consumed by the sync service."""
@@ -114,9 +137,10 @@ class MetadataSyncService:
)
if "trainedWords" in existing_civitai:
existing_trained = existing_civitai.get("trainedWords", [])
new_trained = civitai_metadata.get("trainedWords", [])
merged_trained = list(set(existing_trained + new_trained))
existing_trained = existing_civitai.get("trainedWords", []) or []
new_trained = civitai_metadata.get("trainedWords", []) or []
# Order preserving merge: the saved order drives prompt order.
merged_trained = _merge_ordered_unique(existing_trained, new_trained)
merged_civitai["trainedWords"] = merged_trained
local_metadata["civitai"] = merged_civitai
@@ -222,9 +246,10 @@ class MetadataSyncService:
error_msg = "CivitAI model is deleted and no archive provider is available"
return False, error_msg
else:
is_hf_source = bool(model_data.get("hf_url"))
is_hf_source = has_external_source(model_data)
if is_hf_source:
# HF-sourced model: only check CivitAI API directly.
# External-source model (Hugging Face / ModelScope /
# TensorArt): only check CivitAI API directly.
# CivArchive is almost guaranteed to have no record, and
# hitting it wastes rate-limit budget.
# Use a distinct provider name ("civitai_api" not None) so
+357 -4
View File
@@ -2,13 +2,15 @@ import asyncio
import fnmatch
import os
import logging
import shutil
from typing import Any, Dict, List, Optional, Sequence, Set
from abc import ABC, abstractmethod
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS
from ..services.settings_manager import get_settings_manager
from ..services.model_lifecycle_service import _require_path_in_library_roots
from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
logger = logging.getLogger(__name__)
@@ -473,17 +475,368 @@ class ModelFileService:
class ModelMoveService:
"""Service for handling individual model moves"""
def __init__(self, scanner, model_type: str):
"""Initialize the service
Args:
scanner: Model scanner instance
model_type: Type of model (e.g., 'lora', 'checkpoint')
"""
self.scanner = scanner
self.model_type = model_type
async def create_folder(self, folder_path: str) -> Dict[str, Any]:
"""Create a directory inside the model library roots.
Args:
folder_path: Absolute path of the directory to create (business
path symlinks are not resolved)
Returns:
Dictionary with success flag, the created path and the
library-relative folder name used by folder trees.
"""
try:
if not folder_path or not str(folder_path).strip():
return {"success": False, "error": "Folder path is required"}
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
absolute_path = os.path.abspath(folder_path)
already_exists = os.path.isdir(absolute_path)
os.makedirs(absolute_path, exist_ok=True)
relative_folder = self._calculate_relative_folder(absolute_path)
if relative_folder:
await self.scanner.add_known_folder(relative_folder)
return {
"success": True,
"folder_path": absolute_path.replace(os.sep, "/"),
"folder": relative_folder,
"created": not already_exists,
}
except ValueError as exc:
return {"success": False, "error": str(exc)}
except Exception as exc:
logger.error(f"Error creating folder: {exc}", exc_info=True)
return {"success": False, "error": str(exc)}
def _calculate_relative_folder(self, absolute_path: str) -> str:
"""Return the library-relative folder for an absolute directory path."""
normalized = os.path.abspath(absolute_path)
for root in self.scanner.get_model_roots():
abs_root = os.path.abspath(root)
try:
rel = os.path.relpath(normalized, abs_root)
except ValueError:
continue
if rel == ".":
return ""
if not rel.startswith(".."):
return rel.replace(os.sep, "/")
return ""
async def delete_folder(self, folder_path: str, dry_run: bool = False) -> Dict[str, Any]:
"""Delete a model-free directory inside the model library roots.
Only directories whose subtree holds no model weight files can be
removed: a folder-level cascade would bypass the per-model lifecycle
bookkeeping (metadata sidecars, previews, cache entries, pending-delete
staging and recipe references), so it is deliberately refused. Leftover
non-model files (stray previews, sidecars, ``.bak`` files) are reported
in the manifest before they are removed.
Args:
folder_path: Absolute path of the directory to remove (business
path symlinks are not resolved)
dry_run: When true, only report what would be removed
Returns:
Dictionary with the success flag plus a removal manifest
(``model_count``/``file_count``/``dir_count``/``symlink_count``/
``total_bytes``/``restorable``) on success.
"""
try:
if not folder_path or not str(folder_path).strip():
return {"success": False, "error": "Folder path is required"}
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
absolute_path = os.path.abspath(folder_path)
if os.path.islink(absolute_path):
# shutil.rmtree refuses symlinked roots, and silently deleting
# the link (leaving the real directory behind) is a separate
# decision we do not make here.
return {
"success": False,
"error": "Symlinked folders cannot be deleted",
}
if not os.path.isdir(absolute_path):
return {"success": False, "error": "Folder no longer exists"}
if self._is_model_root(absolute_path):
return {
"success": False,
"error": "The library root itself cannot be deleted",
}
manifest = self._collect_folder_manifest(absolute_path)
if manifest["pending_delete_job"]:
return {
"success": False,
"code": "busy",
"error": (
"A staged delete is still pending inside this folder; "
"wait for the undo window to expire"
),
"manifest": manifest,
}
if manifest["model_count"] > 0:
return {
"success": False,
"code": "not_empty",
"error": (
f"Folder still contains {manifest['model_count']} model "
"file(s); delete or move them first"
),
"manifest": manifest,
}
relative_folder = self._calculate_relative_folder(absolute_path)
if dry_run:
return {
"success": True,
"dry_run": True,
"folder_path": absolute_path.replace(os.sep, "/"),
"folder": relative_folder,
**manifest,
}
shutil.rmtree(absolute_path)
await self._forget_folder(relative_folder)
return {
"success": True,
"dry_run": False,
"folder_path": absolute_path.replace(os.sep, "/"),
"folder": relative_folder,
**manifest,
}
except ValueError as exc:
return {"success": False, "error": str(exc)}
except Exception as exc:
logger.error(f"Error deleting folder: {exc}", exc_info=True)
return {"success": False, "error": str(exc)}
def _is_model_root(self, absolute_path: str) -> bool:
"""Return True when the path *is* one of the configured library roots."""
normalized = os.path.normpath(absolute_path)
for root in self.scanner.get_model_roots():
if os.path.normpath(os.path.abspath(root)) == normalized:
return True
return False
@staticmethod
def _is_model_file(file_name: str) -> bool:
"""Return True when the file name carries a model weight extension."""
return os.path.splitext(file_name)[1].lower() in MODEL_FILE_EXTENSIONS
def _collect_folder_manifest(self, absolute_path: str) -> Dict[str, Any]:
"""Describe everything a recursive delete of *absolute_path* removes.
Walking is intentional: the scanner cache can be stale, and a model file
that appeared on disk since the last scan must still block the delete.
Symbolic links are never followed (``os.walk`` default) and are counted
separately ``shutil.rmtree`` unlinks them without touching their
targets.
"""
model_count = 0
file_count = 0
dir_count = 0
symlink_count = 0
total_bytes = 0
pending_delete_job = False
for dirpath, dirnames, filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames:
pending_delete_job = True
for name in dirnames:
if os.path.islink(os.path.join(dirpath, name)):
symlink_count += 1
else:
dir_count += 1
for name in filenames:
full_path = os.path.join(dirpath, name)
if os.path.islink(full_path):
symlink_count += 1
continue
if self._is_model_file(name):
model_count += 1
else:
file_count += 1
try:
total_bytes += os.path.getsize(full_path)
except OSError: # pragma: no cover - defensive
pass
return {
"model_count": model_count,
"file_count": file_count,
"dir_count": dir_count,
"symlink_count": symlink_count,
"total_bytes": total_bytes,
"pending_delete_job": pending_delete_job,
# A truly empty directory is the only case an "undo" can restore by
# simply recreating it; a folder holding stray files is gone for good.
"restorable": (
model_count == 0
and file_count == 0
and dir_count == 0
and symlink_count == 0
),
}
async def _forget_folder(self, relative_folder: str) -> None:
"""Drop a removed directory from the scanner's folder/cache records."""
if not relative_folder:
return
remove_known_folder = getattr(self.scanner, "remove_known_folder", None)
if callable(remove_known_folder):
await remove_known_folder(relative_folder)
async def rename_folder(self, folder_path: str, new_name: str) -> Dict[str, Any]:
"""Rename a directory inside the model library roots.
Unlike :meth:`delete_folder` this works on folders that hold models.
A rename keeps every file, so no per-model lifecycle step is bypassed:
the directory is renamed on disk and the affected folder, cache, hash
index and metadata-sidecar records are re-keyed onto the new prefix by
the scanner.
Args:
folder_path: Absolute path of the directory to rename (business
path symlinks are not resolved)
new_name: New leaf name; a single path segment, not a path
Returns:
Dictionary with the success flag, the previous/next library-relative
folder names and whether the directory actually moved.
"""
try:
if not folder_path or not str(folder_path).strip():
return {"success": False, "error": "Folder path is required"}
new_name = str(new_name or "").strip()
if not new_name:
return {"success": False, "error": "New folder name is required"}
if new_name in (".", "..") or any(
char in new_name for char in '/\\:*?"<>|'
):
return {"success": False, "error": "Invalid characters in folder name"}
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
absolute_path = os.path.abspath(folder_path)
if os.path.islink(absolute_path):
return {
"success": False,
"error": "Symlinked folders cannot be renamed",
}
if not os.path.isdir(absolute_path):
return {"success": False, "error": "Folder no longer exists"}
if self._is_model_root(absolute_path):
return {
"success": False,
"error": "The library root itself cannot be renamed",
}
previous_relative = self._calculate_relative_folder(absolute_path)
target = os.path.join(os.path.dirname(absolute_path), new_name)
if os.path.normpath(target) == os.path.normpath(absolute_path):
return {
"success": True,
"renamed": False,
"folder": previous_relative,
"previous_folder": previous_relative,
"folder_path": absolute_path.replace(os.sep, "/"),
}
if os.path.exists(target):
return {
"success": False,
"code": "target_exists",
"error": f"A folder named \"{new_name}\" already exists here",
}
# A staging manifest records absolute original/staged paths, so
# moving a folder that holds one would break its undo and purge.
if self._has_pending_delete_job(absolute_path):
return {
"success": False,
"code": "busy",
"error": (
"A staged delete is still pending inside this folder; "
"wait for the undo window to expire"
),
}
os.rename(absolute_path, target)
new_relative = self._calculate_relative_folder(target)
await self._rename_folder_records(
previous_relative, new_relative, absolute_path, target
)
return {
"success": True,
"renamed": True,
"folder": new_relative,
"previous_folder": previous_relative,
"folder_path": target.replace(os.sep, "/"),
}
except ValueError as exc:
return {"success": False, "error": str(exc)}
except Exception as exc:
logger.error(f"Error renaming folder: {exc}", exc_info=True)
return {"success": False, "error": str(exc)}
@staticmethod
def _has_pending_delete_job(absolute_path: str) -> bool:
"""Return True when a staged-delete batch lives inside the subtree."""
for _dirpath, dirnames, _filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames:
return True
return False
async def _rename_folder_records(
self,
previous_relative: str,
new_relative: str,
previous_path: str,
new_path: str,
) -> None:
"""Hand the rename to the scanner so folder/cache records follow it."""
if not previous_relative or not new_relative:
return
rename_known_folder = getattr(self.scanner, "rename_known_folder", None)
if callable(rename_known_folder):
await rename_known_folder(
previous_relative,
new_relative,
previous_path=previous_path,
new_path=new_path,
)
async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
"""Move a single model file
+342 -6
View File
@@ -15,6 +15,7 @@ from ..utils.civitai_utils import resolve_license_info
from .model_cache import ModelCache
from .model_hash_index import ModelHashIndex
from .model_lifecycle_service import delete_model_artifacts, _require_path_in_library_roots
from .model_sources import normalize_metadata_source
from .service_registry import ServiceRegistry
from .websocket_manager import ws_manager
from .persistent_model_cache import get_persistent_cache
@@ -62,6 +63,15 @@ def _is_hidden_relative_path(rel_path: str) -> bool:
return any(part.startswith(".") for part in rel_path.replace(os.sep, "/").split("/"))
def _file_name_stem(file_path: str) -> str:
"""Return the extension-free file name of a normalized model path.
``file_name`` cache/sidecar fields are defined as the on-disk stem, so this
is the authoritative value to compare stored names against (issue #1112).
"""
return os.path.splitext(os.path.basename(file_path))[0]
# Maps a scanner model type to the manager page type used in progress
# broadcasts (e.g. 'lora' -> 'loras').
PAGE_TYPE_MAP = {
@@ -387,8 +397,14 @@ class ModelScanner:
'civitai': civitai_slim,
'civitai_deleted': bool(get_value('civitai_deleted', False)),
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
# External model source (Hugging Face / ModelScope / TensorArt).
# `source_url` + `source_platform` are canonical; `hf_url` stays in
# sync as a legacy alias (normalised below).
'source_platform': get_value('source_platform', '') or '',
'source_url': get_value('source_url', '') or '',
'hf_url': get_value('hf_url', '') or '',
}
normalize_metadata_source(entry)
license_source: Dict[str, Any] = {}
if isinstance(civitai_full, Mapping):
@@ -1069,6 +1085,26 @@ class ModelScanner:
# Track found files and new files
found_paths = set()
new_files = []
# Cached entries whose stored file_name no longer matches the file
# on disk (e.g. dotted stems truncated by the legacy .civitai.info
# migration, issue #1112). Repaired in place after the walk; the
# list stays empty on a clean library, so a no-change reconcile
# only pays one string compare per cached file.
stale_paths: List[str] = []
stale_seen: Set[str] = set()
def mark_stale_if_needed(cached_path: str) -> None:
"""Queue a cached path for file_name repair when it drifted."""
if cached_path in stale_seen:
return
item = path_to_item.get(cached_path)
if item is None:
return
if item.get("file_name") == _file_name_stem(cached_path):
return
stale_seen.add(cached_path)
stale_paths.append(cached_path)
visited_real_paths = set()
discovered_real_files = set()
discovered_folders: Set[str] = set()
@@ -1103,6 +1139,7 @@ class ModelScanner:
# Check if this file is already in cache
if file_path in cached_paths:
found_paths.add(file_path)
mark_stale_if_needed(file_path)
continue
# Only a cache miss needs the physical path, so the
@@ -1113,6 +1150,7 @@ class ModelScanner:
cached_real_match = lookup_cached_real_path(real_file_path)
if cached_real_match:
found_paths.add(cached_real_match)
mark_stale_if_needed(cached_real_match)
continue
if file_path in self._excluded_models:
@@ -1125,6 +1163,7 @@ class ModelScanner:
for cached_path in cached_paths:
if cached_path.lower() == lower_path:
found_paths.add(cached_path)
mark_stale_if_needed(cached_path)
matched = True
break
if matched:
@@ -1235,7 +1274,57 @@ class ModelScanner:
elapsed_seconds=time.time() - start_time,
)
return
# Repair rows whose file_name drifted from the file on disk. Only
# mismatching entries are re-read here, so a clean library never
# touches metadata during a refresh. Each repair goes through the
# single-row update path: load_metadata() normalizes the sidecar
# (MetadataManager._normalize_metadata_paths) and
# _sync_cache_from_metadata_impl() rewrites one targeted SQL delta
# instead of a full cache save, and the mismatch is gone
# afterwards, so the work never repeats (issue #1112).
total_repaired = 0
if stale_paths:
logger.info(
"%s Scanner: Repairing %d cached entries whose file_name no longer matches the file on disk",
self.model_type.capitalize(),
len(stale_paths),
)
for path in stale_paths:
if self.is_cancelled():
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile repair cancelled")
break
try:
metadata, _should_skip = await MetadataManager.load_metadata(
path, self.model_class
)
if metadata is None:
# Missing or corrupt sidecar: keep the existing row
# so a full rebuild can recreate the metadata from
# .civitai.info (or defaults) without losing cached
# fields such as tags or civitai data.
logger.debug(
"%s Scanner: Leaving %s unchanged (no usable metadata to repair from)",
self.model_type.capitalize(),
path,
)
continue
payload = metadata.to_dict()
unknown_fields = getattr(metadata, "_unknown_fields", None)
if isinstance(unknown_fields, dict):
payload.update(unknown_fields)
if await self._sync_cache_from_metadata_impl(path, payload):
total_repaired += 1
except Exception as exc:
logger.warning(
"%s Scanner: Failed to repair file_name for %s: %s",
self.model_type.capitalize(),
path,
exc,
)
# Find missing files (in cache but not in filesystem)
missing_files = cached_paths - found_paths
total_removed = 0
@@ -1316,7 +1405,11 @@ class ModelScanner:
elif folders_changed:
await self._persist_current_cache()
logger.info(f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in {time.time() - start_time:.2f} seconds. Added {total_added}, removed {total_removed} models.")
logger.info(
f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in "
f"{time.time() - start_time:.2f} seconds. Added {total_added}, "
f"removed {total_removed}, repaired {total_repaired} models."
)
await self._broadcast_scan_progress(
'completed', 'process_new', 100, False,
added=total_added, removed=total_removed,
@@ -1384,6 +1477,244 @@ class ModelScanner:
return sorted(folders, key=lambda x: x.lower())
async def add_known_folder(self, folder: str) -> None:
"""Record a folder (and its parents) in the known folder list.
Called when a directory is created between scans (e.g. via the
create-folder API) so folder trees reflect it immediately without
waiting for the next reconciliation. When ``all_folders`` has not
been recorded yet (legacy snapshot), this is a no-op the scheduled
backfill walk discovers the directory from disk instead.
"""
normalized = folder.replace("\\", "/").strip("/")
parts = [part for part in normalized.split("/") if part]
if not parts:
return
cache = self._cache
if cache is None:
return
recorded = getattr(cache, "all_folders", None)
if recorded is None:
return
known = set(recorded)
for i in range(1, len(parts) + 1):
known.add("/".join(parts[:i]))
updated = sorted(known, key=lambda x: x.lower())
if updated != list(recorded):
cache.all_folders = updated
await self._persist_current_cache()
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:
"""Kick off a one-shot background folder walk if none is running."""
if self._all_folders_backfill_running:
@@ -1545,11 +1876,16 @@ class ModelScanner:
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
if file_info:
file_name = os.path.splitext(os.path.basename(file_path))[0]
file_info['name'] = file_name
local_stem = os.path.splitext(os.path.basename(file_path))[0]
# from_civitai_info expects an API-shaped file entry and
# strips one extension itself, so hand it the real
# basename: passing the already extension-free stem made
# it cut dotted names at their last dot ("lora-sd1.5-..."
# became "lora-sd1", issue #1112).
file_info['name'] = os.path.basename(file_path)
metadata = cast(Any, self.model_class).from_civitai_info(version_info, file_info, file_path)
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
metadata.preview_url = find_preview_file(local_stem, os.path.dirname(file_path))
await MetadataManager.save_metadata(file_path, metadata)
logger.info(f"Created metadata from .civitai.info for {file_path} (Reason: .civitai.info was found but .metadata.json was missing)")
except Exception as e:
+86
View File
@@ -0,0 +1,86 @@
"""External model-source providers (Hugging Face, ModelScope, TensorArt).
This package is the single abstraction over "a site that hosts models and
a model card". See :mod:`py.services.model_sources.base` for the provider
protocol and :mod:`py.services.model_sources.registry` for the lookup and
metadata-normalisation helpers used across the codebase.
"""
from __future__ import annotations
from .base import (
GROUP_PREFIXES,
HTTP_TIMEOUT,
ModelCardContext,
ModelSource,
ModelSourceCache,
ModelSourceError,
SourceRef,
USER_AGENT,
clean_source_url,
fetch_json,
fetch_text,
filter_weight_files,
is_valid_source_id,
)
from .huggingface import HuggingFaceSource
from .hydration import (
hydrate_from_source,
load_model_card,
resolve_site_base_model,
)
from .modelscope import ModelScopeIntlSource, ModelScopeSource
from .registry import (
LEGACY_HF_URL_FIELD,
SOURCE_PLATFORM_FIELD,
SOURCE_URL_FIELD,
detect_source,
downloadable_sources,
get_download_source,
get_source,
get_source_platform,
has_external_source,
list_sources,
normalize_metadata_source,
resolve_source_ref,
source_group_key,
source_label,
)
from .tensorart import TensorArtSource
__all__ = [
"GROUP_PREFIXES",
"HTTP_TIMEOUT",
"LEGACY_HF_URL_FIELD",
"ModelCardContext",
"ModelSource",
"ModelSourceCache",
"ModelSourceError",
"HuggingFaceSource",
"ModelScopeIntlSource",
"ModelScopeSource",
"SOURCE_PLATFORM_FIELD",
"SOURCE_URL_FIELD",
"SourceRef",
"TensorArtSource",
"USER_AGENT",
"clean_source_url",
"detect_source",
"downloadable_sources",
"fetch_json",
"fetch_text",
"filter_weight_files",
"get_download_source",
"get_source",
"get_source_platform",
"has_external_source",
"hydrate_from_source",
"is_valid_source_id",
"list_sources",
"load_model_card",
"normalize_metadata_source",
"resolve_site_base_model",
"resolve_source_ref",
"source_group_key",
"source_label",
]
+446
View File
@@ -0,0 +1,446 @@
"""Base types for the external model-source provider abstraction.
A *model source* is a third-party site that hosts model files and a model
card (README) describing them Hugging Face, ModelScope, TensorArt, and
whatever gets added later. Everything the rest of the codebase needs to
know about such a site is expressed by :class:`ModelSource`:
* how to recognise one of its URLs (:meth:`ModelSource.parse`)
* the canonical page URL for a source id (:meth:`ModelSource.canonical_url`)
* how to fetch the model card (:meth:`ModelSource.fetch_model_card`)
* how to fetch the extras that live *outside* the README
(:meth:`ModelSource.fetch_model_card_context`)
* how to turn repository-relative asset paths into absolute URLs
(:meth:`ModelSource.asset_base_url`)
* which capabilities the site actually supports
(``supports_enrichment`` / ``supports_download``)
Keeping this in one place means the agent pipeline, the scanners, and the
HTTP handlers never need site-specific branching.
"""
from __future__ import annotations
import logging
import os
import re
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, Optional
import aiohttp
from ...utils.constants import MODEL_FILE_EXTENSIONS
logger = logging.getLogger(__name__)
#: Shared HTTP timeout for model-card fetches.
HTTP_TIMEOUT = 30
#: User agent used for all model-source HTTP requests.
USER_AGENT = "ComfyUI-LoRA-Manager/1.0"
#: Platform → short prefix used when building version-group keys.
#: ``huggingface`` keeps the historical ``hf:`` prefix for backward
#: compatibility with already-cached group keys.
GROUP_PREFIXES: dict[str, str] = {
"huggingface": "hf",
"modelscope": "ms",
"modelscope-ai": "msai",
"tensorart": "ta",
}
@dataclass(frozen=True)
class SourceRef:
"""A parsed reference to a model hosted on an external site."""
platform: str
"""Canonical platform id, e.g. ``"huggingface"``."""
source_id: str
"""Site-specific identity, e.g. ``"user/repo"`` or ``"827823520299086029"``."""
url: str
"""Canonical URL of the model page."""
@dataclass
class ModelCardContext:
"""Site-specific extras that accompany a model's README model card.
A model card is not always just ``README.md``. ModelScope, for example,
keeps the author's summary, the site-curated tags, and the per-file
example images in its model-detail API rather than in the repository.
Sources with no such extras return an empty context (the default), so
every field here must be treated as optional by callers.
"""
description: str = ""
"""Author-written summary shown on the model page, outside the README."""
model_name: str = ""
"""Site-published display name for the repository.
Sites publish this next to the repository id (ModelScope's ``Name``).
It is what a CivitAI download would store as the model's name, so the
card never has to fall back to the local filename.
"""
model_name_localized: str = ""
"""Site-published localized name (ModelScope's ``ChineseName``)."""
version_name: str = ""
"""Site-published label for the requested file's version.
Resolved per file, like :attr:`example_images`: a repository publishes
one label per checkpoint (ModelScope's ``modelVersion.showName``).
"""
license: str = ""
"""License the site records for the repository."""
model_type: str = ""
"""Site-reported model type, e.g. ModelScope's ``AigcType`` (``LoRA``)."""
base_model: str = ""
"""Base model as reported by the site (possibly a site-local id)."""
base_model_aliases: list[str] = field(default_factory=list)
"""Other names the site uses for the same base model.
Sites often publish both a link-style id (``krea/Krea-2-Turbo``) and an
internal architecture enum (``KREA_2``). The enum usually normalises
cleanly onto this system's canonical vocabulary, so it is the better
resolution hint for :mod:`py.services.agent.base_model_resolver`.
"""
official_tags: list[str] = field(default_factory=list)
"""Content tags curated by the site itself."""
example_images: list[str] = field(default_factory=list)
"""Absolute URLs of example images for the requested model file."""
trigger_words: list[str] = field(default_factory=list)
"""Trigger words the site records for the requested model file."""
def is_empty(self) -> bool:
"""Return ``True`` when the site contributed nothing extra."""
return not any(
(
self.description,
self.model_name,
self.model_name_localized,
self.version_name,
self.license,
self.model_type,
self.base_model,
self.base_model_aliases,
self.official_tags,
self.example_images,
self.trigger_words,
)
)
class ModelSourceError(Exception):
"""Raised when a model source cannot satisfy a request.
Carries the HTTP status the API handler should answer with, so the
handlers stay free of per-site error mapping.
"""
def __init__(self, message: str, status: int = 502) -> None:
super().__init__(message)
self.status = status
class ModelSourceCache:
"""Per-run memo shared between the agent pipeline and a model source.
A collection repository publishes many model files under a single source
id, so enriching each file re-fetches the same README and the same
repository metadata. One cache is created per enrichment run and thrown
away afterwards: nothing is retained across runs (a model card can change
at any time), and download URLs are never routed through it.
"""
def __init__(self) -> None:
#: Provider-agnostic: ``"<platform>:<source_id>"`` → raw README text.
self.readmes: Dict[str, str] = {}
#: Provider-owned scratch space. Keys must be namespaced by the
#: provider (``(platform, kind, source_id)``) so two providers can
#: never collide. Only successful results should be stored, so a
#: transient failure is still retried for the next file.
self.provider: Dict[Any, Any] = {}
#: Repository ids are always exactly ``owner/name``. Components may contain
#: dots (``black-forest-labs/FLUX.1-dev``) but must not be empty, ``.`` / ``..``,
#: or start with a dot - the id is used as a path segment on disk.
_SOURCE_ID_COMPONENT = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$")
def is_valid_source_id(source_id: str) -> bool:
"""Return ``True`` when *source_id* is a safe ``owner/name`` repository id."""
if not source_id or not isinstance(source_id, str) or source_id.count("/") != 1:
return False
owner, name = source_id.split("/", 1)
return all(
part and part not in (".", "..") and _SOURCE_ID_COMPONENT.match(part)
for part in (owner, name)
)
async def fetch_text(url: str, *, timeout: int = HTTP_TIMEOUT) -> str:
"""Fetch *url* and return its body as text, or ``""`` on any failure.
Network problems are expected (offline installs, rate limits, dead
repos) and must never bubble up into the pipeline, so every error is
logged at debug level and normalised to an empty string.
"""
try:
async with aiohttp.ClientSession(
headers={"User-Agent": USER_AGENT},
timeout=aiohttp.ClientTimeout(total=timeout),
) as session:
async with session.get(url) as resp:
if resp.status == 200:
return await resp.text()
logger.debug("Fetch %s returned HTTP %s", url, resp.status)
except Exception as exc: # pragma: no cover - network dependent
logger.debug("Failed to fetch %s: %s", url, exc)
return ""
async def fetch_json(
url: str, *, timeout: int = HTTP_TIMEOUT
) -> tuple[int, Any]:
"""Fetch *url* and return ``(status, parsed_body)``.
Unlike :func:`fetch_text` this reports the status, because callers such as
the file-listing endpoints need to distinguish "repo not found" (404) from
a transport failure. ``parsed_body`` is ``None`` when the response is not
JSON or the request failed outright (status ``0``).
"""
try:
async with aiohttp.ClientSession(
headers={"User-Agent": USER_AGENT},
timeout=aiohttp.ClientTimeout(total=timeout),
) as session:
async with session.get(url) as resp:
if resp.status != 200:
return resp.status, None
try:
return resp.status, await resp.json(content_type=None)
except Exception:
return resp.status, None
except Exception as exc: # pragma: no cover - network dependent
logger.debug("Failed to fetch %s: %s", url, exc)
return 0, None
class ModelSource:
"""Description and I/O for one external model hosting site."""
#: Canonical platform id stored in metadata.
platform: str = ""
#: Human-readable name used in UI copy and prompts.
label: str = ""
#: Whether the agent skill can fetch a model card and run AI extraction.
supports_enrichment: bool = False
#: Whether models can be downloaded directly from this site.
supports_download: bool = False
#: Branch used when the caller does not pass an explicit revision.
default_revision: str = ""
#: Sub-directory the "use default paths" template places downloads in.
default_subdir: str = ""
#: Lenient pattern used to recognise URLs already stored in metadata.
#: Captures the site-specific source id in group ``id``.
url_pattern: re.Pattern[str] | None = None
#: Strict pattern used to validate user input. Must match the whole URL.
strict_url_pattern: re.Pattern[str] | None = None
# ------------------------------------------------------------------
# Parsing
# ------------------------------------------------------------------
def parse(self, url: str, *, strict: bool = False) -> Optional[str]:
"""Return the source id contained in *url*, or ``None``.
With ``strict=True`` the URL must match this site's canonical shape
exactly (used when validating what a user pasted); with
``strict=False`` sub-paths such as ``/resolve/main/file.bin`` are
tolerated (used when normalising already-stored values).
"""
if not url or not isinstance(url, str):
return None
candidate = url.strip()
if not candidate:
return None
pattern = self.strict_url_pattern if strict else self.url_pattern
if pattern is None:
return None
match = pattern.match(candidate)
return match.group("id") if match else None
def ref(self, url: str, *, strict: bool = False) -> Optional[SourceRef]:
"""Return a :class:`SourceRef` for *url*, or ``None`` if not ours."""
source_id = self.parse(url, strict=strict)
if not source_id:
return None
return SourceRef(
platform=self.platform,
source_id=source_id,
url=self.canonical_url(source_id),
)
# ------------------------------------------------------------------
# URLs and content
# ------------------------------------------------------------------
def canonical_url(self, source_id: str) -> str:
"""Return the canonical model-page URL for *source_id*."""
raise NotImplementedError
def asset_base_url(self, source_id: str, revision: str = "") -> str:
"""Base URL used to resolve repository-relative asset paths."""
return ""
def group_key(self, source_id: str) -> str:
"""Return the version-group key for *source_id*."""
prefix = GROUP_PREFIXES.get(self.platform, self.platform)
return f"{prefix}:{source_id}"
async def fetch_model_card(self, source_id: str) -> str:
"""Fetch the raw model card (README) markdown for *source_id*."""
return ""
async def fetch_model_card_context(
self,
source_id: str,
filename: str = "",
*,
sha256: str = "",
cache: Optional["ModelSourceCache"] = None,
) -> ModelCardContext:
"""Return the card extras the site keeps outside the README.
*filename* is the model file's basename (no directory) and *sha256*
its content hash; between them they select the right entry when a
repository holds several models. A site that records per-file hashes
should prefer *sha256*, because it is the only identifier that
survives the user renaming the weights.
*cache* is an optional per-run memo (see :class:`ModelSourceCache`)
that lets a provider avoid re-fetching repository-wide data for every
file in a collection repository.
Sites whose model card is fully described by :meth:`fetch_model_card`
need no override and inherit this empty context.
Implementations must never raise: enrichment treats a missing
context as "the site had nothing extra to say".
"""
return ModelCardContext()
# ------------------------------------------------------------------
# Download support
# ------------------------------------------------------------------
async def list_files(
self, source_id: str, revision: str = ""
) -> list[dict[str, Any]]:
"""List downloadable weight files in *source_id*.
Returns ``[{"filename": <repo-relative path>, "size": <bytes>}]``,
largest first, filtered to :data:`MODEL_FILE_EXTENSIONS`. Sites
without download support return an empty list.
Raises :class:`ModelSourceError` when the repository cannot be read,
so the handler can surface "not found" separately from a transport
failure.
"""
return []
def file_download_url(
self, source_id: str, filename: str, revision: str = ""
) -> str:
"""Return the direct (redirecting) download URL for one file."""
raise ModelSourceError(
f"{self.label or self.platform} does not support downloads", status=400
)
def resolve_revision(self, revision: str = "") -> str:
"""Return *revision*, falling back to this site's default branch."""
return revision or self.default_revision
def page_url_for_file(self, source_id: str, filename: str) -> str:
"""Return the human-facing page for *filename* inside *source_id*."""
return self.canonical_url(source_id)
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<ModelSource {self.platform}>"
def clean_source_url(url: Any) -> str:
"""Normalise a stored source URL value into a stripped string."""
if not isinstance(url, str):
return ""
return url.strip()
def filter_weight_files(entries: Iterable[tuple[str, int]]) -> list[dict[str, Any]]:
"""Keep model-weight files from ``(path, size)`` pairs, largest first.
Every site lists a lot more than weights (READMEs, configs, tokenizers,
); the download picker only ever wants the files ComfyUI can load, which
is exactly :data:`MODEL_FILE_EXTENSIONS`.
"""
files = [
{"filename": path, "size": int(size or 0)}
for path, size in entries
if path and os.path.splitext(path)[1].lower() in MODEL_FILE_EXTENSIONS
]
files.sort(key=lambda entry: entry["size"], reverse=True)
return files
__all__ = [
"GROUP_PREFIXES",
"HTTP_TIMEOUT",
"ModelCardContext",
"ModelSource",
"ModelSourceCache",
"ModelSourceError",
"SourceRef",
"USER_AGENT",
"clean_source_url",
"fetch_json",
"fetch_text",
"filter_weight_files",
"is_valid_source_id",
]
+106
View File
@@ -0,0 +1,106 @@
"""Hugging Face model source."""
from __future__ import annotations
import logging
import re
from .base import (
ModelSource,
ModelSourceError,
fetch_json,
fetch_text,
filter_weight_files,
)
logger = logging.getLogger(__name__)
#: Lenient — used to normalise URLs already stored in metadata; tolerates
#: sub-paths such as ``/resolve/main/model.safetensors``.
_URL_PATTERN = re.compile(
r"https?://(?:www\.)?huggingface\.co/(?P<id>[^/?#\s]+/[^/?#\s]+)"
)
#: Strict — validates what the user pasted into the "link model" dialog.
_STRICT_URL_PATTERN = re.compile(
r"https?://(?:www\.)?huggingface\.co/(?P<id>[^/?#\s]+/[^/?#\s]+)/?$"
)
class HuggingFaceSource(ModelSource):
"""Hugging Face Hub (``huggingface.co``)."""
platform = "huggingface"
label = "Hugging Face"
supports_enrichment = True
supports_download = True
default_revision = "main"
default_subdir = "huggingface"
url_pattern = _URL_PATTERN
strict_url_pattern = _STRICT_URL_PATTERN
def canonical_url(self, source_id: str) -> str:
return f"https://huggingface.co/{source_id}"
def asset_base_url(self, source_id: str, revision: str = "") -> str:
return f"https://huggingface.co/{source_id}/resolve/{self.resolve_revision(revision)}"
async def fetch_model_card(self, source_id: str) -> str:
"""Fetch ``README.md`` from Hugging Face (tries ``main``, then ``master``)."""
for branch in ("main", "master"):
text = await fetch_text(
f"https://huggingface.co/{source_id}/raw/{branch}/README.md"
)
if text:
return text
return ""
async def list_files(
self, source_id: str, revision: str = ""
) -> list[dict]:
"""List weight files via the Hub tree API.
The tree endpoint (rather than the model-info endpoint) is used
because it reports accurate sizes for LFS-tracked files.
"""
revision = self.resolve_revision(revision)
status, payload = await fetch_json(
f"https://huggingface.co/api/models/{source_id}/tree/{revision}"
)
if status == 404:
raise ModelSourceError(f"Repository '{source_id}' not found", status=404)
if status != 200 or not isinstance(payload, list):
raise ModelSourceError(
f"Hugging Face API error while listing '{source_id}' (HTTP {status})"
)
entries = []
for entry in payload:
if not isinstance(entry, dict):
continue
path = entry.get("path", "")
size = entry.get("size", 0) or 0
if not size and isinstance(entry.get("lfs"), dict):
size = entry["lfs"].get("size", 0) or 0
entries.append((path, size))
return filter_weight_files(entries)
def file_download_url(
self, source_id: str, filename: str, revision: str = ""
) -> str:
return (
f"https://huggingface.co/{source_id}/resolve/"
f"{self.resolve_revision(revision)}/{filename}"
)
def page_url_for_file(self, source_id: str, filename: str) -> str:
return (
f"https://huggingface.co/{source_id}/blob/{self.default_revision}/{filename}"
)
__all__ = ["HuggingFaceSource"]
+235
View File
@@ -0,0 +1,235 @@
"""Deterministic metadata hydration for freshly downloaded source models.
A CivitAI download writes a fully-populated metadata sidecar as part of the
download itself: the name, the description, the tags, the trigger words and
the example images all arrive with the file. A download from an external
model source (ModelScope, Hugging Face) has the same information behind a
public API, but historically landed as a bare filename plus a source URL that
the user had to enrich by hand ("Enrich Metadata with AI").
This module closes that gap without involving an LLM. It fetches the linked
site's model card, hands it to the same :class:`~py.services.agent.post_processor.PostProcessor`
the AI skill uses, and writes the result. Everything it applies is data the
site published, so it is safe to run automatically on every download and to
treat as a fallback for the gaps the LLM would otherwise fill.
Nothing here may break a download: every failure is logged and normalised to
"the site had nothing to contribute".
"""
from __future__ import annotations
import logging
import os
import time
from typing import TYPE_CHECKING, Optional
from .base import ModelCardContext, ModelSourceCache
from .registry import get_source, resolve_source_ref
if TYPE_CHECKING: # pragma: no cover - typing only
from .base import ModelSource, SourceRef
logger = logging.getLogger(__name__)
#: How long a fetched repository payload stays usable. A download batch walks
#: a repository's files one HTTP request at a time, and the README plus the
#: detail payload describe the *repository*, not the file, so re-fetching them
#: per file would be pure waste. They expire so an edited model card is still
#: picked up by the next batch.
SHARED_CACHE_TTL = 300.0
#: Upper bound on memoised repositories; a long-running server must not grow
#: without limit.
SHARED_CACHE_MAX_ENTRIES = 32
#: ``"<platform>:<source_id>"`` → ``(expiry, memo)``.
_shared_caches: dict[str, tuple[float, ModelSourceCache]] = {}
def shared_source_cache(platform: str, source_id: str) -> ModelSourceCache:
"""Return a short-lived per-repository memo for download-time hydration."""
now = time.monotonic()
key = f"{platform}:{source_id}"
entry = _shared_caches.get(key)
if entry is not None and entry[0] > now:
return entry[1]
for expired in [k for k, (expiry, _) in _shared_caches.items() if expiry <= now]:
_shared_caches.pop(expired, None)
if len(_shared_caches) >= SHARED_CACHE_MAX_ENTRIES:
oldest = min(_shared_caches, key=lambda k: _shared_caches[k][0])
_shared_caches.pop(oldest, None)
cache = ModelSourceCache()
_shared_caches[key] = (now + SHARED_CACHE_TTL, cache)
return cache
def reset_shared_caches() -> None:
"""Drop every memoised repository — used by tests."""
_shared_caches.clear()
async def load_model_card(
source: "ModelSource",
source_id: str,
cache: Optional[ModelSourceCache] = None,
) -> str:
"""Return *source_id*'s README, reusing *cache* when one is supplied.
Only successful reads are memoised, leaving a transient failure to be
retried for the next file of the same repository.
"""
key = f"{source.platform}:{source_id}"
if cache is not None:
cached = cache.readmes.get(key)
if cached is not None:
return cached
readme = await source.fetch_model_card(source_id)
if cache is not None and readme:
cache.readmes[key] = readme
return readme or ""
async def resolve_site_base_model(context: ModelCardContext) -> str:
"""Resolve the site's base-model hints to a canonical name, or ``""``.
Sites name base models in their own vocabulary (ModelScope publishes both
``krea/Krea-2-Turbo`` and the ``KREA_2_TURBO`` enum). The resolver is
strict and only ever returns a name the canonical vocabulary already
contains, so an uncertain hint yields ``""`` rather than a plausible-looking
wrong value.
"""
hints = [*context.base_model_aliases, context.base_model]
if not any(hints):
return ""
# Imported lazily: pulling in the agent package at module scope would make
# the model-source package import itself while it is still initialising.
try:
from ...metadata_ops import list_base_models
from ..agent.base_model_resolver import resolve_base_model
known_names = await list_base_models()
except Exception as exc:
logger.warning("Could not resolve a site base model: %s", exc)
return ""
return resolve_base_model(hints, known_names)
async def hydrate_from_source(
file_path: str,
*,
ref: "SourceRef",
cache: Optional[ModelSourceCache] = None,
) -> list[str]:
"""Apply the linked site's published metadata to a downloaded model.
This is the deterministic counterpart of the ``enrich_hf_metadata`` skill:
it produces the same populated model card a CivitAI download produces,
without an LLM and without user action.
Args:
file_path: The just-downloaded model file, whose sidecar already
carries the SHA256 used to match the right file in a collection
repository.
ref: The source the file came from.
cache: Optional per-call memo; defaults to a short-lived shared one so
a batch over one repository fetches its card only once.
Returns:
The names of the metadata fields that changed. Never raises a site
that is down, or an API that changed shape, must not fail a download.
"""
try:
source = get_source(ref.platform)
if source is None or not source.supports_enrichment:
return []
from ...metadata_ops import read_metadata
metadata = await read_metadata(file_path)
if not metadata:
logger.debug("No metadata to hydrate for %s", file_path)
return []
# Only a model that is actually linked to this repository may be
# updated. The download path writes those fields just before calling
# us; a file that merely shares a name with the requested one must not
# be given another model's card.
linked = resolve_source_ref(metadata)
if linked is None or (linked.platform, linked.source_id) != (
ref.platform,
ref.source_id,
):
logger.debug(
"Not hydrating %s: linked to %s, not %s",
file_path, linked.url if linked else "no model source", ref.url,
)
return []
memo = cache if cache is not None else shared_source_cache(
ref.platform, ref.source_id
)
readme = await load_model_card(source, ref.source_id, memo)
context = await source.fetch_model_card_context(
ref.source_id,
os.path.basename(file_path),
sha256=(metadata.get("sha256") or "").strip(),
cache=memo,
)
if context.is_empty() and not readme:
logger.debug(
"No published metadata for %s on %s", ref.source_id, ref.platform
)
return []
resolved_base_model = await resolve_site_base_model(context)
from ..agent.post_processor import PostProcessor
result = await PostProcessor().process(
skill_name="enrich_hf_metadata",
model_path=file_path,
llm_output={},
metadata=metadata,
readme_content=readme,
source_context=context,
resolved_base_model=resolved_base_model,
metadata_source=f"source:{ref.platform}",
)
if not result.get("success", True):
logger.debug(
"Hydration reported failure for %s: %s",
file_path, result.get("errors"),
)
return []
updated = list(result.get("updated_fields") or [])
logger.info(
"Hydrated %s from %s (%s): %s",
file_path, source.label or ref.platform, ref.source_id,
", ".join(updated) or "nothing to change",
)
return updated
except Exception as exc: # pragma: no cover - defensive by design
logger.warning("Source hydration failed for %s: %s", file_path, exc)
return []
__all__ = [
"SHARED_CACHE_MAX_ENTRIES",
"SHARED_CACHE_TTL",
"hydrate_from_source",
"load_model_card",
"reset_shared_caches",
"resolve_site_base_model",
"shared_source_cache",
]
+644
View File
@@ -0,0 +1,644 @@
"""ModelScope (魔搭社区) model sources.
ModelScope exposes the same "model card as README.md" convention as
Hugging Face, including a YAML frontmatter block that often carries
``base_model:`` and ``trigger_words:``. Four public endpoints are used,
none of which requires an API key for public models:
* ``/models/{owner}/{name}/resolve/{revision}/README.md`` raw model card
* ``/api/v1/models/{owner}/{name}/repo?Revision=..&FilePath=README.md``
the same content through the API, used as a fallback when the resolve
URL is unavailable.
* ``/api/v1/models/{owner}/{name}`` the model-detail payload behind the
model page. It carries the repository's display name (``Name`` /
``ChineseName``), the author's summary (``Description``), the license, the
AIGC type, the site tags (``OfficialTags``, falling back to ``Tags``), and,
per published version, the model filenames
(``MuseInfo.versions[].stats.fileList``) together with that version's label
(``modelVersion.showName``), example images (``coverImages``) and trigger
words. See :meth:`ModelScopeSource.fetch_model_card_context`.
* ``/api/v1/models/{owner}/{name}/repo/files?Revision=..`` the file
listing backing the download picker. It reports real sizes for LFS
files (not the pointer size), so no extra HEAD request is needed.
Downloads go through ``/models/{owner}/{name}/resolve/{revision}/{path}``,
which redirects to a CDN URL carrying a time-limited ``auth_key``.
Requesting the resolve URL fresh on every attempt (which the shared
downloader does, including for resumable Range requests) keeps that key
valid; the CDN URL must never be cached.
The README and the detail payload both describe the whole repository rather
than one file, so a per-run ``ModelSourceCache`` keeps them from being read
again for every checkpoint of a collection repository.
Two deployments are served by this module. ``modelscope.cn`` (with
``modelscope.com`` as a redirect alias) and ``modelscope.ai`` are *separate
catalogues*, not mirrors, so they are registered as distinct sources:
:class:`ModelScopeSource` and :class:`ModelScopeIntlSource`. Every URL either
class builds is derived from its ``base_url``.
"""
from __future__ import annotations
import json
import logging
import os
import re
from typing import TYPE_CHECKING, Any, Iterable, Optional
from .base import (
ModelCardContext,
ModelSource,
ModelSourceError,
fetch_json,
fetch_text,
filter_weight_files,
)
if TYPE_CHECKING: # pragma: no cover - typing only
from .base import ModelSourceCache
logger = logging.getLogger(__name__)
#: ModelScope runs two independent catalogues. ``modelscope.com`` is a
#: redirect alias of the mainland site, but ``modelscope.ai`` is the
#: *international* deployment with its own repository catalogue — a repository
#: published on one is routinely absent from the other (``referall13/EM1``
#: exists only on ``.ai``, ``jj3550945163/Krea-2-LORA`` only on ``.cn``). The
#: host therefore decides which site, API and CDN a model belongs to, and the
#: two deployments are registered as separate sources rather than folded into
#: one id.
_MAINLAND_HOSTS = r"modelscope\.(?:cn|com)"
_INTERNATIONAL_HOSTS = r"modelscope\.ai"
#: Trailing view segments the site appends to a model URL; accepted verbatim
#: when the user pastes a browser tab URL.
_VIEW_SEGMENTS = r"(?:summary|files|model-file|readme|community|evaluation)?"
def _url_patterns(hosts: str) -> tuple[re.Pattern[str], re.Pattern[str]]:
"""Build the lenient and strict model-URL patterns for *hosts*."""
body = rf"https?://(?:www\.)?(?:{hosts})/models/(?P<id>[^/?#\s]+/[^/?#\s]+)"
return re.compile(body), re.compile(rf"{body}/?{_VIEW_SEGMENTS}/?$")
#: ``master`` is ModelScope's default branch; ``main`` is tried as a fallback
#: for repos imported from Hugging Face.
_REVISIONS = ("master", "main")
class ModelScopeSource(ModelSource):
"""ModelScope's mainland site (``modelscope.cn``).
``modelscope.com`` is accepted as an alias of it. The international
deployment is :class:`ModelScopeIntlSource`; everything below is written in
terms of ``base_url`` so both share one implementation.
"""
platform = "modelscope"
label = "ModelScope"
supports_enrichment = True
supports_download = True
default_revision = "master"
default_subdir = "modelscope"
#: Origin every outgoing URL is built from.
base_url = "https://modelscope.cn"
url_pattern, strict_url_pattern = _url_patterns(_MAINLAND_HOSTS)
def canonical_url(self, source_id: str) -> str:
return f"{self.base_url}/models/{source_id}"
def asset_base_url(self, source_id: str, revision: str = "") -> str:
return (
f"{self.base_url}/models/{source_id}/resolve/"
f"{self.resolve_revision(revision)}"
)
async def fetch_model_card(self, source_id: str) -> str:
"""Fetch the model card, preferring the raw resolve URL."""
for revision in _REVISIONS:
text = await fetch_text(
f"{self.base_url}/models/{source_id}/resolve/{revision}/README.md"
)
if text:
return text
# Fallback: the repo API proxies the same file and is reachable in
# environments where the CDN resolve host is blocked.
for revision in _REVISIONS:
text = await fetch_text(
f"{self.base_url}/api/v1/models/"
f"{source_id}/repo?Revision={revision}&FilePath=README.md"
)
if text:
return text
return ""
async def fetch_model_card_context(
self,
source_id: str,
filename: str = "",
*,
sha256: str = "",
cache: Optional["ModelSourceCache"] = None,
) -> ModelCardContext:
"""Read the model-detail API that backs the ModelScope model page.
ModelScope splits a model card in two: ``README.md`` holds the
long-form content, while the author's summary, the site-curated tags,
and the per-file example images live only here. AIGC repositories
frequently ship an auto-generated README ("the contributor provided
no further description") and put everything useful in ``Description``,
so enrichment that reads only the README comes back nearly empty.
The wanted file is identified by its sha256 when the caller knows it
and by *filename* otherwise; see :func:`_matching_versions`. The
images and trigger words returned belong to that exact
``.safetensors`` essential for collection repositories, where every
checkpoint has its own sample image.
The detail payload describes the whole repository and is therefore
shared across every file in it, so it is read through *cache* when the
caller supplies one; only the per-file selection is redone.
"""
data = await self._fetch_detail(source_id, cache=cache)
if data is None:
return ModelCardContext()
return _build_card_context(data, filename, sha256)
async def _fetch_detail(
self,
source_id: str,
*,
cache: Optional["ModelSourceCache"] = None,
) -> Optional[dict[str, Any]]:
"""Fetch (or reuse) the model-detail payload for *source_id*."""
cache_key = (self.platform, "detail", source_id)
if cache is not None and cache_key in cache.provider:
return cache.provider[cache_key]
status, payload = await fetch_json(
f"{self.base_url}/api/v1/models/{source_id}"
)
if status != 200 or not isinstance(payload, dict):
logger.debug(
"ModelScope detail API returned HTTP %s for %s", status, source_id
)
return None
data = payload.get("Data")
if not isinstance(data, dict):
return None
if cache is not None:
cache.provider[cache_key] = data
return data
async def list_files(
self, source_id: str, revision: str = ""
) -> list[dict]:
"""List weight files via the repo files API.
``master`` is the only branch name the API accepts even repos
imported from Hugging Face are addressed as ``master`` (``main``
returns 404) so no fallback probing is done here.
"""
revision = self.resolve_revision(revision)
status, payload = await fetch_json(
f"{self.base_url}/api/v1/models/"
f"{source_id}/repo/files?Revision={revision}"
)
if status == 404:
raise ModelSourceError(f"Repository '{source_id}' not found", status=404)
if status != 200 or not isinstance(payload, dict):
raise ModelSourceError(
f"ModelScope API error while listing '{source_id}' (HTTP {status})"
)
entries = []
for entry in (payload.get("Data") or {}).get("Files") or []:
if not isinstance(entry, dict) or entry.get("Type") != "blob":
continue
entries.append((entry.get("Path", ""), entry.get("Size", 0) or 0))
return filter_weight_files(entries)
def file_download_url(
self, source_id: str, filename: str, revision: str = ""
) -> str:
return (
f"{self.base_url}/models/{source_id}/resolve/"
f"{self.resolve_revision(revision)}/{filename}"
)
def page_url_for_file(self, source_id: str, filename: str) -> str:
return (
f"{self.base_url}/models/{source_id}/file/view/"
f"{self.default_revision}/{filename}"
)
class ModelScopeIntlSource(ModelScopeSource):
"""ModelScope's international site (``modelscope.ai``).
A separate catalogue rather than a mirror, so it is registered under its
own platform id: the two deployments must not share a version group, a
"use default paths" directory, or a stored ``source_url``. The detail API,
the file listing, the resolve URLs and the CDN redirect all behave exactly
like the mainland site, which is why every URL here is derived from
:attr:`base_url` instead of being duplicated.
"""
platform = "modelscope-ai"
label = "ModelScope (International)"
default_subdir = "modelscope-ai"
base_url = "https://www.modelscope.ai"
url_pattern, strict_url_pattern = _url_patterns(_INTERNATIONAL_HOSTS)
__all__ = ["ModelScopeIntlSource", "ModelScopeSource"]
# ---------------------------------------------------------------------------
# Model-detail API parsing helpers
# ---------------------------------------------------------------------------
#: Trigger-word values that mean "the author left this blank".
_EMPTY_TRIGGER_VALUES = frozenset({"none", "null", "n/a"})
#: Repository tags that only restate what the model *is* (its library, task or
#: framework) rather than what it depicts. ModelScope mixes both into the
#: plain ``Tags`` list, and a card tagged "lora" or "text-to-image" is noise.
_GENERIC_TAGS = frozenset(
{
"any-to-any",
"checkpoint",
"controlnet",
"diffusers",
"embedding",
"image-text-to-text",
"image-to-image",
"image-to-video",
"lora",
"lycoris",
"onnx",
"pytorch",
"safetensors",
"tensorflow",
"text-to-image",
"text-to-speech",
"text-to-video",
"textual-inversion",
"vae",
}
)
def _clean_text(value: Any) -> str:
"""Return a stripped string for *value*, or ``""`` for anything else."""
return value.strip() if isinstance(value, str) else ""
def _first_string(value: Any) -> str:
"""Return the first non-empty string in a list, or ``""``."""
if isinstance(value, list):
for item in value:
text = _clean_text(item)
if text:
return text
return ""
def _build_card_context(
data: dict[str, Any], filename: str, sha256: str = ""
) -> ModelCardContext:
"""Turn a model-detail payload into a :class:`ModelCardContext`.
Separated from the HTTP fetch so the repository-wide payload can be cached
across the files of a collection repository while the per-file selection
is still redone for each one.
"""
context = ModelCardContext(
description=_clean_text(data.get("Description")),
model_name=_clean_text(data.get("Name")),
model_name_localized=_clean_text(data.get("ChineseName")),
license=_clean_text(data.get("License")),
model_type=_clean_text(data.get("AigcType")),
base_model=_first_string(data.get("BaseModel")),
base_model_aliases=_base_model_aliases(data),
official_tags=_official_tags(data),
)
versions = _matching_versions(
data.get("MuseInfo"),
filename,
digests=_file_digests(data),
sha256=sha256,
)
if versions:
context.version_name = _version_label(versions)
context.example_images = _cover_image_urls(versions)
context.trigger_words = _version_trigger_words(versions)
return context
def _base_model_aliases(data: dict[str, Any]) -> list[str]:
"""Return the site's own names for the base model.
ModelScope publishes a link-style id (``krea/Krea-2-Turbo``) plus its
internal architecture enums (``VisionFoundation: KREA_2``,
``SubVisionFoundation: KREA_2_TURBO``). The enums are the better
resolution hint because they normalise onto this system's canonical
vocabulary, so they come first; the owner prefix is also stripped from
the link-style ids.
"""
aliases: list[str] = []
for key in ("VisionFoundation", "SubVisionFoundation"):
value = _clean_text(data.get(key))
if value and value not in aliases:
aliases.append(value)
base_models = data.get("BaseModel")
if isinstance(base_models, list):
for item in base_models:
text = _clean_text(item)
leaf = text.rsplit("/", 1)[-1] if text else ""
if leaf and leaf not in aliases:
aliases.append(leaf)
return aliases
def _official_tags(data: dict[str, Any]) -> list[str]:
"""Return the content tags the site publishes for the repository.
``OfficialTags`` is ModelScope's curated content vocabulary and is
preferred whenever it is populated. Plenty of AIGC repositories leave it
empty and carry only the plain ``Tags`` list, which mixes content tags with
framework and task categories; those categories are dropped so a card is
not handed "lora" and "text-to-image" as if they described the model.
"""
curated = _dedupe(_tag_values(data.get("OfficialTags")))
if curated:
return curated
generic = set(_GENERIC_TAGS)
for value in (
data.get("AigcType"),
data.get("Libraries"),
data.get("Frameworks"),
):
for item in value if isinstance(value, list) else [value]:
text = _clean_text(item).lower()
if text:
generic.add(text)
return _dedupe(
tag for tag in _tag_values(data.get("Tags")) if tag.lower() not in generic
)
def _tag_values(value: Any) -> list[str]:
"""Return the tag strings from either shape ModelScope publishes.
``OfficialTags`` is a list of ``{"Tag": ..., "ChineseName": ...}`` dicts
carrying an English value; the plain ``Tags`` list is already strings.
"""
if not isinstance(value, list):
return []
tags: list[str] = []
for entry in value:
tag = _clean_text(entry.get("Tag") if isinstance(entry, dict) else entry)
if tag:
tags.append(tag)
return tags
def _dedupe(values: Iterable[str]) -> list[str]:
"""Drop empties and repeats, keeping the first spelling seen."""
unique: list[str] = []
for value in values:
if value and value not in unique:
unique.append(value)
return unique
def _version_files(version: dict[str, Any]) -> list[str]:
"""Return the model filenames covered by one ``MuseInfo.versions`` entry.
The listing normally sits in ``stats.fileList``; some payloads only
carry the same field as a JSON-encoded string under
``modelVersion.stats``, so both shapes are accepted.
"""
stats = version.get("stats")
files = stats.get("fileList") if isinstance(stats, dict) else None
if not isinstance(files, list):
model_version = version.get("modelVersion")
raw = model_version.get("stats") if isinstance(model_version, dict) else None
if isinstance(raw, str) and raw.strip():
try:
decoded = json.loads(raw)
except (json.JSONDecodeError, TypeError):
decoded = None
if isinstance(decoded, dict):
files = decoded.get("fileList")
if not isinstance(files, list):
return []
return [item for item in files if isinstance(item, str) and item]
def _version_show_name(version: dict[str, Any]) -> str:
"""Return the human-facing version label (e.g. ``c1-st1000``)."""
model_version = version.get("modelVersion")
if not isinstance(model_version, dict):
return ""
return _clean_text(model_version.get("showName")).lower()
def _version_label(versions: list[dict[str, Any]]) -> str:
"""Return the first published version label, preserving its spelling.
Unlike :func:`_version_show_name` this is for display, so the label is
not lowercased.
"""
for version in versions:
model_version = version.get("modelVersion")
if not isinstance(model_version, dict):
continue
label = _clean_text(model_version.get("showName"))
if label:
return label
return ""
def _file_digests(data: dict[str, Any]) -> dict[str, str]:
"""Return ``basename -> sha256`` for every published weight file.
``ModelInfos`` groups the repository's files by kind (``safetensor``,
) and records a real sha256 for each, which is what makes it possible to
recognise a file the user has renamed.
"""
digests: dict[str, str] = {}
model_infos = data.get("ModelInfos")
if not isinstance(model_infos, dict):
return digests
for info in model_infos.values():
files = info.get("files") if isinstance(info, dict) else None
if not isinstance(files, list):
continue
for entry in files:
if not isinstance(entry, dict):
continue
name = _clean_text(entry.get("name"))
digest = _clean_text(entry.get("sha256"))
if name and digest:
digests.setdefault(os.path.basename(name).lower(), digest.lower())
return digests
def _matching_versions(
muse_info: Any,
filename: str,
*,
digests: dict[str, str] | None = None,
sha256: str = "",
) -> list[dict[str, Any]]:
"""Return the ``versions`` entries that publish the wanted model file.
Strategies, in order:
1. **sha256** the file's content hash, looked up through
:func:`_file_digests`. This is the only strategy that survives the
user renaming the weights, which is common once a model is filed away.
2. **Exact basename** against each version's ``stats.fileList``.
3. **``showName`` inside the file stem**, which absorbs the naming drift
ModelScope sometimes applies to uploaded weights.
A known-but-unmatched hash falls through to the filename strategies
rather than giving up, in case the local file was re-encoded. All matches
are returned so a file re-published across several versions contributes
all of its example images. With no *filename* and no *sha256*, only an
unambiguous single-version repository is used, because a per-file image
must never be attributed to the wrong file.
"""
if not isinstance(muse_info, dict):
return []
versions = muse_info.get("versions")
if not isinstance(versions, list):
return []
entries = [entry for entry in versions if isinstance(entry, dict)]
if not entries:
return []
target_hash = (sha256 or "").strip().lower()
if target_hash:
known = digests or {}
by_hash: list[dict[str, Any]] = []
for version in entries:
for path in _version_files(version):
if known.get(os.path.basename(path).lower()) == target_hash:
by_hash.append(version)
break
if by_hash:
return by_hash
if not filename:
return entries if len(entries) == 1 else []
target = os.path.basename(filename).strip().lower()
if not target:
return []
stem = os.path.splitext(target)[0]
exact: list[dict[str, Any]] = []
fuzzy: list[dict[str, Any]] = []
for version in entries:
files = {os.path.basename(path).lower() for path in _version_files(version)}
if target in files:
exact.append(version)
continue
show_name = _version_show_name(version)
if show_name and show_name in stem:
fuzzy.append(version)
return exact or fuzzy
def _cover_image_urls(versions: list[dict[str, Any]]) -> list[str]:
"""Collect the example-image URLs published by the given versions."""
urls: list[str] = []
for version in versions:
covers = version.get("coverImages")
if not isinstance(covers, list):
continue
for cover in covers:
if not isinstance(cover, dict):
continue
url = _clean_text(cover.get("url"))
if url and url not in urls:
urls.append(url)
return urls
def _version_trigger_words(versions: list[dict[str, Any]]) -> list[str]:
"""Return the first non-empty trigger-word list across *versions*."""
for version in versions:
model_version = version.get("modelVersion")
raw = (
model_version.get("triggerWords")
if isinstance(model_version, dict)
else None
)
words = _parse_trigger_words(raw)
if words:
return words
return []
def _parse_trigger_words(raw: Any) -> list[str]:
"""Decode ModelScope's JSON-encoded trigger-word string list."""
if isinstance(raw, list):
candidates = raw
elif isinstance(raw, str) and raw.strip():
try:
decoded = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return []
if not isinstance(decoded, list):
return []
candidates = decoded
else:
return []
words: list[str] = []
for item in candidates:
word = _clean_text(item)
if not word or word.lower() in _EMPTY_TRIGGER_VALUES:
continue
if word not in words:
words.append(word)
return words
+228
View File
@@ -0,0 +1,228 @@
"""Registry and metadata helpers for external model sources.
The registry is the single place the rest of the codebase asks "which site
is this URL from?", "what is this model's source?", and "can we enrich it?".
Import from :mod:`py.services.model_sources` rather than this module
directly.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Mapping, Optional
from .base import GROUP_PREFIXES, ModelSource, SourceRef, clean_source_url
from .huggingface import HuggingFaceSource
from .modelscope import ModelScopeIntlSource, ModelScopeSource
from .tensorart import TensorArtSource
logger = logging.getLogger(__name__)
#: Order matters only for disambiguation; the URL patterns are disjoint.
#: ``modelscope.ai`` is a separate catalogue from ``modelscope.cn`` rather than
#: an alias, which is why it gets its own entry (see ``modelscope.py``).
_SOURCES: tuple[ModelSource, ...] = (
HuggingFaceSource(),
ModelScopeSource(),
ModelScopeIntlSource(),
TensorArtSource(),
)
_BY_PLATFORM: Dict[str, ModelSource] = {s.platform: s for s in _SOURCES}
#: Metadata keys that carry the canonical external-source identity.
SOURCE_PLATFORM_FIELD = "source_platform"
SOURCE_URL_FIELD = "source_url"
#: Legacy field kept as a read/write alias for Hugging Face models so that
#: older sidecars, cached rows, and third-party consumers keep working.
LEGACY_HF_URL_FIELD = "hf_url"
def list_sources() -> list[ModelSource]:
"""Return every known model source."""
return list(_SOURCES)
def get_source(platform: Optional[str]) -> Optional[ModelSource]:
"""Return the source registered for *platform*, or ``None``."""
if not platform or not isinstance(platform, str):
return None
return _BY_PLATFORM.get(platform.strip().lower())
def source_label(platform: Optional[str], default: str = "") -> str:
"""Return the human-readable label for *platform*."""
source = get_source(platform)
return source.label if source else default
def downloadable_sources() -> list[ModelSource]:
"""Return the sources whose repositories can be downloaded directly."""
return [source for source in _SOURCES if source.supports_download]
def get_download_source(platform: Optional[str]) -> Optional[ModelSource]:
"""Return the source for *platform*, but only when it supports downloads."""
source = get_source(platform)
if source is None or not source.supports_download:
return None
return source
def detect_source(url: Optional[str], *, strict: bool = False) -> Optional[SourceRef]:
"""Return the :class:`SourceRef` for *url*, or ``None`` if unsupported."""
if not url or not isinstance(url, str):
return None
for source in _SOURCES:
ref = source.ref(url, strict=strict)
if ref is not None:
return ref
return None
def resolve_source_ref(metadata: Mapping[str, Any]) -> Optional[SourceRef]:
"""Return the source reference described by a model's metadata.
Handles all three storage states found in the wild:
1. ``source_url`` + ``source_platform`` (current format)
2. ``hf_url`` only (legacy Hugging Face storage)
3. ``hf_url`` plus a newer ``source_url`` (both written by older builds)
"""
if not isinstance(metadata, Mapping):
return None
platform = clean_source_url(metadata.get(SOURCE_PLATFORM_FIELD)).lower()
url = clean_source_url(metadata.get(SOURCE_URL_FIELD))
legacy = clean_source_url(metadata.get(LEGACY_HF_URL_FIELD))
source = get_source(platform)
if url:
if source is not None:
ref = source.ref(url)
if ref is not None:
return ref
ref = detect_source(url)
if ref is not None:
return ref
# Unknown platform but a URL is present: keep it addressable.
return SourceRef(platform=platform or "unknown", source_id="", url=url)
if legacy:
return detect_source(legacy)
return None
def normalize_metadata_source(metadata: Dict[str, Any]) -> Dict[str, Any]:
"""Normalise the external-source fields on *metadata* in place.
Guarantees that ``source_url``/``source_platform`` are present and
consistent, and that ``hf_url`` mirrors ``source_url`` for Hugging Face
models (never for other platforms, so a stale alias can't make a
ModelScope model look like a Hugging Face one).
Returns the same dict for convenient chaining.
"""
if not isinstance(metadata, dict):
return metadata
platform = clean_source_url(metadata.get(SOURCE_PLATFORM_FIELD)).lower()
url = clean_source_url(metadata.get(SOURCE_URL_FIELD))
legacy = clean_source_url(metadata.get(LEGACY_HF_URL_FIELD))
source = get_source(platform)
ref: Optional[SourceRef] = None
if url:
ref = source.ref(url) if source is not None else None
if ref is None:
ref = detect_source(url)
elif legacy:
ref = detect_source(legacy)
if ref is not None and ref.source_id:
platform = ref.platform
url = ref.url or url
if platform:
metadata[SOURCE_PLATFORM_FIELD] = platform
else:
metadata.setdefault(SOURCE_PLATFORM_FIELD, "")
metadata[SOURCE_URL_FIELD] = url
# Keep the legacy alias in sync, but only for Hugging Face.
if url and platform == "huggingface":
metadata[LEGACY_HF_URL_FIELD] = url
elif LEGACY_HF_URL_FIELD in metadata and platform and platform != "huggingface":
metadata[LEGACY_HF_URL_FIELD] = ""
elif legacy and not url:
metadata[LEGACY_HF_URL_FIELD] = legacy
return metadata
def has_external_source(item: Mapping[str, Any]) -> bool:
"""Return ``True`` when *item* is linked to any external model site."""
if not isinstance(item, Mapping):
return False
return bool(
clean_source_url(item.get(SOURCE_URL_FIELD))
or clean_source_url(item.get(LEGACY_HF_URL_FIELD))
)
def get_source_platform(item: Mapping[str, Any]) -> str:
"""Return the platform id stored on *item* (may be empty)."""
if not isinstance(item, Mapping):
return ""
platform = clean_source_url(item.get(SOURCE_PLATFORM_FIELD)).lower()
if platform:
return platform
ref = resolve_source_ref(item)
return ref.platform if ref else ""
def source_group_key(item: Mapping[str, Any]) -> Optional[str]:
"""Return the version-group key for *item*, or ``None``.
Hugging Face keeps the historical ``hf:{owner}/{repo}`` shape; other
platforms use their own short prefix (see :data:`GROUP_PREFIXES`).
"""
ref = resolve_source_ref(item)
if ref is None or not ref.source_id:
return None
source = get_source(ref.platform)
if source is None:
return None
return source.group_key(ref.source_id)
__all__ = [
"GROUP_PREFIXES",
"LEGACY_HF_URL_FIELD",
"SOURCE_PLATFORM_FIELD",
"SOURCE_URL_FIELD",
"detect_source",
"downloadable_sources",
"get_download_source",
"get_source",
"get_source_platform",
"has_external_source",
"list_sources",
"normalize_metadata_source",
"resolve_source_ref",
"source_group_key",
"source_label",
]
+56
View File
@@ -0,0 +1,56 @@
"""TensorArt model source (link / provenance only).
TensorArt support is intentionally limited to *linking* a model to its
TensorArt page. Automatic metadata extraction is not possible without a
user session:
* ``tensor.art`` sits behind a Cloudflare managed challenge, so plain
HTTP clients (aiohttp, requests, curl) receive ``403 "Just a moment..."``.
* Its internal API (``ap-east-1.tensorart.cloud`` / ``cn.tensorart.net``)
answers every ``/v1/model/*`` route with
``{"code":100002,"message":"invalid authorization header"}``.
* The official TAMS API requires an AccessKey/SecretKey pair and request
signatures, which is a poor fit for a "paste a URL" workflow.
``supports_enrichment`` is therefore ``False``: the agent pipeline skips
these models with an explicit reason instead of failing silently, and the
UI keeps showing the "View on TensorArt" link. ``tusi.cn`` is TensorArt's
Chinese mirror and is accepted as the same platform.
"""
from __future__ import annotations
import re
from .base import ModelSource
_DOMAINS = r"(?:tensor\.art|tusi\.cn)"
_URL_PATTERN = re.compile(
rf"https?://(?:www\.)?{_DOMAINS}/models/(?P<id>\d+)"
)
_STRICT_URL_PATTERN = re.compile(
rf"https?://(?:www\.)?{_DOMAINS}/models/(?P<id>\d+)(?:/[^/?#\s]+)?/?$"
)
class TensorArtSource(ModelSource):
"""TensorArt (``tensor.art``)."""
platform = "tensorart"
label = "TensorArt"
supports_enrichment = False
supports_download = False
url_pattern = _URL_PATTERN
strict_url_pattern = _STRICT_URL_PATTERN
def canonical_url(self, source_id: str) -> str:
return f"https://tensor.art/models/{source_id}"
def asset_base_url(self, source_id: str, revision: str = "") -> str:
# Unreachable today: enrichment is disabled for this platform.
return f"https://tensor.art/models/{source_id}"
__all__ = ["TensorArtSource"]
+2
View File
@@ -67,6 +67,8 @@ class OtherModelService(BaseModelService):
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
"version_count": model_data.get("version_count"),
"source_platform": model_data.get("source_platform", ""),
"source_url": model_data.get("source_url", ""),
"hf_url": model_data.get("hf_url", ""),
}
+17
View File
@@ -7,6 +7,7 @@ from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
from .model_sources import normalize_metadata_source
logger = logging.getLogger(__name__)
@@ -62,6 +63,8 @@ class PersistentModelCache:
"db_checked",
"last_checked_at",
"hash_status",
"source_platform",
"source_url",
"hf_url",
)
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
@@ -206,8 +209,13 @@ class PersistentModelCache:
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
"license_flags": int(license_value),
"hash_status": row["hash_status"] or "completed",
"source_platform": row["source_platform"] or "",
"source_url": row["source_url"] or "",
"hf_url": row["hf_url"] or "",
}
# Legacy rows only carry `hf_url`; derive the canonical pair so
# every consumer sees the same shape.
normalize_metadata_source(item)
if row["autov3"] is not None:
item["autov3"] = (row["autov3"] or "").lower()
raw_data.append(item)
@@ -562,6 +570,8 @@ class PersistentModelCache:
db_checked INTEGER,
last_checked_at REAL,
hash_status TEXT,
source_platform TEXT DEFAULT '',
source_url TEXT DEFAULT '',
hf_url TEXT DEFAULT '',
PRIMARY KEY (model_type, file_path)
);
@@ -629,6 +639,8 @@ class PersistentModelCache:
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
"hash_status": "TEXT DEFAULT 'completed'",
"source_platform": "TEXT DEFAULT ''",
"source_url": "TEXT DEFAULT ''",
"hf_url": "TEXT DEFAULT ''",
"autov3": "TEXT",
}
@@ -650,6 +662,9 @@ class PersistentModelCache:
return conn
def _prepare_model_row(self, model_type: str, item: Dict[str, Any]) -> Tuple[Any, ...]:
# Keep `source_*` and the legacy `hf_url` alias consistent no matter
# which caller populated the item.
normalize_metadata_source(item)
civitai = item.get("civitai") or {}
trained_words = civitai.get("trainedWords")
if isinstance(trained_words, str):
@@ -713,6 +728,8 @@ class PersistentModelCache:
1 if item.get("db_checked") else 0,
float(item.get("last_checked_at") or 0.0),
item.get("hash_status", "completed"),
item.get("source_platform") or "",
item.get("source_url") or "",
item.get("hf_url") or "",
)
@@ -7,6 +7,7 @@ import time
from typing import Any, Dict, List, Optional, Protocol, Sequence
from ..metadata_sync_service import MetadataSyncService
from ..model_sources import has_external_source
from ...utils.metadata_manager import MetadataManager
@@ -51,10 +52,11 @@ class BulkMetadataRefreshUseCase:
if not model.get("skip_metadata_refresh", False)
and not self._is_in_skip_path(model.get("folder", ""), skip_paths)
and (not model.get("civitai") or not model["civitai"].get("id"))
# Skip models downloaded from Hugging Face — they are not on
# CivitAI / CivArchive. Users can still refresh them individually
# via the right-click context menu.
and not model.get("hf_url", "")
# Skip models linked to an external model site (Hugging Face /
# ModelScope / TensorArt) — they are not on CivitAI / CivArchive.
# Users can still refresh them individually via the right-click
# context menu.
and not has_external_source(model)
and not (
# Skip models confirmed not on CivitAI when no need to retry
model.get("from_civitai") is False
+7
View File
@@ -170,6 +170,13 @@ class WebSocketManager:
progress_entry['status'] = data['status']
if 'message' in data:
progress_entry['message'] = data['message']
# Post-transfer stage reporting (see `model_source_handlers._report_phase`):
# the byte counter has stopped by then, so the stage is the only thing
# that still says the download is working.
if 'stage' in data:
progress_entry['stage'] = data['stage']
if 'platform' in data:
progress_entry['platform'] = data['platform']
self._download_progress[download_id] = progress_entry
+35 -9
View File
@@ -2,7 +2,11 @@ from dataclasses import dataclass, asdict, field
from typing import Callable, Dict, Optional, List, Any
from datetime import datetime
import os
from .constants import CIVITAI_TYPE_TO_OTHER_SUB_TYPE, INVALID_AUTOV3_EMPTY_HASH
from .constants import (
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
INVALID_AUTOV3_EMPTY_HASH,
MODEL_FILE_EXTENSIONS,
)
from .model_utils import determine_base_model
@@ -46,6 +50,24 @@ def autov3_from_civitai_files(civitai_data: Optional[Dict[str, Any]], sha256: st
return None
def strip_model_extension(file_name: str) -> str:
"""Strip a recognized model file extension, leaving dotted stems intact.
``os.path.splitext`` treats everything after the last dot as an extension,
so applying it to an already extension-free name truncates dotted stems:
``lora-sd1.5-backlight_slider_v10`` becomes ``lora-sd1``. API filenames keep
their extension and need one strip, while migration paths (``.civitai.info``)
pass the local stem as-is, so only remove a suffix that is a known model
extension and both inputs resolve to the same stem (issue #1112).
"""
if not file_name:
return file_name
stem, extension = os.path.splitext(file_name)
if extension.lower() in MODEL_FILE_EXTENSIONS:
return stem
return file_name
@dataclass
class BaseModelMetadata:
"""Base class for all model metadata structures"""
@@ -241,6 +263,7 @@ class LoraMetadata(BaseModelMetadata):
) -> "LoraMetadata":
"""Create LoraMetadata instance from Civitai version info"""
file_name = file_info.get("name", "")
base_name = strip_model_extension(file_name)
base_model = determine_base_model(version_info.get("baseModel", ""))
# Extract tags and description if available
@@ -255,8 +278,8 @@ class LoraMetadata(BaseModelMetadata):
sha256_value = (file_info.get("hashes") or {}).get("SHA256", "").lower()
return cls(
file_name=os.path.splitext(file_name)[0],
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_name=base_name,
model_name=model_data.get("name", base_name),
file_path=save_path.replace(os.sep, "/"),
size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(),
@@ -285,6 +308,7 @@ class CheckpointMetadata(BaseModelMetadata):
) -> "CheckpointMetadata":
"""Create CheckpointMetadata instance from Civitai version info"""
file_name = file_info.get("name", "")
base_name = strip_model_extension(file_name)
base_model = determine_base_model(version_info.get("baseModel", ""))
sha256_value = (file_info.get("hashes") or {}).get("SHA256", "").lower()
sub_type = version_info.get("type", "checkpoint")
@@ -299,8 +323,8 @@ class CheckpointMetadata(BaseModelMetadata):
description = model_data["description"]
return cls(
file_name=os.path.splitext(file_name)[0],
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_name=base_name,
model_name=model_data.get("name", base_name),
file_path=save_path.replace(os.sep, "/"),
size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(),
@@ -336,6 +360,7 @@ class OtherModelMetadata(BaseModelMetadata):
) -> "OtherModelMetadata":
"""Create OtherModelMetadata instance from Civitai version info"""
file_name = file_info.get("name", "")
base_name = strip_model_extension(file_name)
base_model = determine_base_model(version_info.get("baseModel", ""))
sha256_value = (file_info.get("hashes") or {}).get("SHA256", "").lower()
# Map the CivitAI model type onto our sub_types; unknown types keep the
@@ -354,8 +379,8 @@ class OtherModelMetadata(BaseModelMetadata):
description = model_data["description"]
return cls(
file_name=os.path.splitext(file_name)[0],
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_name=base_name,
model_name=model_data.get("name", base_name),
file_path=save_path.replace(os.sep, "/"),
size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(),
@@ -385,6 +410,7 @@ class EmbeddingMetadata(BaseModelMetadata):
) -> "EmbeddingMetadata":
"""Create EmbeddingMetadata instance from Civitai version info"""
file_name = file_info.get("name", "")
base_name = strip_model_extension(file_name)
base_model = determine_base_model(version_info.get("baseModel", ""))
sha256_value = (file_info.get("hashes") or {}).get("SHA256", "").lower()
sub_type = version_info.get("type", "embedding")
@@ -399,8 +425,8 @@ class EmbeddingMetadata(BaseModelMetadata):
description = model_data["description"]
return cls(
file_name=os.path.splitext(file_name)[0],
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_name=base_name,
model_name=model_data.get("name", base_name),
file_path=save_path.replace(os.sep, "/"),
size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(),
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.2.2"
version = "1.2.3"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",
@@ -31,6 +31,10 @@
/* Textarea Styling */
#batchUrlInput {
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;
padding: 12px;
border: 1px solid var(--border-color);
+30
View File
@@ -97,6 +97,32 @@
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 {
font-size: 0.8rem;
color: var(--text-color-secondary, var(--text-color));
@@ -131,4 +157,8 @@
.current-item-bar {
transition: none;
}
.current-item-bar.is-indeterminate::after {
animation: none;
}
}
+28 -1
View File
@@ -46,8 +46,20 @@
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 {
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 {
@@ -55,6 +67,21 @@
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 */
.context-menu-section-header {
padding: 6px 12px 2px;
@@ -12,6 +12,10 @@
.input-group input,
.input-group select {
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;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
@@ -720,6 +724,9 @@
/* Textarea for multi-URL input */
#modelUrl {
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;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
@@ -750,6 +757,42 @@
#downloadModal .modal-content {
display: flex;
flex-direction: column;
overflow: hidden; /* The active step scrolls instead of the whole modal */
}
/* Sticky footer layout (mirrors the import modal fix): fixed header,
scrollable step content, pinned action buttons. Ensures Back/Download
buttons stay visible on short viewports (e.g. 1080p or 150% zoom). */
#downloadModal .modal-header {
flex-shrink: 0;
}
#downloadModal .download-step {
flex: 1 1 auto;
min-height: 0; /* Allow the step to shrink and scroll within the flex container */
overflow-y: auto;
overflow-x: hidden;
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 {
position: sticky;
bottom: 0;
z-index: 1;
background: var(--lora-surface);
border-top: 1px solid var(--lora-border);
padding-top: var(--space-2);
padding-bottom: var(--space-1);
}
#batchPreviewStep {
@@ -747,13 +747,13 @@
}
.priority-tags-input.settings-input-error {
border-color: var(--danger-color, #dc2626);
box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12);
border-color: var(--lora-error);
box-shadow: 0 0 0 2px rgba(from var(--lora-error) r g b / 0.12);
}
.settings-input-error-message {
font-size: 0.8em;
color: var(--danger-color, #dc2626);
color: var(--lora-error);
display: none;
}
+32
View File
@@ -28,6 +28,38 @@
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 {
margin: 0 0 var(--space-1);
padding: var(--space-1);
+76 -10
View File
@@ -92,38 +92,104 @@
border-radius: var(--border-radius-xs);
padding: 4px 8px;
position: relative;
cursor: grab;
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;
}
.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);
cursor: grabbing;
opacity: 0.95;
transition: none;
}
.metadata-item-placeholder {
/* Drop target left behind by the lifted chip */
.reorder-placeholder {
border: 1px dashed var(--lora-accent);
border-radius: var(--border-radius-xs);
background: rgba(255, 255, 255, 0.1);
pointer-events: none;
}
.metadata-items-sorting .metadata-item {
transition: transform 0.18s ease;
}
body.metadata-drag-active {
body.reorder-drag-active {
user-select: none;
cursor: grabbing;
}
body.metadata-drag-active * {
body.reorder-drag-active * {
cursor: grabbing !important;
}
+33 -117
View File
@@ -228,6 +228,18 @@
border-left-color: var(--lora-accent);
}
/* Empty folders (no models) shown when the empty-folders toggle is on */
.sidebar-tree-node-content.empty .sidebar-tree-folder-name,
.sidebar-node-content.empty .sidebar-folder-name {
opacity: 0.55;
font-style: italic;
}
.sidebar-tree-node-content.empty .sidebar-tree-folder-icon,
.sidebar-node-content.empty .sidebar-folder-icon {
opacity: 0.45;
}
.sidebar-tree-node-content.drop-target .sidebar-tree-folder-icon,
.sidebar-node-content.drop-target .sidebar-folder-icon {
color: var(--lora-accent);
@@ -627,88 +639,40 @@
display: inline;
}
/* Create folder drop zone */
.sidebar-create-folder-zone {
position: absolute;
bottom: 16px;
left: 16px;
right: 16px;
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);
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08);
/* Create folder inline row: rendered inside the tree at the creation
location, styled like a regular node row with a full-width input */
.sidebar-create-folder-row {
padding-top: 4px;
padding-bottom: 4px;
cursor: default;
}
.sidebar-tree-node-content.sidebar-create-folder-row:hover,
.sidebar-node-content.sidebar-create-folder-row:hover {
background: transparent;
color: var(--text-color);
}
.sidebar-create-folder-spacer {
opacity: 0;
transform: translateY(10px);
transition: var(--transition-base);
pointer-events: none;
z-index: 10;
}
.sidebar-create-folder-zone.active {
opacity: 1;
transform: translateY(0);
}
.sidebar-create-folder-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
.sidebar-create-folder-row .sidebar-tree-folder-icon,
.sidebar-create-folder-row .sidebar-folder-icon {
color: var(--lora-accent);
font-size: 0.85em;
text-align: center;
}
.sidebar-create-folder-content i {
font-size: 1.5em;
opacity: 0.8;
}
/* Create folder input container */
.sidebar-create-folder-input-container {
position: absolute;
bottom: 16px;
left: 16px;
right: 16px;
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;
opacity: 0.9;
}
.sidebar-create-folder-input {
flex: 1;
padding: 6px 10px;
min-width: 0; /* allow the input to shrink below its intrinsic width */
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-size: 0.85em;
font-size: 1em;
outline: none;
transition: var(--transition-base);
}
@@ -718,49 +682,6 @@
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 */
.folder-sidebar.dragging-active {
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.5);
@@ -772,11 +693,6 @@
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 */
#sidebarFolderContextMenu {
z-index: var(--z-modal, 1002);
+18 -3
View File
@@ -22,6 +22,7 @@ export const MODEL_CONFIG = {
supportsLetterFilter: true,
supportsBulkOperations: true,
supportsMove: true,
supportsFolderManagement: true,
templateName: 'loras.html'
},
[MODEL_TYPES.CHECKPOINT]: {
@@ -31,6 +32,7 @@ export const MODEL_CONFIG = {
supportsLetterFilter: false,
supportsBulkOperations: true,
supportsMove: true,
supportsFolderManagement: true,
templateName: 'checkpoints.html'
},
[MODEL_TYPES.EMBEDDING]: {
@@ -40,6 +42,7 @@ export const MODEL_CONFIG = {
supportsLetterFilter: true,
supportsBulkOperations: true,
supportsMove: true,
supportsFolderManagement: true,
templateName: 'embeddings.html'
},
[MODEL_TYPES.OTHER]: {
@@ -49,6 +52,7 @@ export const MODEL_CONFIG = {
supportsLetterFilter: false,
supportsBulkOperations: true,
supportsMove: true,
supportsFolderManagement: true,
templateName: 'other.html'
}
};
@@ -79,6 +83,9 @@ export function getApiEndpoints(modelType) {
// Move operations (now common for all model types that support move)
moveModel: `/api/lm/${modelType}/move_model`,
moveBulk: `/api/lm/${modelType}/move_models_bulk`,
createFolder: `/api/lm/${modelType}/create-folder`,
deleteFolder: `/api/lm/${modelType}/delete-folder`,
renameFolder: `/api/lm/${modelType}/rename-folder`,
// CivitAI integration
fetchCivitai: `/api/lm/${modelType}/fetch-civitai`,
@@ -203,10 +210,18 @@ export const DOWNLOAD_ENDPOINTS = {
exampleImagesMissing: '/api/lm/download-example-images' // Download only missing example images
};
// Hugging Face API endpoints
// External model source endpoints (Hugging Face / ModelScope).
// The hf-* paths are the historical names, kept as server-side aliases.
export const MODEL_SOURCE_ENDPOINTS = {
repoFiles: '/api/lm/model-source-files',
download: '/api/lm/download-model-source',
sources: '/api/lm/model-sources',
};
/** @deprecated use MODEL_SOURCE_ENDPOINTS */
export const HF_ENDPOINTS = {
repoFiles: '/api/lm/hf-repo-files',
download: '/api/lm/download-hf-model',
repoFiles: MODEL_SOURCE_ENDPOINTS.repoFiles,
download: MODEL_SOURCE_ENDPOINTS.download,
};
// WebSocket endpoints
+135 -11
View File
@@ -8,6 +8,7 @@ import {
isValidModelType,
DOWNLOAD_ENDPOINTS,
HF_ENDPOINTS,
MODEL_SOURCE_ENDPOINTS,
WS_ENDPOINTS
} from './apiConfig.js';
import { resetAndReload } from './modelApiFactory.js';
@@ -1294,9 +1295,13 @@ export class BaseModelApiClient {
}
}
async fetchModelFolders() {
async fetchModelFolders(options = {}) {
try {
const response = await fetch(this.apiConfig.endpoints.folders);
const { includeEmpty = false } = options || {};
const url = includeEmpty
? `${this.apiConfig.endpoints.folders}?include_empty=1`
: this.apiConfig.endpoints.folders;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${this.apiConfig.config.displayName} folders`);
}
@@ -1307,6 +1312,89 @@ export class BaseModelApiClient {
}
}
async createFolder(folderPath) {
const response = await fetch(this.apiConfig.endpoints.createFolder, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ folder_path: folderPath })
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) {
throw new Error(result.error || `Failed to create folder`);
}
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 = {}) {
try {
const { includeEmpty = false } = options;
@@ -1367,30 +1455,52 @@ export class BaseModelApiClient {
}
}
async fetchHfRepoFiles(repo, revision = 'main') {
/**
* List the downloadable weight files of an external repository.
* @param {string} repo - `owner/name`
* @param {string} [platform] - `huggingface` (default) or `modelscope`
* @param {string} [revision] - branch; each site has its own default
*/
async fetchModelSourceFiles(repo, platform = 'huggingface', revision = '') {
try {
const params = new URLSearchParams({ repo, revision });
const response = await fetch(`${HF_ENDPOINTS.repoFiles}?${params}`);
const params = new URLSearchParams({ repo, platform });
if (revision) params.set('revision', revision);
const response = await fetch(`${MODEL_SOURCE_ENDPOINTS.repoFiles}?${params}`);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Failed to fetch HF repo files');
throw new Error(err.error || 'Failed to fetch repository files');
}
return await response.json();
} catch (error) {
console.error('Error fetching HF repo files:', error);
console.error('Error fetching repository files:', error);
throw error;
}
}
async downloadHfModel({ repo, filename, revision, modelRoot, relativePath, useDefaultPaths, download_id }) {
/** Backwards-compatible Hugging Face wrapper. */
async fetchHfRepoFiles(repo, revision = 'main') {
return this.fetchModelSourceFiles(repo, 'huggingface', revision);
}
async downloadModelSource({
platform = 'huggingface',
repo,
filename,
revision,
modelRoot,
relativePath,
useDefaultPaths,
download_id,
}) {
try {
const response = await fetch(HF_ENDPOINTS.download, {
const response = await fetch(MODEL_SOURCE_ENDPOINTS.download, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
platform,
repo,
filename,
revision: revision || 'main',
revision: revision || '',
model_root: modelRoot,
relative_path: relativePath || '',
use_default_paths: useDefaultPaths || false,
@@ -1404,11 +1514,25 @@ export class BaseModelApiClient {
return await response.json();
} catch (error) {
console.error('Error downloading HF model:', error);
console.error('Error downloading model:', error);
throw error;
}
}
/** Backwards-compatible Hugging Face wrapper. */
async downloadHfModel({ repo, filename, revision, modelRoot, relativePath, useDefaultPaths, download_id }) {
return this.downloadModelSource({
platform: 'huggingface',
repo,
filename,
revision: revision || 'main',
modelRoot,
relativePath,
useDefaultPaths,
download_id,
});
}
_buildQueryParams(baseParams, pageState) {
const params = new URLSearchParams(baseParams);
const isExcludedView = pageState.viewMode === 'excluded';
@@ -7,6 +7,8 @@ import { MODEL_CONFIG } from '../../api/apiConfig.js';
import { translate } from '../../utils/i18nHelpers.js';
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
import { classifyModelRelinkUrl } from '../../utils/civitaiUtils.js';
import { parseModelSourceUrl, getModelSourceInfo } from '../../utils/modelSourceHelpers.js';
import { escapeHtml } from '../shared/utils.js';
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
export const ModelContextMenuMixin = {
@@ -211,7 +213,7 @@ export const ModelContextMenuMixin = {
setTimeout(() => urlInput.focus(), 50);
},
// HuggingFace linking methods
// External model source linking (Hugging Face / ModelScope / TensorArt)
showLinkHfModal() {
const filePath = this.currentCard.dataset.filepath;
if (!filePath) return;
@@ -225,15 +227,23 @@ export const ModelContextMenuMixin = {
}
this._boundLinkHfHandler = async () => {
const hfUrl = urlInput.value.trim();
if (!hfUrl) {
errorDiv.textContent = 'Please enter a HuggingFace repository URL.';
const rawUrl = urlInput.value.trim();
if (!rawUrl) {
errorDiv.textContent = translate(
'modals.linkModelSource.urlRequired',
{},
'Please enter a model page URL.'
);
return;
}
const hfPattern = /^https?:\/\/huggingface\.co\/([^/]+\/[^/]+)\/?$/;
if (!hfPattern.test(hfUrl)) {
errorDiv.textContent = 'Invalid URL format. Expected: https://huggingface.co/user/repo';
const sourceInfo = parseModelSourceUrl(rawUrl);
if (!sourceInfo) {
errorDiv.textContent = translate(
'modals.linkModelSource.invalidUrl',
{},
'Unsupported URL. Supported sites: Hugging Face, ModelScope, TensorArt.'
);
return;
}
@@ -241,12 +251,14 @@ export const ModelContextMenuMixin = {
modalManager.closeModal('linkHfModal');
try {
state.loadingManager.showSimpleLoading('Linking to HuggingFace...');
state.loadingManager.showSimpleLoading(
translate('modals.linkModelSource.linking', {}, 'Linking model source...')
);
const response = await fetch('/api/lm/set-hf-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath, hf_url: hfUrl }),
body: JSON.stringify({ file_path: filePath, source_url: sourceInfo.url }),
});
if (!response.ok) {
@@ -262,7 +274,7 @@ export const ModelContextMenuMixin = {
throw new Error(data.error || 'Failed to link model');
}
} catch (error) {
console.error('Error linking model to HuggingFace:', error);
console.error('Error linking model source:', error);
showToast('toast.contextMenu.linkHfFailed', { message: error.message }, 'error');
} finally {
state.loadingManager.hide();
@@ -276,18 +288,68 @@ export const ModelContextMenuMixin = {
modalManager.showModal('linkHfModal');
this._renderSupportedSources();
setTimeout(() => urlInput.focus(), 50);
},
// HF metadata enrichment (AI agent) methods
/**
* Refresh the supported-site hints from the server so the dialog reflects
* whatever sources this backend build actually knows about. Falls back to
* the static markup in the template when the request fails.
*/
async _renderSupportedSources() {
const container = document.getElementById('hfSupportedSources');
if (!container) return;
try {
const response = await fetch('/api/lm/model-sources');
if (!response.ok) return;
const sources = await response.json();
if (!Array.isArray(sources) || sources.length === 0) return;
const examples = sources
.map((source) => source?.example_url)
.filter((url) => typeof url === 'string' && url);
if (examples.length === 0) return;
container.innerHTML = examples
.map((url) => `<strong>${escapeHtml(url)}</strong>`)
.join('<br>');
} catch (error) {
console.debug('Failed to load supported model sources:', error);
}
},
// Model metadata enrichment (AI agent) methods
updateEnrichMenuItem(card) {
const enrichItem = this.menu?.querySelector('[data-action="enrich-hf-llm"]');
if (!enrichItem) return;
const hasHfUrl = !!card.dataset.hf_url;
enrichItem.classList.toggle('disabled', !hasHfUrl);
enrichItem.title = hasHfUrl
? ''
: 'Link this model to a HuggingFace repo first (Link Model → Link to HuggingFace)';
const model = {
source_url: card.dataset.source_url || '',
source_platform: card.dataset.source_platform || '',
hf_url: card.dataset.hf_url || '',
};
const sourceInfo = getModelSourceInfo(model);
const canEnrich = Boolean(sourceInfo && sourceInfo.supportsEnrichment);
enrichItem.classList.toggle('disabled', !canEnrich);
if (canEnrich) {
enrichItem.title = '';
} else if (!sourceInfo) {
enrichItem.title = translate(
'toast.contextMenu.enrichNeedsSource',
{},
'Link this model to a model source first (Link Model → Link to Model Source)'
);
} else {
enrichItem.title = translate(
'toast.contextMenu.enrichUnsupportedSource',
{ source: sourceInfo.label },
`AI enrichment is not available for ${sourceInfo.label} models`
);
}
},
async enrichWithAgent(filePath) {
+32
View File
@@ -494,6 +494,7 @@ class RecipeModal {
this.syncGenerationParams(hydratedRecipe.gen_params);
this.syncResourcesSection(hydratedRecipe);
this.syncHeaderActions();
this.syncBaseModelBadge();
this.syncMetaFooter();
// 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
* 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;
}
if (fullRecipe.base_model !== undefined) {
nextRecipe.base_model = fullRecipe.base_model;
}
if (fullRecipe.checkpoint !== undefined) {
nextRecipe.checkpoint = fullRecipe.checkpoint;
} else {
@@ -718,6 +749,7 @@ class RecipeModal {
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
}
this.syncHeaderActions();
this.syncBaseModelBadge();
this.syncMetaFooter();
}
File diff suppressed because it is too large Load Diff
+25 -12
View File
@@ -1,4 +1,5 @@
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
import { getModelSourceInfo, getModelSourceGroupKey, getModelSourceViewTitle, openModelSource } from '../../utils/modelSourceHelpers.js';
import { state, getCurrentPageState } from '../../state/index.js';
import { showModelModal } from './ModelModal.js';
import { hasCivitaiSource } from './utils.js';
@@ -65,12 +66,15 @@ function handleModelCardEvent_internal(event, modelType) {
if (event.target.closest('.fa-globe')) {
event.stopPropagation();
// CivitAI wins when the model actually has CivitAI data; otherwise fall
// back to HuggingFace. Relying on `from_civitai` here made the two
// sources mutually exclusive whenever one of them was (re)linked (#1094).
// back to the linked external source. Relying on `from_civitai` here
// made the two sources mutually exclusive whenever one of them was
// (re)linked (#1094).
if (card.dataset.has_civitai === 'true') {
openCivitai(card.dataset.filepath);
} else if (card.dataset.hf_url) {
} else if (card.dataset.source_platform === 'huggingface' && card.dataset.hf_url) {
openHuggingFace(card.dataset.hf_url);
} else if (card.dataset.source_url) {
openModelSource(card.dataset.source_url);
}
return true; // Stop propagation
}
@@ -337,6 +341,8 @@ async function showModelModalFromCard(card, modelType) {
modified: card.dataset.modified,
file_size: parseInt(card.dataset.file_size || '0'),
from_civitai: card.dataset.from_civitai === 'true',
source_platform: card.dataset.source_platform || '',
source_url: card.dataset.source_url || '',
hf_url: card.dataset.hf_url || '',
base_model: card.dataset.base_model,
notes: card.dataset.notes || '',
@@ -428,6 +434,8 @@ function showExampleAccessModal(card, modelType) {
modified: card.dataset.modified,
file_size: card.dataset.file_size,
from_civitai: card.dataset.from_civitai === 'true',
source_platform: card.dataset.source_platform || '',
source_url: card.dataset.source_url || '',
hf_url: card.dataset.hf_url || '',
base_model: card.dataset.base_model,
notes: card.dataset.notes,
@@ -490,7 +498,11 @@ export function createModelCard(model, modelType) {
card.dataset.base_model = model.base_model || 'Unknown';
card.dataset.favorite = model.favorite ? 'true' : 'false';
card.dataset.exclude = model.exclude ? 'true' : 'false';
card.dataset.hf_url = model.hf_url || '';
const modelSourceInfo = getModelSourceInfo(model);
card.dataset.source_url = modelSourceInfo?.url || '';
card.dataset.source_platform = modelSourceInfo?.platform || '';
// Legacy alias: only Hugging Face models expose `hf_url`.
card.dataset.hf_url = modelSourceInfo?.platform === 'huggingface' ? modelSourceInfo.url : '';
const hasUpdateAvailable = Boolean(model.update_available);
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
@@ -508,11 +520,12 @@ export function createModelCard(model, modelType) {
const modelId = civitaiData?.modelId ?? civitaiData?.model_id;
if (modelId !== undefined && modelId !== null && modelId !== '') {
card.dataset.modelId = modelId;
} else if (model.hf_url) {
// For HF-only models, derive a group key from hf_url for version grouping
const match = model.hf_url.match(/https?:\/\/huggingface\.co\/([^/]+\/[^/]+)/);
if (match) {
card.dataset.modelId = 'hf:' + match[1];
} else {
// For externally-sourced models, derive a group key from the source
// URL for version grouping (hf:user/repo, ms:user/repo, ta:<id>).
const sourceGroupKey = getModelSourceGroupKey(model);
if (sourceGroupKey) {
card.dataset.modelId = sourceGroupKey;
}
}
@@ -610,10 +623,10 @@ export function createModelCard(model, modelType) {
const hasCivitai = hasCivitaiSource(model.civitai);
const globeTitle = hasCivitai ?
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
model.hf_url ?
translate('modelCard.actions.viewOnHuggingFace', {}, 'View on Hugging Face') :
modelSourceInfo ?
getModelSourceViewTitle(modelSourceInfo) :
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
const globeEnabled = hasCivitai || !!model.hf_url;
const globeEnabled = hasCivitai || !!modelSourceInfo;
let sendTitle;
let copyTitle;
if (modelType === MODEL_TYPES.LORA) {
+18 -9
View File
@@ -1,4 +1,5 @@
import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, sendModelPathToWorkflow, buildLoraSyntax, copyToClipboard } from '../../utils/uiHelpers.js';
import { getModelSourceInfo, getModelSourceGroupKey, getModelSourceViewTitle, openModelSource } from '../../utils/modelSourceHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
import {
@@ -397,10 +398,13 @@ export async function showModelModal(model, modelType) {
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
</div>`.trim() : '';
const escapedHfUrl = modelWithFullData.hf_url ? escapeAttribute(modelWithFullData.hf_url) : '';
const viewOnHuggingFaceAction = escapedHfUrl ? `
<div class="civitai-view" title="${translate('modals.model.actions.viewOnHuggingFace', {}, 'View on Hugging Face')}" data-action="view-huggingface" data-hf-url="${escapedHfUrl}">
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnHuggingFaceText', {}, 'View on Hugging Face')}
const sourceInfo = getModelSourceInfo(modelWithFullData);
const escapedSourceUrl = sourceInfo?.url ? escapeAttribute(sourceInfo.url) : '';
const isHuggingFaceSource = sourceInfo?.platform === 'huggingface';
const sourceTitle = sourceInfo ? getModelSourceViewTitle(sourceInfo) : '';
const viewOnHuggingFaceAction = escapedSourceUrl ? `
<div class="civitai-view" title="${escapeAttribute(sourceTitle)}" data-action="${isHuggingFaceSource ? 'view-huggingface' : 'view-model-source'}" ${isHuggingFaceSource ? 'data-hf-url' : 'data-source-url'}="${escapedSourceUrl}">
<i class="fas fa-globe"></i> ${escapeHtml(sourceTitle)}
</div>`.trim() : '';
const creatorInfoAction = modelWithFullData.civitai?.creator ? `
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
@@ -520,12 +524,12 @@ export async function showModelModal(model, modelType) {
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...');
// Use CivitAI modelId, or derive HF group key for HF-only models
// Use CivitAI modelId, or derive a source group key for externally-linked models
let civitaiModelId = modelWithFullData.civitai?.modelId || '';
if (!civitaiModelId && modelWithFullData.hf_url) {
const match = modelWithFullData.hf_url.match(/https?:\/\/huggingface\.co\/([^/]+\/[^/]+)/);
if (match) {
civitaiModelId = 'hf:' + match[1];
if (!civitaiModelId) {
const sourceGroupKey = getModelSourceGroupKey(modelWithFullData);
if (sourceGroupKey) {
civitaiModelId = sourceGroupKey;
}
}
const civitaiVersionId = modelWithFullData.civitai?.id || '';
@@ -939,6 +943,11 @@ function setupEventHandlers(filePath, modelType) {
window.open(target.dataset.hfUrl, '_blank', 'noopener,noreferrer');
}
break;
case 'view-model-source':
if (target.dataset.sourceUrl) {
openModelSource(target.dataset.sourceUrl);
}
break;
case 'view-creator':
const username = target.dataset.username;
if (username) {
+42 -197
View File
@@ -7,6 +7,12 @@ import { getModelApiClient } from '../../api/modelApiFactory.js';
import { translate } from '../../utils/i18nHelpers.js';
import { getPriorityTagSuggestions } from '../../utils/priorityTagHelpers.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 = {
loras: 'lora',
@@ -18,16 +24,22 @@ const MODEL_TYPE_SUGGESTION_KEY_MAP = {
};
const METADATA_ITEM_SELECTOR = '.metadata-item';
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';
const BODY_DRAGGING_CLASS = 'metadata-drag-active';
/**
* Tag items have no click action of their own, so the whole chip stays
* 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 priorityTagSuggestions = [];
let priorityTagSuggestionsLoaded = false;
let priorityTagSuggestionsPromise = null;
let activeTagDragState = null;
// Configurable options for tag editing (set by setupTagEditMode)
let tagEditOptions = {
@@ -423,6 +435,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
<div class="metadata-items">
${currentTags.map(tag => `
<div class="metadata-item" data-tag="${tag}">
${renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
<span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn">
<i class="fas fa-times"></i>
@@ -431,6 +444,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
`).join('')}
</div>
<div class="metadata-edit-controls">
${renderReorderHint(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
<button class="save-tags-btn" title="Save changes">
<i class="fas fa-save"></i> Save
</button>
@@ -543,8 +557,11 @@ function setupDeleteButtons() {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const tag = this.closest('.metadata-item');
const scope = tag?.closest('.model-tags-container');
tag.remove();
refreshTagReorderState(scope);
// Update status of items in the suggestion dropdown
updateSuggestionsDropdown();
});
@@ -563,204 +580,31 @@ function setupTagDragAndDrop(scopeContainer) {
return;
}
container.querySelectorAll(METADATA_ITEM_SELECTOR).forEach((item) => {
item.removeAttribute('draggable');
if (item.classList.contains(METADATA_ITEM_PLACEHOLDER_CLASS)) {
return;
}
if (item.dataset.pointerDragInit === 'true') {
return;
}
const scope = container.closest('.model-tags-container') || container;
item.addEventListener('pointerdown', handleTagPointerDown);
item.dataset.pointerDragInit = 'true';
enablePointerSort(container, {
...TAG_SORT_CONFIG,
onSorted: () => {
updateSuggestionsDropdown();
refreshTagReorderState(scope);
},
});
refreshTagReorderState(scope);
}
function handleTagPointerDown(event) {
if (event.button !== 0) {
return;
}
if (event.target.closest('.metadata-delete-btn')) {
return;
}
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();
/**
* Refresh the "sortable" flag (and therefore the grip + hint) of a tags section
* @param {Element} [tagsSection] - The .model-tags-container element
*/
function refreshTagReorderState(tagsSection) {
refreshReorderState({
container: tagsSection?.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR),
scope: tagsSection || undefined,
itemSelector: METADATA_ITEM_SELECTOR,
});
}
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
* @param {string} tag - Tag to add
@@ -799,6 +643,7 @@ function addNewTag(tag, scopeElement = null) {
newTag.className = 'metadata-item';
newTag.dataset.tag = tag;
newTag.innerHTML = `
${renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
<span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn">
<i class="fas fa-times"></i>
@@ -5,6 +5,7 @@ import { openCivitaiUrl, showToast } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js';
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
import { parseModelSourceGroupKey } from '../../utils/modelSourceHelpers.js';
import { formatFileSize } from './utils.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
@@ -993,22 +994,23 @@ export function initVersionsTab({
renderErrorState(container, translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.'));
return;
}
// HF group keys (e.g. "hf:user/repo") are not real CivitAI model IDs —
// skip the remote API call and show a helpful message instead.
const isHfGroupKey = typeof modelId === 'string' && modelId.startsWith('hf:');
if (isHfGroupKey) {
// External source group keys (e.g. "hf:user/repo", "ms:user/repo",
// "ta:8278...") are not real CivitAI model IDs — skip the remote API
// call and show a helpful message instead.
const sourceGroup = parseModelSourceGroupKey(modelId);
if (sourceGroup) {
controller.isLoading = false;
controller.hasLoaded = true;
controller.record = null;
const hfMsg = translate(
'modals.model.versions.hfGroupInfo',
{},
'This is a HuggingFace model group. Open the library to see all versions in the grid.'
const sourceMsg = translate(
'modals.model.versions.sourceGroupInfo',
{ source: sourceGroup.label },
`This is a ${sourceGroup.label} model group. Open the library to see all versions in the grid.`
);
container.innerHTML = `
<div class="versions-empty-state">
<i class="fas fa-info-circle"></i>
<p>${escapeHtml(hfMsg)}</p>
<p>${escapeHtml(sourceMsg)}</p>
</div>
`;
return;
+105 -1
View File
@@ -7,10 +7,35 @@ import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.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_TRIGGER_WORD_GROUPS = 100;
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
@@ -182,6 +207,16 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
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
* @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>
<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')}">
<i class="fas fa-save"></i> ${translate('common.actions.save')}
</button>
@@ -228,6 +264,7 @@ export function renderTriggerWords(words, filePath) {
const escapedAttr = escapeAttribute(word);
return `
<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-copy">
<i class="fas fa-copy"></i>
@@ -240,6 +277,7 @@ export function renderTriggerWords(words, filePath) {
</div>
</div>
<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')}">
<i class="fas fa-save"></i> ${translate('common.actions.save')}
</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
// Add loading indicator
const loadingIndicator = document.createElement('div');
@@ -379,6 +421,10 @@ export function setupTriggerWordsEditMode() {
if (tagsContainer) tagsContainer.style.display = 'none';
}
// Leaving edit mode: tags are no longer reorderable
disableTriggerWordSort(triggerWordsSection);
refreshTriggerWordHandleLabels(triggerWordsSection);
// Remove dropdown if present
const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown');
if (dropdown) dropdown.remove();
@@ -433,8 +479,13 @@ export function setupTriggerWordsEditMode() {
function deleteTriggerWord(e) {
e.stopPropagation();
const tag = this.closest('.trigger-word-tag');
const section = tag?.closest('.trigger-words');
tag.remove();
if (section) {
refreshTriggerWordHandleLabels(section);
}
// Update status of items in the trained words dropdown
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
* @param {string} word - Trigger word
@@ -507,6 +599,7 @@ function createTriggerWordTag(word, isEditMode = false) {
const escapedWord = escapeHtml(word);
tag.innerHTML = `
${renderTriggerWordDragHandle()}
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy" style="${isEditMode ? 'display:none;' : ''}">
<i class="fas fa-copy"></i>
@@ -637,7 +730,7 @@ function validateTriggerWord(word, tagsContainer, currentTag = null) {
* @param {Event} e - Click event
*/
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 section = tag?.closest('.trigger-words');
@@ -684,6 +777,11 @@ function startEditTriggerWord(e) {
tag.classList.remove('is-editing');
tag.style.removeProperty('--trigger-word-edit-width');
tag.style.removeProperty('--trigger-word-edit-height');
if (section) {
refreshTriggerWordHandleLabels(section);
}
updateTrainedWordsDropdown();
};
@@ -763,6 +861,12 @@ function addNewTriggerWord(word) {
const newTag = createTriggerWordTag(word, triggerWordsSection.classList.contains('edit-mode'));
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
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);
}
+212 -169
View File
@@ -13,6 +13,13 @@ import { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost
import { formatFileSize } from '../utils/formatters.js';
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
import { openOtherModelsSettings } from '../utils/otherModels.js';
import {
buildModelSourceFilePage,
detectModelSourceDownloadUrl,
getModelSource,
isExternalModelSource,
isValidRepoId,
} from '../utils/modelSourceHelpers.js';
export class DownloadManager {
constructor() {
@@ -39,10 +46,11 @@ export class DownloadManager {
this.isBatchMode = false;
this.editingBatchIndex = -1;
// HF download state
this.hfRepoId = null;
this.hfSelectedFiles = [];
this.hfRepoCollapsed = {};
// External repository download state (Hugging Face / ModelScope)
this.sourcePlatform = 'huggingface';
this.sourceRepoId = null;
this.sourceSelectedFiles = [];
this.sourceRepoCollapsed = {};
this.loadingManager = new LoadingManager();
this.folderTreeManager = new FolderTreeManager();
@@ -186,10 +194,11 @@ export class DownloadManager {
// Reset default path toggle
this.loadDefaultPathSetting();
// Reset HF state
this.hfRepoId = null;
this.hfSelectedFiles = [];
this.hfRepoCollapsed = {};
// Reset external repository state
this.sourcePlatform = 'huggingface';
this.sourceRepoId = null;
this.sourceSelectedFiles = [];
this.sourceRepoCollapsed = {};
}
async retrieveVersionsForModel(modelId, source = null) {
@@ -212,10 +221,12 @@ export class DownloadManager {
// Detect URL types — all URLs must share the same source type
const urlTypes = urls.map(u => DownloadManager.detectUrlType(u));
const isHf = urlTypes.every(t => t && (t.type === 'hf-resolve' || t.type === 'hf-repo'));
const isExternalSource = urlTypes.every(
t => t && (t.type === 'model-source-repo' || t.type === 'model-source-file')
);
const isCivitai = urlTypes.every(t => t && t.type === 'civitai');
if (!isHf && !isCivitai) {
if (!isExternalSource && !isCivitai) {
const allValid = urlTypes.every(t => t !== null);
if (!allValid) {
errorElement.textContent = translate('modals.download.errors.invalidUrl');
@@ -228,8 +239,8 @@ export class DownloadManager {
}
}
if (isHf) {
return this._validateAndFetchHf(urls, errorElement);
if (isExternalSource) {
return this._validateAndFetchExternalRepo(urls, errorElement);
}
// --- Original CivitAI flow below ---
@@ -327,45 +338,87 @@ export class DownloadManager {
this.showBatchPreviewStep();
}
// ---- Hugging Face download flow ----
// ---- External repository download flow (Hugging Face / ModelScope) ----
async _validateAndFetchHf(urls, errorElement) {
/**
* 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. */
_externalGroupKey(item) {
return `${item.source}:${item.repo || 'unknown'}`;
}
_defaultRevisionFor(platform) {
const source = getModelSource(platform);
return (source && source.defaultRevision) || '';
}
_makeExternalItem(url, info, file) {
return {
url,
source: info.platform,
platform: info.platform,
repo: info.repo,
revision: file.revision || this._defaultRevisionFor(info.platform),
filename: file.filename,
displayName: file.filename,
fileSizeBytes: file.size,
selectedVersion: true,
versions: [],
checked: false,
error: null,
};
}
/** Fetch a repository's weight files as flat batch items. */
async _fetchExternalRepoItems(url, info) {
const revision = this._defaultRevisionFor(info.platform);
const files = await this.apiClient.fetchModelSourceFiles(
info.repo, info.platform, revision
);
if (!files || files.length === 0) {
throw new Error(translate('modals.download.errors.noModelFiles'));
}
return files.map(file => this._makeExternalItem(url, info, { ...file, revision }));
}
async _validateAndFetchExternalRepo(urls, errorElement) {
if (urls.length === 1) {
const info = DownloadManager.detectUrlType(urls[0]);
// Direct file resolve URL → skip file selection, go to location
if (info.type === 'hf-resolve') {
// Direct file URL → skip file selection, go to location
if (info.type === 'model-source-file') {
this.isBatchMode = false;
this.hfRepoId = info.repo;
this.hfSelectedFiles = [info.filename];
this.source = 'huggingface';
this.sourcePlatform = info.platform;
this.sourceRepoId = info.repo;
this.sourceSelectedFiles = [info.filename];
this.source = info.platform;
this.proceedToLocation();
return;
}
// Repo URL → fetch file list and convert to batch items
try {
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingRepoFiles'));
const files = await this.apiClient.fetchHfRepoFiles(info.repo);
if (!files || files.length === 0) {
throw new Error(translate('modals.download.errors.noModelFiles'));
}
this.isBatchMode = true;
this.batchModels = [];
this.source = 'huggingface';
for (const file of files) {
this.batchModels.push({
url: urls[0],
source: 'huggingface',
repo: info.repo,
filename: file.filename,
revision: 'main',
displayName: file.filename,
fileSizeBytes: file.size,
selectedVersion: true,
versions: [],
checked: false,
error: null,
});
}
this.batchModels = await this._fetchExternalRepoItems(urls[0], info);
this.source = info.platform;
this.showBatchPreviewStep();
} catch (err) {
errorElement.textContent = err.message;
@@ -375,10 +428,9 @@ export class DownloadManager {
return;
}
// Multiple HF URLs → batch mode: flatten all files from all repos
// Multiple URLs → batch mode: flatten all files from all repos
this.isBatchMode = true;
this.batchModels = [];
this.source = 'huggingface';
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingRepoFiles'));
for (const url of urls) {
@@ -387,42 +439,15 @@ export class DownloadManager {
this.batchModels.push({ url, error: 'Invalid URL', versions: [], selectedVersion: null });
continue;
}
if (info.type === 'hf-resolve') {
this.batchModels.push({
url,
source: 'huggingface',
repo: info.repo,
this.source = info.platform;
if (info.type === 'model-source-file') {
this.batchModels.push(this._makeExternalItem(url, info, {
filename: info.filename,
revision: info.revision || 'main',
displayName: info.filename,
selectedVersion: true,
versions: [],
checked: false,
error: null,
});
} else if (info.type === 'hf-repo') {
revision: info.revision,
}));
} else if (info.type === 'model-source-repo') {
try {
const files = await this.apiClient.fetchHfRepoFiles(info.repo);
if (!files || files.length === 0) {
this.batchModels.push({ url, error: 'No model files found', versions: [], selectedVersion: null });
continue;
}
// Flatten: create one batch item per file, all checked by default
for (const file of files) {
this.batchModels.push({
url,
source: 'huggingface',
repo: info.repo,
filename: file.filename,
revision: 'main',
displayName: file.filename,
fileSizeBytes: file.size,
selectedVersion: true,
versions: [],
checked: false,
error: null,
});
}
this.batchModels.push(...await this._fetchExternalRepoItems(url, info));
} catch (err) {
this.batchModels.push({ url, error: err.message, versions: [], selectedVersion: null });
}
@@ -480,7 +505,8 @@ export class DownloadManager {
* Detect the source type of a download URL.
* @param {string} url
* @returns {{ type: string, repo?: string, filename?: string, revision?: string } | null}
* type: 'civitai' | 'civarchive' | 'hf-resolve' | 'hf-repo' | 'direct-http'
* type: 'civitai' | 'civarchive' | 'model-source-file' | 'model-source-repo'
* | 'direct-http'
*/
static detectUrlType(url) {
const trimmed = url.trim();
@@ -492,38 +518,27 @@ export class DownloadManager {
return { type: 'civitai' };
}
// Hugging Face resolve/blob URL → direct file
// "blob" is the web preview page; it maps 1:1 to the "resolve" download URL
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/(?:resolve|blob)\/([^/\s]+)\/(.+)/i);
if (hfResolveMatch) {
return {
type: 'hf-resolve',
repo: hfResolveMatch[1],
revision: hfResolveMatch[2],
filename: hfResolveMatch[3],
};
}
// Hugging Face repo URL (huggingface.co/user/repo or bare user/repo path)
// Require huggingface.co prefix for full URLs; bare user/repo only without ://
const hfRepoMatch = trimmed.match(
trimmed.includes('://')
? /^https?:\/\/huggingface\.co\/([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)(?:\/?$|$)/
: /^([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)$/
);
if (hfRepoMatch) {
// External model sources (Hugging Face / ModelScope). Repository URLs
// list every weight file; resolve URLs point at one file. Both are
// recognised through the shared registry, so adding a site is a
// registry change rather than a change here.
const sourceInfo = detectModelSourceDownloadUrl(trimmed);
if (sourceInfo) {
// Reject path-traversal patterns like "../.." or "user/.."
const parts = hfRepoMatch[1].split('/');
if (parts.some(p => p === '.' || p === '..')) {
if (!isValidRepoId(sourceInfo.repo)) {
return null;
}
return {
type: 'hf-repo',
repo: hfRepoMatch[1],
type: sourceInfo.kind === 'file' ? 'model-source-file' : 'model-source-repo',
platform: sourceInfo.platform,
repo: sourceInfo.repo,
...(sourceInfo.kind === 'file'
? { revision: sourceInfo.revision, filename: sourceInfo.filename }
: {}),
};
}
// Direct HTTP(S) URL (non-HF)
// Direct HTTP(S) URL (non model-source)
if (/^https?:\/\//i.test(trimmed)) {
return { type: 'direct-http' };
}
@@ -931,7 +946,7 @@ export class DownloadManager {
}
// In single-URL mode, validate version selection (skip for HF)
if (!this.isBatchMode && this.source !== 'huggingface') {
if (!this.isBatchMode && !isExternalModelSource(this.source)) {
if (!this.currentVersion) {
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
return;
@@ -1164,12 +1179,15 @@ export class DownloadManager {
/**
* Synthesize a clickable URL for a single-download failure entry.
* Single downloads have no pasted URL, so the modal link is derived from
* the model/version ids (CivitAI) or the HF repo/file (HuggingFace).
* the model/version ids (CivitAI) or the external repo/file.
*/
_buildSingleItemUrl({ modelId, versionId, source, repo = null, filename = null }) {
if (source === 'huggingface' && repo) {
const base = `https://huggingface.co/${encodeURI(repo)}`;
return filename ? `${base}/blob/${encodeURI('main')}/${encodeURI(filename)}` : base;
if (isExternalModelSource(source) && repo) {
return buildModelSourceFilePage({
platform: source,
repo,
filename,
}) || getModelSource(source).canonical(repo);
}
if (modelId) {
return buildCivitaiUrl({
@@ -1490,8 +1508,8 @@ export class DownloadManager {
* matched card-by-card via `_reconcileViewAfterDownload`; HF
* downloads (no CivitAI identity to match) keep the legacy reload.
*/
async _reconcileBatchViewAfterDownload(completedCivitaiItems = [], hfCompletedCount = 0) {
if (hfCompletedCount > 0) {
async _reconcileBatchViewAfterDownload(completedCivitaiItems = [], externalCompletedCount = 0) {
if (externalCompletedCount > 0) {
await resetAndReload(true);
return;
}
@@ -1661,10 +1679,11 @@ export class DownloadManager {
return failedItems.length === 0;
}
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths, files = null }) {
async _downloadExternalRepoFiles({ modelRoot, targetFolder, useDefaultPaths, files = null }) {
modalManager.closeModal('downloadModal');
this.loadingManager.restoreProgressBar();
const filesToDownload = files || this.hfSelectedFiles;
const platform = this.sourcePlatform;
const filesToDownload = files || this.sourceSelectedFiles;
const totalFiles = filesToDownload.length;
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
@@ -1710,6 +1729,12 @@ export class DownloadManager {
cancelled = true;
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') {
const metrics = {
bytesDownloaded: data.bytes_downloaded,
@@ -1720,10 +1745,11 @@ export class DownloadManager {
}
};
const response = await this.apiClient.downloadHfModel({
repo: this.hfRepoId,
const response = await this.apiClient.downloadModelSource({
platform,
repo: this.sourceRepoId,
filename,
revision: 'main',
revision: this._defaultRevisionFor(platform),
modelRoot,
relativePath: targetFolder,
useDefaultPaths,
@@ -1738,10 +1764,10 @@ export class DownloadManager {
} else {
failedFiles.push({
item: {
source: 'huggingface',
repo: this.hfRepoId,
source: platform,
repo: this.sourceRepoId,
filename,
url: this._buildSingleItemUrl({ source: 'huggingface', repo: this.hfRepoId, filename }),
url: this._buildSingleItemUrl({ source: platform, repo: this.sourceRepoId, filename }),
},
error: response?.error || 'Unknown error',
name: filename,
@@ -1749,13 +1775,13 @@ export class DownloadManager {
}
} catch (err) {
if (!cancelled) {
console.error(`Failed to download HF file ${filename}:`, err);
console.error(`Failed to download repo file ${filename}:`, err);
failedFiles.push({
item: {
source: 'huggingface',
repo: this.hfRepoId,
source: platform,
repo: this.sourceRepoId,
filename,
url: this._buildSingleItemUrl({ source: 'huggingface', repo: this.hfRepoId, filename }),
url: this._buildSingleItemUrl({ source: platform, repo: this.sourceRepoId, filename }),
},
error: err?.message || 'Unknown error',
name: filename,
@@ -1781,7 +1807,7 @@ export class DownloadManager {
total: totalFiles,
completed: completedDownloads,
failedItems: failedFiles,
onRetry: () => this._downloadHfSingle({
onRetry: () => this._downloadExternalRepoFiles({
modelRoot,
targetFolder,
useDefaultPaths,
@@ -1831,7 +1857,7 @@ export class DownloadManager {
const validCount = this.batchModels.filter(m => {
if (m.error) return false;
if (m.source === 'huggingface') return m.checked !== false;
if (isExternalModelSource(m.source)) return m.checked !== false;
return m.selectedVersion;
}).length;
document.getElementById('downloadModalTitle').textContent =
@@ -1839,7 +1865,9 @@ export class DownloadManager {
` (${validCount})`;
const list = document.getElementById('batchPreviewList');
const hasHfItems = this.batchModels.some(m => m.source === 'huggingface' && !m.error);
const hasExternalItems = this.batchModels.some(
m => isExternalModelSource(m.source) && !m.error
);
// Error items render flat, outside any group
const errorItemsHtml = this.batchModels.map((item, index) => {
@@ -1863,7 +1891,7 @@ export class DownloadManager {
// CivitAI items render flat, outside any group (unchanged)
const civitaiItemsHtml = this.batchModels.map((item, index) => {
if (item.error) return null;
if (item.source === 'huggingface') return null;
if (isExternalModelSource(item.source)) return null;
const ver = item.selectedVersion;
const firstImage = ver?.images?.find(img => !img.url.endsWith('.mp4'));
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
@@ -1901,25 +1929,30 @@ export class DownloadManager {
`;
}).filter(Boolean).join('');
// Group HF items by repo (data model stays flat — only rendering groups)
const hfGroups = {};
// Group external-repository items by platform + repo so that the same
// `owner/name` on two sites stays in two groups (data model stays flat
// — only rendering groups).
const externalGroups = {};
this.batchModels.forEach((item, index) => {
if (item.error || item.source !== 'huggingface') return;
const repo = item.repo || 'unknown';
if (!hfGroups[repo]) hfGroups[repo] = [];
hfGroups[repo].push({ item, index });
if (item.error || !isExternalModelSource(item.source)) return;
const groupKey = this._externalGroupKey(item);
if (!externalGroups[groupKey]) {
externalGroups[groupKey] = { repo: item.repo || 'unknown', items: [] };
}
externalGroups[groupKey].items.push({ item, index });
});
const renderHfItem = ({ item, index }) => {
const hfSize = item.fileSizeBytes ? formatFileSize(item.fileSizeBytes) : '?';
const renderExternalItem = ({ item, index }) => {
const fileSize = item.fileSizeBytes ? formatFileSize(item.fileSizeBytes) : '?';
const badge = getModelSource(item.source)?.label || item.source;
return `
<div class="batch-preview-item" data-index="${index}">
<input type="checkbox" class="batch-preview-checkbox"
data-index="${index}" ${item.checked !== false ? 'checked' : ''} />
<div class="batch-preview-info">
<div class="batch-preview-name">${item.displayName || item.filename || `HF #${index}`} <span class="hf-badge">HF</span></div>
<div class="batch-preview-name">${item.displayName || item.filename || `${badge} #${index}`} <span class="hf-badge">${badge}</span></div>
<div class="batch-preview-meta">
<span>${hfSize}</span>
<span>${fileSize}</span>
<span>${item.repo || ''}</span>
</div>
</div>
@@ -1930,32 +1963,32 @@ export class DownloadManager {
`;
};
const hfGroupsHtml = Object.keys(hfGroups).map(repo => {
const items = hfGroups[repo];
const isCollapsed = this.hfRepoCollapsed[repo] === true;
const externalGroupsHtml = Object.keys(externalGroups).map(groupKey => {
const { repo, items } = externalGroups[groupKey];
const isCollapsed = this.sourceRepoCollapsed[groupKey] === true;
const allChecked = items.every(({ item }) => item.checked !== false);
const fileCount = items.length;
return `
<div class="batch-preview-group" data-repo="${repo}">
<div class="batch-preview-group" data-repo="${groupKey}">
<div class="batch-preview-group-header">
<i class="fas fa-chevron-right batch-preview-group-toggle ${isCollapsed ? '' : 'expanded'}"></i>
<span class="batch-preview-group-name">${repo}</span>
<span class="batch-preview-group-count">${fileCount} ${translate('modals.download.fileSelection.files', {}, 'files')}</span>
<input type="checkbox" class="batch-preview-group-select-all" data-repo="${repo}" ${allChecked ? 'checked' : ''} />
<input type="checkbox" class="batch-preview-group-select-all" data-repo="${groupKey}" ${allChecked ? 'checked' : ''} />
</div>
<div class="batch-preview-group-body ${isCollapsed ? '' : 'expanded'}">
${items.map(renderHfItem).join('')}
${items.map(renderExternalItem).join('')}
</div>
</div>
`;
}).join('');
let itemsHtml = errorItemsHtml + civitaiItemsHtml + hfGroupsHtml;
let itemsHtml = errorItemsHtml + civitaiItemsHtml + externalGroupsHtml;
// Prepend select-all toolbar if there are HF items with checkboxes
if (hasHfItems) {
// Prepend select-all toolbar if there are external items with checkboxes
if (hasExternalItems) {
const allChecked = this.batchModels
.filter(m => m.source === 'huggingface' && !m.error)
.filter(m => isExternalModelSource(m.source) && !m.error)
.every(m => m.checked !== false);
itemsHtml = `
<div class="batch-preview-select-all">
@@ -1980,13 +2013,18 @@ export class DownloadManager {
// Global select-all
const selectAll = document.getElementById('batchSelectAll');
if (selectAll) {
const hfItems = this.batchModels.filter(m => m.source === 'huggingface' && !m.error);
selectAll.checked = hfItems.length > 0 && hfItems.every(m => m.checked !== false);
const externalItems = this.batchModels.filter(
m => isExternalModelSource(m.source) && !m.error
);
selectAll.checked = externalItems.length > 0
&& externalItems.every(m => m.checked !== false);
}
// Per-group select-all
list.querySelectorAll('.batch-preview-group-select-all').forEach(gsa => {
const repo = gsa.dataset.repo;
const repoItems = this.batchModels.filter(m => m.source === 'huggingface' && !m.error && m.repo === repo);
const repoItems = this.batchModels.filter(
m => isExternalModelSource(m.source) && !m.error && this._externalGroupKey(m) === repo
);
gsa.checked = repoItems.length > 0 && repoItems.every(m => m.checked !== false);
});
};
@@ -1998,7 +2036,7 @@ export class DownloadManager {
const repo = groupSelectAll.dataset.repo;
const checked = groupSelectAll.checked;
this.batchModels.forEach((m, idx) => {
if (m.source === 'huggingface' && !m.error && m.repo === repo) {
if (isExternalModelSource(m.source) && !m.error && this._externalGroupKey(m) === repo) {
m.checked = checked;
const cb = list.querySelector(`.batch-preview-checkbox[data-index="${idx}"]`);
if (cb) cb.checked = checked;
@@ -2014,9 +2052,9 @@ export class DownloadManager {
const repo = group.dataset.repo;
const body = group.querySelector('.batch-preview-group-body');
const toggle = group.querySelector('.batch-preview-group-toggle');
const isCollapsed = this.hfRepoCollapsed[repo];
const isCollapsed = this.sourceRepoCollapsed[repo];
if (isCollapsed) {
this.hfRepoCollapsed[repo] = false;
this.sourceRepoCollapsed[repo] = false;
body.style.transition = ''; // restore in case collapse was interrupted
body.classList.add('expanded');
toggle.classList.add('expanded');
@@ -2025,13 +2063,13 @@ export class DownloadManager {
body.style.maxHeight = body.scrollHeight + 'px';
const onEnd = (e) => {
if (e.propertyName !== 'max-height') return;
if (this.hfRepoCollapsed[repo] !== false) return;
if (this.sourceRepoCollapsed[repo] !== false) return;
body.style.maxHeight = ''; // fall back to .expanded's 9999px
body.removeEventListener('transitionend', onEnd);
};
body.addEventListener('transitionend', onEnd);
} else {
this.hfRepoCollapsed[repo] = true;
this.sourceRepoCollapsed[repo] = true;
body.style.maxHeight = body.scrollHeight + 'px';
requestAnimationFrame(() => {
// animate only max-height; keep expanded so opacity stays 1
@@ -2040,7 +2078,7 @@ export class DownloadManager {
toggle.classList.remove('expanded');
const onEnd = (e) => {
if (e.propertyName !== 'max-height') return;
if (this.hfRepoCollapsed[repo] !== true) return; // state changed since
if (this.sourceRepoCollapsed[repo] !== true) return; // state changed since
body.classList.remove('expanded');
body.style.transition = '';
body.removeEventListener('transitionend', onEnd);
@@ -2119,7 +2157,7 @@ export class DownloadManager {
// For HF items, respect the checked flag; for CivitAI items, use selectedVersion
const validModels = this.batchModels.filter(m => {
if (m.error) return false;
if (m.source === 'huggingface') return m.checked !== false;
if (isExternalModelSource(m.source)) return m.checked !== false;
return m.selectedVersion;
});
if (validModels.length === 0) return;
@@ -2172,8 +2210,8 @@ export class DownloadManager {
}
if (!this.isBatchMode) {
// Single-item download
if (this.source === 'huggingface') {
return this._downloadHfSingle({
if (isExternalModelSource(this.source)) {
return this._downloadExternalRepoFiles({
modelRoot,
targetFolder,
useDefaultPaths,
@@ -2228,7 +2266,7 @@ export class DownloadManager {
if (m.error) return false;
if (!m.selectedVersion) return false;
// HF items have selectedVersion as a boolean marker + checked flag
if (m.source === 'huggingface') return m.checked !== false;
if (isExternalModelSource(m.source)) return m.checked !== false;
return !m.selectedVersion.existsLocally;
});
if (downloadItems.length === 0) {
@@ -2255,10 +2293,11 @@ export class DownloadManager {
let cancelled = false;
const failedItems = [];
// Successful CivitAI items are reconciled in place afterwards
// (their cards can be matched by model id); HF items keep the
// legacy full reload because they have no CivitAI identity (#1078).
// (their cards can be matched by model id); externally-sourced items
// keep the legacy full reload because they have no CivitAI identity
// (#1078).
const completedCivitaiItems = [];
let hfCompletedCount = 0;
let externalCompletedCount = 0;
loadingManager.showCancelButton(async () => {
if (cancelled) return;
@@ -2301,15 +2340,15 @@ export class DownloadManager {
const item = downloadItems[i];
const name = item.displayName || item.filename || (item.selectedVersion?.name || `Model #${item.modelId}`);
const isHf = item.source === 'huggingface';
const isExternal = isExternalModelSource(item.source);
updateProgress(0, completedDownloads, name);
loadingManager.setStatus(`${i + 1}/${downloadItems.length}: ${name}`);
try {
let response;
if (isHf) {
const downloadId = Date.now().toString() + '_hf_' + i;
if (isExternal) {
const downloadId = Date.now().toString() + '_src_' + i;
const wsHf = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
try {
await new Promise((resolve, reject) => {
@@ -2319,6 +2358,9 @@ export class DownloadManager {
const snapshotCompleted = completedDownloads;
wsHf.onmessage = (event) => {
const data = JSON.parse(event.data);
if (this._applyMetadataStage(data, updateProgress, snapshotCompleted, name)) {
return;
}
if (data.status === 'progress') {
const metrics = {
bytesDownloaded: data.bytes_downloaded,
@@ -2329,10 +2371,11 @@ export class DownloadManager {
}
};
response = await this.apiClient.downloadHfModel({
response = await this.apiClient.downloadModelSource({
platform: item.platform || item.source,
repo: item.repo,
filename: item.filename,
revision: item.revision || 'main',
revision: item.revision || this._defaultRevisionFor(item.platform || item.source),
modelRoot,
relativePath: targetFolder,
useDefaultPaths,
@@ -2363,8 +2406,8 @@ export class DownloadManager {
} else {
completedDownloads++;
updateProgress(100, completedDownloads, '');
if (isHf) {
hfCompletedCount++;
if (isExternal) {
externalCompletedCount++;
} else {
completedCivitaiItems.push(item);
}
@@ -2398,7 +2441,7 @@ export class DownloadManager {
});
}
await this._reconcileBatchViewAfterDownload(completedCivitaiItems, hfCompletedCount);
await this._reconcileBatchViewAfterDownload(completedCivitaiItems, externalCompletedCount);
}
async downloadVersionWithDefaults(modelType, modelId, versionId, {
+79 -8
View File
@@ -1,5 +1,6 @@
import { translate } from '../utils/i18nHelpers.js';
import { formatFileSize } from '../utils/formatters.js';
import { getModelSource } from '../utils/modelSourceHelpers.js';
// Loading management
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
updateTransferStats();
@@ -285,19 +315,62 @@ export class LoadingManager {
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
currentItemProgress.style.width = `${currentProgress}%`;
currentItemPercent.textContent = `${Math.floor(currentProgress)}%`;
currentItemProgress.classList.toggle('is-indeterminate', isMetadata);
// Update current item label if name provided
if (currentName) {
currentItemLabel.textContent = translate(
'modals.download.progress.downloading',
{ name: currentName },
`Downloading: ${currentName}`
currentItemLabel.textContent = isMetadata
? translate(
'modals.download.progress.metadata',
{ 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
@@ -311,8 +384,6 @@ export class LoadingManager {
// Single item, just update main progress
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
const helpModal = document.getElementById('helpModal');
if (helpModal) {
@@ -441,6 +453,7 @@ export class ModalManager {
id === "clearCacheModal" ||
id === "bulkDeleteModal" ||
id === "checkUpdatesConfirmModal" ||
id === "deleteFolderModal" ||
id === "resolveFilenameConflictsModal"
) {
modal.element.classList.add("show");
+311
View File
@@ -0,0 +1,311 @@
/**
* External model source helpers (Hugging Face / ModelScope / TensorArt).
*
* Mirrors `py/services/model_sources/registry.py` so the frontend and the
* backend agree on URL recognition, version-group keys, and which sites
* support AI metadata enrichment.
*
* Models loaded from an older cache may only carry the legacy `hf_url`
* field; every helper here falls back to it, and to the legacy
* `hf:user/repo` group key shape.
*/
import { translate } from './i18nHelpers.js';
export const MODEL_SOURCES = [
{
platform: 'huggingface',
label: 'Hugging Face',
groupPrefix: 'hf',
supportsEnrichment: true,
supportsDownload: true,
defaultRevision: 'main',
defaultSubdir: 'huggingface',
exampleUrl: 'https://huggingface.co/user/repo',
placeholder: 'https://huggingface.co/user/repo',
pattern: /^https?:\/\/(?:www\.)?huggingface\.co\/([^/?#\s]+\/[^/?#\s]+)/i,
// `blob` is the web preview page; it maps 1:1 to the `resolve` download URL.
filePattern:
/^https?:\/\/(?:www\.)?huggingface\.co\/([^/?#\s]+\/[^/?#\s]+)\/(?:resolve|blob)\/([^/?#\s]+)\/(.+)$/i,
canonical: (id) => `https://huggingface.co/${id}`,
filePage: (id, filename) => `https://huggingface.co/${id}/blob/main/${filename}`,
// Bare `user/repo` has always meant Hugging Face; keep that meaning.
bareRepoPattern: /^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)$/,
},
{
platform: 'modelscope',
label: 'ModelScope',
groupPrefix: 'ms',
supportsEnrichment: true,
supportsDownload: true,
defaultRevision: 'master',
defaultSubdir: 'modelscope',
exampleUrl: 'https://modelscope.cn/models/user/repo',
placeholder: 'https://modelscope.cn/models/user/repo',
pattern: /^https?:\/\/(?:www\.)?modelscope\.(?:cn|com)\/models\/([^/?#\s]+\/[^/?#\s]+)/i,
filePattern:
/^https?:\/\/(?:www\.)?modelscope\.(?:cn|com)\/models\/([^/?#\s]+\/[^/?#\s]+)\/resolve\/([^/?#\s]+)\/(.+)$/i,
canonical: (id) => `https://modelscope.cn/models/${id}`,
filePage: (id, 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',
label: 'TensorArt',
groupPrefix: 'ta',
supportsEnrichment: false,
supportsDownload: false,
defaultRevision: '',
defaultSubdir: '',
exampleUrl: 'https://tensor.art/models/827823520299086029',
placeholder: 'https://tensor.art/models/827823520299086029',
pattern: /^https?:\/\/(?:www\.)?(?:tensor\.art|tusi\.cn)\/models\/(\d+)/i,
filePattern: null,
canonical: (id) => `https://tensor.art/models/${id}`,
filePage: null,
},
];
/** Return the source descriptor for a platform id, or null. */
export function getModelSource(platform) {
if (!platform || typeof platform !== 'string') return null;
const needle = platform.trim().toLowerCase();
return MODEL_SOURCES.find((source) => source.platform === needle) || null;
}
/**
* Parse any supported model URL.
* @returns {{platform: string, label: string, groupPrefix: string,
* supportsEnrichment: boolean, supportsDownload: boolean,
* sourceId: string, url: string}|null}
*/
export function parseModelSourceUrl(url) {
if (!url || typeof url !== 'string') return null;
const candidate = url.trim();
if (!candidate) return null;
for (const source of MODEL_SOURCES) {
const match = candidate.match(source.pattern);
if (match) {
return {
...source,
sourceId: match[1],
url: source.canonical(match[1]),
};
}
}
return null;
}
/** Return the stored source URL of a model (new field, then legacy). */
export function getModelSourceUrl(model) {
if (!model) return '';
const value = model.source_url || model.hf_url || '';
return typeof value === 'string' ? value.trim() : '';
}
/** Return the stored source platform of a model. */
export function getModelSourcePlatform(model) {
if (!model) return '';
const value = model.source_platform || '';
return typeof value === 'string' ? value.trim().toLowerCase() : '';
}
/**
* Resolve the full source descriptor for a model, tolerating models that
* predate the `source_*` fields.
*/
export function getModelSourceInfo(model) {
if (!model) return null;
const url = getModelSourceUrl(model);
const declared = getModelSource(getModelSourcePlatform(model));
const parsed = parseModelSourceUrl(url);
if (declared) {
return {
...declared,
sourceId: parsed ? parsed.sourceId : '',
url: parsed ? parsed.url : url,
};
}
return parsed;
}
/**
* Version-group key for a model, matching the backend's `_extract_group_key`.
* Returns `''` when the model has no external source.
*/
export function getModelSourceGroupKey(model) {
const info = getModelSourceInfo(model);
if (!info || !info.sourceId) return '';
return `${info.groupPrefix}:${info.sourceId}`;
}
/** Whether AI metadata enrichment can run for this model's source. */
export function canEnrichModelSource(model) {
const info = getModelSourceInfo(model);
return Boolean(info && info.supportsEnrichment);
}
/**
* Parse a version-group key such as `hf:user/repo`, `ms:user/repo`, or
* `ta:827823520299086029` back into its source descriptor.
*
* These keys are NOT CivitAI model ids, so callers must not send them to the
* CivitAI API.
*
* @returns {{platform: string, label: string, sourceId: string}|null}
*/
export function parseModelSourceGroupKey(groupKey) {
if (!groupKey || typeof groupKey !== 'string') return null;
const separator = groupKey.indexOf(':');
if (separator <= 0) return null;
const prefix = groupKey.slice(0, separator);
const source = MODEL_SOURCES.find((candidate) => candidate.groupPrefix === prefix);
if (!source) return null;
return {
platform: source.platform,
label: source.label,
sourceId: groupKey.slice(separator + 1),
};
}
/** Localised "View on X" title for the source globe icon. */
export function getModelSourceViewTitle(info) {
if (!info) return '';
if (info.platform === 'huggingface') {
return translate('modelCard.actions.viewOnHuggingFace', {}, 'View on Hugging Face');
}
return translate(
'modelCard.actions.viewOnSource',
{ source: info.label },
`View on ${info.label}`
);
}
/** Open a model page on its external site in a new tab. */
export function openModelSource(url) {
if (!url) return;
window.open(url, '_blank', 'noopener,noreferrer');
}
// ---------------------------------------------------------------------------
// Download support
// ---------------------------------------------------------------------------
/** Sources whose repositories the backend can download from. */
export const DOWNLOADABLE_SOURCES = MODEL_SOURCES.filter((s) => s.supportsDownload);
/**
* Whether a DownloadManager `source` value refers to an external repository
* download (as opposed to a CivitAI/CivArchive version or a direct link).
*/
export function isExternalModelSource(source) {
return DOWNLOADABLE_SOURCES.some((s) => s.platform === source);
}
/** Return the downloadable source descriptor for a platform, or null. */
export function getDownloadSource(platform) {
const source = getModelSource(platform);
return source && source.supportsDownload ? source : null;
}
/** Normalise a repository id: reject traversal, exactly one slash. */
export function isValidRepoId(repo) {
if (!repo || typeof repo !== 'string' || repo.split('/').length !== 2) return false;
return repo
.split('/')
.every((part) => part && part !== '.' && part !== '..' && /^[A-Za-z0-9_][\w.-]*$/.test(part));
}
/**
* Recognise a downloadable model-source URL.
*
* Handles both a repository page and a direct file (resolve) URL for every
* source that supports downloads, plus the historical bare `owner/name`
* shorthand, which only ever meant Hugging Face.
*
* @returns {{kind: 'repo'|'file', platform: string, label: string,
* repo: string, revision?: string, filename?: string}|null}
*/
export function detectModelSourceDownloadUrl(url) {
if (!url || typeof url !== 'string') return null;
const candidate = url.trim();
if (!candidate) return null;
// Direct file URLs first: the repo pattern would match their prefix and
// lose the revision/filename.
for (const source of DOWNLOADABLE_SOURCES) {
if (!source.filePattern) continue;
const match = candidate.match(source.filePattern);
if (match) {
return {
kind: 'file',
platform: source.platform,
label: source.label,
repo: match[1],
revision: match[2],
filename: match[3],
};
}
}
for (const source of DOWNLOADABLE_SOURCES) {
const match = candidate.match(source.pattern);
if (match) {
return {
kind: 'repo',
platform: source.platform,
label: source.label,
repo: match[1],
};
}
}
if (!candidate.includes('://')) {
for (const source of DOWNLOADABLE_SOURCES) {
if (!source.bareRepoPattern) continue;
const match = candidate.match(source.bareRepoPattern);
if (match && isValidRepoId(match[1])) {
return {
kind: 'repo',
platform: source.platform,
label: source.label,
repo: match[1],
};
}
}
}
return null;
}
/** Human-facing page for one file of an external repository. */
export function buildModelSourceFilePage({ platform, repo, filename }) {
const source = getModelSource(platform);
if (!source || !source.filePage || !filename) {
return source ? source.canonical(repo) : null;
}
return source.filePage(repo, filename);
}
+1 -1
View File
@@ -21,7 +21,7 @@
<i class="fas fa-external-link-alt"></i> <span>{{ t('loras.contextMenu.linkCivitai') }}</span>
</div>
<div class="context-menu-item" data-action="link-hf">
<i class="fas fa-robot"></i> <span>{{ t('loras.contextMenu.linkHuggingFace') }}</span>
<i class="fas fa-robot"></i> <span>{{ t('loras.contextMenu.linkModelSource') }}</span>
</div>
</div>
</div>
+38 -1
View File
@@ -21,7 +21,7 @@
<i class="fas fa-external-link-alt"></i> <span>{{ t('loras.contextMenu.linkCivitai') }}</span>
</div>
<div class="context-menu-item" data-action="link-hf">
<i class="fas fa-robot"></i> <span>{{ t('loras.contextMenu.linkHuggingFace') }}</span>
<i class="fas fa-robot"></i> <span>{{ t('loras.contextMenu.linkModelSource') }}</span>
</div>
</div>
</div>
@@ -205,10 +205,47 @@
</div>
<!-- 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 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">
<i class="fas fa-folder-plus"></i> <span>{{ t('sidebar.newSubfolder') }}</span>
</div>
<div class="context-menu-item" data-action="rename-folder">
<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>
<!-- Sidebar View Options Menu -->
<div id="sidebarViewOptionsMenu" class="context-menu">
<div class="context-menu-item" data-action="view-mode-tree">
<i class="fas fa-sitemap"></i> <span>{{ t('sidebar.treeView') }}</span>
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
</div>
<div class="context-menu-item" data-action="view-mode-list">
<i class="fas fa-list"></i> <span>{{ t('sidebar.listView') }}</span>
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="toggle-recursive">
<i class="fas fa-code-branch"></i> <span>{{ t('sidebar.recursiveOn') }}</span>
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
</div>
<div class="context-menu-item" data-action="toggle-empty-folders">
<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>
</div>
</div>
<div id="nsfwLevelSelector" class="nsfw-level-selector">
+5 -5
View File
@@ -3,15 +3,15 @@
<div class="sidebar-header" id="sidebarHeader">
<h3><i class="fas fa-home"></i> <span id="sidebarTitle">{{ t('sidebar.modelRoot') }}</span></h3>
<div class="sidebar-header-actions">
<button class="sidebar-action-btn" id="sidebarDisplayModeToggle" title="{{ t('sidebar.switchToListView') }}">
<i class="fas fa-sitemap"></i>
</button>
<button class="sidebar-action-btn active" id="sidebarRecursiveToggle" title="{{ t('sidebar.recursiveOn') }}" aria-pressed="true">
<i class="fas fa-code-branch"></i>
<button class="sidebar-action-btn" id="sidebarCreateFolder" title="{{ t('sidebar.createFolder') }}">
<i class="fas fa-folder-plus"></i>
</button>
<button class="sidebar-action-btn" id="sidebarCollapseAll" title="{{ t('sidebar.collapseAll') }}">
<i class="fas fa-compress-alt"></i>
</button>
<button class="sidebar-action-btn" id="sidebarViewOptions" title="{{ t('sidebar.viewOptions') }}" aria-haspopup="true">
<i class="fas fa-ellipsis-v"></i>
</button>
<button class="sidebar-action-btn" id="sidebarHideToggle" title="{{ t('sidebar.hideOnThisPage') }}">
<i class="fas fa-chevron-left"></i>
</button>
@@ -82,6 +82,21 @@
</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 -->
<div id="bulkDownloadMissingLorasModal" class="modal">
<div class="modal-content">
+14 -8
View File
@@ -1,24 +1,30 @@
<!-- Link to HuggingFace Modal -->
<!-- Link to Model Source Modal -->
<div id="linkHfModal" class="modal">
<div class="modal-content">
<button class="close" onclick="modalManager.closeModal('linkHfModal')">&times;</button>
<h2>{{ t('modals.linkHuggingFace.title') }}</h2>
<h2>{{ t('modals.linkModelSource.title') }}</h2>
<div class="warning-box">
<i class="fas fa-info-circle"></i>
<p>{{ t('modals.linkHuggingFace.infoText') }}</p>
<p>{{ t('modals.linkModelSource.infoText') }}</p>
</div>
<div class="input-group">
<label for="hfModelUrl">{{ t('modals.linkHuggingFace.urlLabel') }}</label>
<input type="text" id="hfModelUrl" placeholder="{{ t('modals.linkHuggingFace.urlPlaceholder') }}" />
<label for="hfModelUrl">{{ t('modals.linkModelSource.urlLabel') }}</label>
<input type="text" id="hfModelUrl" placeholder="{{ t('modals.linkModelSource.urlPlaceholder') }}" />
<div class="input-error" id="hfModelUrlError"></div>
<div class="input-help">
{{ t('modals.linkHuggingFace.helpText') }}<br>
<strong>https://huggingface.co/user/repo</strong>
{{ t('modals.linkModelSource.helpText') }}
<div id="hfSupportedSources">
<strong>https://huggingface.co/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>
</div>
{{ t('modals.linkModelSource.enrichNote') }}
</div>
</div>
<div class="modal-actions">
<button class="cancel-btn" onclick="modalManager.closeModal('linkHfModal')">{{ t('common.actions.cancel') }}</button>
<button class="confirm-btn" id="confirmLinkHfBtn">{{ t('modals.linkHuggingFace.confirmAction') }}</button>
<button class="confirm-btn" id="confirmLinkHfBtn">{{ t('modals.linkModelSource.confirmAction') }}</button>
</div>
</div>
</div>
+7 -2
View File
@@ -26,8 +26,13 @@
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
<!-- Recipe Tags Container (rendered by renderCompactTags) -->
<div id="recipeTagsContainer"></div>
<!-- Tags row: the base model badge is an independent sibling of the
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>
<div class="modal-body">
+1 -1
View File
@@ -21,7 +21,7 @@
<i class="fas fa-external-link-alt"></i> <span>{{ t('loras.contextMenu.linkCivitai') }}</span>
</div>
<div class="context-menu-item" data-action="link-hf">
<i class="fas fa-robot"></i> <span>{{ t('loras.contextMenu.linkHuggingFace') }}</span>
<i class="fas fa-robot"></i> <span>{{ t('loras.contextMenu.linkModelSource') }}</span>
</div>
</div>
</div>
+1 -1
View File
@@ -80,7 +80,7 @@
<i class="fas fa-external-link-alt"></i> <span>{{ t('loras.contextMenu.linkCivitai') }}</span>
</div>
<div class="context-menu-item" data-action="link-hf">
<i class="fas fa-robot"></i> <span>{{ t('loras.contextMenu.linkHuggingFace') }}</span>
<i class="fas fa-robot"></i> <span>{{ t('loras.contextMenu.linkModelSource') }}</span>
</div>
</div>
</div>
@@ -108,7 +108,12 @@ def evaluate_model(
model_description: str = metadata.get("modelDescription") or ""
base_model: str = metadata.get("base_model") or ""
preview_url: str = metadata.get("preview_url") or ""
confidence: str = metadata.get("_llm_confidence") or ""
# `_llm_confidence` is the legacy key: underscore-prefixed metadata keys are
# deliberately not persisted through `BaseModelMetadata`, so older sidecars
# may still carry it while current ones use `llm_confidence`.
confidence: str = (
metadata.get("llm_confidence") or metadata.get("_llm_confidence") or ""
)
# --- base_model ---
base_model_valid = base_model in SUPPORTED_BASE_MODELS
@@ -191,4 +191,60 @@ describe('ModelCard source globe (#1094)', () => {
expect(openHuggingFace).toHaveBeenCalledWith('https://huggingface.co/user/repo');
expect(openCivitai).not.toHaveBeenCalled();
});
it('points the globe at ModelScope for a ModelScope-linked model', () => {
const card = mountCard(
createModelCard,
makeModel({
from_civitai: false,
civitai: {},
source_platform: 'modelscope',
source_url: 'https://modelscope.cn/models/user/repo',
})
);
expect(card.dataset.has_civitai).toBe('false');
expect(card.dataset.source_platform).toBe('modelscope');
expect(card.dataset.hf_url).toBe('');
expect(card.querySelector('.fa-globe').getAttribute('title')).toBe('View on ModelScope');
});
it('opens the ModelScope page when the globe is clicked', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => {});
const card = mountCard(
createModelCard,
makeModel({
from_civitai: false,
civitai: {},
source_platform: 'modelscope',
source_url: 'https://modelscope.cn/models/user/repo',
})
);
setupModelCardEventDelegation('loras');
card.querySelector('.fa-globe').dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(openSpy).toHaveBeenCalledWith(
'https://modelscope.cn/models/user/repo',
'_blank',
'noopener,noreferrer'
);
expect(openCivitai).not.toHaveBeenCalled();
expect(openHuggingFace).not.toHaveBeenCalled();
openSpy.mockRestore();
});
it('points the globe at TensorArt for a TensorArt-linked model', () => {
const card = mountCard(
createModelCard,
makeModel({
from_civitai: false,
civitai: {},
source_platform: 'tensorart',
source_url: 'https://tensor.art/models/827823520299086029',
})
);
expect(card.querySelector('.fa-globe').getAttribute('title')).toBe('View on TensorArt');
});
});
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ModelContextMenuMixin } from '../../../static/js/components/ContextMenu/ModelContextMenuMixin.js';
@@ -14,3 +14,115 @@ describe('ModelContextMenuMixin.getModelTypePrefix', () => {
expect(ModelContextMenuMixin.getModelTypePrefix.call({})).toBe('loras');
});
});
describe('ModelContextMenuMixin.updateEnrichMenuItem', () => {
function setupMenu() {
document.body.innerHTML = '<div id="menu"><div data-action="enrich-hf-llm"></div></div>';
return { menu: document.getElementById('menu') };
}
function cardWith(dataset) {
return { dataset };
}
it('enables enrichment for Hugging Face links', () => {
const context = setupMenu();
ModelContextMenuMixin.updateEnrichMenuItem.call(
context,
cardWith({ hf_url: 'https://huggingface.co/user/repo' })
);
const item = context.menu.querySelector('[data-action="enrich-hf-llm"]');
expect(item.classList.contains('disabled')).toBe(false);
expect(item.title).toBe('');
});
it('enables enrichment for ModelScope links', () => {
const context = setupMenu();
ModelContextMenuMixin.updateEnrichMenuItem.call(
context,
cardWith({
source_platform: 'modelscope',
source_url: 'https://modelscope.cn/models/user/repo',
})
);
const item = context.menu.querySelector('[data-action="enrich-hf-llm"]');
expect(item.classList.contains('disabled')).toBe(false);
});
it('disables enrichment for TensorArt and explains why', () => {
const context = setupMenu();
ModelContextMenuMixin.updateEnrichMenuItem.call(
context,
cardWith({
source_platform: 'tensorart',
source_url: 'https://tensor.art/models/827823520299086029',
})
);
const item = context.menu.querySelector('[data-action="enrich-hf-llm"]');
expect(item.classList.contains('disabled')).toBe(true);
expect(item.title).toContain('TensorArt');
});
it('disables enrichment when no source is linked', () => {
const context = setupMenu();
ModelContextMenuMixin.updateEnrichMenuItem.call(context, cardWith({}));
const item = context.menu.querySelector('[data-action="enrich-hf-llm"]');
expect(item.classList.contains('disabled')).toBe(true);
expect(item.title).toContain('Link this model to a model source');
});
});
describe('ModelContextMenuMixin._renderSupportedSources', () => {
const originalFetch = global.fetch;
beforeEach(() => {
document.body.innerHTML = '<div id="hfSupportedSources">static fallback</div>';
});
afterEach(() => {
global.fetch = originalFetch;
});
it('renders the server-provided example URLs', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => [
{ platform: 'huggingface', example_url: 'https://huggingface.co/user/repo' },
{ platform: 'modelscope', example_url: 'https://modelscope.cn/models/user/repo' },
{ platform: 'tensorart', example_url: 'https://tensor.art/models/123' },
],
});
await ModelContextMenuMixin._renderSupportedSources.call({});
const html = document.getElementById('hfSupportedSources').innerHTML;
expect(html).toContain('https://huggingface.co/user/repo');
expect(html).toContain('https://modelscope.cn/models/user/repo');
expect(html).toContain('https://tensor.art/models/123');
});
it('keeps the static fallback when the request fails', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('offline'));
await ModelContextMenuMixin._renderSupportedSources.call({});
expect(document.getElementById('hfSupportedSources').innerHTML).toBe('static fallback');
});
it('escapes markup from the server payload', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => [{ example_url: '<img src=x onerror=alert(1)>' }],
});
await ModelContextMenuMixin._renderSupportedSources.call({});
const html = document.getElementById('hfSupportedSources').innerHTML;
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img');
});
});
@@ -0,0 +1,189 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
MODEL_TAGS_MODULE,
I18N_HELPERS_MODULE,
UI_HELPERS_MODULE,
MODEL_API_MODULE,
PRIORITY_TAGS_MODULE,
STATE_MODULE,
saveModelMetadataMock,
} = vi.hoisted(() => ({
MODEL_TAGS_MODULE: new URL('../../../static/js/components/shared/ModelTags.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
MODEL_API_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
PRIORITY_TAGS_MODULE: new URL('../../../static/js/utils/priorityTagHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
saveModelMetadataMock: vi.fn(),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((key, params, fallback) => fallback || key),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
copyToClipboard: vi.fn(),
}));
vi.mock(MODEL_API_MODULE, () => ({
getModelApiClient: vi.fn(() => ({
saveModelMetadata: saveModelMetadataMock,
})),
}));
vi.mock(PRIORITY_TAGS_MODULE, () => ({
getPriorityTagSuggestions: vi.fn(async () => []),
}));
vi.mock(STATE_MODULE, () => ({
state: { currentPageType: 'loras' },
}));
const TAG_SECTION_HTML = (tags) => `
<div class="model-tags-container">
<div class="model-tags-header">
<div class="model-tags-compact">
${tags.map((tag) => `<span class="model-tag-compact">${tag}</span>`).join('')}
</div>
<button class="edit-tags-btn" data-file-path="test.safetensors" title="Edit tags">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
<div class="model-tags-tooltip">
<div class="tooltip-content">
${tags.map((tag) => `<span class="tooltip-tag">${tag}</span>`).join('')}
</div>
</div>
</div>
`;
describe("ModelTags reordering", () => {
let setupTagEditMode;
beforeEach(async () => {
document.body.innerHTML = '';
vi.clearAllMocks();
saveModelMetadataMock.mockResolvedValue({});
const module = await import(MODEL_TAGS_MODULE);
setupTagEditMode = module.setupTagEditMode;
});
function section() {
return document.querySelector('.model-tags-container');
}
function items() {
return Array.from(document.querySelectorAll('.metadata-item'));
}
function order() {
return items().map((item) => item.dataset.tag);
}
function handles() {
return Array.from(document.querySelectorAll('.reorder-handle'));
}
function firePointer(type, target, init = {}) {
target.dispatchEvent(new PointerEvent(type, {
bubbles: true,
cancelable: true,
...init,
}));
}
function dragToEnd(target) {
firePointer('pointerdown', target, { clientY: 10 });
firePointer('pointermove', target, { clientY: 999 });
firePointer('pointerup', target, { clientY: 999 });
}
async function enterEditMode(tags = ['alpha', 'beta', 'gamma']) {
document.body.innerHTML = TAG_SECTION_HTML(tags);
setupTagEditMode('loras');
document.querySelector('.edit-tags-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(document.querySelector('.metadata-edit-container')).toBeTruthy();
});
}
it("renders a grip handle per tag and flags the section as sortable", async () => {
await enterEditMode(['alpha', 'beta']);
expect(items()).toHaveLength(2);
expect(handles()).toHaveLength(2);
expect(section().classList.contains('has-sortable-words')).toBe(true);
expect(
document.querySelector('.metadata-items').classList.contains('pointer-sort-enabled'),
).toBe(true);
});
it("does not offer reordering for a single tag", async () => {
await enterEditMode(['alpha']);
expect(handles()).toHaveLength(1);
expect(section().classList.contains('has-sortable-words')).toBe(false);
});
it("keeps the whole chip draggable, not just the grip", async () => {
await enterEditMode();
// Drag from the chip body (no handle involved)
dragToEnd(items()[0].querySelector('.metadata-item-content'));
expect(order()).toEqual(['beta', 'gamma', 'alpha']);
});
it("reorders by dragging the grip", async () => {
await enterEditMode();
dragToEnd(handles()[0]);
expect(order()).toEqual(['beta', 'gamma', 'alpha']);
});
it("does not reorder when the grip is only clicked", async () => {
await enterEditMode();
const handle = handles()[0];
firePointer('pointerdown', handle, { clientY: 10 });
firePointer('pointermove', handle, { clientY: 12 });
firePointer('pointerup', handle, { clientY: 12 });
expect(order()).toEqual(['alpha', 'beta', 'gamma']);
});
it("saves the new order after a drag", async () => {
await enterEditMode();
dragToEnd(handles()[0]);
expect(order()).toEqual(['beta', 'gamma', 'alpha']);
document.querySelector('.save-tags-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(saveModelMetadataMock).toHaveBeenCalled();
});
expect(saveModelMetadataMock).toHaveBeenCalledWith('test.safetensors', {
tags: ['beta', 'gamma', 'alpha'],
});
});
it("updates the sortable flag when tags are deleted", async () => {
await enterEditMode(['alpha', 'beta']);
items()[1].querySelector('.metadata-delete-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(order()).toEqual(['alpha']);
expect(section().classList.contains('has-sortable-words')).toBe(false);
});
});
@@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it } from "vitest";
const POINTER_SORT_MODULE = new URL(
'../../../static/js/components/shared/pointerSort.js',
import.meta.url,
).pathname;
describe("pointerSort", () => {
let enablePointerSort;
let disablePointerSort;
beforeEach(async () => {
document.body.innerHTML = '';
const module = await import(POINTER_SORT_MODULE);
enablePointerSort = module.enablePointerSort;
disablePointerSort = module.disablePointerSort;
});
function buildList(words) {
document.body.innerHTML = `
<div class="list">
${words.map((word) => `
<div class="item" data-id="${word}">
<span class="handle">::</span>
<span class="label">${word}</span>
</div>
`).join('')}
</div>
`;
return {
container: document.querySelector('.list'),
items: Array.from(document.querySelectorAll('.item')),
};
}
/**
* Explicit class names so the assertions test the configurable engine
* rather than the metadata-* defaults used by the tag editor.
*/
function sortOptions(extra = {}) {
return {
itemSelector: '.item',
draggingClass: 'item-dragging',
placeholderClass: 'item-placeholder',
containerSortingClass: 'list-sorting',
bodySortingClass: 'drag-active',
...extra,
};
}
function order() {
return Array.from(document.querySelectorAll('.item')).map((el) => el.dataset.id);
}
function firePointer(type, target, init = {}) {
target.dispatchEvent(new PointerEvent(type, {
bubbles: true,
cancelable: true,
...init,
}));
}
/**
* jsdom reports zero-sized rects for every element, which makes the engine
* treat a high clientY as "past the last item".
*/
function dragToEnd(target) {
firePointer('pointerdown', target);
firePointer('pointermove', target, { clientY: 999 });
firePointer('pointerup', target, { clientY: 999 });
}
it("reorders items when the whole item is draggable", () => {
const { container } = buildList(['a', 'b', 'c']);
enablePointerSort(container, sortOptions());
const first = document.querySelector('.item');
dragToEnd(first);
expect(order()).toEqual(['b', 'c', 'a']);
});
it("keeps the metadata-* defaults the tag editor relies on", () => {
document.body.innerHTML = `
<div class="metadata-items">
<div class="metadata-item" data-id="a">a</div>
<div class="metadata-item" data-id="b">b</div>
</div>
`;
const container = document.querySelector('.metadata-items');
// No options at all: ModelTags.js calls the engine exactly like this
enablePointerSort(container);
const first = container.querySelector('.metadata-item');
firePointer('pointerdown', first);
firePointer('pointermove', first, { clientY: 999 });
firePointer('pointerup', first, { clientY: 999 });
expect(
Array.from(container.querySelectorAll('.metadata-item')).map((el) => el.dataset.id),
).toEqual(['b', 'a']);
expect(container.classList.contains('pointer-sort-enabled')).toBe(true);
expect(document.querySelector('.reorder-placeholder')).toBeNull();
});
it("only starts a drag from the configured handle", () => {
const { container } = buildList(['a', 'b', 'c']);
enablePointerSort(container, sortOptions({ handleSelector: '.handle' }));
// Pressing the item body must not start a drag
dragToEnd(document.querySelector('.item .label'));
expect(order()).toEqual(['a', 'b', 'c']);
expect(document.querySelector('.item-dragging')).toBeNull();
// Pressing the handle does
dragToEnd(document.querySelector('.item .handle'));
expect(order()).toEqual(['b', 'c', 'a']);
});
it("never drags items matching the blocked selector or the ignore selector", () => {
const { container } = buildList(['a', 'b']);
enablePointerSort(container, sortOptions({ blockedItemSelector: '.locked' }));
document.querySelector('.item').classList.add('locked');
dragToEnd(document.querySelector('.item'));
expect(order()).toEqual(['a', 'b']);
document.querySelector('.item').classList.remove('locked');
enablePointerSort(container, sortOptions({ ignoreSelector: '.label' }));
dragToEnd(document.querySelector('.item .label'));
expect(order()).toEqual(['a', 'b']);
});
it("waits for the drag threshold before lifting an item", () => {
const { container } = buildList(['a', 'b']);
enablePointerSort(container, sortOptions({ dragThreshold: 10 }));
const first = document.querySelector('.item');
firePointer('pointerdown', first, { clientX: 10, clientY: 10 });
firePointer('pointermove', first, { clientX: 15, clientY: 10 });
expect(document.querySelector('.item-dragging')).toBeNull();
firePointer('pointermove', first, { clientX: 60, clientY: 10 });
expect(document.querySelector('.item-dragging')).not.toBeNull();
firePointer('pointerup', first, { clientX: 60, clientY: 10 });
expect(order()).toEqual(['b', 'a']);
});
it("calls onSorted once a drop completes", () => {
const { container } = buildList(['a', 'b']);
const onSorted = [];
enablePointerSort(container, sortOptions({
onSorted: (item) => onSorted.push(item.dataset.id),
}));
dragToEnd(document.querySelector('.item'));
expect(onSorted).toEqual(['a']);
});
it("stops handling drags after disablePointerSort", () => {
const { container } = buildList(['a', 'b']);
enablePointerSort(container, sortOptions());
disablePointerSort(container, sortOptions());
dragToEnd(document.querySelector('.item'));
expect(order()).toEqual(['a', 'b']);
});
});
@@ -0,0 +1,234 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const copyToClipboardMock = vi.fn();
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
const loadingManagerStub = {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
show: vi.fn(),
restoreProgressBar: vi.fn(),
};
const recipeItem = {
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
file_path: '/recipes/a1b2c3d4-e5f6-7890-abcd-ef1234567890.png',
title: 'Demo Recipe',
tags: [],
loras: [],
base_model: 'Illustrious',
};
const virtualScrollerStub = {
updateSingleItem: vi.fn(),
getNavigationState: vi.fn(() => ({
index: 0,
hasPrev: false,
hasNext: false,
loadedItems: 1,
totalItems: 1,
})),
getAdjacentItemByFilePath: vi.fn(async () => null),
};
const stateStub = {
global: { settings: {}, loadingManager: loadingManagerStub },
loadingManager: loadingManagerStub,
virtualScroller: virtualScrollerStub,
};
const modalManagerMock = {
showModal: vi.fn(),
closeModal: vi.fn(),
};
const fetchRecipeDetailsMock = vi.fn(async () => ({}));
const updateRecipeMetadataMock = vi.fn(() => Promise.resolve({ success: true }));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
copyToClipboard: copyToClipboardMock,
sendLoraToWorkflow: vi.fn(),
sendModelPathToWorkflow: vi.fn(),
openCivitaiByMetadata: vi.fn(),
stripLoraTags: vi.fn((text) => text),
sendPromptToWorkflow: vi.fn(),
sendGenParamsToWorkflow: vi.fn(),
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: translateMock,
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: stateStub,
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
setSessionItem: vi.fn(),
removeSessionItem: vi.fn(),
getStorageItem: vi.fn(() => null),
setStorageItem: vi.fn(),
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
fetchRecipeDetails: fetchRecipeDetailsMock,
updateRecipeMetadata: updateRecipeMetadataMock,
sendRecipeWorkflow: vi.fn(),
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings',
},
}));
function recipeModalFixture() {
return `
<div id="recipeModal" class="modal">
<div class="modal-content">
<header class="recipe-modal-header">
<div class="recipe-modal-header-row">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="modal-nav-controls">
<button class="modal-nav-btn" id="recipeNavPrevBtn" disabled></button>
<button class="modal-nav-btn" id="recipeNavNextBtn" disabled></button>
</div>
</div>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn"><i class="fas fa-paper-plane"></i></button>
<button class="modal-copy-btn" id="copyRecipeSyntaxBtn"><i class="fas fa-copy"></i></button>
</div>
<div class="recipe-tags-row">
<span id="recipeBaseModelBadge" class="base-model-label recipe-base-model-badge" hidden></span>
<div id="recipeTagsContainer"></div>
</div>
</header>
<div class="modal-body">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
<div class="param-content" id="recipePrompt"></div>
<div class="param-editor" id="recipePromptEditor">
<textarea class="param-textarea" id="recipePromptInput"></textarea>
</div>
</div>
<div class="param-group info-item">
<div class="param-content" id="recipeNegativePrompt"></div>
<div class="param-editor" id="recipeNegativePromptEditor">
<textarea class="param-textarea" id="recipeNegativePromptInput"></textarea>
</div>
</div>
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-actions">
<span id="recipeLorasCount"></span>
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn"></button>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
</div>
</div>
<footer class="recipe-meta-footer" id="recipeMetaFooter" hidden></footer>
</div>
</div>
`;
}
async function flushAsyncTasks() {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
const createdModals = [];
async function createRecipeModal() {
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
const recipeModal = new RecipeModal();
createdModals.push(recipeModal);
return recipeModal;
}
describe('RecipeModal base model badge', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = recipeModalFixture();
global.modalManager = modalManagerMock;
global.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({}),
}));
});
afterEach(() => {
createdModals.forEach(recipeModal => recipeModal.cleanupNavigationShortcuts());
createdModals.length = 0;
document.body.innerHTML = '';
delete global.modalManager;
delete global.fetch;
});
it('shows the full base model name with a labeled tooltip', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeItem);
const badge = document.getElementById('recipeBaseModelBadge');
expect(badge.hidden).toBe(false);
expect(badge.textContent).toBe('Illustrious');
expect(badge.title).toBe('Base Model: Illustrious');
expect(badge.getAttribute('aria-label')).toBe('Base Model: Illustrious');
expect(badge.classList.contains('is-unknown')).toBe(false);
});
it('falls back to a dimmed Unknown badge when no base model is set', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails({ ...recipeItem, base_model: '' });
const badge = document.getElementById('recipeBaseModelBadge');
expect(badge.hidden).toBe(false);
expect(badge.textContent).toBe('Unknown');
expect(badge.title).toBe('Base Model: Unknown');
expect(badge.classList.contains('is-unknown')).toBe(true);
});
it('updates the badge once hydration provides the base model', async () => {
fetchRecipeDetailsMock.mockResolvedValueOnce({
id: recipeItem.id,
file_path: recipeItem.file_path,
base_model: 'Pony',
});
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails({ ...recipeItem, base_model: '' });
await flushAsyncTasks();
const badge = document.getElementById('recipeBaseModelBadge');
expect(badge.textContent).toBe('Pony');
expect(badge.title).toBe('Base Model: Pony');
expect(badge.classList.contains('is-unknown')).toBe(false);
});
it('keeps the list-provided base model when hydration omits it', async () => {
fetchRecipeDetailsMock.mockResolvedValueOnce({
id: recipeItem.id,
file_path: recipeItem.file_path,
});
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeItem);
await flushAsyncTasks();
const badge = document.getElementById('recipeBaseModelBadge');
expect(badge.textContent).toBe('Illustrious');
expect(badge.title).toBe('Base Model: Illustrious');
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,231 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
TRIGGER_WORDS_MODULE,
I18N_HELPERS_MODULE,
UI_HELPERS_MODULE,
MODEL_API_MODULE,
saveModelMetadataMock,
} = vi.hoisted(() => ({
TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
MODEL_API_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
saveModelMetadataMock: vi.fn(),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((key, params, fallback) => fallback || key),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
copyToClipboard: vi.fn(),
}));
vi.mock(MODEL_API_MODULE, () => ({
getModelApiClient: vi.fn(() => ({
saveModelMetadata: saveModelMetadataMock,
})),
}));
describe("TriggerWords reordering", () => {
let renderTriggerWords;
let setupTriggerWordsEditMode;
beforeEach(async () => {
document.body.innerHTML = '';
vi.clearAllMocks();
saveModelMetadataMock.mockResolvedValue({});
global.fetch = vi.fn(async () => ({
json: async () => ({
success: true,
trained_words: [],
class_tokens: null,
}),
}));
const module = await import(TRIGGER_WORDS_MODULE);
renderTriggerWords = module.renderTriggerWords;
setupTriggerWordsEditMode = module.setupTriggerWordsEditMode;
});
function section() {
return document.querySelector('.trigger-words');
}
function order() {
return Array.from(document.querySelectorAll('.trigger-word-tag'))
.map((tag) => tag.dataset.word);
}
function handles() {
return Array.from(document.querySelectorAll('.reorder-handle'));
}
function firePointer(type, target, init = {}) {
target.dispatchEvent(new PointerEvent(type, {
bubbles: true,
cancelable: true,
...init,
}));
}
function dragToEnd(handle) {
firePointer('pointerdown', handle);
firePointer('pointermove', handle, { clientY: 999 });
firePointer('pointerup', handle, { clientY: 999 });
}
async function enterEditMode(words = ["alpha", "beta", "gamma"]) {
document.body.innerHTML = renderTriggerWords(words, "test.safetensors");
setupTriggerWordsEditMode();
document.querySelector('.edit-trigger-words-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(document.querySelector('.metadata-suggestions-dropdown')).toBeTruthy();
});
}
it("renders a handle per word but only offers reordering for 2+ words", async () => {
await enterEditMode(["alpha", "beta"]);
expect(handles()).toHaveLength(2);
expect(section().classList.contains('has-sortable-words')).toBe(true);
});
it("does not offer reordering when there is a single word", async () => {
await enterEditMode(["alpha"]);
expect(handles()).toHaveLength(1);
expect(section().classList.contains('has-sortable-words')).toBe(false);
});
it("keeps the grip a decorative drag affordance, not a keyboard control", async () => {
await enterEditMode();
const grip = handles()[0];
expect(grip.tagName).toBe('SPAN');
expect(grip.getAttribute('aria-hidden')).toBe('true');
expect(grip.hasAttribute('tabindex')).toBe(false);
expect(grip.getAttribute('title')).toBe('Drag to reorder');
});
it("reorders a word by dragging its handle and swallows the follow-up click", async () => {
await enterEditMode();
dragToEnd(handles()[0]);
expect(order()).toEqual(["beta", "gamma", "alpha"]);
// The click generated by the drag must not open the inline editor
const movedTag = document.querySelector('.trigger-word-tag[data-word="alpha"]');
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
movedTag.dispatchEvent(clickEvent);
expect(clickEvent.defaultPrevented).toBe(true);
expect(movedTag.querySelector('.trigger-word-edit-input')).toBeNull();
});
it("does not start a drag from the tag body", async () => {
await enterEditMode();
const content = document.querySelector('.trigger-word-content');
firePointer('pointerdown', content);
expect(document.body.classList.contains('reorder-drag-active')).toBe(false);
firePointer('pointermove', content, { clientY: 999 });
firePointer('pointerup', content, { clientY: 999 });
expect(order()).toEqual(["alpha", "beta", "gamma"]);
expect(document.querySelector('.reorder-dragging')).toBeNull();
});
it("treats a click on the grip without movement as a click, not a drag", async () => {
await enterEditMode();
const grip = handles()[0];
firePointer('pointerdown', grip, { clientY: 10 });
firePointer('pointermove', grip, { clientY: 12 });
firePointer('pointerup', grip, { clientY: 12 });
grip.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(order()).toEqual(["alpha", "beta", "gamma"]);
expect(document.querySelector('.trigger-word-edit-input')).toBeNull();
});
it("saves the new order after a drag", async () => {
await enterEditMode();
dragToEnd(handles()[0]);
expect(order()).toEqual(["beta", "gamma", "alpha"]);
document.querySelector('.metadata-save-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(saveModelMetadataMock).toHaveBeenCalled();
});
expect(saveModelMetadataMock).toHaveBeenCalledWith("test.safetensors", {
civitai: { trainedWords: ["beta", "gamma", "alpha"] },
});
});
it("restores the original order when edit mode is canceled", async () => {
await enterEditMode();
dragToEnd(handles()[0]);
expect(order()).toEqual(["beta", "gamma", "alpha"]);
document.querySelector('.edit-trigger-words-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(order()).toEqual(["alpha", "beta", "gamma"]);
});
it("wires reordering for words added while editing", async () => {
await enterEditMode(["alpha", "beta"]);
const input = document.querySelector('.metadata-input');
input.value = 'gamma';
input.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
}));
expect(order()).toEqual(["alpha", "beta", "gamma"]);
expect(handles()).toHaveLength(3);
expect(section().classList.contains('has-sortable-words')).toBe(true);
// The freshly added word is draggable too
const newHandle = document.querySelector(
'.trigger-word-tag[data-word="gamma"] .reorder-handle',
);
firePointer('pointerdown', newHandle);
firePointer('pointermove', newHandle, { clientY: -999 });
firePointer('pointerup', newHandle, { clientY: -999 });
expect(order()).toEqual(["gamma", "alpha", "beta"]);
});
it("stops handling drags once edit mode is left", async () => {
await enterEditMode();
document.querySelector('.edit-trigger-words-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
dragToEnd(handles()[0]);
expect(order()).toEqual(["alpha", "beta", "gamma"]);
expect(document.body.classList.contains('reorder-drag-active')).toBe(false);
// The grip must not be offered anymore outside edit mode
expect(section().classList.contains('has-sortable-words')).toBe(false);
expect(
document.querySelector('.trigger-words-tags').classList.contains('pointer-sort-enabled'),
).toBe(false);
});
});
@@ -26,6 +26,7 @@ const {
},
},
downloadModel: vi.fn(),
downloadModelSource: vi.fn(),
downloadHfModel: vi.fn(),
cancelDownload: vi.fn(),
getPageState: vi.fn(() => ({})),
@@ -158,7 +159,7 @@ describe('DownloadManager batch download summary flow', () => {
// Reset the shared mocks so mockResolvedValueOnce queues and call
// history never leak between tests.
mockApiClient.downloadModel.mockReset();
mockApiClient.downloadHfModel.mockReset();
mockApiClient.downloadModelSource.mockReset();
mockApiClient.cancelDownload.mockReset();
showToastMock.mockClear();
showDownloadBatchSummaryMock.mockClear();
@@ -406,14 +407,15 @@ describe('DownloadManager batch download summary flow', () => {
expect(showToastMock).toHaveBeenCalledWith('toast.loras.downloadCompleted', {}, 'success');
});
it('shows a summary for HF partial failure and retries only the failed files', async () => {
manager.hfRepoId = 'user/repo';
manager.hfSelectedFiles = ['a.safetensors', 'b.safetensors'];
mockApiClient.downloadHfModel
it('shows a summary for external repo partial failure and retries only the failed files', async () => {
manager.sourcePlatform = 'huggingface';
manager.sourceRepoId = 'user/repo';
manager.sourceSelectedFiles = ['a.safetensors', 'b.safetensors'];
mockApiClient.downloadModelSource
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({ success: false, error: 'denied' });
const result = await manager._downloadHfSingle({ modelRoot: '/m', useDefaultPaths: true });
const result = await manager._downloadExternalRepoFiles({ modelRoot: '/m', useDefaultPaths: true });
expect(result).toBe(false);
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
@@ -428,7 +430,9 @@ describe('DownloadManager batch download summary flow', () => {
await summary.onRetry();
expect(mockApiClient.downloadHfModel).toHaveBeenCalledTimes(3);
expect(mockApiClient.downloadHfModel.mock.calls[2][0].filename).toBe('b.safetensors');
expect(mockApiClient.downloadModelSource).toHaveBeenCalledTimes(3);
const retryArgs = mockApiClient.downloadModelSource.mock.calls[2][0];
expect(retryArgs.filename).toBe('b.safetensors');
expect(retryArgs.platform).toBe('huggingface');
});
});
@@ -0,0 +1,366 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
DOWNLOAD_MANAGER_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE,
STATE_MODULE,
LOADING_MANAGER_MODULE,
API_FACTORY_MODULE,
STORAGE_HELPERS_MODULE,
FOLDER_TREE_MANAGER_MODULE,
I18N_HELPERS_MODULE,
SUMMARY_MODULE,
mockApiClient,
mockLoadingManager,
showDownloadBatchSummaryMock,
} = vi.hoisted(() => {
const mockApiClient = {
apiConfig: { config: { displayName: 'LoRA', singularName: 'lora' } },
downloadModel: vi.fn(),
downloadModelSource: vi.fn(),
fetchModelSourceFiles: vi.fn(),
cancelDownload: vi.fn(),
getPageState: vi.fn(() => ({})),
};
const mockLoadingManager = {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
showDownloadProgress: vi.fn(() => vi.fn()),
setStatus: vi.fn(),
showCancelButton: vi.fn(),
};
return {
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
mockApiClient,
mockLoadingManager,
showDownloadBatchSummaryMock: vi.fn(),
};
});
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
}));
vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn() }));
vi.mock(STATE_MODULE, () => ({
state: { global: { settings: {} }, loadingManager: mockLoadingManager },
}));
vi.mock(LOADING_MANAGER_MODULE, () => ({
LoadingManager: vi.fn(() => mockLoadingManager),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(() => mockApiClient),
resetAndReload: vi.fn(),
}));
vi.mock(STORAGE_HELPERS_MODULE, () => ({
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
setStorageItem: vi.fn(),
}));
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
FolderTreeManager: vi.fn(() => ({ clearSelection: vi.fn(), init: vi.fn() })),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
vi.mock(SUMMARY_MODULE, () => ({
showDownloadBatchSummary: showDownloadBatchSummaryMock,
}));
class FakeWebSocket {
constructor(url) {
this.url = url;
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.close = vi.fn();
queueMicrotask(() => {
if (this.onopen) this.onopen();
});
}
}
const MS_REPO_URL = 'https://modelscope.cn/models/jj3550945163/Krea-2-LORA';
describe('DownloadManager external model source downloads', () => {
let DownloadManager;
let manager;
beforeEach(async () => {
document.body.innerHTML = '';
vi.stubGlobal('WebSocket', FakeWebSocket);
mockApiClient.downloadModelSource.mockReset();
mockApiClient.fetchModelSourceFiles.mockReset();
mockLoadingManager.showSimpleLoading.mockReset();
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
manager = new DownloadManager();
manager.apiClient = mockApiClient;
manager.showBatchPreviewStep = vi.fn();
manager.proceedToLocation = vi.fn();
});
it('loads a ModelScope repo as batch items on the master revision', async () => {
mockApiClient.fetchModelSourceFiles.mockResolvedValue([
{ filename: 'a.safetensors', size: 10 },
{ filename: 'sub/b.safetensors', size: 20 },
]);
const errorElement = { textContent: '' };
await manager._validateAndFetchExternalRepo([MS_REPO_URL], errorElement);
expect(mockApiClient.fetchModelSourceFiles).toHaveBeenCalledWith(
'jj3550945163/Krea-2-LORA',
'modelscope',
'master'
);
expect(errorElement.textContent).toBe('');
expect(manager.source).toBe('modelscope');
expect(manager.isBatchMode).toBe(true);
expect(manager.batchModels).toHaveLength(2);
expect(manager.batchModels[0]).toMatchObject({
source: 'modelscope',
platform: 'modelscope',
repo: 'jj3550945163/Krea-2-LORA',
revision: 'master',
filename: 'a.safetensors',
fileSizeBytes: 10,
displayName: 'a.safetensors',
});
expect(manager.showBatchPreviewStep).toHaveBeenCalled();
});
it('keeps Hugging Face on its own revision', async () => {
mockApiClient.fetchModelSourceFiles.mockResolvedValue([
{ filename: 'a.safetensors', size: 10 },
]);
await manager._validateAndFetchExternalRepo(
['https://huggingface.co/user/repo'],
{ textContent: '' }
);
expect(mockApiClient.fetchModelSourceFiles).toHaveBeenCalledWith(
'user/repo',
'huggingface',
'main'
);
expect(manager.batchModels[0].revision).toBe('main');
});
it('surfaces a listing failure on the URL field', async () => {
mockApiClient.fetchModelSourceFiles.mockRejectedValue(new Error('Repository not found'));
const errorElement = { textContent: '' };
await manager._validateAndFetchExternalRepo([MS_REPO_URL], errorElement);
expect(errorElement.textContent).toBe('Repository not found');
expect(manager.showBatchPreviewStep).not.toHaveBeenCalled();
});
it('skips file selection for a direct ModelScope file URL', async () => {
await manager._validateAndFetchExternalRepo(
[`${MS_REPO_URL}/resolve/master/Krea-2-LORA_c1-st1000.safetensors`],
{ textContent: '' }
);
expect(manager.isBatchMode).toBe(false);
expect(manager.sourcePlatform).toBe('modelscope');
expect(manager.sourceRepoId).toBe('jj3550945163/Krea-2-LORA');
expect(manager.sourceSelectedFiles).toEqual(['Krea-2-LORA_c1-st1000.safetensors']);
expect(manager.proceedToLocation).toHaveBeenCalled();
});
it('downloads a single ModelScope file through the generic endpoint', async () => {
mockApiClient.downloadModelSource.mockResolvedValue({ success: true });
manager.sourcePlatform = 'modelscope';
manager.sourceRepoId = 'jj3550945163/Krea-2-LORA';
manager.sourceSelectedFiles = ['Krea-2-LORA_c1-st1000.safetensors'];
await manager._downloadExternalRepoFiles({
modelRoot: '/models/loras',
targetFolder: '',
useDefaultPaths: true,
});
expect(mockApiClient.downloadModelSource).toHaveBeenCalledTimes(1);
expect(mockApiClient.downloadModelSource.mock.calls[0][0]).toMatchObject({
platform: 'modelscope',
repo: 'jj3550945163/Krea-2-LORA',
filename: 'Krea-2-LORA_c1-st1000.safetensors',
revision: 'master',
});
});
it('carries the platform through a batch download', async () => {
mockApiClient.downloadModelSource.mockResolvedValue({ success: true });
manager.showBatchPreviewStep = vi.fn();
await manager.executeBatchDownload(
[
{
source: 'modelscope',
platform: 'modelscope',
repo: 'u/r',
filename: 'f.safetensors',
revision: 'master',
displayName: 'f.safetensors',
checked: true,
},
],
{ modelRoot: '/models/loras', targetFolder: '', useDefaultPaths: true }
);
expect(mockApiClient.downloadModelSource).toHaveBeenCalledTimes(1);
expect(mockApiClient.downloadModelSource.mock.calls[0][0]).toMatchObject({
platform: 'modelscope',
repo: 'u/r',
filename: 'f.safetensors',
revision: 'master',
});
expect(mockApiClient.downloadModel).not.toHaveBeenCalled();
});
it('links failures to the ModelScope file page', async () => {
expect(
manager._buildSingleItemUrl({
source: 'modelscope',
repo: 'u/r',
filename: 'sub/f.safetensors',
})
).toBe('https://modelscope.cn/models/u/r/file/view/master/sub/f.safetensors');
expect(
manager._buildSingleItemUrl({
source: 'huggingface',
repo: 'u/r',
filename: 'f.safetensors',
})
).toBe('https://huggingface.co/u/r/blob/main/f.safetensors');
});
it('groups the same repo name on two platforms separately', () => {
const hf = { source: 'huggingface', repo: 'u/r' };
const ms = { source: 'modelscope', repo: 'u/r' };
expect(manager._externalGroupKey(hf)).toBe('huggingface:u/r');
expect(manager._externalGroupKey(ms)).toBe('modelscope:u/r');
expect(manager._externalGroupKey(hf)).not.toBe(manager._externalGroupKey(ms));
});
describe('post-transfer stage reporting', () => {
it('ignores ordinary frames', () => {
const updateProgress = vi.fn();
expect(
manager._applyMetadataStage(
{ status: 'progress', progress: 40, bytes_per_second: 10 },
updateProgress,
0,
'f.safetensors'
)
).toBe(false);
expect(updateProgress).not.toHaveBeenCalled();
});
it('routes a metadata stage to the progress bar at 100%', () => {
const updateProgress = vi.fn();
expect(
manager._applyMetadataStage(
{ status: 'metadata', stage: 'source', platform: 'modelscope' },
updateProgress,
3,
'f.safetensors'
)
).toBe(true);
expect(updateProgress).toHaveBeenCalledWith(100, 3, 'f.safetensors', {}, {
phase: 'metadata',
stage: 'source',
platform: 'modelscope',
});
});
it('tolerates a stage frame with no stage or platform', () => {
const updateProgress = vi.fn();
expect(
manager._applyMetadataStage({ status: 'metadata' }, updateProgress, 0, 'f')
).toBe(true);
expect(updateProgress).toHaveBeenCalledWith(100, 0, 'f', {}, {
phase: 'metadata',
stage: '',
platform: '',
});
});
it('surfaces a metadata frame received while the request is in flight', async () => {
// End-to-end through the websocket handler: the backend keeps the socket
// open while it hydrates, and the frame has to reach the progress bar.
const sockets = [];
class RecordingWebSocket {
constructor(url) {
this.url = url;
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.close = vi.fn();
sockets.push(this);
queueMicrotask(() => this.onopen && this.onopen());
}
}
vi.stubGlobal('WebSocket', RecordingWebSocket);
const updateProgress = vi.fn();
mockLoadingManager.showDownloadProgress.mockReturnValue(updateProgress);
mockApiClient.downloadModelSource.mockImplementation(async () => {
sockets.at(-1).onmessage({
data: JSON.stringify({
status: 'metadata',
stage: 'source',
platform: 'modelscope',
}),
});
return { success: true };
});
manager.sourcePlatform = 'modelscope';
manager.sourceRepoId = 'u/r';
manager.sourceSelectedFiles = ['a.safetensors'];
await manager._downloadExternalRepoFiles({
modelRoot: '/models',
targetFolder: '',
useDefaultPaths: false,
});
expect(updateProgress).toHaveBeenCalledWith(100, 0, 'a.safetensors', {}, {
phase: 'metadata',
stage: 'source',
platform: 'modelscope',
});
mockLoadingManager.showDownloadProgress.mockReturnValue(vi.fn());
});
});
});
@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { I18N_MODULE } = vi.hoisted(() => ({
I18N_MODULE: new URL(
'../../../static/js/utils/i18nHelpers.js',
import.meta.url
).pathname,
}));
// Interpolate the English fallback the way the real helper does when a locale
// has not been loaded, so assertions can name the visible text.
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key, params = {}, fallback) => {
if (typeof fallback !== 'string') return key;
return Object.entries(params).reduce(
(text, [name, value]) => text.replace(`{${name}}`, String(value)),
fallback
);
}),
}));
const { LoadingManager } = await import(
'../../../static/js/managers/LoadingManager.js'
);
/**
* A download's byte counter stops when the last byte lands, but the backend
* still hashes the file and reads the model site's API. These tests pin the
* rendering that says so, instead of leaving the bar at 100% showing 0 B/s.
*/
describe('LoadingManager download progress phases', () => {
let manager;
let updateProgress;
beforeEach(() => {
document.body.innerHTML = '';
LoadingManager.instance = null;
manager = new LoadingManager();
updateProgress = manager.showDownloadProgress(1);
});
const speedText = () =>
document.querySelector('.download-transfer-speed')?.textContent;
const itemLabel = () =>
document.querySelector('.current-item-label')?.textContent;
const itemPercent = () =>
document.querySelector('.current-item-percent')?.textContent;
const itemBar = () => document.querySelector('.current-item-bar');
it('shows the byte rate while transferring', () => {
updateProgress(42, 0, 'model.safetensors', {
bytesDownloaded: 1024,
totalBytes: 2048,
bytesPerSecond: 512,
});
expect(itemLabel()).toBe('Downloading: model.safetensors');
expect(itemPercent()).toBe('42%');
expect(speedText()).toMatch(/^Speed: /);
expect(itemBar().classList.contains('is-indeterminate')).toBe(false);
});
it('names the indexing stage instead of a stopped speed', () => {
updateProgress(100, 0, 'model.safetensors', {}, {
phase: 'metadata',
stage: 'indexing',
platform: 'modelscope',
});
expect(itemLabel()).toBe('Metadata: model.safetensors');
expect(itemPercent()).toBe('100%');
expect(speedText()).toBe('Reading model file...');
expect(manager.statusText.textContent).toBe('Reading model file...');
expect(itemBar().classList.contains('is-indeterminate')).toBe(true);
});
it('names the site the metadata is fetched from', () => {
updateProgress(100, 0, 'model.safetensors', {}, {
phase: 'metadata',
stage: 'source',
platform: 'modelscope-ai',
});
expect(speedText()).toBe('Fetching metadata from ModelScope (International)...');
});
it('falls back to a generic message for an unknown site', () => {
updateProgress(100, 0, 'model.safetensors', {}, {
phase: 'metadata',
stage: 'source',
platform: '',
});
expect(speedText()).toBe('Fetching metadata...');
});
it('returns to the transfer rendering for the next file', () => {
updateProgress(100, 0, 'a.safetensors', {}, {
phase: 'metadata',
stage: 'source',
platform: 'modelscope',
});
updateProgress(0, 1, 'b.safetensors');
expect(itemLabel()).toBe('Downloading: b.safetensors');
expect(speedText()).toMatch(/^Speed: /);
expect(itemBar().classList.contains('is-indeterminate')).toBe(false);
});
it('keeps the byte counters visible during the metadata stage', () => {
updateProgress(100, 0, 'model.safetensors', {
bytesDownloaded: 2048,
totalBytes: 2048,
bytesPerSecond: 0,
}, {
phase: 'metadata',
stage: 'source',
platform: 'modelscope',
});
const transferred = document.querySelector('.download-transfer-bytes');
expect(transferred.textContent).toContain('/');
// The 0 B/s figure is what made the pause look like a stall.
expect(speedText()).not.toContain('0 B');
});
it('keeps the batch position visible in the status line', () => {
updateProgress = manager.showDownloadProgress(4);
updateProgress(100, 2, 'c.safetensors', {}, {
phase: 'metadata',
stage: 'source',
platform: 'huggingface',
});
expect(manager.statusText.textContent).toBe(
'3/4: Fetching metadata from Hugging Face...'
);
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync } from 'fs';
import path from 'path';
// Regression guard for the destructive context-menu entries.
//
// They were styled with `var(--danger-color)`, a token defined nowhere in the
// stylesheet tree. A var() reference to an undefined property makes the
// declaration invalid at computed-value time, so the colour silently fell back
// to the menu's inherited text colour: every "Delete …" entry in the folder
// sidebar menu and the model-card menus rendered plain. The first check below
// keeps menu.css wired only to tokens that actually resolve.
describe('Context menu design tokens', () => {
const repoRoot = path.resolve(__dirname, '../../..');
const cssDir = path.join(repoRoot, 'static/css');
const menuCss = readFileSync(path.join(cssDir, 'components/menu.css'), 'utf-8');
const collectCss = (dir, files = []) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) collectCss(full, files);
else if (entry.name.endsWith('.css')) files.push(readFileSync(full, 'utf-8'));
}
return files;
};
const allCss = collectCss(cssDir).join('\n');
const defined = new Set([...allCss.matchAll(/(--[\w-]+)\s*:/g)].map((match) => match[1]));
// var(--x) with no fallback: an undefined name is a silent no-op.
const usedWithoutFallback = [...menuCss.matchAll(/var\(\s*(--[\w-]+)\s*\)/g)].map(
(match) => match[1]
);
it('resolves every custom property used by the context menu', () => {
const unresolved = [...new Set(usedWithoutFallback)].filter((name) => !defined.has(name));
expect(unresolved).toEqual([]);
});
it('paints destructive entries with the themed error colour', () => {
const rule = menuCss.match(/\.context-menu-item\.delete-item\s*\{([^}]*)\}/);
expect(rule).not.toBeNull();
expect(rule[1]).toContain('var(--lora-error)');
expect(defined.has('--lora-error')).toBe(true);
});
it('gives destructive entries their own hover treatment', () => {
// The shared hover paints the accent background, which a red label does
// not read against.
expect(menuCss).toMatch(/\.context-menu-item\.delete-item:hover[\s\S]*?\{/);
expect(menuCss).toMatch(/\.context-menu-item\.delete-item:hover[\s\S]*?var\(--lora-error-bg\)/);
});
it('never references the dead --danger-color token', () => {
// Comments may name it; a var() argument may not.
expect(allCss).not.toMatch(/var\(\s*--danger-color/);
});
});
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import path from 'path';
// Regression guard for the sidebar folder context-menu layout: the update check
// sits on top, the folder operations form a single group, and the destructive
// entry stays last behind its own divider. SidebarManager gates those groups
// per page and collapses the dividers when a group is hidden, so a reorder here
// also changes what the recipes page shows.
describe('Sidebar folder context menu layout', () => {
const repoRoot = path.resolve(__dirname, '../../..');
const html = readFileSync(
path.join(repoRoot, 'templates/components/context_menu.html'),
'utf-8'
);
const menuHtml = html.slice(
html.indexOf('id="sidebarFolderContextMenu"'),
html.indexOf('<!-- Sidebar View Options Menu -->')
);
const sequence = [...menuHtml.matchAll(/<div class="([^"]+)"([^>]*)>/g)].map(([, classes, rest]) => {
if (classes.includes('context-menu-separator')) return 'separator';
return /data-action="([^"]+)"/.exec(rest)?.[1] || null;
});
it('keeps the update check first and the folder operations grouped', () => {
expect(sequence).toEqual([
'check-folder-updates',
'separator',
'create-subfolder',
'rename-folder',
'separator',
'delete-folder',
]);
});
it('keeps the destructive entry last and visually marked', () => {
const deleteEntry = menuHtml.match(/<div class="([^"]*)"\s+data-action="delete-folder"/);
expect(deleteEntry).not.toBeNull();
expect(deleteEntry[1]).toContain('delete-item');
expect(sequence[sequence.length - 1]).toBe('delete-folder');
});
});
@@ -0,0 +1,188 @@
import { describe, it, expect, vi } from 'vitest';
const { I18N_MODULE } = vi.hoisted(() => ({
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)),
}));
const {
MODEL_SOURCES,
parseModelSourceUrl,
getModelSource,
getModelSourceInfo,
getModelSourceUrl,
getModelSourceGroupKey,
canEnrichModelSource,
getModelSourceViewTitle,
parseModelSourceGroupKey,
openModelSource,
} = await import('../../../static/js/utils/modelSourceHelpers.js');
describe('modelSourceHelpers', () => {
it('exposes one descriptor per supported platform', () => {
expect(MODEL_SOURCES.map((s) => s.platform)).toEqual([
'huggingface',
'modelscope',
'modelscope-ai',
'tensorart',
]);
});
describe('parseModelSourceUrl', () => {
it('recognises Hugging Face URLs', () => {
const info = parseModelSourceUrl('https://huggingface.co/user/repo');
expect(info.platform).toBe('huggingface');
expect(info.sourceId).toBe('user/repo');
});
it('recognises ModelScope URLs with view sub-paths', () => {
const info = parseModelSourceUrl('https://modelscope.cn/models/user/repo/summary');
expect(info.platform).toBe('modelscope');
expect(info.sourceId).toBe('user/repo');
expect(info.url).toBe('https://modelscope.cn/models/user/repo');
});
it('recognises ModelScope International as its own platform', () => {
const info = parseModelSourceUrl(
'https://www.modelscope.ai/models/referall13/EM1/files'
);
expect(info.platform).toBe('modelscope-ai');
expect(info.groupPrefix).toBe('msai');
expect(info.sourceId).toBe('referall13/EM1');
expect(info.url).toBe('https://www.modelscope.ai/models/referall13/EM1');
});
it('recognises TensorArt URLs and keeps only the numeric id', () => {
const info = parseModelSourceUrl(
'https://tensor.art/models/827823520299086029/Vivid-Impressions-Storybook-Sstyle-V1.0'
);
expect(info.platform).toBe('tensorart');
expect(info.sourceId).toBe('827823520299086029');
expect(info.url).toBe('https://tensor.art/models/827823520299086029');
});
it('rejects unsupported URLs', () => {
expect(parseModelSourceUrl('https://example.com/x')).toBeNull();
expect(parseModelSourceUrl('')).toBeNull();
expect(parseModelSourceUrl(null)).toBeNull();
});
});
describe('getModelSourceInfo', () => {
it('falls back to the legacy hf_url field', () => {
const info = getModelSourceInfo({ hf_url: 'https://huggingface.co/user/repo' });
expect(info.platform).toBe('huggingface');
expect(info.sourceId).toBe('user/repo');
});
it('prefers the canonical source fields', () => {
const info = getModelSourceInfo({
source_platform: 'modelscope',
source_url: 'https://modelscope.cn/models/user/repo',
hf_url: 'https://huggingface.co/old/repo',
});
expect(info.platform).toBe('modelscope');
});
it('returns null when there is no source', () => {
expect(getModelSourceInfo({})).toBeNull();
expect(getModelSourceInfo({ hf_url: '' })).toBeNull();
});
});
describe('getModelSourceUrl', () => {
it('reads source_url then hf_url', () => {
expect(getModelSourceUrl({ source_url: 'https://a.example/1' })).toBe('https://a.example/1');
expect(getModelSourceUrl({ hf_url: 'https://huggingface.co/u/r' })).toBe(
'https://huggingface.co/u/r'
);
expect(getModelSourceUrl({})).toBe('');
});
});
describe('getModelSourceGroupKey', () => {
it('matches the backend group-key shapes', () => {
expect(getModelSourceGroupKey({ hf_url: 'https://huggingface.co/u/r' })).toBe('hf:u/r');
expect(
getModelSourceGroupKey({ source_url: 'https://modelscope.cn/models/u/r' })
).toBe('ms:u/r');
expect(getModelSourceGroupKey({ source_url: 'https://tensor.art/models/123' })).toBe(
'ta:123'
);
});
it('returns an empty string without a source', () => {
expect(getModelSourceGroupKey({})).toBe('');
});
});
describe('canEnrichModelSource', () => {
it('allows Hugging Face and ModelScope', () => {
expect(canEnrichModelSource({ hf_url: 'https://huggingface.co/u/r' })).toBe(true);
expect(
canEnrichModelSource({ source_url: 'https://modelscope.cn/models/u/r' })
).toBe(true);
});
it('disallows TensorArt and unlinked models', () => {
expect(canEnrichModelSource({ source_url: 'https://tensor.art/models/123' })).toBe(false);
expect(canEnrichModelSource({})).toBe(false);
});
});
describe('getModelSourceViewTitle', () => {
it('uses the branded label for non-HF sources', () => {
expect(getModelSourceViewTitle(getModelSource('modelscope'))).toBe('View on ModelScope');
expect(getModelSourceViewTitle(getModelSource('tensorart'))).toBe('View on TensorArt');
});
it('keeps the historical Hugging Face title', () => {
expect(getModelSourceViewTitle(getModelSource('huggingface'))).toBe(
'View on Hugging Face'
);
});
});
describe('parseModelSourceGroupKey', () => {
it('parses every external group-key prefix', () => {
expect(parseModelSourceGroupKey('hf:user/repo')).toEqual({
platform: 'huggingface',
label: 'Hugging Face',
sourceId: 'user/repo',
});
expect(parseModelSourceGroupKey('ms:user/repo').platform).toBe('modelscope');
expect(parseModelSourceGroupKey('ta:123').platform).toBe('tensorart');
});
it('rejects numeric CivitAI model ids and unknown prefixes', () => {
expect(parseModelSourceGroupKey(222)).toBeNull();
expect(parseModelSourceGroupKey('222')).toBeNull();
expect(parseModelSourceGroupKey('unknown:1')).toBeNull();
expect(parseModelSourceGroupKey('')).toBeNull();
expect(parseModelSourceGroupKey(null)).toBeNull();
});
});
describe('openModelSource', () => {
it('opens the URL in a new tab', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => {});
openModelSource('https://modelscope.cn/models/u/r');
expect(openSpy).toHaveBeenCalledWith(
'https://modelscope.cn/models/u/r',
'_blank',
'noopener,noreferrer'
);
openSpy.mockRestore();
});
it('does nothing without a URL', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => {});
openModelSource('');
expect(openSpy).not.toHaveBeenCalled();
openSpy.mockRestore();
});
});
});
@@ -1,14 +1,15 @@
import { describe, it, expect } from 'vitest';
import { DownloadManager } from '../../../static/js/managers/DownloadManager.js';
describe('DownloadManager.detectUrlType — HF URL detection', () => {
describe('DownloadManager.detectUrlType — external model source URLs', () => {
it('detects HF resolve URL with file', () => {
const result = DownloadManager.detectUrlType(
'https://huggingface.co/dx8152/Flux2-Klein-9B-Consistency/resolve/main/Flux2-Klein-9B-consistency-V2.safetensors'
);
expect(result).toEqual({
type: 'hf-resolve',
type: 'model-source-file',
platform: 'huggingface',
repo: 'dx8152/Flux2-Klein-9B-Consistency',
revision: 'main',
filename: 'Flux2-Klein-9B-consistency-V2.safetensors',
@@ -20,7 +21,8 @@ describe('DownloadManager.detectUrlType — HF URL detection', () => {
'https://huggingface.co/user/repo/resolve/main/subdir/model.safetensors'
);
expect(result).toEqual({
type: 'hf-resolve',
type: 'model-source-file',
platform: 'huggingface',
repo: 'user/repo',
revision: 'main',
filename: 'subdir/model.safetensors',
@@ -32,7 +34,8 @@ describe('DownloadManager.detectUrlType — HF URL detection', () => {
'https://huggingface.co/dx8152/Flux2-Klein-9B-Consistency'
);
expect(result).toEqual({
type: 'hf-repo',
type: 'model-source-repo',
platform: 'huggingface',
repo: 'dx8152/Flux2-Klein-9B-Consistency',
});
});
@@ -40,7 +43,8 @@ describe('DownloadManager.detectUrlType — HF URL detection', () => {
it('detects HF repo URL (bare user/repo)', () => {
const result = DownloadManager.detectUrlType('dx8152/Flux2-Klein-9B-Consistency');
expect(result).toEqual({
type: 'hf-repo',
type: 'model-source-repo',
platform: 'huggingface',
repo: 'dx8152/Flux2-Klein-9B-Consistency',
});
});
@@ -50,7 +54,8 @@ describe('DownloadManager.detectUrlType — HF URL detection', () => {
'https://huggingface.co/user/repo/'
);
expect(result).toEqual({
type: 'hf-repo',
type: 'model-source-repo',
platform: 'huggingface',
repo: 'user/repo',
});
});
@@ -60,7 +65,8 @@ describe('DownloadManager.detectUrlType — HF URL detection', () => {
'https://huggingface.co/Comfy-Org/z_image_turbo/blob/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors'
);
expect(result).toEqual({
type: 'hf-resolve',
type: 'model-source-file',
platform: 'huggingface',
repo: 'Comfy-Org/z_image_turbo',
revision: 'main',
filename: 'split_files/diffusion_models/z_image_turbo_bf16.safetensors',
@@ -115,7 +121,7 @@ describe('DownloadManager.detectUrlType — HF URL detection', () => {
const result = DownloadManager.detectUrlType(
'https://huggingface.co/user/repo/resolve/main/file.safetensors'
);
expect(result?.type).toBe('hf-resolve');
expect(result?.type).toBe('model-source-file');
});
it('prefers CivitAI over HF when both match', () => {
@@ -126,4 +132,99 @@ describe('DownloadManager.detectUrlType — HF URL detection', () => {
);
expect(result?.type).toBe('civitai');
});
it('detects a ModelScope repo URL', () => {
const result = DownloadManager.detectUrlType(
'https://modelscope.cn/models/jj3550945163/Krea-2-LORA'
);
expect(result).toEqual({
type: 'model-source-repo',
platform: 'modelscope',
repo: 'jj3550945163/Krea-2-LORA',
});
});
it('detects a ModelScope file URL with revision and subdirectory', () => {
const result = DownloadManager.detectUrlType(
'https://modelscope.cn/models/AI-ModelScope/stable-diffusion-v1-5/resolve/master/vae/diffusion_pytorch_model.bin'
);
expect(result).toEqual({
type: 'model-source-file',
platform: 'modelscope',
repo: 'AI-ModelScope/stable-diffusion-v1-5',
revision: 'master',
filename: 'vae/diffusion_pytorch_model.bin',
});
});
it('detects a ModelScope view sub-page as a repo URL', () => {
const result = DownloadManager.detectUrlType(
'https://www.modelscope.cn/models/user/repo/summary'
);
expect(result).toEqual({
type: 'model-source-repo',
platform: 'modelscope',
repo: 'user/repo',
});
});
it('does not treat a bare owner/name as ModelScope', () => {
// The shorthand has always meant Hugging Face; ModelScope needs its host.
const result = DownloadManager.detectUrlType('user/repo');
expect(result.platform).toBe('huggingface');
});
// modelscope.ai is a separate catalogue from modelscope.cn, not an alias,
// so it carries its own platform id all the way to the backend.
it('detects a ModelScope International repo URL', () => {
const result = DownloadManager.detectUrlType(
'https://www.modelscope.ai/models/referall13/EM1'
);
expect(result).toEqual({
type: 'model-source-repo',
platform: 'modelscope-ai',
repo: 'referall13/EM1',
});
});
it('detects a ModelScope International repo URL without the www prefix', () => {
const result = DownloadManager.detectUrlType(
'https://modelscope.ai/models/ErLubu/krea2_style_260911_02'
);
expect(result).toEqual({
type: 'model-source-repo',
platform: 'modelscope-ai',
repo: 'ErLubu/krea2_style_260911_02',
});
});
it('detects a ModelScope International file URL', () => {
const result = DownloadManager.detectUrlType(
'https://www.modelscope.ai/models/referall13/EM1/resolve/master/EM1_c1-st1000.safetensors'
);
expect(result).toEqual({
type: 'model-source-file',
platform: 'modelscope-ai',
repo: 'referall13/EM1',
revision: 'master',
filename: 'EM1_c1-st1000.safetensors',
});
});
it('keeps the two ModelScope deployments distinct', () => {
const mainland = DownloadManager.detectUrlType(
'https://modelscope.cn/models/referall13/EM1'
);
const intl = DownloadManager.detectUrlType(
'https://www.modelscope.ai/models/referall13/EM1'
);
expect(mainland.platform).toBe('modelscope');
expect(intl.platform).toBe('modelscope-ai');
});
it('rejects path traversal in either platform', () => {
expect(
DownloadManager.detectUrlType('https://modelscope.cn/models/../etc/passwd')
).toBeNull();
});
});
+115
View File
@@ -490,3 +490,118 @@ class TestStripFencedCodeBlocks:
def test_pattern(self, R):
text = "x\n```yaml\nkey: val\n```\ny"
assert "key: val" not in R._strip_fenced_code_blocks(text)
# ======================================================================
# Site-generated placeholder cards
# ======================================================================
#: The card ModelScope renders when the uploader wrote no README. Copied from
#: a live repository so the marker strings stay honest.
PLACEHOLDER_CARD = """---
base_model: krea/Krea-2-Turbo
license: Apache License 2.0
tags:
- LoRA
- text-to-image
- \u68a6\u5e7b\u5149\u5f71
---
### \u5f53\u524d\u6a21\u578b\u7684\u8d21\u732e\u8005\u672a\u63d0\u4f9b\u66f4\u52a0\u8be6\u7ec6\u7684\u6a21\u578b\u4ecb\u7ecd\u3002\u6a21\u578b\u6587\u4ef6\u548c\u6743\u91cd\uff0c\u53ef\u6d4f\u89c8\u201c\u6a21\u578b\u6587\u4ef6\u201d\u9875\u9762\u83b7\u53d6\u3002
#### \u60a8\u53ef\u4ee5\u901a\u8fc7\u5982\u4e0bgit clone\u547d\u4ee4\uff0c\u6216\u8005ModelScope SDK\u6765\u4e0b\u8f7d\u6a21\u578b
SDK\u4e0b\u8f7d
```bash
#\u5b89\u88c5ModelScope
pip install modelscope
```
Git\u4e0b\u8f7d
```
#Git\u6a21\u578b\u4e0b\u8f7d
git clone https://www.modelscope.cn/yan303145427/krea2-CcFQWZ-Portrait.git
```
<p style="color: lightgrey;">\u5982\u679c\u60a8\u662f\u672c\u6a21\u578b\u7684\u8d21\u732e\u8005\uff0c\u6211\u4eec\u9080\u8bf7\u60a8\u6839\u636e<a href="x">\u6a21\u578b\u8d21\u732e\u6587\u6863</a>\uff0c\u53ca\u65f6\u5b8c\u5584\u6a21\u578b\u5361\u7247\u5185\u5bb9\u3002</p>
"""
#: A real, author-written card (ModelScope AIGC training output).
REAL_CARD = """---
base_model: krea/Krea-2-Turbo
---
# krea\u8138\u6a21
## \u6a21\u578b\u4ecb\u7ecd
\u672c\u6a21\u578b\u4f9d\u6258\u9b54\u642d\u793e\u533a\u5b8c\u6210\u8bad\u7ec3\u3002
## \u63a8\u7406\u4ee3\u7801
\u5b89\u88c5 DiffSynth-Studio\uff1a
"""
class TestStripGeneratedCardBoilerplate:
def test_removes_the_whole_placeholder_body(self, R):
stripped = R._strip_generated_card_boilerplate(PLACEHOLDER_CARD)
assert "\u5f53\u524d\u6a21\u578b\u7684\u8d21\u732e\u8005" not in stripped
assert "SDK\u4e0b\u8f7d" not in stripped
assert "git clone" not in stripped
assert "\u9080\u8bf7\u60a8" not in stripped
# The frontmatter is the only thing that survives.
assert "base_model: krea/Krea-2-Turbo" in stripped
def test_leaves_a_real_card_untouched(self, R):
assert R._strip_generated_card_boilerplate(REAL_CARD) == REAL_CARD
def test_drops_a_standalone_invitation_line(self, R):
text = "real body\n<p>\u5982\u679c\u60a8\u662f\u672c\u6a21\u578b\u7684\u8d21\u732e\u8005\uff0c\u8bf7\u5b8c\u5584</p>\nmore body"
stripped = R._strip_generated_card_boilerplate(text)
assert "real body" in stripped
assert "more body" in stripped
assert "\u8d21\u732e\u8005" not in stripped
def test_keeps_content_added_after_the_placeholder(self, R):
"""An author who later wrote a real section must not lose it."""
text = (
"### \u5f53\u524d\u6a21\u578b\u7684\u8d21\u732e\u8005\u672a\u63d0\u4f9b\u66f4\u52a0\u8be6\u7ec6\u7684\u6a21\u578b\u4ecb\u7ecd\u3002\n"
"#### \u60a8\u53ef\u4ee5\u901a\u8fc7\u5982\u4e0bgit clone\u547d\u4ee4\u4e0b\u8f7d\u6a21\u578b\n"
"```\ngit clone x\n```\n"
"## \u6211\u7684\u771f\u5b9e\u4ecb\u7ecd\n"
"\u8fd9\u662f\u4f5c\u8005\u540e\u6765\u8865\u5199\u7684\u5185\u5bb9\u3002\n"
)
stripped = R._strip_generated_card_boilerplate(text)
assert "\u6211\u7684\u771f\u5b9e\u4ecb\u7ecd" in stripped
assert "\u4f5c\u8005\u540e\u6765\u8865\u5199\u7684\u5185\u5bb9" in stripped
assert "git clone" not in stripped
def test_handles_html_headings(self, R):
text = (
"<h3>\u5f53\u524d\u6a21\u578b\u7684\u8d21\u732e\u8005\u672a\u63d0\u4f9b\u66f4\u52a0\u8be6\u7ec6\u7684\u6a21\u578b\u4ecb\u7ecd\u3002</h3>\n"
"<p>pip install modelscope</p>\n"
)
assert R._strip_generated_card_boilerplate(text).strip() == ""
class TestCleanReadmeForLlmPlaceholder:
def test_boilerplate_is_gone_but_frontmatter_survives(self, R):
cleaned = R.clean_readme_for_llm(PLACEHOLDER_CARD)
assert "pip install modelscope" not in cleaned
assert "git clone" not in cleaned
assert "\u5f53\u524d\u6a21\u578b\u7684\u8d21\u732e\u8005" not in cleaned
# Metadata the LLM still needs.
assert "base_model: krea/Krea-2-Turbo" in cleaned
assert "\u68a6\u5e7b\u5149\u5f71" in cleaned
def test_a_real_card_keeps_its_body(self, R):
cleaned = R.clean_readme_for_llm(REAL_CARD)
assert "krea\u8138\u6a21" in cleaned
assert "\u6a21\u578b\u4ecb\u7ecd" in cleaned
class TestConvertReadmeToHtmlPlaceholder:
def test_a_placeholder_card_renders_to_nothing(self, R):
assert R.convert_readme_to_html(PLACEHOLDER_CARD) == ""
def test_a_real_card_still_renders(self, R):
html = R.convert_readme_to_html(REAL_CARD)
assert "<h1>krea\u8138\u6a21</h1>" in html
assert "DiffSynth-Studio" in html
-156
View File
@@ -1,156 +0,0 @@
"""Tests for the HuggingFace link handler (``set_hf_url``).
Regression coverage for issue #1094: linking a model to HuggingFace must not
clear its CivitAI provenance or metadata, so both "View on CivitAI" and
"View on Hugging Face" can coexist.
"""
from __future__ import annotations
import json
import os
from typing import Any
from unittest.mock import AsyncMock
import pytest
from py.routes.handlers import hf_handlers
from py.routes.handlers.hf_handlers import HfHandler
from py.utils.metadata_manager import MetadataManager
def _json_payload(response) -> dict[str, Any]:
assert response.text is not None
return json.loads(response.text)
class FakeRequest:
def __init__(self, *, json_data=None):
self._json_data = json_data or {}
async def json(self):
return self._json_data
def _sidecar_path(model_path) -> str:
return f"{os.path.splitext(str(model_path))[0]}.metadata.json"
@pytest.fixture
def hf_env(tmp_path, monkeypatch):
"""Point HF linking at *tmp_path* and stub the scanner cache write."""
monkeypatch.setattr(hf_handlers, "_find_matching_root", lambda _dir: str(tmp_path))
cache_write = AsyncMock()
monkeypatch.setattr(hf_handlers, "_add_to_scanner_cache", cache_write)
return {"root": tmp_path, "cache_write": cache_write}
async def _write_model(model_path, payload: dict[str, Any]) -> None:
model_path.write_bytes(b"x" * 32)
await MetadataManager.save_metadata(str(model_path), payload)
@pytest.mark.asyncio
async def test_set_hf_url_keeps_civitai_metadata_and_provenance(tmp_path, hf_env):
model_path = tmp_path / "civitai_model.safetensors"
await _write_model(
model_path,
{
"file_name": "civitai_model",
"model_name": "CivitAI Model",
"file_path": str(model_path),
"size": 32,
"modified": 1.0,
"sha256": "a" * 64,
"base_model": "SDXL 1.0",
"preview_url": "",
"from_civitai": True,
"civitai": {"id": 111, "modelId": 222, "name": "v1", "trainedWords": []},
},
)
response = await HfHandler().set_hf_url(
FakeRequest(
json_data={
"file_path": str(model_path),
"hf_url": "https://huggingface.co/user/repo",
}
)
)
assert response.status == 200
assert _json_payload(response)["success"] is True
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
assert saved["hf_url"] == "https://huggingface.co/user/repo"
# Linking HF must not erase the model's CivitAI provenance or data.
assert saved["from_civitai"] is True
assert saved["civitai"]["modelId"] == 222
assert saved["civitai"]["id"] == 111
hf_env["cache_write"].assert_awaited_once()
cached_metadata = hf_env["cache_write"].await_args.args[1]
assert cached_metadata["hf_url"] == "https://huggingface.co/user/repo"
assert cached_metadata["from_civitai"] is True
assert cached_metadata["civitai"]["modelId"] == 222
@pytest.mark.asyncio
async def test_set_hf_url_does_not_force_from_civitai_false(tmp_path, hf_env):
"""A model without CivitAI data keeps its existing provenance flag."""
model_path = tmp_path / "hf_only.safetensors"
await _write_model(
model_path,
{
"file_name": "hf_only",
"model_name": "HF Only",
"file_path": str(model_path),
"size": 32,
"modified": 1.0,
"sha256": "b" * 64,
"base_model": "Unknown",
"preview_url": "",
"from_civitai": True,
},
)
response = await HfHandler().set_hf_url(
FakeRequest(
json_data={
"file_path": str(model_path),
"hf_url": "https://huggingface.co/user/repo",
}
)
)
assert response.status == 200
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
assert saved["hf_url"] == "https://huggingface.co/user/repo"
assert saved["from_civitai"] is True
@pytest.mark.asyncio
async def test_set_hf_url_rejects_non_repo_url(tmp_path, hf_env):
model_path = tmp_path / "model.safetensors"
await _write_model(
model_path,
{
"file_name": "model",
"model_name": "model",
"file_path": str(model_path),
"size": 32,
"modified": 1.0,
"sha256": "c" * 64,
"base_model": "Unknown",
"preview_url": "",
},
)
response = await HfHandler().set_hf_url(
FakeRequest(json_data={"file_path": str(model_path), "hf_url": "https://example.com/x"})
)
assert response.status == 400
payload = _json_payload(response)
assert payload["success"] is False
hf_env["cache_write"].assert_not_awaited()
+333
View File
@@ -0,0 +1,333 @@
import json
import logging
import pytest
from py.routes.handlers.model_handlers import ModelMoveHandler
class FakeMoveService:
def __init__(self, result):
self._result = result
self.received_path = None
self.received_dry_run = None
self.received_new_name = None
async def create_folder(self, folder_path):
self.received_path = folder_path
return self._result
async def delete_folder(self, folder_path, dry_run=False):
self.received_path = folder_path
self.received_dry_run = dry_run
return self._result
async def rename_folder(self, folder_path, new_name):
self.received_path = folder_path
self.received_new_name = new_name
return self._result
class FakeRequest:
def __init__(self, payload):
self._payload = payload
async def json(self):
return self._payload
def _make_handler(result):
service = FakeMoveService(result)
handler = ModelMoveHandler(
move_service=service, logger=logging.getLogger(__name__)
)
return handler, service
@pytest.mark.asyncio
async def test_create_folder_success():
handler, service = _make_handler(
{"success": True, "folder": "characters/anime", "created": True}
)
response = await handler.create_folder(
FakeRequest({"folder_path": "/library/characters/anime"})
)
assert response.status == 200
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["folder"] == "characters/anime"
assert service.received_path == "/library/characters/anime"
@pytest.mark.asyncio
async def test_create_folder_missing_path():
handler, service = _make_handler({"success": True})
response = await handler.create_folder(FakeRequest({}))
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert service.received_path is None
@pytest.mark.asyncio
async def test_create_folder_service_failure_maps_to_400():
handler, _service = _make_handler(
{
"success": False,
"error": "Folder path '/etc/evil' is outside configured library directories",
}
)
response = await handler.create_folder(FakeRequest({"folder_path": "/etc/evil"}))
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert "outside configured library" in payload["error"]
@pytest.mark.asyncio
async def test_create_folder_invalid_json_body():
class BadJsonRequest:
async def json(self):
raise ValueError("bad json")
handler, _service = _make_handler({"success": True})
response = await handler.create_folder(BadJsonRequest())
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
@pytest.mark.asyncio
async def test_delete_folder_success():
handler, service = _make_handler(
{
"success": True,
"folder": "characters/anime",
"model_count": 0,
"restorable": True,
}
)
response = await handler.delete_folder(
FakeRequest({"folder_path": "/library/characters/anime"})
)
assert response.status == 200
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["restorable"] is True
assert service.received_path == "/library/characters/anime"
assert service.received_dry_run is False
@pytest.mark.asyncio
async def test_delete_folder_forwards_dry_run():
handler, service = _make_handler({"success": True, "dry_run": True})
response = await handler.delete_folder(
FakeRequest({"folder_path": "/library/empty", "dry_run": True})
)
assert response.status == 200
assert service.received_dry_run is True
assert json.loads(response.text)["dry_run"] is True
@pytest.mark.asyncio
async def test_delete_folder_missing_path():
handler, service = _make_handler({"success": True})
response = await handler.delete_folder(FakeRequest({}))
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert service.received_path is None
@pytest.mark.asyncio
async def test_delete_folder_not_empty_maps_to_409():
handler, _service = _make_handler(
{
"success": False,
"code": "not_empty",
"error": "Folder still contains 2 model file(s); delete or move them first",
"manifest": {"model_count": 2},
}
)
response = await handler.delete_folder(
FakeRequest({"folder_path": "/library/full"})
)
assert response.status == 409
payload = json.loads(response.text)
assert payload["success"] is False
assert payload["code"] == "not_empty"
assert payload["manifest"]["model_count"] == 2
@pytest.mark.asyncio
async def test_delete_folder_busy_maps_to_409():
handler, _service = _make_handler(
{"success": False, "code": "busy", "error": "staged delete pending"}
)
response = await handler.delete_folder(
FakeRequest({"folder_path": "/library/full"})
)
assert response.status == 409
assert json.loads(response.text)["code"] == "busy"
@pytest.mark.asyncio
async def test_delete_folder_containment_failure_maps_to_400():
handler, _service = _make_handler(
{
"success": False,
"error": "Folder path '/etc/evil' is outside configured library directories",
}
)
response = await handler.delete_folder(FakeRequest({"folder_path": "/etc/evil"}))
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert "outside configured library" in payload["error"]
@pytest.mark.asyncio
async def test_delete_folder_invalid_json_body():
class BadJsonRequest:
async def json(self):
raise ValueError("bad json")
handler, _service = _make_handler({"success": True})
response = await handler.delete_folder(BadJsonRequest())
assert response.status == 400
assert json.loads(response.text)["success"] is False
@pytest.mark.asyncio
async def test_rename_folder_success():
handler, service = _make_handler(
{
"success": True,
"renamed": True,
"folder": "characters/animation",
"previous_folder": "characters/anime",
}
)
response = await handler.rename_folder(
FakeRequest(
{"folder_path": "/library/characters/anime", "new_name": "animation"}
)
)
assert response.status == 200
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["folder"] == "characters/animation"
assert service.received_path == "/library/characters/anime"
assert service.received_new_name == "animation"
@pytest.mark.asyncio
async def test_rename_folder_missing_path():
handler, service = _make_handler({"success": True})
response = await handler.rename_folder(FakeRequest({"new_name": "animation"}))
assert response.status == 400
assert json.loads(response.text)["success"] is False
assert service.received_path is None
@pytest.mark.asyncio
async def test_rename_folder_missing_name():
handler, service = _make_handler({"success": True})
response = await handler.rename_folder(
FakeRequest({"folder_path": "/library/characters/anime"})
)
assert response.status == 400
payload = json.loads(response.text)
assert payload["success"] is False
assert service.received_new_name is None
@pytest.mark.asyncio
async def test_rename_folder_target_exists_maps_to_409():
handler, _service = _make_handler(
{
"success": False,
"code": "target_exists",
"error": 'A folder named "animation" already exists here',
}
)
response = await handler.rename_folder(
FakeRequest(
{"folder_path": "/library/characters/anime", "new_name": "animation"}
)
)
assert response.status == 409
payload = json.loads(response.text)
assert payload["code"] == "target_exists"
@pytest.mark.asyncio
async def test_rename_folder_busy_maps_to_409():
handler, _service = _make_handler(
{"success": False, "code": "busy", "error": "staged delete pending"}
)
response = await handler.rename_folder(
FakeRequest({"folder_path": "/library/full", "new_name": "renamed"})
)
assert response.status == 409
assert json.loads(response.text)["code"] == "busy"
@pytest.mark.asyncio
async def test_rename_folder_invalid_name_maps_to_400():
handler, _service = _make_handler(
{"success": False, "error": "Invalid characters in folder name"}
)
response = await handler.rename_folder(
FakeRequest({"folder_path": "/library/full", "new_name": "a/b"})
)
assert response.status == 400
assert json.loads(response.text)["success"] is False
@pytest.mark.asyncio
async def test_rename_folder_invalid_json_body():
class BadJsonRequest:
async def json(self):
raise ValueError("bad json")
handler, _service = _make_handler({"success": True})
response = await handler.rename_folder(BadJsonRequest())
assert response.status == 400
assert json.loads(response.text)["success"] is False

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