Compare commits

...

16 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
71 changed files with 7845 additions and 1323 deletions
+246 -235
View File
@@ -7,190 +7,199 @@
], ],
"allSupporters": [ "allSupporters": [
"Takkan", "Takkan",
"2018cfh",
"megakirbs", "megakirbs",
"Brennok", "Brennok",
"Charles Blakemore", "2018cfh",
"Rob Williams", "Rob Williams",
"Insomnia Art Designs", "Charles Blakemore",
"Arlecchino Shion", "Arlecchino Shion",
"Insomnia Art Designs",
"Mozzel",
"Gingko Biloba", "Gingko Biloba",
"stone9k", "stone9k",
"Kiba",
"onesecondinosaur", "onesecondinosaur",
"Skalabananen", "Skalabananen",
"Sterilized",
"Polymorphic Indeterminate", "Polymorphic Indeterminate",
"Liam MacDougal", "Liam MacDougal",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"Rosenthal", "Rosenthal",
"ClockDaemon",
"Francisco Tatis", "Francisco Tatis",
"Tobi_Swagg", "Tobi_Swagg",
"SG",
"jmack",
"Andrew Wilson", "Andrew Wilson",
"Greybush", "Greybush",
"Ricky Carter", "Ricky Carter",
"JongWon Han", "JongWon Han",
"VantAI", "VantAI",
"レプサイ",
"Michael Wong",
"Illrigger", "Illrigger",
"Tom Corrigan",
"JackieWang",
"FreelancerZ", "FreelancerZ",
"Mozzel", "fnkylove",
"Lilleman",
"Robert Stacey",
"PM",
"Marc Whiffen", "Marc Whiffen",
"Dogwalkerbr",
"Birdy", "Birdy",
"Kiba", "quarz",
"$MetaSamsara", "$MetaSamsara",
"jean jahren",
"Reno Lam", "Reno Lam",
"Aleksander Wujczyk", "Aleksander Wujczyk",
"AM Kuro",
"JSST",
"sig", "sig",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"J\\B/ 8r0wns0n", "J\\B/ 8r0wns0n",
"Snaggwort", "Snaggwort",
"Anthony+Rizzo", "Anthony+Rizzo",
"W+K+White", "W+K+White",
"ClockDaemon", "Baekdoosixt",
"Jonathan Ross", "Jonathan Ross",
"KD", "KD",
"Omnidex", "Omnidex",
"Nolife_M", "Nolife_M",
"Melville Parrish",
"daniel dove",
"Lustre",
"Tyler Trebuchon", "Tyler Trebuchon",
"Release Cabrakan", "Release Cabrakan",
"SG", "JW Sin",
"Alex",
"carozzz", "carozzz",
"Marlon Daniels",
"James Dooley", "James Dooley",
"zenbound", "zenbound",
"Buzzard", "Buzzard",
"jmack",
"Adam Shaw", "Adam Shaw",
"Mark Corneglio", "Mark Corneglio",
"RedrockVP", "RedrockVP",
"James Todd", "James Todd",
"Wicked Choices by ASLPro3D",
"FinalyFree",
"Fyf", "Fyf",
"レプサイ",
"Timmy", "Timmy",
"Johnny", "Johnny",
"Tak",
"Lisster", "Lisster",
"Michael Wong", "Big Red",
"whudunit", "whudunit",
"Tom Corrigan", "Luc Job",
"JackieWang", "corde",
"fnkylove",
"Yushio", "Yushio",
"Vik71it", "Vik71it",
"Bishoujoker",
"Echo", "Echo",
"Lilleman",
"Robert Stacey",
"PM",
"Todd Keck", "Todd Keck",
"Briton Heilbrun", "Briton Heilbrun",
"wildnut",
"Edgar Tejeda", "Edgar Tejeda",
"Sterilized",
"BadassArabianMofo", "BadassArabianMofo",
"Dogwalkerbr", "MiraiKuriyamaSy",
"quarz",
"Pascal Dahle", "Pascal Dahle",
"Greg", "Greg",
"jean jahren", "Akira HentAI",
"AM Kuro", "otaku fra",
"JSST",
"lmsupporter", "lmsupporter",
"andrew.tappan",
"wackop", "wackop",
"Phil", "Phil",
"Greenmoustache",
"Carl G.", "Carl G.",
"wfpearl", "wfpearl",
"jeaness",
"Dsperado", "Dsperado",
"Baekdoosixt",
"Jack B Nimble", "Jack B Nimble",
"Melville Parrish",
"daniel dove",
"Lustre",
"JW Sin",
"Alex",
"bh", "bh",
"Marlon Daniels", "Jwk0205",
"Starkselle", "Starkselle",
"Olive",
"Aaron Bleuer", "Aaron Bleuer",
"LacesOut!", "LacesOut!",
"greebles", "greebles",
"SarcasticHashtag", "SarcasticHashtag",
"Wicked Choices by ASLPro3D", "Some Guy Named Barry",
"M Postkasse",
"Jacob Hoehler", "Jacob Hoehler",
"FinalyFree", "Matt Wenzel",
"Weasyl", "Weasyl",
"Lex Song", "Lex Song",
"Cory Paza", "Cory Paza",
"Tak",
"Gonzalo Andre Allendes Lopez", "Gonzalo Andre Allendes Lopez",
"Big Red", "Serge Bekenkamp",
"AIJimmy", "AIJimmy",
"Luc Job",
"Philip Hempel", "Philip Hempel",
"corde", "dan",
"Bishoujoker",
"aai", "aai",
"wildnut",
"Ran C", "Ran C",
"ViperC", "ViperC",
"itismyelement", "itismyelement",
"Sangheili460", "Sangheili460",
"MagnaInsomnia", "MagnaInsomnia",
"Karl P.", "Karl P.",
"Akira HentAI",
"MiraiKuriyamaSy",
"LarsesFPC", "LarsesFPC",
"otaku fra", "Weird_With_A_Beard",
"andrew.tappan",
"N/A", "N/A",
"The Spawn", "The Spawn",
"graysock", "graysock",
"Pozadine1", "Pozadine1",
"Greenmoustache",
"fancypants",
"jeaness",
"Joboshy",
"Digital",
"JaxMax",
"Bohemian Corporal",
"Dan",
"Jwk0205",
"Bro Xie",
"batblue",
"carey6409",
"Olive",
"太郎 ゲーム",
"Some Guy Named Barry",
"jinxedx",
"M Postkasse",
"AELOX",
"Dankin-Pics",
"Nicfit23",
"wamekukyouzin",
"drum matthieu",
"DogmaR34",
"Matt Wenzel",
"Frank Nitty",
"Christopher Michel",
"runte3221",
"Serge Bekenkamp",
"DougPeterson",
"LeoZero",
"dl0901dm",
"Antonio Pontes",
"kushiroK9",
"Kevin John Duck",
"Dustin Chen",
"dan",
"Blackfish95",
"Mouthlessman",
"Paul Kroll",
"Fraser Cross",
"Bas Imagineer",
"Dušan Ryban",
"Adam Taylor",
"Weird_With_A_Beard",
"Qarob", "Qarob",
"AIGooner", "AIGooner",
"Luc", "Luc",
"ProtonPrince", "ProtonPrince",
"DiffDuck", "DiffDuck",
"fancypants",
"John+Edwards",
"Joboshy",
"Digital",
"JaxMax",
"Bohemian Corporal",
"Dan",
"Bro Xie",
"seed123_AIart",
"batblue",
"carey6409",
"太郎 ゲーム",
"Roslynd",
"jinxedx",
"AELOX",
"Dankin-Pics",
"Nicfit23",
"Cristian Vazquez",
"wamekukyouzin",
"drum matthieu",
"DogmaR34",
"Frank Nitty",
"The Magic Noob",
"Christopher Michel",
"runte3221",
"DougPeterson",
"LeoZero",
"dl0901dm",
"Antonio Pontes",
"Bruce",
"kushiroK9",
"Kevin John Duck",
"Dustin Chen",
"Blackfish95",
"Tori",
"Mouthlessman",
"Paul Kroll",
"Fraser Cross",
"Bas Imagineer",
"John Statham",
"Dušan Ryban",
"Adam Taylor",
"decoy",
"elu3199", "elu3199",
"Hasturkun", "Hasturkun",
"Jon Sandman", "Jon Sandman",
@@ -201,39 +210,38 @@
"wundershark", "wundershark",
"mr_dinosaur", "mr_dinosaur",
"Tyrswood", "Tyrswood",
"linnfrey",
"griffin+dahlberg",
"John+Edwards",
"ElitaSSJ4",
"Matt+J",
"Josef Lanzl",
"New folder (1)",
"seed123_AIart",
"Error_Rule34_Not_found",
"Roslynd",
"Geolog",
"Neco28",
"Resist's Creations - Spicy Edition 🔥",
"David Ortega",
"Wolffen",
"Cristian Vazquez",
"The Magic Noob",
"Jeff",
"nwalker94",
"Bruce",
"Kevin Christopher",
"Chad Idk",
"Tori",
"dd",
"John Statham",
"sjon kreutz",
"Metryman55",
"AlexDuKaNa",
"decoy",
"Ray Wing", "Ray Wing",
"Ranzitho", "Ranzitho",
"Gus", "Gus",
"MJG", "MJG",
"linnfrey",
"griffin+dahlberg",
"ElitaSSJ4",
"Matt+J",
"Josef Lanzl",
"New folder (1)",
"sanborondon",
"Error_Rule34_Not_found",
"jcay015",
"Erik Lopez",
"Mateo Curić",
"Geolog",
"Neco28",
"Eris3D",
"Resist's Creations - Spicy Edition 🔥",
"David Ortega",
"Wolffen",
"a _",
"Jeff",
"nwalker94",
"James Coleman",
"Kevin Christopher",
"Chad Idk",
"dd",
"Sam",
"sjon kreutz",
"Metryman55",
"AlexDuKaNa",
"ae", "ae",
"Tr4shP4nda", "Tr4shP4nda",
"Gamalonia", "Gamalonia",
@@ -248,37 +256,41 @@
"Kland", "Kland",
"Hailshem", "Hailshem",
"Naomi Hale Danchi", "Naomi Hale Danchi",
"epicgamer0020690",
"Joshua Porrata",
"Andrew",
"Brian M", "Brian M",
"sanborondon", "Robert Wegemund",
"Littlehuggy",
"Brian Buie",
"Thought2Form", "Thought2Form",
"jcay015",
"RAIDiation", "RAIDiation",
"Erik Lopez", "Sadlip",
"Mateo Curić",
"Eris3D",
"Gooohokrbe", "Gooohokrbe",
"m", "m",
"OldBones", "OldBones",
"Pierce McBride", "Pierce McBride",
"Zach Gonser", "Zach Gonser",
"Mikko Hemilä", "Mikko Hemilä",
"Jacob McDaniel",
"Jamie Ogletree", "Jamie Ogletree",
"a _", "Temikus",
"James Coleman", "Artokun",
"Michael Taylor",
"Martial", "Martial",
"Emil Andersson", "Emil Andersson",
"Ouro Boros", "Ouro Boros",
"Atilla Berke Pekduyar",
"Decx _",
"Yuji Kaneko", "Yuji Kaneko",
"Rops Alot", "Rops Alot",
"Sam",
"Penfore", "Penfore",
"Gordon Cole", "Gordon Cole",
"Ace Ventura", "Ace Ventura",
"AbstractAss", "AbstractAss",
"David LaVallee", "David LaVallee",
"ken", "ken",
"epicgamer0020690", "Crocket",
"Joshua Porrata",
"keemun", "keemun",
"SuBu", "SuBu",
"RedPIXel", "RedPIXel",
@@ -297,15 +309,19 @@
"KitKatM", "KitKatM",
"socrasteeze", "socrasteeze",
"MudkipMedkitz", "MudkipMedkitz",
"deanbrian",
"Alex Wortman",
"Cody",
"emadsultan",
"InformedViewz",
"Bubbafett",
"leaf",
"Adam Rinehart",
"gzmzmvp", "gzmzmvp",
"takyamtom", "takyamtom",
"Andrew", "Aberr",
"Robert Wegemund",
"Littlehuggy",
"Gregory Kozhemiak", "Gregory Kozhemiak",
"Brian Buie",
"aezin", "aezin",
"Sadlip",
"Eric Whitney", "Eric Whitney",
"Joey Callahan", "Joey Callahan",
"Ivan Tadic", "Ivan Tadic",
@@ -315,17 +331,12 @@
"Elliot E", "Elliot E",
"Morgandel", "Morgandel",
"Theerat Jiramate", "Theerat Jiramate",
"Jacob McDaniel",
"X", "X",
"SloanSteddyAI", "SloanSteddyAI",
"Temikus",
"Artokun",
"Michael Taylor",
"Steven Owens", "Steven Owens",
"hexxish",
"Derek Baker", "Derek Baker",
"Atilla Berke Pekduyar",
"NICHOLAS BAXLEY", "NICHOLAS BAXLEY",
"Decx _",
"Ed Wang", "Ed Wang",
"Saya", "Saya",
"Xeeosat", "Xeeosat",
@@ -333,18 +344,10 @@
"四糸凜音", "四糸凜音",
"esthe", "esthe",
"FrxzenSnxw", "FrxzenSnxw",
"Crocket",
"chriphost", "chriphost",
"ResidentDeviant", "ResidentDeviant",
"deanbrian", "Ginnie",
"Alex Wortman",
"Cody",
"emadsultan",
"InformedViewz",
"Bubbafett",
"leaf",
"Skyfire83", "Skyfire83",
"Adam Rinehart",
"Pitpe11", "Pitpe11",
"IamAyam", "IamAyam",
"TheD1rtyD03", "TheD1rtyD03",
@@ -356,17 +359,25 @@
"SpringBootisTrash", "SpringBootisTrash",
"carsten", "carsten",
"ikok", "ikok",
"quantenmecha",
"Jason+Nash",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"Duk3+Rand0m",
"Nathen+Choi", "Nathen+Choi",
"T", "T",
"D",
"David Schenck", "David Schenck",
"Wolfe7D1", "Wolfe7D1",
"Aberr",
"Andrew Marshall", "Andrew Marshall",
"Taylor Funk", "Taylor Funk",
"elleshar666", "elleshar666",
"Gerald Welly", "Gerald Welly",
"Tee Gee", "Tee Gee",
"ACTUALLY_the_Real_Willem_Dafoe", "ACTUALLY_the_Real_Willem_Dafoe",
"Михал Михалыч",
"tarek helmi", "tarek helmi",
"Kauffy", "Kauffy",
"Max Marklund", "Max Marklund",
@@ -376,13 +387,15 @@
"Vane Holzer", "Vane Holzer",
"psytrax", "psytrax",
"Cyrus Fett", "Cyrus Fett",
"hexxish",
"lh qwe", "lh qwe",
"conner", "conner",
"Xenon Xue",
"Michael Anthony Scott", "Michael Anthony Scott",
"notedfakes", "notedfakes",
"Princess Bright Eyes", "Princess Bright Eyes",
"Michael Scott", "Michael Scott",
"Solixer",
"Jimmy Borup",
"Wes Sims", "Wes Sims",
"Donor4115", "Donor4115",
"Filippo Ferrari", "Filippo Ferrari",
@@ -393,11 +406,19 @@
"momokai", "momokai",
"몽타주", "몽타주",
"kudari", "kudari",
"Whitepinetrader",
"OrganicArtifact", "OrganicArtifact",
"Ginnie",
"Raku", "Raku",
"CHKeeho80", "CHKeeho80",
"nanana", "nanana",
"Alex",
"Karru",
"ChaChanoKo",
"ghoulars",
"null",
"Beau",
"redcarrot",
"powerbot99",
"Fthehappy", "Fthehappy",
"J", "J",
"Jeff+Kesemeyer", "Jeff+Kesemeyer",
@@ -407,39 +428,32 @@
"Doug+Rintoul", "Doug+Rintoul",
"Noor", "Noor",
"Yorunai", "Yorunai",
"D",
"quantenmecha",
"Jason+Nash",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"Duk3+Rand0m",
"Richard", "Richard",
"奚明 刘", "奚明 刘",
"준희 김", "준희 김",
"りん あめ", "りん あめ",
"Михал Михалыч",
"Matt", "Matt",
"Tomohiro Baba", "Tomohiro Baba",
"Noora", "Noora",
"Frogmilk", "Frogmilk",
"SPJ", "SPJ",
"Kor",
"Bryan Rutkowski", "Bryan Rutkowski",
"Noah", "Noah",
"Xenon Xue", "TenaciousD",
"Dmitry Ryzhov", "Dmitry Ryzhov",
"DarkSunset", "DarkSunset",
"Edward Ten Eyck", "Edward Ten Eyck",
"Steam Steam", "Steam Steam",
"CryptoTraderJK", "CryptoTraderJK",
"Davaitamin", "Davaitamin",
"Solixer", "Pete Pain",
"Nathan", "Nathan",
"Jimmy Borup",
"tedcor", "tedcor",
"RHopkirk",
"jinksta187", "jinksta187",
"Fotek Design", "Fotek Design",
"Maxim",
"Manu Thetug", "Manu Thetug",
"Lyavph", "Lyavph",
"Nihongasuki", "Nihongasuki",
@@ -450,8 +464,14 @@
"starbugx", "starbugx",
"dc7431", "dc7431",
"Inversity", "Inversity",
"Whitepinetrader",
"Vir", "Vir",
"Sildoren",
"Darv",
"Seon+Song",
"2turbo",
"Dmitry+Viznesenskiy",
"tanjin90",
"sternenkrieger",
"Pascalou", "Pascalou",
"Patrick+Bryan", "Patrick+Bryan",
"lighthawke", "lighthawke",
@@ -468,23 +488,17 @@
"Bob+Barker", "Bob+Barker",
"Dark_Pest", "Dark_Pest",
"Eldithor", "Eldithor",
"Alex",
"Karru",
"ChaChanoKo",
"ghoulars",
"redcarrot",
"null",
"Beau",
"powerbot99",
"Ko-fi+Supporter", "Ko-fi+Supporter",
"lrdchs2", "lrdchs2",
"Tú Nguyễn Lý Hoàng", "Tú Nguyễn Lý Hoàng",
"shira1011",
"Kalli Core", "Kalli Core",
"Ben D", "Ben D",
"Draven T", "Draven T",
"marioandluigi", "marioandluigi",
"G", "G",
"Ronan Delevacq", "Ronan Delevacq",
"Leslie Andrew Ridings",
"Aquatic Coffee", "Aquatic Coffee",
"Dave Abraham", "Dave Abraham",
"Joaquin Hierrezuelo", "Joaquin Hierrezuelo",
@@ -492,25 +506,27 @@
"StudOx Tech", "StudOx Tech",
"yves.poezevara", "yves.poezevara",
"Jarrid Lee", "Jarrid Lee",
"Kor", "Poophead27 Blyat",
"Joseph Hanson", "Joseph Hanson",
"John Rednoulf", "John Rednoulf",
"Focuschannel", "Focuschannel",
"Boba Smith", "Boba Smith",
"matt",
"somethingtosay8",
"ivistorm", "ivistorm",
"Anthony Faxlandez", "Anthony Faxlandez",
"Sauv", "Sauv",
"TenaciousD",
"Ted Cart", "Ted Cart",
"Sage Himeros",
"Zeeble", "Zeeble",
"Pat Hen", "Pat Hen",
"Pete Pain",
"Draconach", "Draconach",
"Tigon", "Tigon",
"ItsGeneralButtNaked",
"Jordan Shaw", "Jordan Shaw",
"RHopkirk",
"g unit", "g unit",
"Maxim", "Dkom22",
"Marcos Tortosa Carmona",
"Distortik", "Distortik",
"JC", "JC",
"Prompt Pirate", "Prompt Pirate",
@@ -518,11 +534,22 @@
"Marcus thronico", "Marcus thronico",
"zenobeus", "zenobeus",
"ryoma", "ryoma",
"dg",
"Stryker", "Stryker",
"smart.edge5178", "smart.edge5178",
"Menard", "Menard",
"SomeDude", "SomeDude",
"raf8osz", "raf8osz",
"Gold_miner_ego",
"bakeliteboy",
"TequiTequi",
"Homero+Banda",
"Nick",
"Monix",
"Trolinka",
"PredragR",
"Clauzmak",
"Nerick",
"SundayRage", "SundayRage",
"matter", "matter",
"SRCRCOSS", "SRCRCOSS",
@@ -539,13 +566,6 @@
"Mobius2020", "Mobius2020",
"ExLightSaber", "ExLightSaber",
"YaboiRay", "YaboiRay",
"Sildoren",
"Darv",
"Seon+Song",
"2turbo",
"Dmitry+Viznesenskiy",
"tanjin90",
"sternenkrieger",
"boston666", "boston666",
"cocona", "cocona",
"Obsidian.Studios", "Obsidian.Studios",
@@ -553,52 +573,53 @@
"Aquaneo", "Aquaneo",
"blikkies", "blikkies",
"JBsuede", "JBsuede",
"shira1011", "Wolf and Fox Legends",
"ゼクス、六",
"Neko Desco", "Neko Desco",
"Vinarus", "Vinarus",
"Josh Snyder", "Josh Snyder",
"Shock Shockor", "Shock Shockor",
"Goldwaters", "Goldwaters",
"Leslie Andrew Ridings",
"Zude", "Zude",
"Poophead27 Blyat", "Room Light",
"Kyler", "Kyler",
"Justin Blaylock", "Justin Blaylock",
"aRtFuL_DodGeR", "aRtFuL_DodGeR",
"Snorklebort", "Snorklebort",
"TheFusion", "TheFusion",
"MR.Bear", "MR.Bear",
"matt",
"somethingtosay8",
"3zS4QNQ4", "3zS4QNQ4",
"Terminuz", "Terminuz",
"Matt M.", "Matt M.",
"Ivan Imes", "Ivan Imes",
"J M",
"Steven", "Steven",
"Borte", "Borte",
"Sage Himeros", "yyuvuvu",
"Billy Gladky", "Billy Gladky",
"Nomki",
"Probis", "Probis",
"Jack Lawfield", "Jack Lawfield",
"SkibidiRizzler", "SkibidiRizzler",
"Maxon - Plans", "Maxon - Plans",
"Kalle Björk", "Kalle Björk",
"ItsGeneralButtNaked",
"Karlanx", "Karlanx",
"operationancut", "operationancut",
"Nacho Ferrando", "Nacho Ferrando",
"Marcos Tortosa Carmona",
"Dkom22",
"Youguang", "Youguang",
"andrewzpong", "andrewzpong",
"BossGame", "BossGame",
"lrdchs", "lrdchs",
"Tree Tagger", "Tree Tagger",
"Janik",
"AIVORY3D", "AIVORY3D",
"Kevinj", "Kevinj",
"Mitchell Robson", "Mitchell Robson",
"dg",
"POPPIN", "POPPIN",
"meatyalien",
"Tony+V",
"draganjankovic1975dj528",
"kinz",
"YoruHime", "YoruHime",
"Mark+Staaf", "Mark+Staaf",
"Michael+Fürmann", "Michael+Fürmann",
@@ -611,17 +632,7 @@
"thomasand01", "thomasand01",
"Shiba+Sama", "Shiba+Sama",
"Celestial+Kitten", "Celestial+Kitten",
"TequiTequi",
"Homero+Banda",
"bakeliteboy",
"Nick",
"Gold_miner_ego",
"IshouI;_;", "IshouI;_;",
"Monix",
"Trolinka",
"PredragR",
"Clauzmak",
"Nerick",
"SAVEagleBasement", "SAVEagleBasement",
"Adam+Spreer", "Adam+Spreer",
"BillyBoy84", "BillyBoy84",
@@ -629,18 +640,17 @@
"Welkor", "Welkor",
"dubious1one", "dubious1one",
"Brandon Thomas", "Brandon Thomas",
"Dustin Hendel",
"moranqianlong", "moranqianlong",
"Wolf and Fox Legends",
"ゼクス、六",
"Liberation", "Liberation",
"Ninja Tom", "Ninja Tom",
"75marc", "75marc",
"Elemnt", "Elemnt",
"Bradley Turner",
"swra", "swra",
"JollRodrigo", "JollRodrigo",
"Oliverfish", "Oliverfish",
"uruksayshi", "uruksayshi",
"Room Light",
"Patryk Serious", "Patryk Serious",
"nk8", "nk8",
"Kyron Mahan", "Kyron Mahan",
@@ -648,17 +658,18 @@
"Nimhloth", "Nimhloth",
"TBitz33", "TBitz33",
"Anonym dkjglfleeoeldldldlkf", "Anonym dkjglfleeoeldldldlkf",
"Tsani Prodanov",
"Ezokewn", "Ezokewn",
"SendingRavens", "SendingRavens",
"J M",
"Slacks", "Slacks",
"Glenn Hoetker", "Glenn Hoetker",
"JackJohnnyJim", "JackJohnnyJim",
"Khánh Đặng", "Khánh Đặng",
"Michael Hicks",
"Homero Banda", "Homero Banda",
"Michael Docherty", "Michael Docherty",
"yyuvuvu", "MadGod",
"Nomki", "GhostyGhost",
"Paul Hartsuyker", "Paul Hartsuyker",
"elitassj", "elitassj",
"Never_M", "Never_M",
@@ -667,6 +678,7 @@
"Andrew Wilkinson", "Andrew Wilkinson",
"David", "David",
"floeki75pad", "floeki75pad",
"TheJohnes",
"deadwishd", "deadwishd",
"shinonomeiro", "shinonomeiro",
"Snille", "Snille",
@@ -675,7 +687,6 @@
"xybrightsummer", "xybrightsummer",
"jreedatchison", "jreedatchison",
"PhilW", "PhilW",
"Janik",
"Cruel", "Cruel",
"MRBlack", "MRBlack",
"Kiyoe", "Kiyoe",
@@ -685,6 +696,15 @@
"Scott", "Scott",
"Muratoraccio", "Muratoraccio",
"D", "D",
"Daevalus",
"Milky+Mai",
"Krash",
"PP",
"thababydjac",
"belligerencebk",
"tortor",
"Peter",
"T",
"zipzorpp", "zipzorpp",
"Anton", "Anton",
"actual", "actual",
@@ -706,11 +726,7 @@
"plonk", "plonk",
"Anvil+Girl", "Anvil+Girl",
"Kotetsu", "Kotetsu",
"meatyalien",
"Tony+V",
"draganjankovic1975dj528",
"miduzza", "miduzza",
"kinz",
"Somebody", "Somebody",
"てぃんてぃんひーろー", "てぃんてぃんひーろー",
"you+halo9", "you+halo9",
@@ -727,12 +743,12 @@
"4IXplr0r3r", "4IXplr0r3r",
"hayden", "hayden",
"ahoystan", "ahoystan",
"Civitaier",
"BakunyuuWaifu", "BakunyuuWaifu",
"edk", "edk",
"Dustin Hendel", "Joey Leto",
"Anagra Nouma", "Anagra Nouma",
"tafapayo", "tafapayo",
"Bradley Turner",
"ja s", "ja s",
"Doug Mason", "Doug Mason",
"scoreswazey", "scoreswazey",
@@ -747,8 +763,8 @@
"David Murcko", "David Murcko",
"Justin Defer", "Justin Defer",
"Ben Brogger", "Ben Brogger",
"Tsani Prodanov",
"Jack Dole", "Jack Dole",
"dsffsdfsdfsdfsdfsdf",
"V Bj", "V Bj",
"Rj Joplin", "Rj Joplin",
"Kurt", "Kurt",
@@ -757,15 +773,13 @@
"Taylor Dominy", "Taylor Dominy",
"Faith", "Faith",
"Bouya shaka", "Bouya shaka",
"Michael Hicks",
"Maso", "Maso",
"MadGod",
"Kevin Wallace", "Kevin Wallace",
"GhostyGhost",
"ChicRic", "ChicRic",
"Bastard-Sama", "Bastard-Sama",
"mercur", "mercur",
"Sunny", "Sunny",
"Somebody",
"inusanorthcape", "inusanorthcape",
"Kane Sturzebecher", "Kane Sturzebecher",
"Yavizu3d", "Yavizu3d",
@@ -776,7 +790,6 @@
"Evgeniya Smolentseva", "Evgeniya Smolentseva",
"Raf Stahelin", "Raf Stahelin",
"Вячеслав Маринин", "Вячеслав Маринин",
"TheJohnes",
"Cola Matthew", "Cola Matthew",
"OniNoKen", "OniNoKen",
"Iain Wisely", "Iain Wisely",
@@ -819,6 +832,12 @@
"SelfishMedic", "SelfishMedic",
"adderleighn", "adderleighn",
"EnragedAntelope", "EnragedAntelope",
"mcmalt",
"cesasol",
"Null",
"fdfac",
"Eli",
"Somebody",
"8/4", "8/4",
"ivan.morgado.siles", "ivan.morgado.siles",
"SEI", "SEI",
@@ -830,16 +849,7 @@
"gdfgfdgfds", "gdfgfdgfds",
"Benjamin+Doerr", "Benjamin+Doerr",
"D", "D",
"Daevalus",
"MilkyMai",
"Krash",
"PP",
"babydjac",
"belligerencebk",
"tortor",
"Cryphius", "Cryphius",
"Peter+Timothy+Stover",
"Joel+Magnusson",
"Connor+Hall", "Connor+Hall",
"Macho+Grump", "Macho+Grump",
"Morcoddd", "Morcoddd",
@@ -879,13 +889,11 @@
"proto merp", "proto merp",
"_ G3n", "_ G3n",
"Donovan Jenkins", "Donovan Jenkins",
"Civitaier",
"Hans Meier", "Hans Meier",
"jboul", "jboul",
"Michael Eid", "Michael Eid",
"Super Sigma Reborne", "Super Sigma Reborne",
"Veloce", "Veloce",
"Joey Leto",
"Bob barker", "Bob barker",
"Michael Rivera", "Michael Rivera",
"karim ben brik", "karim ben brik",
@@ -916,6 +924,7 @@
"DrB", "DrB",
"wknight", "wknight",
"Moneymaker412K", "Moneymaker412K",
"Jacid",
"unkeiknown", "unkeiknown",
"Towelie", "Towelie",
"Alex Ross", "Alex Ross",
@@ -926,10 +935,12 @@
"john Greene", "john Greene",
"jimyjomson", "jimyjomson",
"JaeHyun Jang", "JaeHyun Jang",
"sbone",
"BigBoss", "BigBoss",
"Chase Kwon", "Chase Kwon",
"Bob Ling", "Bob Ling",
"Inyoshu", "Inyoshu",
"nick Meadows",
"Chad Barnes", "Chad Barnes",
"redlines3", "redlines3",
"Adam Gardner", "Adam Gardner",
@@ -944,6 +955,7 @@
"Somebody", "Somebody",
"Somebody", "Somebody",
"Somebody", "Somebody",
"Somebody",
"CoffeeMage", "CoffeeMage",
"Ken+Suzuki", "Ken+Suzuki",
"hannibal", "hannibal",
@@ -954,8 +966,7 @@
"L C", "L C",
"Dude", "Dude",
"Somebody", "Somebody",
"Somebody",
"CK" "CK"
], ],
"totalCount": 954 "totalCount": 965
} }
+53 -1
View File
@@ -71,9 +71,18 @@ Enriches models linked to an external model site with metadata extracted by an L
| Platform | Link | AI enrichment | Direct download | | Platform | Link | AI enrichment | Direct download |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Hugging Face | yes | yes | yes | | Hugging Face | yes | yes | yes |
| ModelScope | yes | yes | yes | | ModelScope (`modelscope.cn`) | yes | yes | yes |
| ModelScope International (`modelscope.ai`) | yes | yes | yes |
| TensorArt | yes | no (see below) | no | | TensorArt | yes | no (see below) | no |
`modelscope.cn` and `modelscope.ai` are **separate catalogues, not mirrors** — a
repository published on one is routinely absent from the other — so each is
registered as its own source (`ModelScopeSource` / `ModelScopeIntlSource` in
`py/services/model_sources/modelscope.py`). The host therefore decides which
API and CDN a model resolves against, and the two deployments get separate
version groups (`ms:` / `msai:`) and default download directories. Keep the two
tables in `modelSourceHelpers.js` and `registry.py` in step when adding a site.
TensorArt is link-only: `tensor.art` sits behind a Cloudflare managed challenge and its internal API requires session authorization, so the backend cannot read its model pages. Linking still stores the canonical page URL and the "View on TensorArt" link works. TensorArt is link-only: `tensor.art` sits behind a Cloudflare managed challenge and its internal API requires session authorization, so the backend cannot read its model pages. Linking still stores the canonical page URL and the "View on TensorArt" link works.
**What it does**: **What it does**:
@@ -133,7 +142,9 @@ gaps the LLM leaves behind:
| Field | Deterministic source | LLM role | | Field | Deterministic source | LLM role |
| --- | --- | --- | | --- | --- | --- |
| `model_name` | site display name (`Name`), written only while the value is still the file stem | — |
| `modelDescription` | author summary + README as HTML | — | | `modelDescription` | author summary + README as HTML | — |
| `civitai.name` | the matched version's label (`modelVersion.showName`) | — |
| `civitai.images` | site example images, then README images | — | | `civitai.images` | site example images, then README images | — |
| `preview_url` | first available example image | may propose one from the README | | `preview_url` | first available example image | may propose one from the README |
| `tags` | site-curated tags, always merged in | proposes additional content tags | | `tags` | site-curated tags, always merged in | proposes additional content tags |
@@ -147,6 +158,47 @@ Models with no source, an unknown source, or a source without model-card access
**Model types**: LoRA, Checkpoint, Embedding **Model types**: LoRA, Checkpoint, Embedding
### Download-time hydration
The same deterministic mapping runs automatically when a model is downloaded
from a model source, so a ModelScope or Hugging Face download lands with the
populated card a CivitAI download produces instead of a bare filename and
hash. Nothing needs to be triggered by hand and no provider is called.
`py/services/model_sources/hydration.py` owns this path:
* `_save_source_metadata()` in `py/routes/handlers/model_source_handlers.py`
creates the sidecar (hash, source link, scanner-cache entry) and then calls
`hydrate_from_source()`. It also runs for a file that was already on disk, so
models downloaded before this existed get topped up on the next attempt.
* Metadata is created through the **owning scanner**
(`scanner._create_default_metadata()`) rather than
`MetadataManager.create_default_metadata()`, so the per-type lazy-hash rule
applies: `CheckpointScanner` and `OtherScanner` store
`hash_status="pending"` with an empty `sha256` for their multi-GB files, and
the generic helper would read a 10 GB checkpoint end to end inside the
download request. Hydration copes with the empty hash — `_matching_versions()`
falls back to the repository basename, which the download just wrote.
* Hydration reuses `PostProcessor` with an empty `llm_output`, so the two paths
cannot drift apart. It reports `metadata_source = "source:<platform>"` rather
than the skill's `agent:enrich_hf_metadata`, and — because no provider ran —
it does not stamp `llm_enriched_at`.
* `model_name` is only written while it still equals the file stem: once a user
renames a model, that choice is kept.
* Only a model whose stored `source_platform`/`source_url` match the repository
being downloaded is updated; a local file that merely shares a name must not
receive another model's card.
* The README and repository payload describe the *repository*, so a short-lived
process-wide `ModelSourceCache` (`shared_source_cache`, 300 s, 32 entries)
keeps a batch over one repository to two HTTP requests.
* Every failure — unreachable site, changed payload shape, broken post-processor
— is logged and swallowed. Metadata hydration can never fail a download.
* Neither stage advances the byte counter, so both are announced to the
progress UI (`_report_phase()``{"status": "metadata", "stage": ...}`) as
they start. Without that the bar sits at 100% reporting `0 B/s` for several
seconds and the download looks stuck. `stage` and `platform` are
machine-readable; the wording is localised in `LoadingManager`.
## Adding a New Skill ## Adding a New Skill
### 1. Create the skill directory ### 1. Create the skill directory
+53 -1
View File
@@ -4,7 +4,7 @@ This document is the canonical set of conventions for translating LoRA Manager U
It applies to **human translators and AI agents** alike. Read it before editing anything in It applies to **human translators and AI agents** alike. Read it before editing anything in
`locales/`. `locales/`.
Source of truth: `locales/en.json` (10 locales, 1982 leaf keys; all locales share the exact Source of truth: `locales/en.json` (10 locales, 2025 leaf keys; all locales share the exact
same key structure). same key structure).
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL). Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
@@ -42,6 +42,20 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
> which had hardcoded "HF" for a button that now also enriches ModelScope models. The > which had hardcoded "HF" for a button that now also enriches ModelScope models. The
> `modals.linkModelSource.urlPlaceholder` value stays byte-identical to `en.json` (it is a URL, > `modals.linkModelSource.urlPlaceholder` value stays byte-identical to `en.json` (it is a URL,
> the §6 exception). Terminology in §2, "Model source feature". > the §6 exception). Terminology in §2, "Model source feature".
>
> **Status (2026-09, folder sidebar):** the model-root sidebar gained on-disk folder management
> (create / rename / delete folders, show empty folders, tree vs list view) plus its `...`
> view-options menu, adding 35 `sidebar.*` keys. Those were the only `[TODO: Translate]`
> placeholders left behind by the feature series, and all 35 are now translated in all 9
> locales, so the "no remaining placeholders" claim above holds again. Terminology in §2,
> "Folder sidebar feature".
>
> **Status (2026-09, chip reordering):** model tags and trigger words now share one drag/`⠿`
> grip reorder affordance, which added the single `common.reorder.dragHandle` key (it lives
> under `common` because both editors render it). All 9 locales are translated (renderings in
> §2, "Chip reordering"). Reordering is pointer-only by design: an `Alt + Arrow` shortcut was
> prototyped and removed because it collided with the browser's Alt + Arrow handling and the
> modal's arrow-key navigation.
--- ---
@@ -330,6 +344,44 @@ in `en`, not "Enrich HF Metadata": they cover ModelScope as well, so no locale m
an `HF` qualifier in `loras.contextMenu.enrichHfAgent` / `loras.bulkOperations.enrichHfAgent` an `HF` qualifier in `loras.contextMenu.enrichHfAgent` / `loras.bulkOperations.enrichHfAgent`
(the key names keep the historical `Hf`; only the values changed). (the key names keep the historical `Hf`; only the values changed).
### Folder sidebar feature (create / rename / delete folders, empty folders, view options)
The model-root sidebar manages on-disk folders. "Folder" reuses the noun already fixed in §2
(the `folder key` row); the rest is new surface:
| Term | Rendering |
|---|---|
| folder | zh-CN 文件夹 · zh-TW 資料夾 · ja フォルダ · ko 폴더 · fr dossier · de Ordner · es carpeta · ru папка · he תיקייה |
| model root (as in "no model root is configured") | zh-CN 模型根目录 · zh-TW 模型根目錄 · ja モデルルート · ko 모델 루트 · fr racine de modèle · de Modell-Stammverzeichnis · es raíz de modelo · ru корневая папка моделей · he שורש מודלים — note `sidebar.modelRoot` alone is the shorter 根目录 / 根目錄 / ルート / 루트 / Racine / Stammverzeichnis / Raíz / Корень / שורש |
| tree view / list view | zh-CN 树形视图 / 列表视图 · zh-TW 樹狀檢視 / 清單檢視 · ja ツリー表示 / リスト表示 · ko 트리 보기 / 목록 보기 · fr Vue arborescente / Vue liste · de Baumansicht / Listenansicht · es Vista de árbol / Vista de lista · ru Дерево / Список · he תצוגת עץ / תצוגת רשימה |
| sidebar | reuse each locale's `sidebar.hideOnThisPage` noun: zh-CN 侧边栏 · zh-TW 側邊欄 · ja サイドバー · ko 사이드바 · fr barre latérale · de Seitenleiste · es barra lateral · ru боковая панель · he סרגל צד |
Deleting a folder **never cascades over model files** — the backend refuses it and
`sidebar.deleteFolderModal.notEmptyMessage` states the rule in every locale, so keep that
clause (and its `—`) when the copy is edited. The `{name}` / `{count}` / `{message}` tokens in
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
### Chip reordering (model tags / trigger words)
Model tags and trigger-word chips share a single reorder affordance (drag the chip, or its
`⠿` grip where the chip body is click-to-edit), so the copy sits in `common.reorder.dragHandle`
instead of a feature namespace. It is used twice per editor: as the grip tooltip and as the
hint shown in the edit controls row. There is deliberately **no keyboard shortcut** — an
`Alt + Arrow` binding fought the browser's own Alt + Arrow handling and the modal's arrow-key
navigation, so reordering is pointer-only and the grip is a decorative, non-focusable
affordance. Do not reintroduce a shortcut or a "position X of Y" screen-reader string without
re-adding the corresponding keys.
`dragHandle` is a fragment, not a sentence: it labels both the grip and the hint, so keep it
short and imperative and do not append a keyboard hint in any locale.
| Term | Rendering |
|---|---|
| drag to reorder | zh-CN 拖拽以调整顺序 · zh-TW 拖曳以調整順序 · ja ドラッグして並べ替え · ko 드래그하여 순서 변경 · fr Glisser pour réordonner · de Zum Neuordnen ziehen · es Arrastra para reordenar · ru Перетащите, чтобы изменить порядок · he גרור כדי לשנות סדר |
The grip itself is an icon and is never translated.
--- ---
## 3. Cross-cutting confusion hot-spots (must-fix list) ## 3. Cross-cutting confusion hot-spots (must-fix list)
+51 -16
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "Abbrechen", "cancel": "Abbrechen",
"confirm": "Bestätigen", "confirm": "Bestätigen",
"reorder": {
"dragHandle": "Zum Neuordnen ziehen"
},
"actions": { "actions": {
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",
@@ -915,7 +918,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "Basismodell",
"unknown": "Unbekannt"
}, },
"actions": { "actions": {
"openFileLocation": "Dateispeicherort öffnen", "openFileLocation": "Dateispeicherort öffnen",
@@ -1246,37 +1251,63 @@
"sidebar": { "sidebar": {
"modelRoot": "Stammverzeichnis", "modelRoot": "Stammverzeichnis",
"collapseAll": "Alle Ordner einklappen", "collapseAll": "Alle Ordner einklappen",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "In der Listenansicht nicht verfügbar",
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden", "hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
"showSidebar": "Seitenleiste anzeigen", "showSidebar": "Seitenleiste anzeigen",
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet", "sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "Ansichtsoptionen",
"treeView": "[TODO: Translate] Tree view", "treeView": "Baumansicht",
"listView": "[TODO: Translate] List view", "listView": "Listenansicht",
"recursiveOn": "Unterordner einbeziehen", "recursiveOn": "Unterordner einbeziehen",
"createFolder": "[TODO: Translate] New folder", "createFolder": "Neuer Ordner",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "Neuer Unterordner",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "Leere Ordner anzeigen",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "Ordner \"{name}\" erstellt",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "Ordner konnte nicht erstellt werden: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "Das Erstellen von Ordnern wird auf dieser Seite nicht unterstützt",
"noRoot": "[TODO: Translate] No model root is configured" "noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
},
"deleteFolder": "Ordner löschen",
"deleteFolderModal": {
"title": "Ordner löschen?",
"message": "Der Ordner und sein gesamter Inhalt werden endgültig vom Datenträger gelöscht.",
"folderLabel": "Ordner",
"emptyNote": "Dieser Ordner enthält keine Modelle. Alle anderen darin enthaltenen Dateien werden ebenfalls gelöscht.",
"notEmptyTitle": "Ordner ist nicht leer",
"notEmptyMessage": "Dieser Ordner enthält noch Modelle. Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
"confirm": "Ordner löschen"
},
"deleteFolderResult": {
"success": "Ordner \"{name}\" gelöscht",
"successWithFiles": "Ordner \"{name}\" sowie {count} weitere(s) Element(e) gelöscht",
"restored": "Ordner wiederhergestellt",
"failed": "Ordner konnte nicht gelöscht werden: {message}",
"notEmpty": "Dieser Ordner enthält noch Modelle. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
"unsupported": "Das Löschen von Ordnern wird auf dieser Seite nicht unterstützt",
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
},
"renameFolder": "Ordner umbenennen",
"renameFolderResult": {
"success": "Ordner umbenannt in \"{name}\"",
"failed": "Ordner konnte nicht umbenannt werden: {message}",
"targetExists": "Ein Ordner mit diesem Namen ist hier bereits vorhanden",
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
"unsupported": "Das Umbenennen von Ordnern wird auf dieser Seite nicht unterstützt",
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
}, },
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.", "unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
"moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.", "moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.",
"createFolderHint": "Loslassen, um einen neuen Ordner zu erstellen",
"newFolderName": "Neuer Ordnername", "newFolderName": "Neuer Ordnername",
"folderNameHint": "Eingabetaste zum Bestätigen, Escape zum Abbrechen",
"emptyFolderName": "Bitte geben Sie einen Ordnernamen ein", "emptyFolderName": "Bitte geben Sie einen Ordnernamen ein",
"invalidFolderName": "Ordnername enthält ungültige Zeichen", "invalidFolderName": "Ordnername enthält ungültige Zeichen",
"noDragState": "Kein ausstehender Ziehvorgang gefunden" "noDragState": "Kein ausstehender Ziehvorgang gefunden"
}, },
"empty": { "empty": {
"noFolders": "Keine Ordner gefunden", "noFolders": "Keine Ordner gefunden",
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen", "createHint": "Klicken Sie oben auf „Neuer Ordner“, um Ordner zu erstellen"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Auf Updates in diesem Ordner prüfen", "label": "Auf Updates in diesem Ordner prüfen",
@@ -1455,6 +1486,10 @@
"progress": { "progress": {
"currentFile": "Aktuelle Datei:", "currentFile": "Aktuelle Datei:",
"downloading": "Wird heruntergeladen: {name}", "downloading": "Wird heruntergeladen: {name}",
"metadata": "Metadaten: {name}",
"indexingFile": "Modelldatei wird gelesen...",
"fetchingSourceMetadata": "Metadaten werden von {source} abgerufen...",
"fetchingMetadata": "Metadaten werden abgerufen...",
"transferred": "Heruntergeladen: {downloaded} / {total}", "transferred": "Heruntergeladen: {downloaded} / {total}",
"transferredSimple": "Heruntergeladen: {downloaded}", "transferredSimple": "Heruntergeladen: {downloaded}",
"transferredUnknown": "Heruntergeladen: --", "transferredUnknown": "Heruntergeladen: --",
+40 -5
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm", "confirm": "Confirm",
"reorder": {
"dragHandle": "Drag to reorder"
},
"actions": { "actions": {
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
@@ -915,7 +918,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "Base Model",
"unknown": "Unknown"
}, },
"actions": { "actions": {
"openFileLocation": "Open File Location", "openFileLocation": "Open File Location",
@@ -1263,20 +1268,46 @@
"unsupported": "Folder creation is not supported on this page", "unsupported": "Folder creation is not supported on this page",
"noRoot": "No model root is configured" "noRoot": "No model root is configured"
}, },
"deleteFolder": "Delete folder",
"deleteFolderModal": {
"title": "Delete folder?",
"message": "The folder and everything inside it will be permanently removed from disk.",
"folderLabel": "Folder",
"emptyNote": "This folder contains no models. Any other files it holds will be deleted too.",
"notEmptyTitle": "Folder is not empty",
"notEmptyMessage": "This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
"confirm": "Delete folder"
},
"deleteFolderResult": {
"success": "Folder \"{name}\" deleted",
"successWithFiles": "Folder \"{name}\" deleted along with {count} other item(s)",
"restored": "Folder restored",
"failed": "Failed to delete folder: {message}",
"notEmpty": "This folder still contains models. Refresh the sidebar and try again.",
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
"unsupported": "Folder deletion is not supported on this page",
"noRoot": "No model root is configured"
},
"renameFolder": "Rename folder",
"renameFolderResult": {
"success": "Folder renamed to \"{name}\"",
"failed": "Failed to rename folder: {message}",
"targetExists": "A folder with that name already exists here",
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
"unsupported": "Folder renaming is not supported on this page",
"noRoot": "No model root is configured"
},
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Unable to determine destination path for move.", "unableToResolveRoot": "Unable to determine destination path for move.",
"moveUnsupported": "Move is not supported for this item.", "moveUnsupported": "Move is not supported for this item.",
"createFolderHint": "Release to create new folder",
"newFolderName": "New folder name", "newFolderName": "New folder name",
"folderNameHint": "Press Enter to confirm, Escape to cancel",
"emptyFolderName": "Please enter a folder name", "emptyFolderName": "Please enter a folder name",
"invalidFolderName": "Folder name contains invalid characters", "invalidFolderName": "Folder name contains invalid characters",
"noDragState": "No pending drag operation found" "noDragState": "No pending drag operation found"
}, },
"empty": { "empty": {
"noFolders": "No folders found", "noFolders": "No folders found",
"dragHint": "Drag items here to create folders", "createHint": "Click the New Folder button above to create folders"
"createHint": "Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Check for updates in this folder", "label": "Check for updates in this folder",
@@ -1455,6 +1486,10 @@
"progress": { "progress": {
"currentFile": "Current file:", "currentFile": "Current file:",
"downloading": "Downloading: {name}", "downloading": "Downloading: {name}",
"metadata": "Metadata: {name}",
"indexingFile": "Reading model file...",
"fetchingSourceMetadata": "Fetching metadata from {source}...",
"fetchingMetadata": "Fetching metadata...",
"transferred": "Transferred: {downloaded} / {total}", "transferred": "Transferred: {downloaded} / {total}",
"transferredSimple": "Transferred: {downloaded}", "transferredSimple": "Transferred: {downloaded}",
"transferredUnknown": "Transferred: --", "transferredUnknown": "Transferred: --",
+51 -16
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "Cancelar", "cancel": "Cancelar",
"confirm": "Confirmar", "confirm": "Confirmar",
"reorder": {
"dragHandle": "Arrastra para reordenar"
},
"actions": { "actions": {
"save": "Guardar", "save": "Guardar",
"cancel": "Cancelar", "cancel": "Cancelar",
@@ -915,7 +918,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "Modelo base",
"unknown": "Desconocido"
}, },
"actions": { "actions": {
"openFileLocation": "Abrir ubicación del archivo", "openFileLocation": "Abrir ubicación del archivo",
@@ -1246,37 +1251,63 @@
"sidebar": { "sidebar": {
"modelRoot": "Raíz", "modelRoot": "Raíz",
"collapseAll": "Colapsar todas las carpetas", "collapseAll": "Colapsar todas las carpetas",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "No disponible en la vista de lista",
"hideOnThisPage": "Ocultar barra lateral en esta página", "hideOnThisPage": "Ocultar barra lateral en esta página",
"showSidebar": "Mostrar barra lateral", "showSidebar": "Mostrar barra lateral",
"sidebarHiddenNotification": "Barra lateral oculta en la página {page}", "sidebarHiddenNotification": "Barra lateral oculta en la página {page}",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "Opciones de vista",
"treeView": "[TODO: Translate] Tree view", "treeView": "Vista de árbol",
"listView": "[TODO: Translate] List view", "listView": "Vista de lista",
"recursiveOn": "Incluir subcarpetas", "recursiveOn": "Incluir subcarpetas",
"createFolder": "[TODO: Translate] New folder", "createFolder": "Nueva carpeta",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "Nueva subcarpeta",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "Mostrar carpetas vacías",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "Carpeta \"{name}\" creada",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "Error al crear la carpeta: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "La creación de carpetas no es compatible con esta página",
"noRoot": "[TODO: Translate] No model root is configured" "noRoot": "No hay ninguna raíz de modelo configurada"
},
"deleteFolder": "Eliminar carpeta",
"deleteFolderModal": {
"title": "¿Eliminar carpeta?",
"message": "La carpeta y todo su contenido se eliminarán permanentemente del disco.",
"folderLabel": "Carpeta",
"emptyNote": "Esta carpeta no contiene modelos. Los demás archivos que contenga también se eliminarán.",
"notEmptyTitle": "La carpeta no está vacía",
"notEmptyMessage": "Esta carpeta aún contiene modelos. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
"confirm": "Eliminar carpeta"
},
"deleteFolderResult": {
"success": "Carpeta \"{name}\" eliminada",
"successWithFiles": "Carpeta \"{name}\" eliminada junto con {count} elemento(s) más",
"restored": "Carpeta restaurada",
"failed": "Error al eliminar la carpeta: {message}",
"notEmpty": "Esta carpeta aún contiene modelos. Actualiza la barra lateral e inténtalo de nuevo.",
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
"unsupported": "La eliminación de carpetas no es compatible con esta página",
"noRoot": "No hay ninguna raíz de modelo configurada"
},
"renameFolder": "Cambiar nombre de la carpeta",
"renameFolderResult": {
"success": "Carpeta renombrada a \"{name}\"",
"failed": "Error al cambiar el nombre de la carpeta: {message}",
"targetExists": "Ya existe una carpeta con ese nombre aquí",
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
"unsupported": "El cambio de nombre de carpetas no es compatible con esta página",
"noRoot": "No hay ninguna raíz de modelo configurada"
}, },
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.", "unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
"moveUnsupported": "El movimiento no es compatible con este elemento.", "moveUnsupported": "El movimiento no es compatible con este elemento.",
"createFolderHint": "Suelta para crear una nueva carpeta",
"newFolderName": "Nombre de la nueva carpeta", "newFolderName": "Nombre de la nueva carpeta",
"folderNameHint": "Presiona Enter para confirmar, Escape para cancelar",
"emptyFolderName": "Por favor, introduce un nombre de carpeta", "emptyFolderName": "Por favor, introduce un nombre de carpeta",
"invalidFolderName": "El nombre de la carpeta contiene caracteres no válidos", "invalidFolderName": "El nombre de la carpeta contiene caracteres no válidos",
"noDragState": "No se encontró ninguna operación de arrastre pendiente" "noDragState": "No se encontró ninguna operación de arrastre pendiente"
}, },
"empty": { "empty": {
"noFolders": "No se encontraron carpetas", "noFolders": "No se encontraron carpetas",
"dragHint": "Arrastra elementos aquí para crear carpetas", "createHint": "Haz clic en el botón Nueva carpeta de arriba para crear carpetas"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Buscar actualizaciones en esta carpeta", "label": "Buscar actualizaciones en esta carpeta",
@@ -1455,6 +1486,10 @@
"progress": { "progress": {
"currentFile": "Archivo actual:", "currentFile": "Archivo actual:",
"downloading": "Descargando: {name}", "downloading": "Descargando: {name}",
"metadata": "Metadatos: {name}",
"indexingFile": "Leyendo el archivo de modelo...",
"fetchingSourceMetadata": "Obteniendo metadatos de {source}...",
"fetchingMetadata": "Obteniendo metadatos...",
"transferred": "Descargado: {downloaded} / {total}", "transferred": "Descargado: {downloaded} / {total}",
"transferredSimple": "Descargado: {downloaded}", "transferredSimple": "Descargado: {downloaded}",
"transferredUnknown": "Descargado: --", "transferredUnknown": "Descargado: --",
+51 -16
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "Annuler", "cancel": "Annuler",
"confirm": "Confirmer", "confirm": "Confirmer",
"reorder": {
"dragHandle": "Glisser pour réordonner"
},
"actions": { "actions": {
"save": "Enregistrer", "save": "Enregistrer",
"cancel": "Annuler", "cancel": "Annuler",
@@ -915,7 +918,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "Modèle de base",
"unknown": "Inconnu"
}, },
"actions": { "actions": {
"openFileLocation": "Ouvrir lemplacement du fichier", "openFileLocation": "Ouvrir lemplacement du fichier",
@@ -1246,37 +1251,63 @@
"sidebar": { "sidebar": {
"modelRoot": "Racine", "modelRoot": "Racine",
"collapseAll": "Réduire tous les dossiers", "collapseAll": "Réduire tous les dossiers",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "Non disponible en vue liste",
"hideOnThisPage": "Masquer la barre latérale sur cette page", "hideOnThisPage": "Masquer la barre latérale sur cette page",
"showSidebar": "Afficher la barre latérale", "showSidebar": "Afficher la barre latérale",
"sidebarHiddenNotification": "Barre latérale masquée sur la page {page}", "sidebarHiddenNotification": "Barre latérale masquée sur la page {page}",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "Options daffichage",
"treeView": "[TODO: Translate] Tree view", "treeView": "Vue arborescente",
"listView": "[TODO: Translate] List view", "listView": "Vue liste",
"recursiveOn": "Inclure les sous-dossiers", "recursiveOn": "Inclure les sous-dossiers",
"createFolder": "[TODO: Translate] New folder", "createFolder": "Nouveau dossier",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "Nouveau sous-dossier",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "Afficher les dossiers vides",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "Dossier \"{name}\" créé",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "Échec de la création du dossier : {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "La création de dossiers nest pas prise en charge sur cette page",
"noRoot": "[TODO: Translate] No model root is configured" "noRoot": "Aucune racine de modèle nest configurée"
},
"deleteFolder": "Supprimer le dossier",
"deleteFolderModal": {
"title": "Supprimer le dossier ?",
"message": "Le dossier et tout son contenu seront définitivement supprimés du disque.",
"folderLabel": "Dossier",
"emptyNote": "Ce dossier ne contient aucun modèle. Les autres fichiers quil contient seront également supprimés.",
"notEmptyTitle": "Le dossier nest pas vide",
"notEmptyMessage": "Ce dossier contient encore des modèles. Supprimez-les ou déplacez-les dabord — la suppression dun dossier nentraîne jamais celle des fichiers de modèles.",
"confirm": "Supprimer le dossier"
},
"deleteFolderResult": {
"success": "Dossier \"{name}\" supprimé",
"successWithFiles": "Dossier \"{name}\" supprimé, ainsi que {count} autre(s) élément(s)",
"restored": "Dossier restauré",
"failed": "Échec de la suppression du dossier : {message}",
"notEmpty": "Ce dossier contient encore des modèles. Actualisez la barre latérale et réessayez.",
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre dannulation.",
"unsupported": "La suppression de dossiers nest pas prise en charge sur cette page",
"noRoot": "Aucune racine de modèle nest configurée"
},
"renameFolder": "Renommer le dossier",
"renameFolderResult": {
"success": "Dossier renommé en \"{name}\"",
"failed": "Échec du renommage du dossier : {message}",
"targetExists": "Un dossier portant ce nom existe déjà ici",
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre dannulation.",
"unsupported": "Le renommage de dossiers nest pas pris en charge sur cette page",
"noRoot": "Aucune racine de modèle nest configurée"
}, },
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.", "unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
"moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.", "moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.",
"createFolderHint": "Relâcher pour créer un nouveau dossier",
"newFolderName": "Nom du nouveau dossier", "newFolderName": "Nom du nouveau dossier",
"folderNameHint": "Appuyez sur Entrée pour confirmer, Échap pour annuler",
"emptyFolderName": "Veuillez saisir un nom de dossier", "emptyFolderName": "Veuillez saisir un nom de dossier",
"invalidFolderName": "Le nom du dossier contient des caractères invalides", "invalidFolderName": "Le nom du dossier contient des caractères invalides",
"noDragState": "Aucune opération de glissement en attente trouvée" "noDragState": "Aucune opération de glissement en attente trouvée"
}, },
"empty": { "empty": {
"noFolders": "Aucun dossier trouvé", "noFolders": "Aucun dossier trouvé",
"dragHint": "Faites glisser des éléments ici pour créer des dossiers", "createHint": "Cliquez sur le bouton Nouveau dossier ci-dessus pour créer des dossiers"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Vérifier les mises à jour dans ce dossier", "label": "Vérifier les mises à jour dans ce dossier",
@@ -1455,6 +1486,10 @@
"progress": { "progress": {
"currentFile": "Fichier actuel :", "currentFile": "Fichier actuel :",
"downloading": "Téléchargement : {name}", "downloading": "Téléchargement : {name}",
"metadata": "Métadonnées : {name}",
"indexingFile": "Lecture du fichier de modèle...",
"fetchingSourceMetadata": "Récupération des métadonnées depuis {source}...",
"fetchingMetadata": "Récupération des métadonnées...",
"transferred": "Téléchargé : {downloaded} / {total}", "transferred": "Téléchargé : {downloaded} / {total}",
"transferredSimple": "Téléchargé : {downloaded}", "transferredSimple": "Téléchargé : {downloaded}",
"transferredUnknown": "Téléchargé : --", "transferredUnknown": "Téléchargé : --",
+51 -16
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "ביטול", "cancel": "ביטול",
"confirm": "אישור", "confirm": "אישור",
"reorder": {
"dragHandle": "גרור כדי לשנות סדר"
},
"actions": { "actions": {
"save": "שמירה", "save": "שמירה",
"cancel": "ביטול", "cancel": "ביטול",
@@ -915,7 +918,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "מודל בסיס",
"unknown": "לא ידוע"
}, },
"actions": { "actions": {
"openFileLocation": "פתח מיקום קובץ", "openFileLocation": "פתח מיקום קובץ",
@@ -1246,37 +1251,63 @@
"sidebar": { "sidebar": {
"modelRoot": "שורש", "modelRoot": "שורש",
"collapseAll": "כווץ את כל התיקיות", "collapseAll": "כווץ את כל התיקיות",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "לא זמין בתצוגת רשימה",
"hideOnThisPage": "הסתר סרגל צד בדף זה", "hideOnThisPage": "הסתר סרגל צד בדף זה",
"showSidebar": "הצג סרגל צד", "showSidebar": "הצג סרגל צד",
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}", "sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "אפשרויות תצוגה",
"treeView": "[TODO: Translate] Tree view", "treeView": "תצוגת עץ",
"listView": "[TODO: Translate] List view", "listView": "תצוגת רשימה",
"recursiveOn": "כלול תיקיות משנה", "recursiveOn": "כלול תיקיות משנה",
"createFolder": "[TODO: Translate] New folder", "createFolder": "תיקייה חדשה",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "תיקיית משנה חדשה",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "הצג תיקיות ריקות",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "התיקייה \"{name}\" נוצרה",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "יצירת התיקייה נכשלה: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "יצירת תיקיות אינה נתמכת בדף זה",
"noRoot": "[TODO: Translate] No model root is configured" "noRoot": "לא הוגדר שורש מודלים"
},
"deleteFolder": "מחק תיקייה",
"deleteFolderModal": {
"title": "למחוק את התיקייה?",
"message": "התיקייה וכל תוכנה יימחקו לצמיתות מהדיסק.",
"folderLabel": "תיקייה",
"emptyNote": "אין מודלים בתיקייה זו. קבצים אחרים שבה יימחקו גם הם.",
"notEmptyTitle": "התיקייה אינה ריקה",
"notEmptyMessage": "בתיקייה זו עדיין יש מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"confirm": "מחק תיקייה"
},
"deleteFolderResult": {
"success": "התיקייה \"{name}\" נמחקה",
"successWithFiles": "התיקייה \"{name}\" נמחקה יחד עם {count} פריטים נוספים",
"restored": "התיקייה שוחזרה",
"failed": "מחיקת התיקייה נכשלה: {message}",
"notEmpty": "בתיקייה זו עדיין יש מודלים. רענן את סרגל הצד ונסה שוב.",
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
"noRoot": "לא הוגדר שורש מודלים"
},
"renameFolder": "שנה שם תיקייה",
"renameFolderResult": {
"success": "שם התיקייה שונה ל-\"{name}\"",
"failed": "שינוי שם התיקייה נכשל: {message}",
"targetExists": "תיקייה בשם זה כבר קיימת כאן",
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
"unsupported": "שינוי שם תיקיות אינו נתמך בדף זה",
"noRoot": "לא הוגדר שורש מודלים"
}, },
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.", "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
"moveUnsupported": "העברה אינה נתמכת עבור פריט זה.", "moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
"createFolderHint": "שחרר כדי ליצור תיקייה חדשה",
"newFolderName": "שם תיקייה חדשה", "newFolderName": "שם תיקייה חדשה",
"folderNameHint": "הקש Enter לאישור, Escape לביטול",
"emptyFolderName": "אנא הזן שם תיקייה", "emptyFolderName": "אנא הזן שם תיקייה",
"invalidFolderName": "שם התיקייה מכיל תווים לא חוקיים", "invalidFolderName": "שם התיקייה מכיל תווים לא חוקיים",
"noDragState": "לא נמצאה פעולת גרירה ממתינה" "noDragState": "לא נמצאה פעולת גרירה ממתינה"
}, },
"empty": { "empty": {
"noFolders": "לא נמצאו תיקיות", "noFolders": "לא נמצאו תיקיות",
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות", "createHint": "לחץ על כפתור תיקייה חדשה למעלה כדי ליצור תיקיות"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "בדוק עדכונים בתיקייה זו", "label": "בדוק עדכונים בתיקייה זו",
@@ -1455,6 +1486,10 @@
"progress": { "progress": {
"currentFile": "הקובץ הנוכחי:", "currentFile": "הקובץ הנוכחי:",
"downloading": "מוריד: {name}", "downloading": "מוריד: {name}",
"metadata": "מטא-נתונים: {name}",
"indexingFile": "קורא קובץ מודל...",
"fetchingSourceMetadata": "מביא מטא-נתונים מ-{source}...",
"fetchingMetadata": "מביא מטא-נתונים...",
"transferred": "הורד: {downloaded} / {total}", "transferred": "הורד: {downloaded} / {total}",
"transferredSimple": "הורד: {downloaded}", "transferredSimple": "הורד: {downloaded}",
"transferredUnknown": "הורד: --", "transferredUnknown": "הורד: --",
+51 -16
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "キャンセル", "cancel": "キャンセル",
"confirm": "確認", "confirm": "確認",
"reorder": {
"dragHandle": "ドラッグして並べ替え"
},
"actions": { "actions": {
"save": "保存", "save": "保存",
"cancel": "キャンセル", "cancel": "キャンセル",
@@ -915,7 +918,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "ベースモデル",
"unknown": "不明"
}, },
"actions": { "actions": {
"openFileLocation": "ファイルの場所を開く", "openFileLocation": "ファイルの場所を開く",
@@ -1246,37 +1251,63 @@
"sidebar": { "sidebar": {
"modelRoot": "ルート", "modelRoot": "ルート",
"collapseAll": "すべてのフォルダを折りたたむ", "collapseAll": "すべてのフォルダを折りたたむ",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "リスト表示では利用できません",
"hideOnThisPage": "このページでサイドバーを非表示", "hideOnThisPage": "このページでサイドバーを非表示",
"showSidebar": "サイドバーを表示", "showSidebar": "サイドバーを表示",
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています", "sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "表示オプション",
"treeView": "[TODO: Translate] Tree view", "treeView": "ツリー表示",
"listView": "[TODO: Translate] List view", "listView": "リスト表示",
"recursiveOn": "サブフォルダーを含める", "recursiveOn": "サブフォルダーを含める",
"createFolder": "[TODO: Translate] New folder", "createFolder": "新規フォルダ",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "新規サブフォルダ",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "空のフォルダを表示",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "フォルダ \"{name}\" を作成しました",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "フォルダの作成に失敗しました: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "このページではフォルダを作成できません",
"noRoot": "[TODO: Translate] No model root is configured" "noRoot": "モデルルートが設定されていません"
},
"deleteFolder": "フォルダを削除",
"deleteFolderModal": {
"title": "フォルダを削除しますか?",
"message": "フォルダとその内容はすべてディスクから完全に削除されます。",
"folderLabel": "フォルダ",
"emptyNote": "このフォルダにはモデルがありません。他のファイルもすべて削除されます。",
"notEmptyTitle": "フォルダが空ではありません",
"notEmptyMessage": "このフォルダにはまだモデルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"confirm": "フォルダを削除"
},
"deleteFolderResult": {
"success": "フォルダ \"{name}\" を削除しました",
"successWithFiles": "フォルダ \"{name}\" を削除し、他に {count} 件の項目も削除しました",
"restored": "フォルダを復元しました",
"failed": "フォルダの削除に失敗しました: {message}",
"notEmpty": "このフォルダにはまだモデルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダを削除できません",
"noRoot": "モデルルートが設定されていません"
},
"renameFolder": "フォルダ名を変更",
"renameFolderResult": {
"success": "フォルダ名を \"{name}\" に変更しました",
"failed": "フォルダ名の変更に失敗しました: {message}",
"targetExists": "同じ名前のフォルダが既に存在します",
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダ名を変更できません",
"noRoot": "モデルルートが設定されていません"
}, },
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "移動先のパスを特定できません。", "unableToResolveRoot": "移動先のパスを特定できません。",
"moveUnsupported": "この項目の移動はサポートされていません。", "moveUnsupported": "この項目の移動はサポートされていません。",
"createFolderHint": "放して新しいフォルダを作成",
"newFolderName": "新しいフォルダ名", "newFolderName": "新しいフォルダ名",
"folderNameHint": "Enterで確定、Escでキャンセル",
"emptyFolderName": "フォルダ名を入力してください", "emptyFolderName": "フォルダ名を入力してください",
"invalidFolderName": "フォルダ名に無効な文字が含まれています", "invalidFolderName": "フォルダ名に無効な文字が含まれています",
"noDragState": "保留中のドラッグ操作が見つかりません" "noDragState": "保留中のドラッグ操作が見つかりません"
}, },
"empty": { "empty": {
"noFolders": "フォルダが見つかりません", "noFolders": "フォルダが見つかりません",
"dragHint": "ここへアイテムをドラッグしてフォルダを作成ます", "createHint": "上部の新規フォルダボタンからフォルダを作成できます"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "このフォルダのアップデートを確認", "label": "このフォルダのアップデートを確認",
@@ -1455,6 +1486,10 @@
"progress": { "progress": {
"currentFile": "現在のファイル:", "currentFile": "現在のファイル:",
"downloading": "ダウンロード中: {name}", "downloading": "ダウンロード中: {name}",
"metadata": "メタデータ: {name}",
"indexingFile": "モデルファイルを読み込み中...",
"fetchingSourceMetadata": "{source} からメタデータを取得中...",
"fetchingMetadata": "メタデータを取得中...",
"transferred": "ダウンロード済み: {downloaded} / {total}", "transferred": "ダウンロード済み: {downloaded} / {total}",
"transferredSimple": "ダウンロード済み: {downloaded}", "transferredSimple": "ダウンロード済み: {downloaded}",
"transferredUnknown": "ダウンロード済み: --", "transferredUnknown": "ダウンロード済み: --",
+51 -16
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "취소", "cancel": "취소",
"confirm": "확인", "confirm": "확인",
"reorder": {
"dragHandle": "드래그하여 순서 변경"
},
"actions": { "actions": {
"save": "저장", "save": "저장",
"cancel": "취소", "cancel": "취소",
@@ -915,7 +918,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "베이스 모델",
"unknown": "알 수 없음"
}, },
"actions": { "actions": {
"openFileLocation": "파일 위치 열기", "openFileLocation": "파일 위치 열기",
@@ -1246,37 +1251,63 @@
"sidebar": { "sidebar": {
"modelRoot": "루트", "modelRoot": "루트",
"collapseAll": "모든 폴더 접기", "collapseAll": "모든 폴더 접기",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
"hideOnThisPage": "이 페이지에서 사이드바 숨기기", "hideOnThisPage": "이 페이지에서 사이드바 숨기기",
"showSidebar": "사이드바 표시", "showSidebar": "사이드바 표시",
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다", "sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "보기 옵션",
"treeView": "[TODO: Translate] Tree view", "treeView": "트리 보기",
"listView": "[TODO: Translate] List view", "listView": "목록 보기",
"recursiveOn": "하위 폴더 포함", "recursiveOn": "하위 폴더 포함",
"createFolder": "[TODO: Translate] New folder", "createFolder": "새 폴더",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "새 하위 폴더",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "빈 폴더 표시",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "\"{name}\" 폴더를 생성했습니다",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "폴더 생성 실패: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "이 페이지에서는 폴더를 만들 수 없습니다",
"noRoot": "[TODO: Translate] No model root is configured" "noRoot": "모델 루트가 설정되지 않았습니다"
},
"deleteFolder": "폴더 삭제",
"deleteFolderModal": {
"title": "폴더를 삭제할까요?",
"message": "폴더와 그 안의 모든 내용이 디스크에서 영구적으로 삭제됩니다.",
"folderLabel": "폴더",
"emptyNote": "이 폴더에는 모델이 없습니다. 폴더 안의 다른 파일도 함께 삭제됩니다.",
"notEmptyTitle": "폴더가 비어 있지 않습니다",
"notEmptyMessage": "이 폴더에는 아직 모델이 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"confirm": "폴더 삭제"
},
"deleteFolderResult": {
"success": "\"{name}\" 폴더를 삭제했습니다",
"successWithFiles": "\"{name}\" 폴더와 {count}개 항목을 함께 삭제했습니다",
"restored": "폴더를 복원했습니다",
"failed": "폴더 삭제 실패: {message}",
"notEmpty": "이 폴더에는 아직 모델이 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
"noRoot": "모델 루트가 설정되지 않았습니다"
},
"renameFolder": "폴더 이름 바꾸기",
"renameFolderResult": {
"success": "폴더 이름을 \"{name}\"(으)로 변경했습니다",
"failed": "폴더 이름 바꾸기 실패: {message}",
"targetExists": "같은 이름의 폴더가 이미 있습니다",
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
"unsupported": "이 페이지에서는 폴더 이름을 바꿀 수 없습니다",
"noRoot": "모델 루트가 설정되지 않았습니다"
}, },
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.", "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
"moveUnsupported": "이 항목은 이동을 지원하지 않습니다.", "moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
"createFolderHint": "놓아서 새 폴더 만들기",
"newFolderName": "새 폴더 이름", "newFolderName": "새 폴더 이름",
"folderNameHint": "Enter를 눌러 확인, Escape를 눌러 취소",
"emptyFolderName": "폴더 이름을 입력하세요", "emptyFolderName": "폴더 이름을 입력하세요",
"invalidFolderName": "폴더 이름에 잘못된 문자가 포함되어 있습니다", "invalidFolderName": "폴더 이름에 잘못된 문자가 포함되어 있습니다",
"noDragState": "보류 중인 드래그 작업을 찾을 수 없습니다" "noDragState": "보류 중인 드래그 작업을 찾을 수 없습니다"
}, },
"empty": { "empty": {
"noFolders": "폴더를 찾을 수 없습니다", "noFolders": "폴더를 찾을 수 없습니다",
"dragHint": "항목을 여기로 드래그하여 폴더를 만니다", "createHint": "위의 새 폴더 버튼을 클릭하여 폴더를 만들 수 있습니다"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "이 폴더의 업데이트 확인", "label": "이 폴더의 업데이트 확인",
@@ -1455,6 +1486,10 @@
"progress": { "progress": {
"currentFile": "현재 파일:", "currentFile": "현재 파일:",
"downloading": "다운로드 중: {name}", "downloading": "다운로드 중: {name}",
"metadata": "메타데이터: {name}",
"indexingFile": "모델 파일 읽는 중...",
"fetchingSourceMetadata": "{source}에서 메타데이터 가져오는 중...",
"fetchingMetadata": "메타데이터 가져오는 중...",
"transferred": "다운로드됨: {downloaded} / {total}", "transferred": "다운로드됨: {downloaded} / {total}",
"transferredSimple": "다운로드됨: {downloaded}", "transferredSimple": "다운로드됨: {downloaded}",
"transferredUnknown": "다운로드됨: --", "transferredUnknown": "다운로드됨: --",
+51 -16
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "Отмена", "cancel": "Отмена",
"confirm": "Подтвердить", "confirm": "Подтвердить",
"reorder": {
"dragHandle": "Перетащите, чтобы изменить порядок"
},
"actions": { "actions": {
"save": "Сохранить", "save": "Сохранить",
"cancel": "Отмена", "cancel": "Отмена",
@@ -915,7 +918,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "Базовая модель",
"unknown": "Неизвестно"
}, },
"actions": { "actions": {
"openFileLocation": "Открыть расположение файла", "openFileLocation": "Открыть расположение файла",
@@ -1246,37 +1251,63 @@
"sidebar": { "sidebar": {
"modelRoot": "Корень", "modelRoot": "Корень",
"collapseAll": "Свернуть все папки", "collapseAll": "Свернуть все папки",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "Недоступно в виде списка",
"hideOnThisPage": "Скрыть боковую панель на этой странице", "hideOnThisPage": "Скрыть боковую панель на этой странице",
"showSidebar": "Показать боковую панель", "showSidebar": "Показать боковую панель",
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}", "sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "Параметры отображения",
"treeView": "[TODO: Translate] Tree view", "treeView": "Дерево",
"listView": "[TODO: Translate] List view", "listView": "Список",
"recursiveOn": "Включать вложенные папки", "recursiveOn": "Включать вложенные папки",
"createFolder": "[TODO: Translate] New folder", "createFolder": "Новая папка",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "Новая вложенная папка",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "Показывать пустые папки",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "Папка \"{name}\" создана",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "Не удалось создать папку: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "Создание папок не поддерживается на этой странице",
"noRoot": "[TODO: Translate] No model root is configured" "noRoot": "Корневая папка моделей не настроена"
},
"deleteFolder": "Удалить папку",
"deleteFolderModal": {
"title": "Удалить папку?",
"message": "Папка и всё её содержимое будут безвозвратно удалены с диска.",
"folderLabel": "Папка",
"emptyNote": "В этой папке нет моделей. Остальные файлы в ней тоже будут удалены.",
"notEmptyTitle": "Папка не пуста",
"notEmptyMessage": "В этой папке ещё есть модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
"confirm": "Удалить папку"
},
"deleteFolderResult": {
"success": "Папка \"{name}\" удалена",
"successWithFiles": "Папка \"{name}\" удалена вместе с ещё {count} элемент(ами)",
"restored": "Папка восстановлена",
"failed": "Не удалось удалить папку: {message}",
"notEmpty": "В этой папке ещё есть модели. Обновите боковую панель и повторите попытку.",
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
"unsupported": "Удаление папок не поддерживается на этой странице",
"noRoot": "Корневая папка моделей не настроена"
},
"renameFolder": "Переименовать папку",
"renameFolderResult": {
"success": "Папка переименована в \"{name}\"",
"failed": "Не удалось переименовать папку: {message}",
"targetExists": "Папка с таким именем уже существует здесь",
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
"unsupported": "Переименование папок не поддерживается на этой странице",
"noRoot": "Корневая папка моделей не настроена"
}, },
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.", "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
"moveUnsupported": "Перемещение этого элемента не поддерживается.", "moveUnsupported": "Перемещение этого элемента не поддерживается.",
"createFolderHint": "Отпустите, чтобы создать новую папку",
"newFolderName": "Имя новой папки", "newFolderName": "Имя новой папки",
"folderNameHint": "Нажмите Enter для подтверждения, Escape для отмены",
"emptyFolderName": "Пожалуйста, введите имя папки", "emptyFolderName": "Пожалуйста, введите имя папки",
"invalidFolderName": "Имя папки содержит недопустимые символы", "invalidFolderName": "Имя папки содержит недопустимые символы",
"noDragState": "Ожидающая операция перетаскивания не найдена" "noDragState": "Ожидающая операция перетаскивания не найдена"
}, },
"empty": { "empty": {
"noFolders": "Папки не найдены", "noFolders": "Папки не найдены",
"dragHint": "Перетащите элементы сюда, чтобы создать папки", "createHint": "Нажмите кнопку «Новая папка» вверху, чтобы создать папки"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Проверить обновления в этой папке", "label": "Проверить обновления в этой папке",
@@ -1455,6 +1486,10 @@
"progress": { "progress": {
"currentFile": "Текущий файл:", "currentFile": "Текущий файл:",
"downloading": "Скачивается: {name}", "downloading": "Скачивается: {name}",
"metadata": "Метаданные: {name}",
"indexingFile": "Чтение файла модели...",
"fetchingSourceMetadata": "Получение метаданных из {source}...",
"fetchingMetadata": "Получение метаданных...",
"transferred": "Скачано: {downloaded} / {total}", "transferred": "Скачано: {downloaded} / {total}",
"transferredSimple": "Скачано: {downloaded}", "transferredSimple": "Скачано: {downloaded}",
"transferredUnknown": "Скачано: --", "transferredUnknown": "Скачано: --",
+51 -16
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "取消", "cancel": "取消",
"confirm": "确认", "confirm": "确认",
"reorder": {
"dragHandle": "拖拽以调整顺序"
},
"actions": { "actions": {
"save": "保存", "save": "保存",
"cancel": "取消", "cancel": "取消",
@@ -915,7 +918,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "基础模型",
"unknown": "未知"
}, },
"actions": { "actions": {
"openFileLocation": "打开文件位置", "openFileLocation": "打开文件位置",
@@ -1246,37 +1251,63 @@
"sidebar": { "sidebar": {
"modelRoot": "根目录", "modelRoot": "根目录",
"collapseAll": "折叠所有文件夹", "collapseAll": "折叠所有文件夹",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "列表视图下不可用",
"hideOnThisPage": "隐藏此页面侧边栏", "hideOnThisPage": "隐藏此页面侧边栏",
"showSidebar": "显示侧边栏", "showSidebar": "显示侧边栏",
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏", "sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "视图选项",
"treeView": "[TODO: Translate] Tree view", "treeView": "树形视图",
"listView": "[TODO: Translate] List view", "listView": "列表视图",
"recursiveOn": "包含子文件夹", "recursiveOn": "包含子文件夹",
"createFolder": "[TODO: Translate] New folder", "createFolder": "新建文件夹",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "新建子文件夹",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "显示空文件夹",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "已创建文件夹 \"{name}\"",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "创建文件夹失败: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "此页面不支持创建文件夹",
"noRoot": "[TODO: Translate] No model root is configured" "noRoot": "未配置模型根目录"
},
"deleteFolder": "删除文件夹",
"deleteFolderModal": {
"title": "删除文件夹?",
"message": "该文件夹及其中所有内容都将从磁盘上永久删除。",
"folderLabel": "文件夹",
"emptyNote": "该文件夹中没有模型,其中的其他文件也会一并删除。",
"notEmptyTitle": "文件夹不为空",
"notEmptyMessage": "该文件夹中仍有模型,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
"confirm": "删除文件夹"
},
"deleteFolderResult": {
"success": "已删除文件夹 \"{name}\"",
"successWithFiles": "已删除文件夹 \"{name}\",同时删除了另外 {count} 项内容",
"restored": "文件夹已恢复",
"failed": "删除文件夹失败: {message}",
"notEmpty": "该文件夹中仍有模型。请刷新侧边栏后重试。",
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
"unsupported": "此页面不支持删除文件夹",
"noRoot": "未配置模型根目录"
},
"renameFolder": "重命名文件夹",
"renameFolderResult": {
"success": "文件夹已重命名为 \"{name}\"",
"failed": "重命名文件夹失败: {message}",
"targetExists": "此处已存在同名文件夹",
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
"unsupported": "此页面不支持重命名文件夹",
"noRoot": "未配置模型根目录"
}, },
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "无法确定移动的目标路径。", "unableToResolveRoot": "无法确定移动的目标路径。",
"moveUnsupported": "此条目不支持移动。", "moveUnsupported": "此条目不支持移动。",
"createFolderHint": "释放以创建新文件夹",
"newFolderName": "新文件夹名称", "newFolderName": "新文件夹名称",
"folderNameHint": "按 Enter 确认,Escape 取消",
"emptyFolderName": "请输入文件夹名称", "emptyFolderName": "请输入文件夹名称",
"invalidFolderName": "文件夹名称包含无效字符", "invalidFolderName": "文件夹名称包含无效字符",
"noDragState": "未找到待处理的拖放操作" "noDragState": "未找到待处理的拖放操作"
}, },
"empty": { "empty": {
"noFolders": "未找到文件夹", "noFolders": "未找到文件夹",
"dragHint": "拖拽项目到此处以创建文件夹", "createHint": "点击上方的新建文件夹按钮即可创建文件夹"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "检查此文件夹的更新", "label": "检查此文件夹的更新",
@@ -1455,6 +1486,10 @@
"progress": { "progress": {
"currentFile": "当前文件:", "currentFile": "当前文件:",
"downloading": "下载中:{name}", "downloading": "下载中:{name}",
"metadata": "元数据:{name}",
"indexingFile": "正在读取模型文件...",
"fetchingSourceMetadata": "正在从 {source} 获取元数据...",
"fetchingMetadata": "正在获取元数据...",
"transferred": "已下载:{downloaded} / {total}", "transferred": "已下载:{downloaded} / {total}",
"transferredSimple": "已下载:{downloaded}", "transferredSimple": "已下载:{downloaded}",
"transferredUnknown": "已下载:--", "transferredUnknown": "已下载:--",
+51 -16
View File
@@ -2,6 +2,9 @@
"common": { "common": {
"cancel": "取消", "cancel": "取消",
"confirm": "確認", "confirm": "確認",
"reorder": {
"dragHandle": "拖曳以調整順序"
},
"actions": { "actions": {
"save": "儲存", "save": "儲存",
"cancel": "取消", "cancel": "取消",
@@ -915,7 +918,9 @@
}, },
"modal": { "modal": {
"metadata": { "metadata": {
"id": "ID" "id": "ID",
"baseModel": "基礎模型",
"unknown": "未知"
}, },
"actions": { "actions": {
"openFileLocation": "開啟檔案位置", "openFileLocation": "開啟檔案位置",
@@ -1246,37 +1251,63 @@
"sidebar": { "sidebar": {
"modelRoot": "根目錄", "modelRoot": "根目錄",
"collapseAll": "全部摺疊資料夾", "collapseAll": "全部摺疊資料夾",
"collapseAllDisabled": "[TODO: Translate] Not available in list view", "collapseAllDisabled": "清單檢視下無法使用",
"hideOnThisPage": "隱藏此頁面側邊欄", "hideOnThisPage": "隱藏此頁面側邊欄",
"showSidebar": "顯示側邊欄", "showSidebar": "顯示側邊欄",
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏", "sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
"viewOptions": "[TODO: Translate] View options", "viewOptions": "檢視選項",
"treeView": "[TODO: Translate] Tree view", "treeView": "樹狀檢視",
"listView": "[TODO: Translate] List view", "listView": "清單檢視",
"recursiveOn": "包含子資料夾", "recursiveOn": "包含子資料夾",
"createFolder": "[TODO: Translate] New folder", "createFolder": "新增資料夾",
"newSubfolder": "[TODO: Translate] New subfolder", "newSubfolder": "新增子資料夾",
"showEmptyFolders": "[TODO: Translate] Show empty folders", "showEmptyFolders": "顯示空資料夾",
"createFolderResult": { "createFolderResult": {
"success": "[TODO: Translate] Folder \"{name}\" created", "success": "已建立資料夾 \"{name}\"",
"failed": "[TODO: Translate] Failed to create folder: {message}", "failed": "建立資料夾失敗: {message}",
"unsupported": "[TODO: Translate] Folder creation is not supported on this page", "unsupported": "此頁面不支援建立資料夾",
"noRoot": "[TODO: Translate] No model root is configured" "noRoot": "未設定模型根目錄"
},
"deleteFolder": "刪除資料夾",
"deleteFolderModal": {
"title": "刪除資料夾?",
"message": "該資料夾及其中的所有內容都將從磁碟上永久刪除。",
"folderLabel": "資料夾",
"emptyNote": "該資料夾中沒有模型,其中的其他檔案也會一併刪除。",
"notEmptyTitle": "資料夾不是空的",
"notEmptyMessage": "該資料夾中仍有模型,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
"confirm": "刪除資料夾"
},
"deleteFolderResult": {
"success": "已刪除資料夾 \"{name}\"",
"successWithFiles": "已刪除資料夾 \"{name}\",同時刪除了另外 {count} 項內容",
"restored": "資料夾已還原",
"failed": "刪除資料夾失敗: {message}",
"notEmpty": "該資料夾中仍有模型。請重新整理側邊欄後再試。",
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
"unsupported": "此頁面不支援刪除資料夾",
"noRoot": "未設定模型根目錄"
},
"renameFolder": "重新命名資料夾",
"renameFolderResult": {
"success": "資料夾已重新命名為 \"{name}\"",
"failed": "重新命名資料夾失敗: {message}",
"targetExists": "此處已存在同名資料夾",
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
"unsupported": "此頁面不支援重新命名資料夾",
"noRoot": "未設定模型根目錄"
}, },
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "無法確定移動的目標路徑。", "unableToResolveRoot": "無法確定移動的目標路徑。",
"moveUnsupported": "此項目不支援移動。", "moveUnsupported": "此項目不支援移動。",
"createFolderHint": "放開以建立新資料夾",
"newFolderName": "新資料夾名稱", "newFolderName": "新資料夾名稱",
"folderNameHint": "按 Enter 確認,Escape 取消",
"emptyFolderName": "請輸入資料夾名稱", "emptyFolderName": "請輸入資料夾名稱",
"invalidFolderName": "資料夾名稱包含無效字元", "invalidFolderName": "資料夾名稱包含無效字元",
"noDragState": "未找到待處理的拖放操作" "noDragState": "未找到待處理的拖放操作"
}, },
"empty": { "empty": {
"noFolders": "未找到資料夾", "noFolders": "未找到資料夾",
"dragHint": "將項目拖到此處以建立資料夾", "createHint": "點擊上方的新增資料夾按鈕即可建立資料夾"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "檢查此資料夾的更新", "label": "檢查此資料夾的更新",
@@ -1455,6 +1486,10 @@
"progress": { "progress": {
"currentFile": "目前檔案:", "currentFile": "目前檔案:",
"downloading": "下載中:{name}", "downloading": "下載中:{name}",
"metadata": "中繼資料:{name}",
"indexingFile": "正在讀取模型檔案...",
"fetchingSourceMetadata": "正在從 {source} 取得中繼資料...",
"fetchingMetadata": "正在取得中繼資料...",
"transferred": "已下載:{downloaded} / {total}", "transferred": "已下載:{downloaded} / {total}",
"transferredSimple": "已下載:{downloaded}", "transferredSimple": "已下載:{downloaded}",
"transferredUnknown": "已下載:--", "transferredUnknown": "已下載:--",
+71
View File
@@ -1910,6 +1910,11 @@ class ModelDownloadHandler:
response_payload["status"] = status response_payload["status"] = status
if "message" in progress_data: if "message" in progress_data:
response_payload["message"] = progress_data["message"] response_payload["message"] = progress_data["message"]
# Post-transfer stage (indexing / source metadata); polling
# consumers need it to tell "working" from "stuck".
for field in ("stage", "platform"):
if field in progress_data:
response_payload[field] = progress_data[field]
elif status is None and "message" in progress_data: elif status is None and "message" in progress_data:
response_payload["message"] = progress_data["message"] response_payload["message"] = progress_data["message"]
@@ -2499,6 +2504,70 @@ class ModelMoveHandler:
self._logger.error("Error creating folder: %s", exc, exc_info=True) self._logger.error("Error creating folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def delete_folder(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
try:
folder_path = data.get("folder_path")
if not folder_path:
return web.json_response(
{"success": False, "error": "Folder path is required"}, status=400
)
dry_run = bool(data.get("dry_run"))
result = await self._move_service.delete_folder(
folder_path, dry_run=dry_run
)
if result.get("success"):
if not dry_run:
_broadcast_models_changed()
return web.json_response(result, status=200)
# "not_empty" / "busy" are conflicts between the tree the client
# rendered and the on-disk truth; everything else is a bad request.
code = result.get("code")
status = 409 if code in ("not_empty", "busy") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error deleting folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def rename_folder(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
try:
folder_path = data.get("folder_path")
new_name = data.get("new_name")
if not folder_path:
return web.json_response(
{"success": False, "error": "Folder path is required"}, status=400
)
if not new_name:
return web.json_response(
{"success": False, "error": "New folder name is required"}, status=400
)
result = await self._move_service.rename_folder(folder_path, new_name)
if result.get("success"):
if result.get("renamed"):
_broadcast_models_changed()
return web.json_response(result, status=200)
# A name collision or a staged delete inside the subtree is a
# conflict with the state the client rendered, not a bad request.
code = result.get("code")
status = 409 if code in ("target_exists", "busy") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error renaming folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def move_model(self, request: web.Request) -> web.Response: async def move_model(self, request: web.Request) -> web.Response:
try: try:
data = await request.json() data = await request.json()
@@ -3450,6 +3519,8 @@ class ModelHandlerSet:
"move_model": self.move.move_model, "move_model": self.move.move_model,
"move_models_bulk": self.move.move_models_bulk, "move_models_bulk": self.move.move_models_bulk,
"create_folder": self.move.create_folder, "create_folder": self.move.create_folder,
"delete_folder": self.move.delete_folder,
"rename_folder": self.move.rename_folder,
"auto_organize_models": self.auto_organize.auto_organize_models, "auto_organize_models": self.auto_organize.auto_organize_models,
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress, "get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
"get_model_notes": self.query.get_model_notes, "get_model_notes": self.query.get_model_notes,
+96 -31
View File
@@ -30,6 +30,7 @@ from ...services.model_sources import (
SourceRef, SourceRef,
detect_source, detect_source,
get_download_source, get_download_source,
hydrate_from_source,
is_valid_source_id, is_valid_source_id,
list_sources, list_sources,
normalize_metadata_source, normalize_metadata_source,
@@ -85,25 +86,77 @@ def _infer_model_type(model_root: str) -> tuple[Any, str]:
return _DEFAULT_MODEL_CLASS, _DEFAULT_SCANNER_GETTER return _DEFAULT_MODEL_CLASS, _DEFAULT_SCANNER_GETTER
async def _report_phase(
download_id: str | None, stage: str, platform: str = ""
) -> None:
"""Tell the progress UI which post-transfer stage is running.
A download's byte counter stops the moment the last byte lands, but the
backend still has to index the file and read the model site's API. Without
this the bar sits at 100% reporting "0 B/s" and the download looks stuck for
several seconds. *stage* is machine-readable the UI localises it and
*platform* lets it name the site the metadata comes from.
"""
if not download_id:
return
try:
await ws_manager.broadcast_download_progress(
download_id,
{
"status": "metadata",
"stage": stage,
"platform": platform,
"progress": 100,
},
)
except Exception as exc: # pragma: no cover - progress must never be fatal
logger.debug("Failed to report the '%s' phase: %s", stage, exc)
async def _save_source_metadata( async def _save_source_metadata(
dest_path: str, ref: SourceRef, model_root: str dest_path: str, ref: SourceRef, model_root: str, *, download_id: str | None = None
) -> None: ) -> None:
"""Create a proper .metadata.json and add the model to the scanner cache. """Create a proper .metadata.json and add the model to the scanner cache.
Uses ``MetadataManager.create_default_metadata()`` which computes the The metadata is created through the owning scanner rather than
SHA256 hash, extracts safetensors header metadata (base_model), and ``MetadataManager.create_default_metadata()``, because that is the only
produces a fully-populated ``LoraMetadata`` (or ``CheckpointMetadata`` / factory that knows when hashing must be deferred: ``CheckpointScanner`` and
``EmbeddingMetadata``) object. We then overlay the external-source fields ``OtherScanner`` deliberately record ``hash_status="pending"`` with an empty
and register the model in the in-memory scanner cache so it appears ``sha256`` for their multi-GB files, and the generic helper would read a
immediately without a full filesystem walk. 10 GB checkpoint end to end *inside the download request*. Scanners for the
small types delegate straight back to it, so nothing changes for them.
The external-source fields are then overlaid and the model is registered in
the in-memory scanner cache so it appears immediately without a full
filesystem walk.
Finally the site's own published metadata is applied (see
:func:`~py.services.model_sources.hydration.hydrate_from_source`), so a
ModelScope or Hugging Face download lands with the same populated model
card a CivitAI download produces instead of a bare filename and hash.
Both post-transfer stages are reported through *download_id* when the UI is
watching one, because neither advances the byte counter.
""" """
try: try:
model_class, scanner_getter_name = _infer_model_type(model_root) model_class, scanner_getter_name = _infer_model_type(model_root)
# 1. Create proper metadata (computes SHA256, reads safetensors headers) scanner = None
metadata = await MetadataManager.create_default_metadata( scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
dest_path, model_class=model_class if scanner_getter is not None:
) scanner = await scanner_getter()
# 1. Create proper metadata (reads safetensors headers; hashes only for
# the model types whose scanner does not defer it)
await _report_phase(download_id, "indexing", ref.platform)
create_metadata = getattr(scanner, "_create_default_metadata", None)
if create_metadata is not None:
metadata = await create_metadata(dest_path)
else:
metadata = await MetadataManager.create_default_metadata(
dest_path, model_class=model_class
)
if metadata is None: if metadata is None:
logger.warning("create_default_metadata returned None for %s", dest_path) logger.warning("create_default_metadata returned None for %s", dest_path)
return return
@@ -120,8 +173,8 @@ async def _save_source_metadata(
# 3. Save metadata atomically # 3. Save metadata atomically
await MetadataManager.save_metadata(dest_path, metadata) await MetadataManager.save_metadata(dest_path, metadata)
logger.info( logger.info(
"Saved %s metadata (source=%s) for %s", "Saved %s metadata (source=%s, hash_status=%s) for %s",
ref.platform, ref.url, dest_path, ref.platform, ref.url, getattr(metadata, "hash_status", "?"), dest_path,
) )
# 4. Determine relative folder path for cache # 4. Determine relative folder path for cache
@@ -132,13 +185,16 @@ async def _save_source_metadata(
folder = rel.replace(os.sep, "/") if rel != "." else "" folder = rel.replace(os.sep, "/") if rel != "." else ""
# 5. Add to scanner cache (same as CivitAI's _execute_download does) # 5. Add to scanner cache (same as CivitAI's _execute_download does)
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None) if scanner is not None:
if scanner_getter is not None: metadata_dict = normalize_metadata_source(metadata.to_dict())
scanner = await scanner_getter() await scanner.add_model_to_cache(metadata_dict, folder)
if scanner is not None: logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
metadata_dict = normalize_metadata_source(metadata.to_dict())
await scanner.add_model_to_cache(metadata_dict, folder) # 6. Top up from the site's public API. Runs last so the scanner-cache
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder) # refresh it performs lands on the entry created above. It never
# raises and never fails the download.
await _report_phase(download_id, "source", ref.platform)
await hydrate_from_source(dest_path, ref=ref)
except Exception as exc: except Exception as exc:
logger.warning("Failed to save source metadata for %s: %s", dest_path, exc) logger.warning("Failed to save source metadata for %s: %s", dest_path, exc)
@@ -466,15 +522,6 @@ class ModelSourceHandler:
os.makedirs(target_dir, exist_ok=True) os.makedirs(target_dir, exist_ok=True)
dest_path = os.path.join(target_dir, file_base) dest_path = os.path.join(target_dir, file_base)
# Check if already exists (simple skip)
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
logger.info("download_model_source: file already exists, skipping — %s", dest_path)
return web.json_response({
"success": True,
"message": f"File already exists: {dest_path}",
"path": dest_path,
})
# Built per request: sites that redirect to a CDN hand out a # Built per request: sites that redirect to a CDN hand out a
# time-limited token in the redirect, so the URL must never be cached. # time-limited token in the redirect, so the URL must never be cached.
resolve_url = source.file_download_url(repo, filename, revision) resolve_url = source.file_download_url(repo, filename, revision)
@@ -482,6 +529,20 @@ class ModelSourceHandler:
platform=source.platform, source_id=repo, url=source.canonical_url(repo) platform=source.platform, source_id=repo, url=source.canonical_url(repo)
) )
# Check if already exists (simple skip)
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
logger.info("download_model_source: file already exists, skipping — %s", dest_path)
# The sidecar may predate the source metadata being fetched, or may
# have been deleted, so top it up instead of skipping past it.
# Hydration no-ops when there is no sidecar to update.
await _report_phase(download_id, "source", source.platform)
await hydrate_from_source(dest_path, ref=ref)
return web.json_response({
"success": True,
"message": f"File already exists: {dest_path}",
"path": dest_path,
})
# Set up progress callback if download_id is provided # Set up progress callback if download_id is provided
progress_callback = None progress_callback = None
if download_id: if download_id:
@@ -530,7 +591,9 @@ class ModelSourceHandler:
progress_callback=progress_callback, progress_callback=progress_callback,
) )
if ok: if ok:
await _save_source_metadata(dest_path, ref, model_root) await _save_source_metadata(
dest_path, ref, model_root, download_id=download_id
)
return web.json_response({ return web.json_response({
"success": True, "success": True,
"message": f"Downloaded to {dest_path}", "message": f"Downloaded to {dest_path}",
@@ -557,7 +620,9 @@ class ModelSourceHandler:
progress_callback=progress_callback, progress_callback=progress_callback,
) )
if success: if success:
await _save_source_metadata(dest_path, ref, model_root) await _save_source_metadata(
dest_path, ref, model_root, download_id=download_id
)
return web.json_response({ return web.json_response({
"success": True, "success": True,
"message": f"Downloaded to {result}", "message": f"Downloaded to {result}",
+2
View File
@@ -41,6 +41,8 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"), RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"),
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"), RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"), RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"),
RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"),
RouteDefinition("POST", "/api/lm/{prefix}/rename-folder", "rename_folder"),
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition( RouteDefinition(
+3 -18
View File
@@ -33,8 +33,8 @@ from ..model_sources import (
resolve_source_ref, resolve_source_ref,
source_label, source_label,
) )
from ..model_sources.hydration import load_model_card, resolve_site_base_model
from ..websocket_manager import ws_manager from ..websocket_manager import ws_manager
from .base_model_resolver import resolve_base_model
from .post_processor import PostProcessor from .post_processor import PostProcessor
from .skill_registry import SkillRegistry from .skill_registry import SkillRegistry
from .skills.enrich_hf_metadata.readme_processor import ( from .skills.enrich_hf_metadata.readme_processor import (
@@ -466,12 +466,7 @@ class AgentService:
raw_basename = os.path.splitext(os.path.basename(model_path))[0] raw_basename = os.path.splitext(os.path.basename(model_path))[0]
variables["asset_base_url"] = source.asset_base_url(ref.source_id) variables["asset_base_url"] = source.asset_base_url(ref.source_id)
cache_key = f"{ref.platform}:{ref.source_id}" readme = await load_model_card(source, ref.source_id, cache)
readme = cache.readmes.get(cache_key) if cache is not None else None
if readme is None:
readme = await source.fetch_model_card(ref.source_id)
if cache is not None and readme:
cache.readmes[cache_key] = readme
# Sites such as ModelScope keep part of the model card outside the # Sites such as ModelScope keep part of the model card outside the
# README (author summary, curated tags, per-file example images). The # README (author summary, curated tags, per-file example images). The
@@ -507,17 +502,7 @@ class AgentService:
async def _resolve_site_base_model(self, source_context: ModelCardContext) -> str: async def _resolve_site_base_model(self, source_context: ModelCardContext) -> str:
"""Resolve the site's base-model hints to a canonical name, or ``""``.""" """Resolve the site's base-model hints to a canonical name, or ``""``."""
from ...metadata_ops import list_base_models return await resolve_site_base_model(source_context)
hints = [*source_context.base_model_aliases, source_context.base_model]
if not any(hints):
return ""
try:
known_names = await list_base_models()
except Exception as exc:
logger.debug("Failed to list base models for site resolution: %s", exc)
return ""
return resolve_base_model(hints, known_names)
async def _build_prompt_context( async def _build_prompt_context(
self, self,
+58 -29
View File
@@ -48,6 +48,7 @@ class PostProcessor:
readme_content: str = "", readme_content: str = "",
source_context: Optional["ModelCardContext"] = None, source_context: Optional["ModelCardContext"] = None,
resolved_base_model: str = "", resolved_base_model: str = "",
metadata_source: str = "agent:enrich_hf_metadata",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Route *llm_output* to the correct skill post-processor. """Route *llm_output* to the correct skill post-processor.
@@ -63,13 +64,18 @@ class PostProcessor:
hints resolve to, used when the LLM did not supply one (which is the hints resolve to, used when the LLM did not supply one (which is the
normal case when the LLM was skipped). normal case when the LLM was skipped).
*metadata_source* records who produced the metadata. The AI skill
keeps its historical value; the deterministic download-time hydration
passes its own so the two remain distinguishable. ``llm_enriched_at``
is only stamped when *llm_output* actually carries a provider answer.
Returns a dict with keys ``success`` (bool), ``updated_fields`` (list), Returns a dict with keys ``success`` (bool), ``updated_fields`` (list),
``preview_downloaded`` (bool), and ``errors`` (list). ``preview_downloaded`` (bool), and ``errors`` (list).
""" """
if skill_name == "enrich_hf_metadata": if skill_name == "enrich_hf_metadata":
return await self._process_enrich_hf_metadata( return await self._process_enrich_hf_metadata(
model_path, llm_output, metadata, readme_content, source_context, model_path, llm_output, metadata, readme_content, source_context,
resolved_base_model, resolved_base_model, metadata_source,
) )
return { return {
"success": False, "success": False,
@@ -89,6 +95,7 @@ class PostProcessor:
readme_content: str = "", readme_content: str = "",
source_context: Optional["ModelCardContext"] = None, source_context: Optional["ModelCardContext"] = None,
resolved_base_model: str = "", resolved_base_model: str = "",
metadata_source: str = "agent:enrich_hf_metadata",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
from ...metadata_ops import ( from ...metadata_ops import (
apply_metadata_updates, apply_metadata_updates,
@@ -135,6 +142,17 @@ class PostProcessor:
if new_base and self._should_overwrite(current_base, is_source_model): if new_base and self._should_overwrite(current_base, is_source_model):
updates["base_model"] = new_base updates["base_model"] = new_base
# model_name — the site's own display name, so a source download never
# shows up under its local filename. Written only while the name is
# still the untouched file stem: once a user renames a model that
# choice is theirs to keep.
site_name = ((source_context.model_name if source_context else "") or "").strip()
if is_source_model and site_name:
current_name = (metadata.get("model_name") or "").strip()
file_stem = (metadata.get("file_name") or "").strip()
if not current_name or current_name == file_stem:
updates["model_name"] = site_name
# trigger words → civitai.trainedWords # trigger words → civitai.trainedWords
new_triggers = llm_output.get("trigger_words", []) new_triggers = llm_output.get("trigger_words", [])
trigger_words_empty = True trigger_words_empty = True
@@ -142,14 +160,9 @@ class PostProcessor:
cleaned = [t.strip() for t in new_triggers if t.strip()] cleaned = [t.strip() for t in new_triggers if t.strip()]
cleaned = [t for t in cleaned if t.lower() not in ("none", "null", "n/a")] cleaned = [t for t in cleaned if t.lower() not in ("none", "null", "n/a")]
trigger_words_empty = not cleaned trigger_words_empty = not cleaned
current_civitai = metadata.get("civitai") or {} current_triggers = (metadata.get("civitai") or {}).get("trainedWords") or []
current_triggers = current_civitai.get("trainedWords") or []
if self._should_overwrite_list(current_triggers, is_source_model): if self._should_overwrite_list(current_triggers, is_source_model):
trig_civitai = dict(current_civitai) self._merge_civitai(updates, metadata, trainedWords=cleaned)
if "civitai" in updates and isinstance(updates["civitai"], dict):
trig_civitai.update(updates["civitai"])
trig_civitai["trainedWords"] = cleaned
updates["civitai"] = trig_civitai
# modelDescription — the author's own summary (when the site keeps one # modelDescription — the author's own summary (when the site keeps one
# outside the README, e.g. ModelScope's ``Description``) followed by the # outside the README, e.g. ModelScope's ``Description``) followed by the
@@ -175,12 +188,16 @@ class PostProcessor:
if not short_desc: if not short_desc:
short_desc = site_description short_desc = site_description
if short_desc and is_source_model: if short_desc and is_source_model:
current_civitai = metadata.get("civitai") or {} self._merge_civitai(updates, metadata, description=short_desc)
desc_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict): # The version label completes the card the way a CivitAI download does:
desc_civitai.update(updates["civitai"]) # the UI renders `civitai.name` as the version chip. It is per file,
desc_civitai["description"] = short_desc # so a collection repository shows that checkpoint's own label.
updates["civitai"] = desc_civitai site_version = (
(source_context.version_name if source_context else "") or ""
).strip()
if is_source_model and site_version:
self._merge_civitai(updates, metadata, name=site_version)
# gallery images → civitai.images (site example images, YAML frontmatter # gallery images → civitai.images (site example images, YAML frontmatter
# widget entries, and Sample Gallery markdown tables in the README body) # widget entries, and Sample Gallery markdown tables in the README body)
@@ -244,12 +261,7 @@ class PostProcessor:
all_images = _dedupe_images(site_images + readme_images) all_images = _dedupe_images(site_images + readme_images)
if all_images: if all_images:
gallery_images = all_images gallery_images = all_images
current_civitai = metadata.get("civitai") or {} self._merge_civitai(updates, metadata, images=all_images)
gallery_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
gallery_civitai.update(updates["civitai"])
gallery_civitai["images"] = all_images
updates["civitai"] = gallery_civitai
# tags — the site's curated tags are authoritative content vocabulary, so # tags — the site's curated tags are authoritative content vocabulary, so
# they are kept alongside whatever the LLM proposed (the LLM is skipped # they are kept alongside whatever the LLM proposed (the LLM is skipped
@@ -269,9 +281,12 @@ class PostProcessor:
if len(merged) > len(existing_tags) or is_source_model: if len(merged) > len(existing_tags) or is_source_model:
updates["tags"] = merged updates["tags"] = merged
# metadata_source & llm_enriched_at (always set) # metadata_source is recorded for provenance; llm_enriched_at only means
updates["metadata_source"] = "agent:enrich_hf_metadata" # something when a provider actually answered, so the deterministic
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat() # download-time hydration does not claim an enrichment that never ran.
updates["metadata_source"] = metadata_source
if llm_output:
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat()
# LLM confidence, stored for the enrichment evaluation harness. The key # LLM confidence, stored for the enrichment evaluation harness. The key
# must NOT start with an underscore: `BaseModelMetadata.from_dict()` # must NOT start with an underscore: `BaseModelMetadata.from_dict()`
@@ -292,12 +307,7 @@ class PostProcessor:
if instance_prompt: if instance_prompt:
site_triggers = [instance_prompt] site_triggers = [instance_prompt]
if site_triggers: if site_triggers:
current_civitai = metadata.get("civitai") or {} self._merge_civitai(updates, metadata, trainedWords=site_triggers)
trig_civitai = dict(current_civitai)
if "civitai" in updates and isinstance(updates["civitai"], dict):
trig_civitai.update(updates["civitai"])
trig_civitai["trainedWords"] = site_triggers
updates["civitai"] = trig_civitai
preview_remote_url = (llm_output.get("preview_url") or "").strip() preview_remote_url = (llm_output.get("preview_url") or "").strip()
# Fallback: if the LLM couldn't find a preview image in the cleaned # Fallback: if the LLM couldn't find a preview image in the cleaned
@@ -371,6 +381,25 @@ class PostProcessor:
"", "unknown", "", "unknown",
) )
@staticmethod
def _merge_civitai(
updates: Dict[str, Any], metadata: Dict[str, Any], **fields: Any
) -> None:
"""Layer *fields* onto the ``civitai`` block being assembled.
Description, version label, trigger words and gallery images all live
in the same dict and are contributed by separate branches, so each one
starts from what is already on disk and then applies whatever an
earlier branch queued in *updates*.
"""
merged = dict(metadata.get("civitai") or {})
queued = updates.get("civitai")
if isinstance(queued, dict):
merged.update(queued)
merged.update(fields)
updates["civitai"] = merged
@staticmethod @staticmethod
def _should_overwrite_list(current_list: List[str], is_source_model: bool) -> bool: def _should_overwrite_list(current_list: List[str], is_source_model: bool) -> bool:
"""Return ``True`` when a list field should be overwritten.""" """Return ``True`` when a list field should be overwritten."""
+26 -3
View File
@@ -19,6 +19,28 @@ from .model_sources import has_external_source
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _merge_ordered_unique(existing: Iterable[str], new: Iterable[str]) -> list[str]:
"""Concatenate two word lists, dropping duplicates without reordering.
Trigger word order is meaningful: the sequence stored in
``civitai.trainedWords`` is the order used when building prompts, and users
can reorder it in the UI. A plain ``set`` union used to shuffle that order on
every metadata refresh, so existing words are kept first (in their saved
order) and newly discovered ones are appended.
"""
merged: list[str] = []
seen: set[str] = set()
for word in list(existing) + list(new):
if word in seen:
continue
seen.add(word)
merged.append(word)
return merged
class MetadataProviderProtocol(Protocol): class MetadataProviderProtocol(Protocol):
"""Subset of metadata provider interface consumed by the sync service.""" """Subset of metadata provider interface consumed by the sync service."""
@@ -115,9 +137,10 @@ class MetadataSyncService:
) )
if "trainedWords" in existing_civitai: if "trainedWords" in existing_civitai:
existing_trained = existing_civitai.get("trainedWords", []) existing_trained = existing_civitai.get("trainedWords", []) or []
new_trained = civitai_metadata.get("trainedWords", []) new_trained = civitai_metadata.get("trainedWords", []) or []
merged_trained = list(set(existing_trained + new_trained)) # Order preserving merge: the saved order drives prompt order.
merged_trained = _merge_ordered_unique(existing_trained, new_trained)
merged_civitai["trainedWords"] = merged_trained merged_civitai["trainedWords"] = merged_trained
local_metadata["civitai"] = merged_civitai local_metadata["civitai"] = merged_civitai
+302 -1
View File
@@ -2,13 +2,15 @@ import asyncio
import fnmatch import fnmatch
import os import os
import logging import logging
import shutil
from typing import Any, Dict, List, Optional, Sequence, Set from typing import Any, Dict, List, Optional, Sequence, Set
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from ..services.model_lifecycle_service import _require_path_in_library_roots from ..services.model_lifecycle_service import _require_path_in_library_roots
from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -536,6 +538,305 @@ class ModelMoveService:
return rel.replace(os.sep, "/") return rel.replace(os.sep, "/")
return "" return ""
async def delete_folder(self, folder_path: str, dry_run: bool = False) -> Dict[str, Any]:
"""Delete a model-free directory inside the model library roots.
Only directories whose subtree holds no model weight files can be
removed: a folder-level cascade would bypass the per-model lifecycle
bookkeeping (metadata sidecars, previews, cache entries, pending-delete
staging and recipe references), so it is deliberately refused. Leftover
non-model files (stray previews, sidecars, ``.bak`` files) are reported
in the manifest before they are removed.
Args:
folder_path: Absolute path of the directory to remove (business
path symlinks are not resolved)
dry_run: When true, only report what would be removed
Returns:
Dictionary with the success flag plus a removal manifest
(``model_count``/``file_count``/``dir_count``/``symlink_count``/
``total_bytes``/``restorable``) on success.
"""
try:
if not folder_path or not str(folder_path).strip():
return {"success": False, "error": "Folder path is required"}
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
absolute_path = os.path.abspath(folder_path)
if os.path.islink(absolute_path):
# shutil.rmtree refuses symlinked roots, and silently deleting
# the link (leaving the real directory behind) is a separate
# decision we do not make here.
return {
"success": False,
"error": "Symlinked folders cannot be deleted",
}
if not os.path.isdir(absolute_path):
return {"success": False, "error": "Folder no longer exists"}
if self._is_model_root(absolute_path):
return {
"success": False,
"error": "The library root itself cannot be deleted",
}
manifest = self._collect_folder_manifest(absolute_path)
if manifest["pending_delete_job"]:
return {
"success": False,
"code": "busy",
"error": (
"A staged delete is still pending inside this folder; "
"wait for the undo window to expire"
),
"manifest": manifest,
}
if manifest["model_count"] > 0:
return {
"success": False,
"code": "not_empty",
"error": (
f"Folder still contains {manifest['model_count']} model "
"file(s); delete or move them first"
),
"manifest": manifest,
}
relative_folder = self._calculate_relative_folder(absolute_path)
if dry_run:
return {
"success": True,
"dry_run": True,
"folder_path": absolute_path.replace(os.sep, "/"),
"folder": relative_folder,
**manifest,
}
shutil.rmtree(absolute_path)
await self._forget_folder(relative_folder)
return {
"success": True,
"dry_run": False,
"folder_path": absolute_path.replace(os.sep, "/"),
"folder": relative_folder,
**manifest,
}
except ValueError as exc:
return {"success": False, "error": str(exc)}
except Exception as exc:
logger.error(f"Error deleting folder: {exc}", exc_info=True)
return {"success": False, "error": str(exc)}
def _is_model_root(self, absolute_path: str) -> bool:
"""Return True when the path *is* one of the configured library roots."""
normalized = os.path.normpath(absolute_path)
for root in self.scanner.get_model_roots():
if os.path.normpath(os.path.abspath(root)) == normalized:
return True
return False
@staticmethod
def _is_model_file(file_name: str) -> bool:
"""Return True when the file name carries a model weight extension."""
return os.path.splitext(file_name)[1].lower() in MODEL_FILE_EXTENSIONS
def _collect_folder_manifest(self, absolute_path: str) -> Dict[str, Any]:
"""Describe everything a recursive delete of *absolute_path* removes.
Walking is intentional: the scanner cache can be stale, and a model file
that appeared on disk since the last scan must still block the delete.
Symbolic links are never followed (``os.walk`` default) and are counted
separately ``shutil.rmtree`` unlinks them without touching their
targets.
"""
model_count = 0
file_count = 0
dir_count = 0
symlink_count = 0
total_bytes = 0
pending_delete_job = False
for dirpath, dirnames, filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames:
pending_delete_job = True
for name in dirnames:
if os.path.islink(os.path.join(dirpath, name)):
symlink_count += 1
else:
dir_count += 1
for name in filenames:
full_path = os.path.join(dirpath, name)
if os.path.islink(full_path):
symlink_count += 1
continue
if self._is_model_file(name):
model_count += 1
else:
file_count += 1
try:
total_bytes += os.path.getsize(full_path)
except OSError: # pragma: no cover - defensive
pass
return {
"model_count": model_count,
"file_count": file_count,
"dir_count": dir_count,
"symlink_count": symlink_count,
"total_bytes": total_bytes,
"pending_delete_job": pending_delete_job,
# A truly empty directory is the only case an "undo" can restore by
# simply recreating it; a folder holding stray files is gone for good.
"restorable": (
model_count == 0
and file_count == 0
and dir_count == 0
and symlink_count == 0
),
}
async def _forget_folder(self, relative_folder: str) -> None:
"""Drop a removed directory from the scanner's folder/cache records."""
if not relative_folder:
return
remove_known_folder = getattr(self.scanner, "remove_known_folder", None)
if callable(remove_known_folder):
await remove_known_folder(relative_folder)
async def rename_folder(self, folder_path: str, new_name: str) -> Dict[str, Any]:
"""Rename a directory inside the model library roots.
Unlike :meth:`delete_folder` this works on folders that hold models.
A rename keeps every file, so no per-model lifecycle step is bypassed:
the directory is renamed on disk and the affected folder, cache, hash
index and metadata-sidecar records are re-keyed onto the new prefix by
the scanner.
Args:
folder_path: Absolute path of the directory to rename (business
path symlinks are not resolved)
new_name: New leaf name; a single path segment, not a path
Returns:
Dictionary with the success flag, the previous/next library-relative
folder names and whether the directory actually moved.
"""
try:
if not folder_path or not str(folder_path).strip():
return {"success": False, "error": "Folder path is required"}
new_name = str(new_name or "").strip()
if not new_name:
return {"success": False, "error": "New folder name is required"}
if new_name in (".", "..") or any(
char in new_name for char in '/\\:*?"<>|'
):
return {"success": False, "error": "Invalid characters in folder name"}
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
absolute_path = os.path.abspath(folder_path)
if os.path.islink(absolute_path):
return {
"success": False,
"error": "Symlinked folders cannot be renamed",
}
if not os.path.isdir(absolute_path):
return {"success": False, "error": "Folder no longer exists"}
if self._is_model_root(absolute_path):
return {
"success": False,
"error": "The library root itself cannot be renamed",
}
previous_relative = self._calculate_relative_folder(absolute_path)
target = os.path.join(os.path.dirname(absolute_path), new_name)
if os.path.normpath(target) == os.path.normpath(absolute_path):
return {
"success": True,
"renamed": False,
"folder": previous_relative,
"previous_folder": previous_relative,
"folder_path": absolute_path.replace(os.sep, "/"),
}
if os.path.exists(target):
return {
"success": False,
"code": "target_exists",
"error": f"A folder named \"{new_name}\" already exists here",
}
# A staging manifest records absolute original/staged paths, so
# moving a folder that holds one would break its undo and purge.
if self._has_pending_delete_job(absolute_path):
return {
"success": False,
"code": "busy",
"error": (
"A staged delete is still pending inside this folder; "
"wait for the undo window to expire"
),
}
os.rename(absolute_path, target)
new_relative = self._calculate_relative_folder(target)
await self._rename_folder_records(
previous_relative, new_relative, absolute_path, target
)
return {
"success": True,
"renamed": True,
"folder": new_relative,
"previous_folder": previous_relative,
"folder_path": target.replace(os.sep, "/"),
}
except ValueError as exc:
return {"success": False, "error": str(exc)}
except Exception as exc:
logger.error(f"Error renaming folder: {exc}", exc_info=True)
return {"success": False, "error": str(exc)}
@staticmethod
def _has_pending_delete_job(absolute_path: str) -> bool:
"""Return True when a staged-delete batch lives inside the subtree."""
for _dirpath, dirnames, _filenames in os.walk(absolute_path):
if PENDING_DELETE_DIR_NAME in dirnames:
return True
return False
async def _rename_folder_records(
self,
previous_relative: str,
new_relative: str,
previous_path: str,
new_path: str,
) -> None:
"""Hand the rename to the scanner so folder/cache records follow it."""
if not previous_relative or not new_relative:
return
rename_known_folder = getattr(self.scanner, "rename_known_folder", None)
if callable(rename_known_folder):
await rename_known_folder(
previous_relative,
new_relative,
previous_path=previous_path,
new_path=new_path,
)
async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]: async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
"""Move a single model file """Move a single model file
+210
View File
@@ -1505,6 +1505,216 @@ class ModelScanner:
await self._persist_current_cache() await self._persist_current_cache()
self.bump_cache_version() self.bump_cache_version()
async def remove_known_folder(self, folder: str) -> None:
"""Forget a folder (and its subtree) that no longer exists on disk.
Counterpart of :meth:`add_known_folder`, called after a directory is
removed between scans (e.g. via the delete-folder API) so folder trees
and the move/download destination pickers stop offering it without a
full rescan. Ancestors are kept on purpose: every recorded ancestor
exists on disk in its own right, so only the removed subtree is dropped.
Cache entries that referenced the now-missing directory are purged as
well, which keeps a stale (phantom) model card from surviving the
deletion. When ``all_folders`` has not been recorded yet (legacy
snapshot) only the cache purge runs the scheduled backfill walk
rebuilds the folder list from disk.
"""
normalized = folder.replace("\\", "/").strip("/")
if not normalized:
return
cache = self._cache
if cache is None:
return
prefix = f"{normalized}/"
folders_changed = False
recorded = getattr(cache, "all_folders", None)
if recorded is not None:
updated = [
entry
for entry in recorded
if entry != normalized and not entry.startswith(prefix)
]
if updated != list(recorded):
cache.all_folders = updated
folders_changed = True
stale_paths = [
item.get("file_path")
for item in (cache.raw_data or [])
if self._folder_within(item.get("folder", ""), normalized)
]
if stale_paths:
# The purge persists the cache — including the already updated
# all_folders list — and bumps the version itself.
await self._batch_update_cache_for_deleted_models(stale_paths)
folders = set(item.get("folder", "") for item in cache.raw_data)
cache.folders = sorted(folders, key=lambda x: x.lower())
elif folders_changed:
await self._persist_current_cache()
self.bump_cache_version()
@staticmethod
def _folder_within(candidate: str, target: str) -> bool:
"""Return True when *candidate* is *target* or lives below it."""
return candidate == target or candidate.startswith(f"{target}/")
@staticmethod
def _rekey_path(value: str, old_prefix: str, new_prefix: str) -> str:
"""Move a stored path (or URL) from *old_prefix* onto *new_prefix*."""
if not value:
return value
normalized = value.replace("\\", "/")
if normalized.startswith(old_prefix):
return new_prefix + normalized[len(old_prefix):]
return value
async def rename_known_folder(
self,
previous_folder: str,
new_folder: str,
*,
previous_path: str,
new_path: str,
) -> bool:
"""Re-key folder, cache and metadata records after a directory rename.
Counterpart of :meth:`add_known_folder` / :meth:`remove_known_folder`.
A rename keeps every file, so nothing may be dropped: the recorded
folder list, the affected cache entries (``file_path``/``folder``/
``preview_url``), the hash index and the on-disk metadata sidecars are
all rewritten onto the new prefix. That is what lets a folder full of
models be renamed without a rescan and without breaking per-model
bookkeeping.
Args:
previous_folder: Library-relative folder name before the rename
new_folder: Library-relative folder name after the rename
previous_path: Absolute directory path before the rename
new_path: Absolute directory path after the rename
Returns:
True when any recorded data was rewritten.
"""
previous = previous_folder.replace("\\", "/").strip("/")
current = new_folder.replace("\\", "/").strip("/")
if not previous or not current or previous == current:
return False
old_rel_prefix = f"{previous}/"
new_rel_prefix = f"{current}/"
old_abs_prefix = f"{str(previous_path).replace(chr(92), '/').rstrip('/')}/"
new_abs_prefix = f"{str(new_path).replace(chr(92), '/').rstrip('/')}/"
cache = self._cache
if cache is None:
return False
changed = False
recorded = getattr(cache, "all_folders", None)
if recorded is not None:
rekeyed = sorted(
(
self._rekey_folder_name(entry, previous, old_rel_prefix, new_rel_prefix)
for entry in recorded
),
key=lambda entry: entry.lower(),
)
if rekeyed != list(recorded):
cache.all_folders = rekeyed
changed = True
excluded = getattr(self, "_excluded_models", None)
if excluded:
rekeyed_excluded = [
self._rekey_path(entry, old_abs_prefix, new_abs_prefix)
for entry in excluded
]
if rekeyed_excluded != list(excluded):
self._excluded_models = rekeyed_excluded
changed = True
touched: List[Dict[str, Any]] = []
for item in cache.raw_data or []:
folder_value = item.get("folder", "") or self._calculate_folder(
item.get("file_path", "")
)
if not self._folder_within(folder_value, previous):
continue
old_file_path = item.get("file_path", "")
if old_file_path:
cache.remove_from_version_index(item)
item["file_path"] = self._rekey_path(
old_file_path, old_abs_prefix, new_abs_prefix
)
hash_value = (item.get("sha256") or "").lower()
if hash_value:
self._hash_index.remove_by_path(old_file_path, hash_value)
self._hash_index.add_entry(
hash_value, item["file_path"], item.get("autov3") or None
)
item["folder"] = self._rekey_folder_name(
folder_value, previous, old_rel_prefix, new_rel_prefix
)
if item.get("preview_url"):
item["preview_url"] = self._rekey_path(
item["preview_url"], old_abs_prefix, new_abs_prefix
)
touched.append(item)
if touched:
changed = True
await self._rewrite_sidecar_paths(touched)
folders = set(item.get("folder", "") for item in cache.raw_data)
cache.folders = sorted(folders, key=lambda x: x.lower())
cache.rebuild_version_index()
await cache.resort()
if changed:
await self._persist_current_cache()
self.bump_cache_version()
return changed
@staticmethod
def _rekey_folder_name(
entry: str, previous: str, old_rel_prefix: str, new_rel_prefix: str
) -> str:
"""Move a library-relative folder name (and its subtree) under a new name."""
if entry == previous:
return new_rel_prefix.rstrip("/")
if entry.startswith(old_rel_prefix):
return new_rel_prefix + entry[len(old_rel_prefix):]
return entry
async def _rewrite_sidecar_paths(self, entries: List[Dict[str, Any]]) -> None:
"""Point each model's metadata sidecar at its new location.
Sidecars travel with the renamed directory, so only the recorded
``file_path``/``preview_url`` inside them need rewriting. Failures are
logged and skipped a stale sidecar is repaired by the next metadata
refresh, and must not abort the rename.
"""
for item in entries:
file_path = item.get("file_path")
if not file_path:
continue
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
if not os.path.exists(metadata_path):
continue
try:
await self._update_metadata_paths(metadata_path, file_path)
except Exception as exc: # pragma: no cover - defensive
logger.warning(
"Failed to rewrite metadata sidecar %s: %s", metadata_path, exc
)
def _schedule_all_folders_backfill(self) -> None: def _schedule_all_folders_backfill(self) -> None:
"""Kick off a one-shot background folder walk if none is running.""" """Kick off a one-shot background folder walk if none is running."""
if self._all_folders_backfill_running: if self._all_folders_backfill_running:
+10 -1
View File
@@ -24,7 +24,12 @@ from .base import (
is_valid_source_id, is_valid_source_id,
) )
from .huggingface import HuggingFaceSource from .huggingface import HuggingFaceSource
from .modelscope import ModelScopeSource from .hydration import (
hydrate_from_source,
load_model_card,
resolve_site_base_model,
)
from .modelscope import ModelScopeIntlSource, ModelScopeSource
from .registry import ( from .registry import (
LEGACY_HF_URL_FIELD, LEGACY_HF_URL_FIELD,
SOURCE_PLATFORM_FIELD, SOURCE_PLATFORM_FIELD,
@@ -52,6 +57,7 @@ __all__ = [
"ModelSourceCache", "ModelSourceCache",
"ModelSourceError", "ModelSourceError",
"HuggingFaceSource", "HuggingFaceSource",
"ModelScopeIntlSource",
"ModelScopeSource", "ModelScopeSource",
"SOURCE_PLATFORM_FIELD", "SOURCE_PLATFORM_FIELD",
"SOURCE_URL_FIELD", "SOURCE_URL_FIELD",
@@ -68,9 +74,12 @@ __all__ = [
"get_source", "get_source",
"get_source_platform", "get_source_platform",
"has_external_source", "has_external_source",
"hydrate_from_source",
"is_valid_source_id", "is_valid_source_id",
"list_sources", "list_sources",
"load_model_card",
"normalize_metadata_source", "normalize_metadata_source",
"resolve_site_base_model",
"resolve_source_ref", "resolve_source_ref",
"source_group_key", "source_group_key",
"source_label", "source_label",
+30
View File
@@ -45,6 +45,7 @@ USER_AGENT = "ComfyUI-LoRA-Manager/1.0"
GROUP_PREFIXES: dict[str, str] = { GROUP_PREFIXES: dict[str, str] = {
"huggingface": "hf", "huggingface": "hf",
"modelscope": "ms", "modelscope": "ms",
"modelscope-ai": "msai",
"tensorart": "ta", "tensorart": "ta",
} }
@@ -77,6 +78,30 @@ class ModelCardContext:
description: str = "" description: str = ""
"""Author-written summary shown on the model page, outside the README.""" """Author-written summary shown on the model page, outside the README."""
model_name: str = ""
"""Site-published display name for the repository.
Sites publish this next to the repository id (ModelScope's ``Name``).
It is what a CivitAI download would store as the model's name, so the
card never has to fall back to the local filename.
"""
model_name_localized: str = ""
"""Site-published localized name (ModelScope's ``ChineseName``)."""
version_name: str = ""
"""Site-published label for the requested file's version.
Resolved per file, like :attr:`example_images`: a repository publishes
one label per checkpoint (ModelScope's ``modelVersion.showName``).
"""
license: str = ""
"""License the site records for the repository."""
model_type: str = ""
"""Site-reported model type, e.g. ModelScope's ``AigcType`` (``LoRA``)."""
base_model: str = "" base_model: str = ""
"""Base model as reported by the site (possibly a site-local id).""" """Base model as reported by the site (possibly a site-local id)."""
@@ -104,6 +129,11 @@ class ModelCardContext:
return not any( return not any(
( (
self.description, self.description,
self.model_name,
self.model_name_localized,
self.version_name,
self.license,
self.model_type,
self.base_model, self.base_model,
self.base_model_aliases, self.base_model_aliases,
self.official_tags, self.official_tags,
+235
View File
@@ -0,0 +1,235 @@
"""Deterministic metadata hydration for freshly downloaded source models.
A CivitAI download writes a fully-populated metadata sidecar as part of the
download itself: the name, the description, the tags, the trigger words and
the example images all arrive with the file. A download from an external
model source (ModelScope, Hugging Face) has the same information behind a
public API, but historically landed as a bare filename plus a source URL that
the user had to enrich by hand ("Enrich Metadata with AI").
This module closes that gap without involving an LLM. It fetches the linked
site's model card, hands it to the same :class:`~py.services.agent.post_processor.PostProcessor`
the AI skill uses, and writes the result. Everything it applies is data the
site published, so it is safe to run automatically on every download and to
treat as a fallback for the gaps the LLM would otherwise fill.
Nothing here may break a download: every failure is logged and normalised to
"the site had nothing to contribute".
"""
from __future__ import annotations
import logging
import os
import time
from typing import TYPE_CHECKING, Optional
from .base import ModelCardContext, ModelSourceCache
from .registry import get_source, resolve_source_ref
if TYPE_CHECKING: # pragma: no cover - typing only
from .base import ModelSource, SourceRef
logger = logging.getLogger(__name__)
#: How long a fetched repository payload stays usable. A download batch walks
#: a repository's files one HTTP request at a time, and the README plus the
#: detail payload describe the *repository*, not the file, so re-fetching them
#: per file would be pure waste. They expire so an edited model card is still
#: picked up by the next batch.
SHARED_CACHE_TTL = 300.0
#: Upper bound on memoised repositories; a long-running server must not grow
#: without limit.
SHARED_CACHE_MAX_ENTRIES = 32
#: ``"<platform>:<source_id>"`` → ``(expiry, memo)``.
_shared_caches: dict[str, tuple[float, ModelSourceCache]] = {}
def shared_source_cache(platform: str, source_id: str) -> ModelSourceCache:
"""Return a short-lived per-repository memo for download-time hydration."""
now = time.monotonic()
key = f"{platform}:{source_id}"
entry = _shared_caches.get(key)
if entry is not None and entry[0] > now:
return entry[1]
for expired in [k for k, (expiry, _) in _shared_caches.items() if expiry <= now]:
_shared_caches.pop(expired, None)
if len(_shared_caches) >= SHARED_CACHE_MAX_ENTRIES:
oldest = min(_shared_caches, key=lambda k: _shared_caches[k][0])
_shared_caches.pop(oldest, None)
cache = ModelSourceCache()
_shared_caches[key] = (now + SHARED_CACHE_TTL, cache)
return cache
def reset_shared_caches() -> None:
"""Drop every memoised repository — used by tests."""
_shared_caches.clear()
async def load_model_card(
source: "ModelSource",
source_id: str,
cache: Optional[ModelSourceCache] = None,
) -> str:
"""Return *source_id*'s README, reusing *cache* when one is supplied.
Only successful reads are memoised, leaving a transient failure to be
retried for the next file of the same repository.
"""
key = f"{source.platform}:{source_id}"
if cache is not None:
cached = cache.readmes.get(key)
if cached is not None:
return cached
readme = await source.fetch_model_card(source_id)
if cache is not None and readme:
cache.readmes[key] = readme
return readme or ""
async def resolve_site_base_model(context: ModelCardContext) -> str:
"""Resolve the site's base-model hints to a canonical name, or ``""``.
Sites name base models in their own vocabulary (ModelScope publishes both
``krea/Krea-2-Turbo`` and the ``KREA_2_TURBO`` enum). The resolver is
strict and only ever returns a name the canonical vocabulary already
contains, so an uncertain hint yields ``""`` rather than a plausible-looking
wrong value.
"""
hints = [*context.base_model_aliases, context.base_model]
if not any(hints):
return ""
# Imported lazily: pulling in the agent package at module scope would make
# the model-source package import itself while it is still initialising.
try:
from ...metadata_ops import list_base_models
from ..agent.base_model_resolver import resolve_base_model
known_names = await list_base_models()
except Exception as exc:
logger.warning("Could not resolve a site base model: %s", exc)
return ""
return resolve_base_model(hints, known_names)
async def hydrate_from_source(
file_path: str,
*,
ref: "SourceRef",
cache: Optional[ModelSourceCache] = None,
) -> list[str]:
"""Apply the linked site's published metadata to a downloaded model.
This is the deterministic counterpart of the ``enrich_hf_metadata`` skill:
it produces the same populated model card a CivitAI download produces,
without an LLM and without user action.
Args:
file_path: The just-downloaded model file, whose sidecar already
carries the SHA256 used to match the right file in a collection
repository.
ref: The source the file came from.
cache: Optional per-call memo; defaults to a short-lived shared one so
a batch over one repository fetches its card only once.
Returns:
The names of the metadata fields that changed. Never raises a site
that is down, or an API that changed shape, must not fail a download.
"""
try:
source = get_source(ref.platform)
if source is None or not source.supports_enrichment:
return []
from ...metadata_ops import read_metadata
metadata = await read_metadata(file_path)
if not metadata:
logger.debug("No metadata to hydrate for %s", file_path)
return []
# Only a model that is actually linked to this repository may be
# updated. The download path writes those fields just before calling
# us; a file that merely shares a name with the requested one must not
# be given another model's card.
linked = resolve_source_ref(metadata)
if linked is None or (linked.platform, linked.source_id) != (
ref.platform,
ref.source_id,
):
logger.debug(
"Not hydrating %s: linked to %s, not %s",
file_path, linked.url if linked else "no model source", ref.url,
)
return []
memo = cache if cache is not None else shared_source_cache(
ref.platform, ref.source_id
)
readme = await load_model_card(source, ref.source_id, memo)
context = await source.fetch_model_card_context(
ref.source_id,
os.path.basename(file_path),
sha256=(metadata.get("sha256") or "").strip(),
cache=memo,
)
if context.is_empty() and not readme:
logger.debug(
"No published metadata for %s on %s", ref.source_id, ref.platform
)
return []
resolved_base_model = await resolve_site_base_model(context)
from ..agent.post_processor import PostProcessor
result = await PostProcessor().process(
skill_name="enrich_hf_metadata",
model_path=file_path,
llm_output={},
metadata=metadata,
readme_content=readme,
source_context=context,
resolved_base_model=resolved_base_model,
metadata_source=f"source:{ref.platform}",
)
if not result.get("success", True):
logger.debug(
"Hydration reported failure for %s: %s",
file_path, result.get("errors"),
)
return []
updated = list(result.get("updated_fields") or [])
logger.info(
"Hydrated %s from %s (%s): %s",
file_path, source.label or ref.platform, ref.source_id,
", ".join(updated) or "nothing to change",
)
return updated
except Exception as exc: # pragma: no cover - defensive by design
logger.warning("Source hydration failed for %s: %s", file_path, exc)
return []
__all__ = [
"SHARED_CACHE_MAX_ENTRIES",
"SHARED_CACHE_TTL",
"hydrate_from_source",
"load_model_card",
"reset_shared_caches",
"resolve_site_base_model",
"shared_source_cache",
]
+169 -38
View File
@@ -1,4 +1,4 @@
"""ModelScope (魔搭社区) model source. """ModelScope (魔搭社区) model sources.
ModelScope exposes the same "model card as README.md" convention as ModelScope exposes the same "model card as README.md" convention as
Hugging Face, including a YAML frontmatter block that often carries Hugging Face, including a YAML frontmatter block that often carries
@@ -10,11 +10,13 @@ none of which requires an API key for public models:
the same content through the API, used as a fallback when the resolve the same content through the API, used as a fallback when the resolve
URL is unavailable. URL is unavailable.
* ``/api/v1/models/{owner}/{name}`` the model-detail payload behind the * ``/api/v1/models/{owner}/{name}`` the model-detail payload behind the
model page. It carries the author's summary (``Description``), the model page. It carries the repository's display name (``Name`` /
site-curated tags (``OfficialTags``), and, per published version, the ``ChineseName``), the author's summary (``Description``), the license, the
model filenames (``MuseInfo.versions[].stats.fileList``) together with AIGC type, the site tags (``OfficialTags``, falling back to ``Tags``), and,
that file's example images (``coverImages``) and trigger words. See per published version, the model filenames
:meth:`ModelScopeSource.fetch_model_card_context`. (``MuseInfo.versions[].stats.fileList``) together with that version's label
(``modelVersion.showName``), example images (``coverImages``) and trigger
words. See :meth:`ModelScopeSource.fetch_model_card_context`.
* ``/api/v1/models/{owner}/{name}/repo/files?Revision=..`` the file * ``/api/v1/models/{owner}/{name}/repo/files?Revision=..`` the file
listing backing the download picker. It reports real sizes for LFS listing backing the download picker. It reports real sizes for LFS
files (not the pointer size), so no extra HEAD request is needed. files (not the pointer size), so no extra HEAD request is needed.
@@ -28,6 +30,12 @@ valid; the CDN URL must never be cached.
The README and the detail payload both describe the whole repository rather The README and the detail payload both describe the whole repository rather
than one file, so a per-run ``ModelSourceCache`` keeps them from being read than one file, so a per-run ``ModelSourceCache`` keeps them from being read
again for every checkpoint of a collection repository. again for every checkpoint of a collection repository.
Two deployments are served by this module. ``modelscope.cn`` (with
``modelscope.com`` as a redirect alias) and ``modelscope.ai`` are *separate
catalogues*, not mirrors, so they are registered as distinct sources:
:class:`ModelScopeSource` and :class:`ModelScopeIntlSource`. Every URL either
class builds is derived from its ``base_url``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -36,7 +44,7 @@ import json
import logging import logging
import os import os
import re import re
from typing import TYPE_CHECKING, Any, Optional from typing import TYPE_CHECKING, Any, Iterable, Optional
from .base import ( from .base import (
ModelCardContext, ModelCardContext,
@@ -52,18 +60,28 @@ if TYPE_CHECKING: # pragma: no cover - typing only
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_URL_PATTERN = re.compile( #: ModelScope runs two independent catalogues. ``modelscope.com`` is a
r"https?://(?:www\.)?modelscope\.(?:cn|com)/models/(?P<id>[^/?#\s]+/[^/?#\s]+)" #: redirect alias of the mainland site, but ``modelscope.ai`` is the
) #: *international* deployment with its own repository catalogue — a repository
#: published on one is routinely absent from the other (``referall13/EM1``
#: exists only on ``.ai``, ``jj3550945163/Krea-2-LORA`` only on ``.cn``). The
#: host therefore decides which site, API and CDN a model belongs to, and the
#: two deployments are registered as separate sources rather than folded into
#: one id.
_MAINLAND_HOSTS = r"modelscope\.(?:cn|com)"
_INTERNATIONAL_HOSTS = r"modelscope\.ai"
#: Trailing view segments the site appends to a model URL; accepted verbatim #: Trailing view segments the site appends to a model URL; accepted verbatim
#: when the user pastes a browser tab URL. #: when the user pastes a browser tab URL.
_VIEW_SEGMENTS = r"(?:summary|files|model-file|readme|community|evaluation)?" _VIEW_SEGMENTS = r"(?:summary|files|model-file|readme|community|evaluation)?"
_STRICT_URL_PATTERN = re.compile(
r"https?://(?:www\.)?modelscope\.(?:cn|com)/models/(?P<id>[^/?#\s]+/[^/?#\s]+)" def _url_patterns(hosts: str) -> tuple[re.Pattern[str], re.Pattern[str]]:
rf"/?{_VIEW_SEGMENTS}/?$" """Build the lenient and strict model-URL patterns for *hosts*."""
)
body = rf"https?://(?:www\.)?(?:{hosts})/models/(?P<id>[^/?#\s]+/[^/?#\s]+)"
return re.compile(body), re.compile(rf"{body}/?{_VIEW_SEGMENTS}/?$")
#: ``master`` is ModelScope's default branch; ``main`` is tried as a fallback #: ``master`` is ModelScope's default branch; ``main`` is tried as a fallback
#: for repos imported from Hugging Face. #: for repos imported from Hugging Face.
@@ -71,7 +89,12 @@ _REVISIONS = ("master", "main")
class ModelScopeSource(ModelSource): class ModelScopeSource(ModelSource):
"""ModelScope (``modelscope.cn``).""" """ModelScope's mainland site (``modelscope.cn``).
``modelscope.com`` is accepted as an alias of it. The international
deployment is :class:`ModelScopeIntlSource`; everything below is written in
terms of ``base_url`` so both share one implementation.
"""
platform = "modelscope" platform = "modelscope"
label = "ModelScope" label = "ModelScope"
@@ -79,15 +102,18 @@ class ModelScopeSource(ModelSource):
supports_download = True supports_download = True
default_revision = "master" default_revision = "master"
default_subdir = "modelscope" default_subdir = "modelscope"
url_pattern = _URL_PATTERN
strict_url_pattern = _STRICT_URL_PATTERN #: Origin every outgoing URL is built from.
base_url = "https://modelscope.cn"
url_pattern, strict_url_pattern = _url_patterns(_MAINLAND_HOSTS)
def canonical_url(self, source_id: str) -> str: def canonical_url(self, source_id: str) -> str:
return f"https://modelscope.cn/models/{source_id}" return f"{self.base_url}/models/{source_id}"
def asset_base_url(self, source_id: str, revision: str = "") -> str: def asset_base_url(self, source_id: str, revision: str = "") -> str:
return ( return (
f"https://modelscope.cn/models/{source_id}/resolve/" f"{self.base_url}/models/{source_id}/resolve/"
f"{self.resolve_revision(revision)}" f"{self.resolve_revision(revision)}"
) )
@@ -96,7 +122,7 @@ class ModelScopeSource(ModelSource):
for revision in _REVISIONS: for revision in _REVISIONS:
text = await fetch_text( text = await fetch_text(
f"https://modelscope.cn/models/{source_id}/resolve/{revision}/README.md" f"{self.base_url}/models/{source_id}/resolve/{revision}/README.md"
) )
if text: if text:
return text return text
@@ -105,7 +131,7 @@ class ModelScopeSource(ModelSource):
# environments where the CDN resolve host is blocked. # environments where the CDN resolve host is blocked.
for revision in _REVISIONS: for revision in _REVISIONS:
text = await fetch_text( text = await fetch_text(
"https://modelscope.cn/api/v1/models/" f"{self.base_url}/api/v1/models/"
f"{source_id}/repo?Revision={revision}&FilePath=README.md" f"{source_id}/repo?Revision={revision}&FilePath=README.md"
) )
if text: if text:
@@ -158,7 +184,7 @@ class ModelScopeSource(ModelSource):
return cache.provider[cache_key] return cache.provider[cache_key]
status, payload = await fetch_json( status, payload = await fetch_json(
f"https://modelscope.cn/api/v1/models/{source_id}" f"{self.base_url}/api/v1/models/{source_id}"
) )
if status != 200 or not isinstance(payload, dict): if status != 200 or not isinstance(payload, dict):
logger.debug( logger.debug(
@@ -185,7 +211,7 @@ class ModelScopeSource(ModelSource):
revision = self.resolve_revision(revision) revision = self.resolve_revision(revision)
status, payload = await fetch_json( status, payload = await fetch_json(
"https://modelscope.cn/api/v1/models/" f"{self.base_url}/api/v1/models/"
f"{source_id}/repo/files?Revision={revision}" f"{source_id}/repo/files?Revision={revision}"
) )
@@ -208,18 +234,37 @@ class ModelScopeSource(ModelSource):
self, source_id: str, filename: str, revision: str = "" self, source_id: str, filename: str, revision: str = ""
) -> str: ) -> str:
return ( return (
f"https://modelscope.cn/models/{source_id}/resolve/" f"{self.base_url}/models/{source_id}/resolve/"
f"{self.resolve_revision(revision)}/{filename}" f"{self.resolve_revision(revision)}/{filename}"
) )
def page_url_for_file(self, source_id: str, filename: str) -> str: def page_url_for_file(self, source_id: str, filename: str) -> str:
return ( return (
f"https://modelscope.cn/models/{source_id}/file/view/" f"{self.base_url}/models/{source_id}/file/view/"
f"{self.default_revision}/{filename}" f"{self.default_revision}/{filename}"
) )
__all__ = ["ModelScopeSource"] class ModelScopeIntlSource(ModelScopeSource):
"""ModelScope's international site (``modelscope.ai``).
A separate catalogue rather than a mirror, so it is registered under its
own platform id: the two deployments must not share a version group, a
"use default paths" directory, or a stored ``source_url``. The detail API,
the file listing, the resolve URLs and the CDN redirect all behave exactly
like the mainland site, which is why every URL here is derived from
:attr:`base_url` instead of being duplicated.
"""
platform = "modelscope-ai"
label = "ModelScope (International)"
default_subdir = "modelscope-ai"
base_url = "https://www.modelscope.ai"
url_pattern, strict_url_pattern = _url_patterns(_INTERNATIONAL_HOSTS)
__all__ = ["ModelScopeIntlSource", "ModelScopeSource"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -229,6 +274,33 @@ __all__ = ["ModelScopeSource"]
#: Trigger-word values that mean "the author left this blank". #: Trigger-word values that mean "the author left this blank".
_EMPTY_TRIGGER_VALUES = frozenset({"none", "null", "n/a"}) _EMPTY_TRIGGER_VALUES = frozenset({"none", "null", "n/a"})
#: Repository tags that only restate what the model *is* (its library, task or
#: framework) rather than what it depicts. ModelScope mixes both into the
#: plain ``Tags`` list, and a card tagged "lora" or "text-to-image" is noise.
_GENERIC_TAGS = frozenset(
{
"any-to-any",
"checkpoint",
"controlnet",
"diffusers",
"embedding",
"image-text-to-text",
"image-to-image",
"image-to-video",
"lora",
"lycoris",
"onnx",
"pytorch",
"safetensors",
"tensorflow",
"text-to-image",
"text-to-speech",
"text-to-video",
"textual-inversion",
"vae",
}
)
def _clean_text(value: Any) -> str: def _clean_text(value: Any) -> str:
"""Return a stripped string for *value*, or ``""`` for anything else.""" """Return a stripped string for *value*, or ``""`` for anything else."""
@@ -259,9 +331,13 @@ def _build_card_context(
context = ModelCardContext( context = ModelCardContext(
description=_clean_text(data.get("Description")), description=_clean_text(data.get("Description")),
model_name=_clean_text(data.get("Name")),
model_name_localized=_clean_text(data.get("ChineseName")),
license=_clean_text(data.get("License")),
model_type=_clean_text(data.get("AigcType")),
base_model=_first_string(data.get("BaseModel")), base_model=_first_string(data.get("BaseModel")),
base_model_aliases=_base_model_aliases(data), base_model_aliases=_base_model_aliases(data),
official_tags=_official_tags(data.get("OfficialTags")), official_tags=_official_tags(data),
) )
versions = _matching_versions( versions = _matching_versions(
@@ -271,6 +347,7 @@ def _build_card_context(
sha256=sha256, sha256=sha256,
) )
if versions: if versions:
context.version_name = _version_label(versions)
context.example_images = _cover_image_urls(versions) context.example_images = _cover_image_urls(versions)
context.trigger_words = _version_trigger_words(versions) context.trigger_words = _version_trigger_words(versions)
return context return context
@@ -303,26 +380,63 @@ def _base_model_aliases(data: dict[str, Any]) -> list[str]:
return aliases return aliases
def _official_tags(value: Any) -> list[str]: def _official_tags(data: dict[str, Any]) -> list[str]:
"""Extract the site-curated tag values from ``OfficialTags``. """Return the content tags the site publishes for the repository.
ModelScope's entries are dicts carrying an English ``Tag`` plus a ``OfficialTags`` is ModelScope's curated content vocabulary and is
``ChineseName``; the English value is the curated content vocabulary, so preferred whenever it is populated. Plenty of AIGC repositories leave it
that is the one surfaced here. empty and carry only the plain ``Tags`` list, which mixes content tags with
framework and task categories; those categories are dropped so a card is
not handed "lora" and "text-to-image" as if they described the model.
"""
curated = _dedupe(_tag_values(data.get("OfficialTags")))
if curated:
return curated
generic = set(_GENERIC_TAGS)
for value in (
data.get("AigcType"),
data.get("Libraries"),
data.get("Frameworks"),
):
for item in value if isinstance(value, list) else [value]:
text = _clean_text(item).lower()
if text:
generic.add(text)
return _dedupe(
tag for tag in _tag_values(data.get("Tags")) if tag.lower() not in generic
)
def _tag_values(value: Any) -> list[str]:
"""Return the tag strings from either shape ModelScope publishes.
``OfficialTags`` is a list of ``{"Tag": ..., "ChineseName": ...}`` dicts
carrying an English value; the plain ``Tags`` list is already strings.
""" """
tags: list[str] = []
if not isinstance(value, list): if not isinstance(value, list):
return tags return []
tags: list[str] = []
for entry in value: for entry in value:
if not isinstance(entry, dict): tag = _clean_text(entry.get("Tag") if isinstance(entry, dict) else entry)
continue if tag:
tag = _clean_text(entry.get("Tag"))
if tag and tag not in tags:
tags.append(tag) tags.append(tag)
return tags return tags
def _dedupe(values: Iterable[str]) -> list[str]:
"""Drop empties and repeats, keeping the first spelling seen."""
unique: list[str] = []
for value in values:
if value and value not in unique:
unique.append(value)
return unique
def _version_files(version: dict[str, Any]) -> list[str]: def _version_files(version: dict[str, Any]) -> list[str]:
"""Return the model filenames covered by one ``MuseInfo.versions`` entry. """Return the model filenames covered by one ``MuseInfo.versions`` entry.
@@ -359,6 +473,23 @@ def _version_show_name(version: dict[str, Any]) -> str:
return _clean_text(model_version.get("showName")).lower() return _clean_text(model_version.get("showName")).lower()
def _version_label(versions: list[dict[str, Any]]) -> str:
"""Return the first published version label, preserving its spelling.
Unlike :func:`_version_show_name` this is for display, so the label is
not lowercased.
"""
for version in versions:
model_version = version.get("modelVersion")
if not isinstance(model_version, dict):
continue
label = _clean_text(model_version.get("showName"))
if label:
return label
return ""
def _file_digests(data: dict[str, Any]) -> dict[str, str]: def _file_digests(data: dict[str, Any]) -> dict[str, str]:
"""Return ``basename -> sha256`` for every published weight file. """Return ``basename -> sha256`` for every published weight file.
+4 -1
View File
@@ -13,15 +13,18 @@ from typing import Any, Dict, Mapping, Optional
from .base import GROUP_PREFIXES, ModelSource, SourceRef, clean_source_url from .base import GROUP_PREFIXES, ModelSource, SourceRef, clean_source_url
from .huggingface import HuggingFaceSource from .huggingface import HuggingFaceSource
from .modelscope import ModelScopeSource from .modelscope import ModelScopeIntlSource, ModelScopeSource
from .tensorart import TensorArtSource from .tensorart import TensorArtSource
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
#: Order matters only for disambiguation; the URL patterns are disjoint. #: Order matters only for disambiguation; the URL patterns are disjoint.
#: ``modelscope.ai`` is a separate catalogue from ``modelscope.cn`` rather than
#: an alias, which is why it gets its own entry (see ``modelscope.py``).
_SOURCES: tuple[ModelSource, ...] = ( _SOURCES: tuple[ModelSource, ...] = (
HuggingFaceSource(), HuggingFaceSource(),
ModelScopeSource(), ModelScopeSource(),
ModelScopeIntlSource(),
TensorArtSource(), TensorArtSource(),
) )
+7
View File
@@ -170,6 +170,13 @@ class WebSocketManager:
progress_entry['status'] = data['status'] progress_entry['status'] = data['status']
if 'message' in data: if 'message' in data:
progress_entry['message'] = data['message'] progress_entry['message'] = data['message']
# Post-transfer stage reporting (see `model_source_handlers._report_phase`):
# the byte counter has stopped by then, so the stage is the only thing
# that still says the download is working.
if 'stage' in data:
progress_entry['stage'] = data['stage']
if 'platform' in data:
progress_entry['platform'] = data['platform']
self._download_progress[download_id] = progress_entry self._download_progress[download_id] = progress_entry
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.2.2" version = "1.2.3"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",
@@ -31,6 +31,10 @@
/* Textarea Styling */ /* Textarea Styling */
#batchUrlInput { #batchUrlInput {
width: 100%; width: 100%;
/* Content-box sizing made the border box wider than the modal's content
box, so the right border/halo fell outside the clipped area and was cut
off. Include padding and border in the declared width. */
box-sizing: border-box;
min-height: 120px; min-height: 120px;
padding: 12px; padding: 12px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
+30
View File
@@ -97,6 +97,32 @@
width: 0%; width: 0%;
} }
/* The transfer is done but the backend is still indexing the file and reading
the model site's API. A sheen over the full bar reads as "busy" where a
motionless 100% bar reads as "stuck". */
.current-item-bar.is-indeterminate {
position: relative;
overflow: hidden;
}
.current-item-bar.is-indeterminate::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.5) 50%,
transparent 100%
);
animation: progress-sheen 1.2s ease-in-out infinite;
}
@keyframes progress-sheen {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
.current-item-percent { .current-item-percent {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-color-secondary, var(--text-color)); color: var(--text-color-secondary, var(--text-color));
@@ -131,4 +157,8 @@
.current-item-bar { .current-item-bar {
transition: none; transition: none;
} }
.current-item-bar.is-indeterminate::after {
animation: none;
}
} }
+28 -1
View File
@@ -46,8 +46,20 @@
pointer-events: none; pointer-events: none;
} }
/* Destructive entries. The token used to be the nonexistent `--danger-color`,
which made the declaration invalid at computed-value time: the colour then
fell back to the menu's inherited text colour, so every "Delete …" entry in
the folder and model-card context menus rendered plain. */
.context-menu-item.delete-item { .context-menu-item.delete-item {
color: var(--danger-color); color: var(--lora-error);
}
/* The shared .context-menu-item:hover paints the accent background, which the
red label does not read against destructive entries get their own wash. */
.context-menu-item.delete-item:hover,
.context-menu-item.delete-item:focus-visible {
background-color: var(--lora-error-bg);
color: var(--lora-error);
} }
.context-menu-item i { .context-menu-item i {
@@ -55,6 +67,21 @@
text-align: center; text-align: center;
} }
/* Muted counter shown next to a menu label (e.g. how many empty folders the
"Show empty folders" toggle would reveal) */
.context-menu-count {
color: var(--text-muted);
font-size: 12px;
}
/* The count keeps the label/tally muted even while the row is hovered, since
the accent background would otherwise wash the muted colour out. */
.context-menu-item:hover .context-menu-count,
.context-menu-item:focus-visible .context-menu-count {
color: var(--lora-text);
opacity: 0.8;
}
/* Section Headers */ /* Section Headers */
.context-menu-section-header { .context-menu-section-header {
padding: 6px 12px 2px; padding: 6px 12px 2px;
@@ -12,6 +12,10 @@
.input-group input, .input-group input,
.input-group select { .input-group select {
width: 100%; width: 100%;
/* Include padding/border in the declared width so full-width fields do not
spill past the modal's content box, where their right border gets
clipped by the step's overflow-x: hidden. */
box-sizing: border-box;
padding: 8px; padding: 8px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
@@ -720,6 +724,9 @@
/* Textarea for multi-URL input */ /* Textarea for multi-URL input */
#modelUrl { #modelUrl {
width: 100%; width: 100%;
/* Content-box sizing pushed the border box 2px past the step's content
edge, clipping the right border. Include padding/border in the width. */
box-sizing: border-box;
padding: 8px; padding: 8px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
@@ -768,6 +775,16 @@
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
/* Fields sit flush against the scrollable step's content edge; the global
focus outline (offset: 2px) has its left/right edges clipped by the step's
overflow-x. Draw the ring inset so the full outline stays visible.
(Same fix as #importModal in import-modal.css.) */
#downloadModal input:focus-visible,
#downloadModal select:focus-visible,
#downloadModal textarea:focus-visible {
outline-offset: -2px;
}
#downloadModal .download-step .modal-actions { #downloadModal .download-step .modal-actions {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
@@ -747,13 +747,13 @@
} }
.priority-tags-input.settings-input-error { .priority-tags-input.settings-input-error {
border-color: var(--danger-color, #dc2626); border-color: var(--lora-error);
box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12); box-shadow: 0 0 0 2px rgba(from var(--lora-error) r g b / 0.12);
} }
.settings-input-error-message { .settings-input-error-message {
font-size: 0.8em; font-size: 0.8em;
color: var(--danger-color, #dc2626); color: var(--lora-error);
display: none; display: none;
} }
+32
View File
@@ -28,6 +28,38 @@
width: 100%; width: 100%;
} }
/* Tags row: the base model badge shares one line with the compact tags. */
.recipe-tags-row {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
}
.recipe-tags-row #recipeTagsContainer {
flex: 1;
min-width: 0;
}
/* Header base model badge: reuses the card .base-model-label pill shape but
swaps the on-image overlay styling (text shadow, backdrop blur) for the
accent-tinted chip look used by resource rows in this modal. */
.recipe-base-model-badge {
flex-shrink: 0;
max-width: 160px;
text-shadow: none;
backdrop-filter: none;
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
padding: 2px 8px;
}
.recipe-base-model-badge.is-unknown {
background: var(--surface-subtle);
color: var(--text-color);
opacity: 0.6;
}
.recipe-modal-header h2 { .recipe-modal-header h2 {
margin: 0 0 var(--space-1); margin: 0 0 var(--space-1);
padding: var(--space-1); padding: var(--space-1);
+76 -10
View File
@@ -92,38 +92,104 @@
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
padding: 4px 8px; padding: 4px 8px;
position: relative; position: relative;
cursor: grab;
transition: transform 0.18s ease; transition: transform 0.18s ease;
} }
.metadata-item:active { /* --- Shared chip reordering (tags + trigger words) ------------------------ */
/* Chips in a list that is actually sortable advertise the grab gesture only
then, so lists that cannot be reordered never lie about it. */
.metadata-items.pointer-sort-enabled .metadata-item {
cursor: grab;
}
.metadata-items.pointer-sort-enabled .metadata-item:active {
cursor: grabbing; cursor: grabbing;
} }
.metadata-item-dragging { /* Grip handle: always in the DOM, revealed when the list is sortable */
.reorder-handle {
display: none;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
margin-left: -2px;
border: none;
background: transparent;
color: var(--text-color);
opacity: 0.4;
font-size: 0.8em;
line-height: 1;
cursor: grab;
/* Keep a touch drag on the handle from scrolling the surrounding panel */
touch-action: none;
user-select: none;
transition: opacity 0.2s ease, color 0.2s ease;
}
.has-sortable-words .reorder-handle {
display: inline-flex;
}
/* Tag chips have no flex gap (unlike trigger word tags), so the grip needs its
own spacing before the tag text */
.metadata-item .reorder-handle {
margin-right: 4px;
}
.reorder-handle:hover {
opacity: 0.9;
color: var(--lora-accent);
}
.reorder-handle:active {
cursor: grabbing;
}
/* Hint shown in the edit controls row while reordering is available */
.reorder-hint {
display: none;
align-items: center;
gap: 4px;
margin-right: auto;
font-size: 0.75em;
color: var(--text-color);
opacity: 0.6;
white-space: nowrap;
}
.has-sortable-words .reorder-hint {
display: inline-flex;
}
/* Snapped-to-grid transition for the remaining chips while dragging */
.reorder-sorting > * {
transition: transform 0.18s ease;
}
/* The lifted chip that follows the pointer */
.reorder-dragging {
box-shadow: var(--shadow-dialog); box-shadow: var(--shadow-dialog);
cursor: grabbing; cursor: grabbing;
opacity: 0.95; opacity: 0.95;
transition: none; transition: none;
} }
.metadata-item-placeholder { /* Drop target left behind by the lifted chip */
.reorder-placeholder {
border: 1px dashed var(--lora-accent); border: 1px dashed var(--lora-accent);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
pointer-events: none; pointer-events: none;
} }
.metadata-items-sorting .metadata-item { body.reorder-drag-active {
transition: transform 0.18s ease;
}
body.metadata-drag-active {
user-select: none; user-select: none;
cursor: grabbing; cursor: grabbing;
} }
body.metadata-drag-active * { body.reorder-drag-active * {
cursor: grabbing !important; cursor: grabbing !important;
} }
+20 -118
View File
@@ -639,90 +639,40 @@
display: inline; display: inline;
} }
/* Create folder drop zone */ /* Create folder inline row: rendered inside the tree at the creation
.sidebar-create-folder-zone { location, styled like a regular node row with a full-width input */
position: absolute; .sidebar-create-folder-row {
bottom: 16px; padding-top: 4px;
left: 16px; padding-bottom: 4px;
right: 16px; cursor: default;
padding: 16px; }
border: 2px dashed oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4);
border-radius: var(--border-radius-xs); .sidebar-tree-node-content.sidebar-create-folder-row:hover,
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08); .sidebar-node-content.sidebar-create-folder-row:hover {
background: transparent;
color: var(--text-color);
}
.sidebar-create-folder-spacer {
opacity: 0; opacity: 0;
transform: translateY(10px);
transition: var(--transition-base);
pointer-events: none; pointer-events: none;
z-index: 10;
} }
.sidebar-create-folder-zone.active { .sidebar-create-folder-row .sidebar-tree-folder-icon,
opacity: 1; .sidebar-create-folder-row .sidebar-folder-icon {
transform: translateY(0);
}
.sidebar-create-folder-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--lora-accent); color: var(--lora-accent);
font-size: 0.85em; opacity: 0.9;
text-align: center;
}
.sidebar-create-folder-content i {
font-size: 1.5em;
opacity: 0.8;
}
/* Create folder input container */
.sidebar-create-folder-input-container {
/* Sticky footer inside the scroll container: always visible at the
bottom of the viewport regardless of tree scroll position */
position: sticky;
bottom: 8px;
margin: 8px 16px 0;
padding: 12px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
box-shadow: var(--shadow-lg);
z-index: 20;
animation: slideUp 0.2s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.sidebar-create-folder-input-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-create-folder-input-wrapper > i {
color: var(--lora-accent);
font-size: 1em;
} }
.sidebar-create-folder-input { .sidebar-create-folder-input {
flex: 1; flex: 1;
min-width: 0; /* allow the input to shrink below its intrinsic width */ min-width: 0; /* allow the input to shrink below its intrinsic width */
padding: 6px 10px; padding: 4px 8px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
background: var(--bg-color); background: var(--bg-color);
color: var(--text-color); color: var(--text-color);
font-size: 0.85em; font-size: 1em;
outline: none; outline: none;
transition: var(--transition-base); transition: var(--transition-base);
} }
@@ -732,49 +682,6 @@
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15); box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
} }
.sidebar-create-folder-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: var(--transition-base);
background: transparent;
color: var(--text-muted);
}
.sidebar-create-folder-btn:hover,
.sidebar-create-folder-btn:focus-visible {
background: var(--lora-surface);
color: var(--text-color);
outline: none;
}
.sidebar-create-folder-confirm:hover,
.sidebar-create-folder-confirm:focus-visible {
background: oklch(from var(--success-color) l c h / 0.15);
color: var(--success-color);
outline: none;
}
.sidebar-create-folder-cancel:hover,
.sidebar-create-folder-cancel:focus-visible {
background: oklch(from var(--error-color) l c h / 0.15);
color: var(--error-color);
outline: none;
}
.sidebar-create-folder-hint {
margin-top: 6px;
font-size: 0.75em;
color: var(--text-muted);
text-align: center;
opacity: 0.8;
}
/* Dragging state for sidebar */ /* Dragging state for sidebar */
.folder-sidebar.dragging-active { .folder-sidebar.dragging-active {
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.5); border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.5);
@@ -786,11 +693,6 @@
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02); background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02);
} }
/* Tree container positioning for create folder elements */
.sidebar-tree-container {
position: relative;
}
/* Folder context menu - positioned relative to sidebar */ /* Folder context menu - positioned relative to sidebar */
#sidebarFolderContextMenu { #sidebarFolderContextMenu {
z-index: var(--z-modal, 1002); z-index: var(--z-modal, 1002);
+2
View File
@@ -84,6 +84,8 @@ export function getApiEndpoints(modelType) {
moveModel: `/api/lm/${modelType}/move_model`, moveModel: `/api/lm/${modelType}/move_model`,
moveBulk: `/api/lm/${modelType}/move_models_bulk`, moveBulk: `/api/lm/${modelType}/move_models_bulk`,
createFolder: `/api/lm/${modelType}/create-folder`, createFolder: `/api/lm/${modelType}/create-folder`,
deleteFolder: `/api/lm/${modelType}/delete-folder`,
renameFolder: `/api/lm/${modelType}/rename-folder`,
// CivitAI integration // CivitAI integration
fetchCivitai: `/api/lm/${modelType}/fetch-civitai`, fetchCivitai: `/api/lm/${modelType}/fetch-civitai`,
+65
View File
@@ -1330,6 +1330,71 @@ export class BaseModelApiClient {
return result; return result;
} }
/**
* Delete a model-free folder inside the library roots.
*
* Only model-free folders can be removed; the backend answers with a 409
* `not_empty`/`busy` conflict otherwise. Those codes are attached to the
* thrown Error (`code`, `manifest`) so callers can explain the refusal
* instead of showing a bare message.
*
* @param {string} folderPath Absolute business path of the folder
* @param {{dryRun?: boolean}} [options]
*/
async deleteFolder(folderPath, options = {}) {
const { dryRun = false } = options || {};
const response = await fetch(this.apiConfig.endpoints.deleteFolder, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ folder_path: folderPath, dry_run: dryRun })
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) {
const error = new Error(result.error || `Failed to delete folder`);
error.code = result.code || null;
error.manifest = result.manifest || null;
throw error;
}
return result;
}
/**
* Rename a folder inside the library roots.
*
* Works on folders that hold models too the backend re-keys the affected
* cache records instead of cascading. A name collision or a staged delete
* inside the subtree surfaces as a 409 conflict, attached to the thrown
* Error as `code`.
*
* @param {string} folderPath Absolute business path of the folder
* @param {string} newName New leaf name (a single path segment)
*/
async renameFolder(folderPath, newName) {
const response = await fetch(this.apiConfig.endpoints.renameFolder, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ folder_path: folderPath, new_name: newName })
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) {
const error = new Error(result.error || `Failed to rename folder`);
error.code = result.code || null;
throw error;
}
return result;
}
async fetchUnifiedFolderTree(options = {}) { async fetchUnifiedFolderTree(options = {}) {
try { try {
const { includeEmpty = false } = options; const { includeEmpty = false } = options;
+32
View File
@@ -494,6 +494,7 @@ class RecipeModal {
this.syncGenerationParams(hydratedRecipe.gen_params); this.syncGenerationParams(hydratedRecipe.gen_params);
this.syncResourcesSection(hydratedRecipe); this.syncResourcesSection(hydratedRecipe);
this.syncHeaderActions(); this.syncHeaderActions();
this.syncBaseModelBadge();
this.syncMetaFooter(); this.syncMetaFooter();
// Show the modal // Show the modal
@@ -520,6 +521,32 @@ class RecipeModal {
} }
} }
/**
* Render the recipe-level base model badge in the header tags row.
* Unlike the width-constrained card overlay (which abbreviates), the
* modal has room for the full base model name matching the model
* modal's info grid and this modal's resource rows. Falls back to a
* dimmed "Unknown" instead of hiding so the header layout does not
* shift when hydration fills the value in.
*/
syncBaseModelBadge() {
const badge = document.getElementById('recipeBaseModelBadge');
if (!badge) {
return;
}
const rawLabel = (this.currentRecipe?.base_model || '').trim();
const unknownLabel = translate('recipes.modal.metadata.unknown', {}, 'Unknown');
const baseModelLabel = rawLabel || unknownLabel;
const fieldLabel = translate('recipes.modal.metadata.baseModel', {}, 'Base Model');
badge.textContent = baseModelLabel;
badge.title = `${fieldLabel}: ${baseModelLabel}`;
badge.setAttribute('aria-label', badge.title);
badge.classList.toggle('is-unknown', !rawLabel);
badge.hidden = false;
}
/** /**
* Render the meta footer: clickable file location (opens the recipe JSON * Render the meta footer: clickable file location (opens the recipe JSON
* in the OS file manager) plus the truncated recipe ID with copy button. * in the OS file manager) plus the truncated recipe ID with copy button.
@@ -661,6 +688,10 @@ class RecipeModal {
nextRecipe.has_workflow = fullRecipe.has_workflow; nextRecipe.has_workflow = fullRecipe.has_workflow;
} }
if (fullRecipe.base_model !== undefined) {
nextRecipe.base_model = fullRecipe.base_model;
}
if (fullRecipe.checkpoint !== undefined) { if (fullRecipe.checkpoint !== undefined) {
nextRecipe.checkpoint = fullRecipe.checkpoint; nextRecipe.checkpoint = fullRecipe.checkpoint;
} else { } else {
@@ -718,6 +749,7 @@ class RecipeModal {
this.updateSourceUrlDisplay(this.currentRecipe.source_path || ''); this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
} }
this.syncHeaderActions(); this.syncHeaderActions();
this.syncBaseModelBadge();
this.syncMetaFooter(); this.syncMetaFooter();
} }
File diff suppressed because it is too large Load Diff
+42 -197
View File
@@ -7,6 +7,12 @@ import { getModelApiClient } from '../../api/modelApiFactory.js';
import { translate } from '../../utils/i18nHelpers.js'; import { translate } from '../../utils/i18nHelpers.js';
import { getPriorityTagSuggestions } from '../../utils/priorityTagHelpers.js'; import { getPriorityTagSuggestions } from '../../utils/priorityTagHelpers.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { enablePointerSort } from './pointerSort.js';
import {
refreshReorderState,
renderReorderHandle,
renderReorderHint,
} from './reorderSupport.js';
const MODEL_TYPE_SUGGESTION_KEY_MAP = { const MODEL_TYPE_SUGGESTION_KEY_MAP = {
loras: 'lora', loras: 'lora',
@@ -18,16 +24,22 @@ const MODEL_TYPE_SUGGESTION_KEY_MAP = {
}; };
const METADATA_ITEM_SELECTOR = '.metadata-item'; const METADATA_ITEM_SELECTOR = '.metadata-item';
const METADATA_ITEMS_CONTAINER_SELECTOR = '.metadata-items'; const METADATA_ITEMS_CONTAINER_SELECTOR = '.metadata-items';
const METADATA_ITEM_DRAGGING_CLASS = 'metadata-item-dragging';
const METADATA_ITEM_PLACEHOLDER_CLASS = 'metadata-item-placeholder'; /**
const METADATA_ITEMS_SORTING_CLASS = 'metadata-items-sorting'; * Tag items have no click action of their own, so the whole chip stays
const BODY_DRAGGING_CLASS = 'metadata-drag-active'; * draggable (handleSelector is null); the small threshold keeps a click on the
* grip from starting a drag (it just focuses the grip). Touch users drag by the
* grip, which is the element that opts out of scrolling via touch-action.
*/
const TAG_SORT_CONFIG = {
itemSelector: METADATA_ITEM_SELECTOR,
dragThreshold: 5,
};
let activeModelTypeKey = ''; let activeModelTypeKey = '';
let priorityTagSuggestions = []; let priorityTagSuggestions = [];
let priorityTagSuggestionsLoaded = false; let priorityTagSuggestionsLoaded = false;
let priorityTagSuggestionsPromise = null; let priorityTagSuggestionsPromise = null;
let activeTagDragState = null;
// Configurable options for tag editing (set by setupTagEditMode) // Configurable options for tag editing (set by setupTagEditMode)
let tagEditOptions = { let tagEditOptions = {
@@ -423,6 +435,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
<div class="metadata-items"> <div class="metadata-items">
${currentTags.map(tag => ` ${currentTags.map(tag => `
<div class="metadata-item" data-tag="${tag}"> <div class="metadata-item" data-tag="${tag}">
${renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
<span class="metadata-item-content">${tag}</span> <span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn"> <button class="metadata-delete-btn">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
@@ -431,6 +444,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
`).join('')} `).join('')}
</div> </div>
<div class="metadata-edit-controls"> <div class="metadata-edit-controls">
${renderReorderHint(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
<button class="save-tags-btn" title="Save changes"> <button class="save-tags-btn" title="Save changes">
<i class="fas fa-save"></i> Save <i class="fas fa-save"></i> Save
</button> </button>
@@ -543,8 +557,11 @@ function setupDeleteButtons() {
btn.addEventListener('click', function(e) { btn.addEventListener('click', function(e) {
e.stopPropagation(); e.stopPropagation();
const tag = this.closest('.metadata-item'); const tag = this.closest('.metadata-item');
const scope = tag?.closest('.model-tags-container');
tag.remove(); tag.remove();
refreshTagReorderState(scope);
// Update status of items in the suggestion dropdown // Update status of items in the suggestion dropdown
updateSuggestionsDropdown(); updateSuggestionsDropdown();
}); });
@@ -563,204 +580,31 @@ function setupTagDragAndDrop(scopeContainer) {
return; return;
} }
container.querySelectorAll(METADATA_ITEM_SELECTOR).forEach((item) => { const scope = container.closest('.model-tags-container') || container;
item.removeAttribute('draggable');
if (item.classList.contains(METADATA_ITEM_PLACEHOLDER_CLASS)) {
return;
}
if (item.dataset.pointerDragInit === 'true') {
return;
}
item.addEventListener('pointerdown', handleTagPointerDown); enablePointerSort(container, {
item.dataset.pointerDragInit = 'true'; ...TAG_SORT_CONFIG,
onSorted: () => {
updateSuggestionsDropdown();
refreshTagReorderState(scope);
},
}); });
refreshTagReorderState(scope);
} }
function handleTagPointerDown(event) { /**
if (event.button !== 0) { * Refresh the "sortable" flag (and therefore the grip + hint) of a tags section
return; * @param {Element} [tagsSection] - The .model-tags-container element
} */
function refreshTagReorderState(tagsSection) {
if (event.target.closest('.metadata-delete-btn')) { refreshReorderState({
return; container: tagsSection?.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR),
} scope: tagsSection || undefined,
itemSelector: METADATA_ITEM_SELECTOR,
const item = event.currentTarget;
const container = item?.closest(METADATA_ITEMS_CONTAINER_SELECTOR);
if (!item || !container) {
return;
}
event.preventDefault();
startPointerDrag({ item, container, startEvent: event });
}
function startPointerDrag({ item, container, startEvent }) {
if (activeTagDragState) {
finishPointerDrag();
}
const itemRect = item.getBoundingClientRect();
const placeholder = document.createElement('div');
placeholder.className = `metadata-item ${METADATA_ITEM_PLACEHOLDER_CLASS}`;
placeholder.style.width = `${itemRect.width}px`;
placeholder.style.height = `${itemRect.height}px`;
container.insertBefore(placeholder, item);
item.classList.add(METADATA_ITEM_DRAGGING_CLASS);
item.style.width = `${itemRect.width}px`;
item.style.height = `${itemRect.height}px`;
item.style.position = 'fixed';
item.style.left = `${itemRect.left}px`;
item.style.top = `${itemRect.top}px`;
item.style.pointerEvents = 'none';
item.style.zIndex = '1000';
container.classList.add(METADATA_ITEMS_SORTING_CLASS);
if (document.body) {
document.body.classList.add(BODY_DRAGGING_CLASS);
}
const dragState = {
container,
item,
placeholder,
offsetX: startEvent.clientX - itemRect.left,
offsetY: startEvent.clientY - itemRect.top,
lastKnownPointer: { x: startEvent.clientX, y: startEvent.clientY },
rafId: null,
};
activeTagDragState = dragState;
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
document.addEventListener('pointercancel', handlePointerUp);
}
function handlePointerMove(event) {
if (!activeTagDragState) {
return;
}
activeTagDragState.lastKnownPointer = { x: event.clientX, y: event.clientY };
if (activeTagDragState.rafId !== null) {
return;
}
activeTagDragState.rafId = requestAnimationFrame(() => {
if (!activeTagDragState) {
return;
}
activeTagDragState.rafId = null;
updateDraggingItemPosition();
updatePlaceholderPosition();
}); });
} }
function handlePointerUp() {
finishPointerDrag();
}
function updateDraggingItemPosition() {
if (!activeTagDragState) {
return;
}
const { item, offsetX, offsetY, lastKnownPointer } = activeTagDragState;
const left = lastKnownPointer.x - offsetX;
const top = lastKnownPointer.y - offsetY;
item.style.left = `${left}px`;
item.style.top = `${top}px`;
}
function updatePlaceholderPosition() {
if (!activeTagDragState) {
return;
}
const { container, placeholder, item, lastKnownPointer } = activeTagDragState;
const siblings = Array.from(
container.querySelectorAll(
`${METADATA_ITEM_SELECTOR}:not(.${METADATA_ITEM_PLACEHOLDER_CLASS})`
)
).filter((element) => element !== item);
let insertAfter = null;
for (const sibling of siblings) {
const rect = sibling.getBoundingClientRect();
if (lastKnownPointer.y < rect.top) {
container.insertBefore(placeholder, sibling);
return;
}
if (lastKnownPointer.y <= rect.bottom) {
if (lastKnownPointer.x < rect.left + rect.width / 2) {
container.insertBefore(placeholder, sibling);
return;
}
insertAfter = sibling;
continue;
}
insertAfter = sibling;
}
if (!insertAfter) {
container.insertBefore(placeholder, container.firstElementChild);
return;
}
container.insertBefore(placeholder, insertAfter.nextSibling);
}
function finishPointerDrag() {
if (!activeTagDragState) {
return;
}
const { container, item, placeholder, rafId } = activeTagDragState;
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
document.removeEventListener('pointercancel', handlePointerUp);
container.classList.remove(METADATA_ITEMS_SORTING_CLASS);
if (document.body) {
document.body.classList.remove(BODY_DRAGGING_CLASS);
}
if (rafId !== null) {
cancelAnimationFrame(rafId);
activeTagDragState.rafId = null;
updateDraggingItemPosition();
updatePlaceholderPosition();
}
if (placeholder && placeholder.parentNode === container) {
container.insertBefore(item, placeholder);
container.removeChild(placeholder);
}
item.classList.remove(METADATA_ITEM_DRAGGING_CLASS);
item.style.position = '';
item.style.width = '';
item.style.height = '';
item.style.left = '';
item.style.top = '';
item.style.pointerEvents = '';
item.style.zIndex = '';
activeTagDragState = null;
updateSuggestionsDropdown();
}
/** /**
* Add a new tag * Add a new tag
* @param {string} tag - Tag to add * @param {string} tag - Tag to add
@@ -799,6 +643,7 @@ function addNewTag(tag, scopeElement = null) {
newTag.className = 'metadata-item'; newTag.className = 'metadata-item';
newTag.dataset.tag = tag; newTag.dataset.tag = tag;
newTag.innerHTML = ` newTag.innerHTML = `
${renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
<span class="metadata-item-content">${tag}</span> <span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn"> <button class="metadata-delete-btn">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
+105 -1
View File
@@ -7,10 +7,35 @@ import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js'; import { translate } from '../../utils/i18nHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js'; import { getModelApiClient } from '../../api/modelApiFactory.js';
import { escapeAttribute, escapeHtml } from './utils.js'; import { escapeAttribute, escapeHtml } from './utils.js';
import {
enablePointerSort,
disablePointerSort,
} from './pointerSort.js';
import {
refreshReorderState,
renderReorderHandle,
renderReorderHint,
} from './reorderSupport.js';
const MAX_WORDS_PER_TRIGGER_GROUP = 500; const MAX_WORDS_PER_TRIGGER_GROUP = 500;
const MAX_TRIGGER_WORD_GROUPS = 100; const MAX_TRIGGER_WORD_GROUPS = 100;
const TRIGGER_WORD_CLICK_DELAY_MS = 220; const TRIGGER_WORD_CLICK_DELAY_MS = 220;
const TRIGGER_WORD_DRAG_HANDLE_SELECTOR = '.reorder-handle';
/**
* Drag-to-reorder configuration for trigger word tags.
* Handlers are installed when entering edit mode and removed again on exit, so
* display mode keeps its click-to-copy / double-click-to-edit behaviour.
* The item body is click-to-edit here, so only the grip starts a drag, and the
* small threshold keeps a click on the grip from lifting the tag.
*/
const TRIGGER_WORD_DRAG_CONFIG = {
itemSelector: '.trigger-word-tag',
handleSelector: TRIGGER_WORD_DRAG_HANDLE_SELECTOR,
ignoreSelector: '.metadata-delete-btn, .trigger-word-edit-input',
blockedItemSelector: '.is-editing',
dragThreshold: 5,
};
/** /**
* Fetch trained words for a model * Fetch trained words for a model
@@ -182,6 +207,16 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
return dropdown; return dropdown;
} }
/**
* Render the drag handle of a trigger word tag.
* The handle is always in the DOM but only visible (and clickable) in edit mode,
* so switching modes never has to rebuild the tag markup.
* @returns {string} Handle markup
*/
function renderTriggerWordDragHandle() {
return renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'));
}
/** /**
* Render trigger words * Render trigger words
* @param {Array} words - Array of trigger words * @param {Array} words - Array of trigger words
@@ -203,6 +238,7 @@ export function renderTriggerWords(words, filePath) {
<div class="trigger-words-tags" style="display:none;"></div> <div class="trigger-words-tags" style="display:none;"></div>
</div> </div>
<div class="metadata-edit-controls" style="display:none;"> <div class="metadata-edit-controls" style="display:none;">
${renderReorderHint(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}"> <button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
<i class="fas fa-save"></i> ${translate('common.actions.save')} <i class="fas fa-save"></i> ${translate('common.actions.save')}
</button> </button>
@@ -228,6 +264,7 @@ export function renderTriggerWords(words, filePath) {
const escapedAttr = escapeAttribute(word); const escapedAttr = escapeAttribute(word);
return ` return `
<div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyOrEditWord')}"> <div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyOrEditWord')}">
${renderTriggerWordDragHandle()}
<span class="trigger-word-content">${escapedWord}</span> <span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy"> <span class="trigger-word-copy">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
@@ -240,6 +277,7 @@ export function renderTriggerWords(words, filePath) {
</div> </div>
</div> </div>
<div class="metadata-edit-controls" style="display:none;"> <div class="metadata-edit-controls" style="display:none;">
${renderReorderHint(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}"> <button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
<i class="fas fa-save"></i> ${translate('common.actions.save')} <i class="fas fa-save"></i> ${translate('common.actions.save')}
</button> </button>
@@ -316,6 +354,10 @@ export function setupTriggerWordsEditMode() {
} }
}); });
// Enable drag-to-reorder (grip handle) for the current words
enableTriggerWordSort(triggerWordsSection);
refreshTriggerWordHandleLabels(triggerWordsSection);
// Load trained words and display dropdown when entering edit mode // Load trained words and display dropdown when entering edit mode
// Add loading indicator // Add loading indicator
const loadingIndicator = document.createElement('div'); const loadingIndicator = document.createElement('div');
@@ -379,6 +421,10 @@ export function setupTriggerWordsEditMode() {
if (tagsContainer) tagsContainer.style.display = 'none'; if (tagsContainer) tagsContainer.style.display = 'none';
} }
// Leaving edit mode: tags are no longer reorderable
disableTriggerWordSort(triggerWordsSection);
refreshTriggerWordHandleLabels(triggerWordsSection);
// Remove dropdown if present // Remove dropdown if present
const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown'); const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown');
if (dropdown) dropdown.remove(); if (dropdown) dropdown.remove();
@@ -433,8 +479,13 @@ export function setupTriggerWordsEditMode() {
function deleteTriggerWord(e) { function deleteTriggerWord(e) {
e.stopPropagation(); e.stopPropagation();
const tag = this.closest('.trigger-word-tag'); const tag = this.closest('.trigger-word-tag');
const section = tag?.closest('.trigger-words');
tag.remove(); tag.remove();
if (section) {
refreshTriggerWordHandleLabels(section);
}
// Update status of items in the trained words dropdown // Update status of items in the trained words dropdown
updateTrainedWordsDropdown(); updateTrainedWordsDropdown();
} }
@@ -493,6 +544,47 @@ function restoreOriginalTriggerWords(section, originalWords) {
}); });
} }
/**
* Refresh the "sortable" flag (and therefore the grip + hint) of a section.
* Reordering is drag-only and only offered while editing: the tag body itself
* is click-to-edit, so the grip must not appear in display mode.
* @param {HTMLElement} section - The .trigger-words section
*/
function refreshTriggerWordHandleLabels(section) {
refreshReorderState({
container: section.querySelector('.trigger-words-tags'),
scope: section,
itemSelector: TRIGGER_WORD_DRAG_CONFIG.itemSelector,
isActive: () => section.classList.contains('edit-mode'),
});
}
/**
* Enable drag-to-reorder for the tags of a section (edit mode only)
* @param {HTMLElement} section - The .trigger-words section
*/
function enableTriggerWordSort(section) {
const tagsContainer = section.querySelector('.trigger-words-tags');
if (!tagsContainer) return;
enablePointerSort(tagsContainer, {
...TRIGGER_WORD_DRAG_CONFIG,
onSorted: () => refreshTriggerWordHandleLabels(section),
});
}
/**
* Remove drag-to-reorder handlers when leaving edit mode
* @param {HTMLElement} section - The .trigger-words section
*/
function disableTriggerWordSort(section) {
const tagsContainer = section.querySelector('.trigger-words-tags');
if (!tagsContainer) return;
disablePointerSort(tagsContainer, TRIGGER_WORD_DRAG_CONFIG);
refreshTriggerWordHandleLabels(section);
}
/** /**
* Create a trigger word tag element * Create a trigger word tag element
* @param {string} word - Trigger word * @param {string} word - Trigger word
@@ -507,6 +599,7 @@ function createTriggerWordTag(word, isEditMode = false) {
const escapedWord = escapeHtml(word); const escapedWord = escapeHtml(word);
tag.innerHTML = ` tag.innerHTML = `
${renderTriggerWordDragHandle()}
<span class="trigger-word-content">${escapedWord}</span> <span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy" style="${isEditMode ? 'display:none;' : ''}"> <span class="trigger-word-copy" style="${isEditMode ? 'display:none;' : ''}">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
@@ -637,7 +730,7 @@ function validateTriggerWord(word, tagsContainer, currentTag = null) {
* @param {Event} e - Click event * @param {Event} e - Click event
*/ */
function startEditTriggerWord(e) { function startEditTriggerWord(e) {
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input')) return; if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input') || e.target.closest(TRIGGER_WORD_DRAG_HANDLE_SELECTOR)) return;
const tag = this.closest('.trigger-word-tag'); const tag = this.closest('.trigger-word-tag');
const section = tag?.closest('.trigger-words'); const section = tag?.closest('.trigger-words');
@@ -684,6 +777,11 @@ function startEditTriggerWord(e) {
tag.classList.remove('is-editing'); tag.classList.remove('is-editing');
tag.style.removeProperty('--trigger-word-edit-width'); tag.style.removeProperty('--trigger-word-edit-width');
tag.style.removeProperty('--trigger-word-edit-height'); tag.style.removeProperty('--trigger-word-edit-height');
if (section) {
refreshTriggerWordHandleLabels(section);
}
updateTrainedWordsDropdown(); updateTrainedWordsDropdown();
}; };
@@ -763,6 +861,12 @@ function addNewTriggerWord(word) {
const newTag = createTriggerWordTag(word, triggerWordsSection.classList.contains('edit-mode')); const newTag = createTriggerWordTag(word, triggerWordsSection.classList.contains('edit-mode'));
tagsContainer.appendChild(newTag); tagsContainer.appendChild(newTag);
if (triggerWordsSection.classList.contains('edit-mode')) {
// Wire the freshly added tag for reordering too
enableTriggerWordSort(triggerWordsSection);
refreshTriggerWordHandleLabels(triggerWordsSection);
}
// Update status of items in the trained words dropdown // Update status of items in the trained words dropdown
updateTrainedWordsDropdown(); updateTrainedWordsDropdown();
} }
+367
View File
@@ -0,0 +1,367 @@
/**
* pointerSort.js
* Shared pointer-based drag-and-drop sorting for wrapped item lists
* (model tags, trigger words, ...).
*
* The engine lifts the dragged item into a fixed-position "ghost", leaves a
* correctly sized placeholder behind, and moves that placeholder around based
* on the pointer position. Because the dragged node is re-inserted where the
* placeholder ended up, the resulting DOM order *is* the new sort order the
* save path simply reads the items in DOM order.
*/
const DEFAULT_OPTIONS = {
// Selector of the sortable items inside the container.
itemSelector: '.metadata-item',
// When set, a drag can only start from inside this element (a handle).
// When null the whole item is draggable.
handleSelector: null,
// Elements inside an item that must never start a drag.
ignoreSelector: '.metadata-delete-btn',
// Items matching this selector cannot be dragged (e.g. while being edited).
blockedItemSelector: null,
draggingClass: 'reorder-dragging',
placeholderClass: 'reorder-placeholder',
containerSortingClass: 'reorder-sorting',
// Added to <body> while dragging to disable text selection globally.
bodySortingClass: 'reorder-drag-active',
// Pointer travel (px) required before a drag starts. 0 = start on pointerdown.
dragThreshold: 0,
// Called after a successful drop with (item, container).
onSorted: null,
};
// Marks a container whose items are actually sortable, so styles can offer the
// grab affordance only where dragging really works.
const CONTAINER_ENABLED_CLASS = 'pointer-sort-enabled';
let activeDragState = null;
let pendingDragState = null;
function resolveConfig(options = {}) {
return { ...DEFAULT_OPTIONS, ...options };
}
function itemInitKey(config) {
// Any option that changes how a pointerdown is interpreted is part of the
// key, so re-enabling a container with new options replaces the handler
// instead of silently keeping the old one.
return [
config.itemSelector,
config.handleSelector || '',
config.ignoreSelector || '',
config.blockedItemSelector || '',
config.dragThreshold,
].join('|');
}
/**
* Make the items of a container draggable within it.
* Safe to call repeatedly (e.g. after adding an item): already-configured items
* are skipped, and newly added items get wired up.
* @param {HTMLElement} container - Element holding the sortable items
* @param {Object} [options] - See DEFAULT_OPTIONS
*/
export function enablePointerSort(container, options = {}) {
if (!container) return;
const config = resolveConfig(options);
const initKey = itemInitKey(config);
container.__pointerSortConfig = config;
container.classList.add(CONTAINER_ENABLED_CLASS);
container.querySelectorAll(config.itemSelector).forEach((item) => {
item.removeAttribute('draggable');
if (item.classList.contains(config.placeholderClass)) return;
if (item.__pointerSortKey === initKey) return;
if (item.__pointerSortHandler) {
item.removeEventListener('pointerdown', item.__pointerSortHandler);
}
const handler = (event) => handlePointerDown(event, item, container, config);
item.addEventListener('pointerdown', handler);
item.__pointerSortKey = initKey;
item.__pointerSortHandler = handler;
});
}
/**
* Remove drag handlers previously installed by enablePointerSort().
* @param {HTMLElement} container - Element holding the sortable items
* @param {Object} [options] - Used when the container has no stored config
*/
export function disablePointerSort(container, options = {}) {
if (!container) return;
const config = resolveConfig(container.__pointerSortConfig || options);
container.querySelectorAll(config.itemSelector).forEach((item) => {
if (item.__pointerSortHandler) {
item.removeEventListener('pointerdown', item.__pointerSortHandler);
}
delete item.__pointerSortHandler;
delete item.__pointerSortKey;
});
delete container.__pointerSortConfig;
container.classList.remove(CONTAINER_ENABLED_CLASS);
cancelPendingDrag(container);
if (activeDragState && activeDragState.container === container) {
finishPointerDrag();
}
}
function handlePointerDown(event, item, container, config) {
if (activeDragState || pendingDragState) return;
if (typeof event.button === 'number' && event.button !== 0) return;
if (config.ignoreSelector && event.target.closest(config.ignoreSelector)) return;
if (config.handleSelector && !event.target.closest(config.handleSelector)) return;
if (config.blockedItemSelector && item.matches(config.blockedItemSelector)) return;
if (item.classList.contains(config.placeholderClass)) return;
if (config.dragThreshold > 0) {
startPendingDrag({ item, container, config, startEvent: event });
return;
}
// Prevent the browser's native text selection / image drag from kicking in.
event.preventDefault();
startPointerDrag({ item, container, config, startEvent: event });
}
function startPendingDrag({ item, container, config, startEvent }) {
const state = {
item,
container,
config,
startX: startEvent.clientX,
startY: startEvent.clientY,
};
state.onMove = (event) => {
const dx = event.clientX - state.startX;
const dy = event.clientY - state.startY;
if (Math.hypot(dx, dy) < config.dragThreshold) return;
cleanupPendingDrag();
event.preventDefault();
clearTextSelection();
startPointerDrag({ item, container, config, startEvent: event });
};
state.onUp = () => cleanupPendingDrag();
pendingDragState = state;
document.addEventListener('pointermove', state.onMove);
document.addEventListener('pointerup', state.onUp);
document.addEventListener('pointercancel', state.onUp);
}
function cleanupPendingDrag() {
if (!pendingDragState) return;
const { onMove, onUp } = pendingDragState;
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
pendingDragState = null;
}
function cancelPendingDrag(container) {
if (pendingDragState && (!container || pendingDragState.container === container)) {
cleanupPendingDrag();
}
}
function clearTextSelection() {
if (typeof window === 'undefined' || !window.getSelection) return;
const selection = window.getSelection();
if (selection && selection.removeAllRanges) selection.removeAllRanges();
}
function startPointerDrag({ item, container, config, startEvent }) {
if (activeDragState) finishPointerDrag();
const itemRect = item.getBoundingClientRect();
const placeholder = document.createElement('div');
const placeholderClasses = Array.from(item.classList).filter(
(name) => name !== config.draggingClass && name !== config.placeholderClass,
);
placeholderClasses.push(config.placeholderClass);
placeholder.className = placeholderClasses.join(' ');
placeholder.style.width = `${itemRect.width}px`;
placeholder.style.height = `${itemRect.height}px`;
container.insertBefore(placeholder, item);
item.classList.add(config.draggingClass);
item.style.width = `${itemRect.width}px`;
item.style.height = `${itemRect.height}px`;
item.style.position = 'fixed';
item.style.left = `${itemRect.left}px`;
item.style.top = `${itemRect.top}px`;
item.style.pointerEvents = 'none';
item.style.zIndex = '1000';
container.classList.add(config.containerSortingClass);
if (config.bodySortingClass && document.body) {
document.body.classList.add(config.bodySortingClass);
}
// Swallow the click generated by this pointer sequence so dropping an item
// never triggers its own click handler (copy-to-clipboard, inline editing).
// Scoped to the dragged container so unrelated clicks are never affected.
const swallowClick = (event) => {
if (event.target !== container && !container.contains(event.target)) {
return;
}
event.preventDefault();
event.stopPropagation();
document.removeEventListener('click', swallowClick, true);
};
document.addEventListener('click', swallowClick, true);
activeDragState = {
container,
item,
placeholder,
config,
offsetX: startEvent.clientX - itemRect.left,
offsetY: startEvent.clientY - itemRect.top,
lastKnownPointer: { x: startEvent.clientX, y: startEvent.clientY },
rafId: null,
swallowClick,
};
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
document.addEventListener('pointercancel', handlePointerUp);
}
function handlePointerMove(event) {
if (!activeDragState) return;
activeDragState.lastKnownPointer = { x: event.clientX, y: event.clientY };
if (activeDragState.rafId !== null) return;
activeDragState.rafId = requestAnimationFrame(() => {
if (!activeDragState) return;
activeDragState.rafId = null;
updateDraggingItemPosition();
updatePlaceholderPosition();
});
}
function handlePointerUp() {
finishPointerDrag();
}
function updateDraggingItemPosition() {
if (!activeDragState) return;
const { item, offsetX, offsetY, lastKnownPointer } = activeDragState;
const left = lastKnownPointer.x - offsetX;
const top = lastKnownPointer.y - offsetY;
item.style.left = `${left}px`;
item.style.top = `${top}px`;
}
function updatePlaceholderPosition() {
if (!activeDragState) return;
const { container, placeholder, item, config, lastKnownPointer } = activeDragState;
const siblings = Array.from(
container.querySelectorAll(
`${config.itemSelector}:not(.${config.placeholderClass})`,
),
).filter((element) => element !== item);
let insertAfter = null;
for (const sibling of siblings) {
const rect = sibling.getBoundingClientRect();
if (lastKnownPointer.y < rect.top) {
container.insertBefore(placeholder, sibling);
return;
}
if (lastKnownPointer.y <= rect.bottom) {
if (lastKnownPointer.x < rect.left + rect.width / 2) {
container.insertBefore(placeholder, sibling);
return;
}
insertAfter = sibling;
continue;
}
insertAfter = sibling;
}
if (!insertAfter) {
container.insertBefore(placeholder, container.firstElementChild);
return;
}
container.insertBefore(placeholder, insertAfter.nextSibling);
}
function finishPointerDrag() {
if (!activeDragState) return;
const { container, item, placeholder, config, rafId, swallowClick } = activeDragState;
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
document.removeEventListener('pointercancel', handlePointerUp);
container.classList.remove(config.containerSortingClass);
if (config.bodySortingClass && document.body) {
document.body.classList.remove(config.bodySortingClass);
}
if (rafId !== null) {
cancelAnimationFrame(rafId);
activeDragState.rafId = null;
}
// Always settle the placeholder from the last known pointer: the drop must
// reflect the final pointer position even when no animation frame ran
// (fast drags, or drags that started from the threshold-crossing move).
updateDraggingItemPosition();
updatePlaceholderPosition();
if (placeholder && placeholder.parentNode === container) {
container.insertBefore(item, placeholder);
container.removeChild(placeholder);
}
item.classList.remove(config.draggingClass);
item.style.position = '';
item.style.width = '';
item.style.height = '';
item.style.left = '';
item.style.top = '';
item.style.pointerEvents = '';
item.style.zIndex = '';
activeDragState = null;
if (typeof config.onSorted === 'function') {
config.onSorted(item, container);
}
cleanupSwallowClick(swallowClick);
}
/**
* The click that follows a drop is dispatched right after pointerup, so the
* guard has to survive until the next macrotask.
* @param {Function} handler - Capture-phase click handler to remove
*/
function cleanupSwallowClick(handler) {
if (!handler) return;
setTimeout(() => document.removeEventListener('click', handler, true), 0);
}
@@ -0,0 +1,64 @@
/**
* reorderSupport.js
* Shared drag affordance for chip lists sorted with pointerSort.
*
* The drag gesture itself lives in pointerSort.js; this module owns the parts
* every sortable list needs on top of it:
* - the `` grip markup,
* - the "sortable" flag that reveals the grip only when reordering is possible.
*
* Convention used by both callers: a list always shows the grip while it is
* sortable. Whether the item *body* is draggable as well depends on the item:
* - body has no click action (model/recipe tags) -> whole item is draggable,
* - body is click-to-edit (trigger words) -> only the grip starts a drag.
*
* Reordering is deliberately pointer-only: a keyboard shortcut would have to
* fight the browser's own Alt + Arrow handling and the modal's arrow-key
* navigation, so the grip is a plain decorative affordance rather than a
* focusable control.
*/
import { escapeAttribute, escapeHtml } from './utils.js';
const SORTABLE_CLASS = 'has-sortable-words';
/**
* Render the shared reorder grip
* @param {string} label - Tooltip text
* @returns {string} Handle markup
*/
export function renderReorderHandle(label) {
const safeLabel = escapeAttribute(label || '');
return `<span class="reorder-handle" aria-hidden="true" title="${safeLabel}"><i class="fas fa-grip-vertical"></i></span>`;
}
/**
* Render the shared reorder hint shown in an edit controls row
* @param {string} label - Hint text
* @returns {string} Hint markup
*/
export function renderReorderHint(label) {
return `<span class="reorder-hint"><i class="fas fa-grip-vertical"></i> ${escapeHtml(label || '')}</span>`;
}
/**
* Show or hide the grip and hint of a list.
* They are only offered while the list is editable and holds more than one
* item, so the UI never shows an affordance that cannot do anything.
* @param {Object} options - Options
* @param {HTMLElement} options.container - Element holding the sortable items
* @param {HTMLElement} [options.scope] - Element that receives the sortable flag
* @param {string} options.itemSelector - Selector of the sortable items
* @param {Function} [options.isActive] - Whether reordering is currently allowed
*/
export function refreshReorderState({
container,
scope = container,
itemSelector,
isActive = () => true,
}) {
if (!container) return;
const items = container.querySelectorAll(itemSelector);
scope.classList.toggle(SORTABLE_CLASS, isActive() && items.length > 1);
}
+30
View File
@@ -340,6 +340,27 @@ export class DownloadManager {
// ---- External repository download flow (Hugging Face / ModelScope) ---- // ---- External repository download flow (Hugging Face / ModelScope) ----
/**
* Report a post-transfer stage frame to the progress UI.
*
* The backend keeps working after the last byte lands it indexes the
* file and reads the model site's API and announces those stages with
* `status: 'metadata'`. Without them the bar sits at 100% showing "0 B/s"
* and the download looks stuck. The stage and platform are machine
* readable so LoadingManager can localise the wording.
*
* @returns {boolean} `true` when the frame was a stage frame.
*/
_applyMetadataStage(data, updateProgress, completed, name) {
if (data?.status !== 'metadata') return false;
updateProgress(100, completed, name, {}, {
phase: 'metadata',
stage: data.stage || '',
platform: data.platform || '',
});
return true;
}
/** Rendering group key: the same repo on two sites is two groups. */ /** Rendering group key: the same repo on two sites is two groups. */
_externalGroupKey(item) { _externalGroupKey(item) {
return `${item.source}:${item.repo || 'unknown'}`; return `${item.source}:${item.repo || 'unknown'}`;
@@ -1708,6 +1729,12 @@ export class DownloadManager {
cancelled = true; cancelled = true;
return; return;
} }
// Indexing / site metadata: the transfer is over but the
// backend is still working, so say so instead of
// leaving the bar frozen at 100%.
if (this._applyMetadataStage(data, updateProgress, snapshotCompleted, filename)) {
return;
}
if (data.status === 'progress') { if (data.status === 'progress') {
const metrics = { const metrics = {
bytesDownloaded: data.bytes_downloaded, bytesDownloaded: data.bytes_downloaded,
@@ -2331,6 +2358,9 @@ export class DownloadManager {
const snapshotCompleted = completedDownloads; const snapshotCompleted = completedDownloads;
wsHf.onmessage = (event) => { wsHf.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (this._applyMetadataStage(data, updateProgress, snapshotCompleted, name)) {
return;
}
if (data.status === 'progress') { if (data.status === 'progress') {
const metrics = { const metrics = {
bytesDownloaded: data.bytes_downloaded, bytesDownloaded: data.bytes_downloaded,
+79 -8
View File
@@ -1,5 +1,6 @@
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { formatFileSize } from '../utils/formatters.js'; import { formatFileSize } from '../utils/formatters.js';
import { getModelSource } from '../utils/modelSourceHelpers.js';
// Loading management // Loading management
export class LoadingManager { export class LoadingManager {
@@ -278,6 +279,35 @@ export class LoadingManager {
} }
}; };
/**
* Describe a post-transfer stage in the status line.
*
* The byte counter stops as soon as the last byte lands, but the
* backend still hashes the file and reads the model site's API. Naming
* that work is what stops the bar looking frozen at 100%.
*/
const describeMetadataStage = (stage, platform) => {
if (stage === 'indexing') {
return translate(
'modals.download.progress.indexingFile',
{},
'Reading model file...'
);
}
const label = getModelSource(platform)?.label || platform || '';
return label
? translate(
'modals.download.progress.fetchingSourceMetadata',
{ source: label },
`Fetching metadata from ${label}...`
)
: translate(
'modals.download.progress.fetchingMetadata',
{},
'Fetching metadata...'
);
};
// Initialize transfer stats with empty data // Initialize transfer stats with empty data
updateTransferStats(); updateTransferStats();
@@ -285,19 +315,62 @@ export class LoadingManager {
this.loadingContent.appendChild(this.cancelButton); this.loadingContent.appendChild(this.cancelButton);
} }
// Return update function /**
return (currentProgress, currentIndex = 0, currentName = '', metrics = {}) => { * Update the progress UI.
*
* @param {number} currentProgress Percentage of the current item.
* @param {number} [currentIndex] Items finished so far.
* @param {string} [currentName] File being processed.
* @param {object} [metrics] Byte counters; only meaningful while
* transferring.
* @param {object} [phase] `{ phase: 'metadata', stage, platform }` once
* the transfer has finished, so the UI can show what is still running
* instead of a 0 B/s speed.
*/
return (
currentProgress,
currentIndex = 0,
currentName = '',
metrics = {},
phase = null
) => {
const isMetadata = phase?.phase === 'metadata';
// Update current item progress // Update current item progress
currentItemProgress.style.width = `${currentProgress}%`; currentItemProgress.style.width = `${currentProgress}%`;
currentItemPercent.textContent = `${Math.floor(currentProgress)}%`; currentItemPercent.textContent = `${Math.floor(currentProgress)}%`;
currentItemProgress.classList.toggle('is-indeterminate', isMetadata);
// Update current item label if name provided // Update current item label if name provided
if (currentName) { if (currentName) {
currentItemLabel.textContent = translate( currentItemLabel.textContent = isMetadata
'modals.download.progress.downloading', ? translate(
{ name: currentName }, 'modals.download.progress.metadata',
`Downloading: ${currentName}` { name: currentName },
`Metadata: ${currentName}`
)
: translate(
'modals.download.progress.downloading',
{ name: currentName },
`Downloading: ${currentName}`
);
}
// No bytes are moving any more, so report the stage instead of a
// rate that has dropped to zero.
if (isMetadata) {
updateTransferStats({ bytesDownloaded: metrics.bytesDownloaded, totalBytes: metrics.totalBytes });
const stageText = describeMetadataStage(phase.stage, phase.platform);
speedDetail.textContent = stageText;
// Keep the batch position visible; the status line is the one
// place a caller also writes to.
this.setStatus(
totalItems > 1
? `${Math.min(currentIndex + 1, totalItems)}/${totalItems}: ${stageText}`
: stageText
); );
} else {
updateTransferStats(metrics);
} }
// Update overall label if multiple items // Update overall label if multiple items
@@ -311,8 +384,6 @@ export class LoadingManager {
// Single item, just update main progress // Single item, just update main progress
this.setProgress(currentProgress); this.setProgress(currentProgress);
} }
updateTransferStats(metrics);
}; };
} }
+13
View File
@@ -243,6 +243,18 @@ export class ModalManager {
}); });
} }
// Add deleteFolderModal registration
const deleteFolderModal = document.getElementById('deleteFolderModal');
if (deleteFolderModal) {
this.registerModal('deleteFolderModal', {
element: deleteFolderModal,
onClose: () => {
this.getModal('deleteFolderModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add helpModal registration // Add helpModal registration
const helpModal = document.getElementById('helpModal'); const helpModal = document.getElementById('helpModal');
if (helpModal) { if (helpModal) {
@@ -441,6 +453,7 @@ export class ModalManager {
id === "clearCacheModal" || id === "clearCacheModal" ||
id === "bulkDeleteModal" || id === "bulkDeleteModal" ||
id === "checkUpdatesConfirmModal" || id === "checkUpdatesConfirmModal" ||
id === "deleteFolderModal" ||
id === "resolveFilenameConflictsModal" id === "resolveFilenameConflictsModal"
) { ) {
modal.element.classList.add("show"); modal.element.classList.add("show");
+20
View File
@@ -49,6 +49,26 @@ export const MODEL_SOURCES = [
filePage: (id, filename) => filePage: (id, filename) =>
`https://modelscope.cn/models/${id}/file/view/master/${filename}`, `https://modelscope.cn/models/${id}/file/view/master/${filename}`,
}, },
{
// A separate catalogue from `modelscope.cn`, not an alias: a repository
// published on one is routinely absent from the other, so the host is part
// of the model's identity. Mirrors ModelScopeIntlSource in the backend.
platform: 'modelscope-ai',
label: 'ModelScope (International)',
groupPrefix: 'msai',
supportsEnrichment: true,
supportsDownload: true,
defaultRevision: 'master',
defaultSubdir: 'modelscope-ai',
exampleUrl: 'https://www.modelscope.ai/models/user/repo',
placeholder: 'https://www.modelscope.ai/models/user/repo',
pattern: /^https?:\/\/(?:www\.)?modelscope\.ai\/models\/([^/?#\s]+\/[^/?#\s]+)/i,
filePattern:
/^https?:\/\/(?:www\.)?modelscope\.ai\/models\/([^/?#\s]+\/[^/?#\s]+)\/resolve\/([^/?#\s]+)\/(.+)$/i,
canonical: (id) => `https://www.modelscope.ai/models/${id}`,
filePage: (id, filename) =>
`https://www.modelscope.ai/models/${id}/file/view/master/${filename}`,
},
{ {
platform: 'tensorart', platform: 'tensorart',
label: 'TensorArt', label: 'TensorArt',
+15 -2
View File
@@ -205,12 +205,24 @@
</div> </div>
<!-- Sidebar Folder Context Menu --> <!-- Sidebar Folder Context Menu -->
<!-- Order: the content action (update check) first, then the folder operations
as one group, then the destructive action behind its own divider. The
dividers are collapsed by SidebarManager when a group is hidden on the
current page (recipes keep only the update check). -->
<div id="sidebarFolderContextMenu" class="context-menu"> <div id="sidebarFolderContextMenu" class="context-menu">
<div class="context-menu-item" data-action="check-folder-updates">
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="create-subfolder"> <div class="context-menu-item" data-action="create-subfolder">
<i class="fas fa-folder-plus"></i> <span>{{ t('sidebar.newSubfolder') }}</span> <i class="fas fa-folder-plus"></i> <span>{{ t('sidebar.newSubfolder') }}</span>
</div> </div>
<div class="context-menu-item" data-action="check-folder-updates"> <div class="context-menu-item" data-action="rename-folder">
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span> <i class="fas fa-i-cursor"></i> <span>{{ t('sidebar.renameFolder') }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item delete-item" data-action="delete-folder">
<i class="fas fa-trash"></i> <span>{{ t('sidebar.deleteFolder') }}</span>
</div> </div>
</div> </div>
@@ -231,6 +243,7 @@
</div> </div>
<div class="context-menu-item" data-action="toggle-empty-folders"> <div class="context-menu-item" data-action="toggle-empty-folders">
<i class="fas fa-folder-open"></i> <span>{{ t('sidebar.showEmptyFolders') }}</span> <i class="fas fa-folder-open"></i> <span>{{ t('sidebar.showEmptyFolders') }}</span>
<span id="sidebarEmptyFoldersCount" class="context-menu-count"></span>
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i> <i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
</div> </div>
</div> </div>
@@ -82,6 +82,21 @@
</div> </div>
</div> </div>
<!-- Sidebar Folder Delete Confirmation Modal -->
<!-- Shared by two states: 'confirm' (model-free folder) and 'blocked' (the
subtree still holds models, so a cascade delete is refused). -->
<div id="deleteFolderModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<h2 data-role="title">{{ t('sidebar.deleteFolderModal.title') }}</h2>
<p class="delete-message" data-role="message">{{ t('sidebar.deleteFolderModal.message') }}</p>
<div class="delete-model-info" data-role="info"></div>
<div class="modal-actions">
<button class="cancel-btn" data-action="cancel-delete-folder">{{ t('common.actions.cancel') }}</button>
<button class="delete-btn" data-action="confirm-delete-folder">{{ t('sidebar.deleteFolderModal.confirm') }}</button>
</div>
</div>
</div>
<!-- Bulk Download Missing LoRAs Confirmation Modal --> <!-- Bulk Download Missing LoRAs Confirmation Modal -->
<div id="bulkDownloadMissingLorasModal" class="modal"> <div id="bulkDownloadMissingLorasModal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -16,6 +16,7 @@
<div id="hfSupportedSources"> <div id="hfSupportedSources">
<strong>https://huggingface.co/user/repo</strong><br> <strong>https://huggingface.co/user/repo</strong><br>
<strong>https://modelscope.cn/models/user/repo</strong><br> <strong>https://modelscope.cn/models/user/repo</strong><br>
<strong>https://www.modelscope.ai/models/user/repo</strong><br>
<strong>https://tensor.art/models/827823520299086029</strong> <strong>https://tensor.art/models/827823520299086029</strong>
</div> </div>
{{ t('modals.linkModelSource.enrichNote') }} {{ t('modals.linkModelSource.enrichNote') }}
+7 -2
View File
@@ -26,8 +26,13 @@
<i class="fas fa-trash" aria-hidden="true"></i> <i class="fas fa-trash" aria-hidden="true"></i>
</button> </button>
</div> </div>
<!-- Recipe Tags Container (rendered by renderCompactTags) --> <!-- Tags row: the base model badge is an independent sibling of the
<div id="recipeTagsContainer"></div> tags container so renderCompactTags re-renders and tag edit mode
never touch it. Badge is populated by RecipeModal.syncBaseModelBadge(). -->
<div class="recipe-tags-row">
<span id="recipeBaseModelBadge" class="base-model-label recipe-base-model-badge" hidden></span>
<div id="recipeTagsContainer"></div>
</div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
@@ -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');
});
});
@@ -9,6 +9,7 @@ const {
UI_HELPERS_MODULE, UI_HELPERS_MODULE,
UPDATE_CHECK_MODULE, UPDATE_CHECK_MODULE,
STATE_MODULE, STATE_MODULE,
MODAL_MANAGER_MODULE,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname, SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname, STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
@@ -18,17 +19,23 @@ const {
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
UPDATE_CHECK_MODULE: new URL('../../../static/js/utils/updateCheckHelpers.js', import.meta.url).pathname, UPDATE_CHECK_MODULE: new URL('../../../static/js/utils/updateCheckHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
})); }));
vi.mock(MODEL_API_FACTORY_MODULE, () => ({ getModelApiClient: vi.fn() })); vi.mock(MODEL_API_FACTORY_MODULE, () => ({ getModelApiClient: vi.fn() }));
vi.mock(I18N_MODULE, () => ({ translate: (key, _args, fallback) => fallback || key })); vi.mock(I18N_MODULE, () => ({ translate: (key, _args, fallback) => fallback || key }));
vi.mock(BULK_MANAGER_MODULE, () => ({ bulkManager: {} })); vi.mock(BULK_MANAGER_MODULE, () => ({ bulkManager: {} }));
vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn() })); vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn(), showActionToast: vi.fn() }));
vi.mock(UPDATE_CHECK_MODULE, () => ({ performFolderUpdateCheck: vi.fn() })); vi.mock(UPDATE_CHECK_MODULE, () => ({ performFolderUpdateCheck: vi.fn() }));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
}));
const { SidebarManager } = await import(SIDEBAR_MANAGER_MODULE); const { SidebarManager } = await import(SIDEBAR_MANAGER_MODULE);
const { state } = await import(STATE_MODULE); const { state } = await import(STATE_MODULE);
const { setStorageItem, getStorageItem } = await import(STORAGE_HELPERS_MODULE); const { setStorageItem, getStorageItem } = await import(STORAGE_HELPERS_MODULE);
const { showToast, showActionToast } = await import(UI_HELPERS_MODULE);
const { modalManager } = await import(MODAL_MANAGER_MODULE);
function createApiClient(overrides = {}) { function createApiClient(overrides = {}) {
return { return {
@@ -44,6 +51,20 @@ function createApiClient(overrides = {}) {
fetchModelFolders: vi.fn().mockResolvedValue({ folders: ['', 'full'] }), fetchModelFolders: vi.fn().mockResolvedValue({ folders: ['', 'full'] }),
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/models/loras'] }), fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/models/loras'] }),
createFolder: vi.fn().mockResolvedValue({ success: true, folder: 'new-folder', created: true }), createFolder: vi.fn().mockResolvedValue({ success: true, folder: 'new-folder', created: true }),
deleteFolder: vi.fn().mockResolvedValue({
success: true,
folder: 'empty',
model_count: 0,
file_count: 0,
dir_count: 0,
restorable: true,
}),
renameFolder: vi.fn().mockResolvedValue({
success: true,
renamed: true,
folder: 'renamed',
previous_folder: 'empty',
}),
...overrides, ...overrides,
}; };
} }
@@ -65,28 +86,33 @@ describe('SidebarManager empty folders toggle', () => {
document.body.innerHTML = ''; document.body.innerHTML = '';
}); });
it('requests the models-only tree by default', async () => { it('loads the full and models-only folder lists and counts the empty folders', async () => {
const apiClient = createApiClient(); const apiClient = createApiClient();
apiClient.fetchUnifiedFolderTree.mockResolvedValue({ tree: { full: {}, empty: {} } });
apiClient.fetchModelFolders.mockResolvedValue({ folders: ['', 'full'] });
const manager = createManager(apiClient); const manager = createManager(apiClient);
await manager.loadFolderTree(); await manager.loadFolderTree();
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith();
expect(apiClient.fetchModelFolders).not.toHaveBeenCalled();
expect(manager.nonEmptyFolders).toBeNull();
expect(manager.treeData).toEqual({ full: {}, empty: {} });
});
it('includes empty folders and tracks the models-only set when enabled', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.showEmptyFolders = true;
await manager.loadFolderTree();
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith({ includeEmpty: true }); expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith({ includeEmpty: true });
expect(apiClient.fetchModelFolders).toHaveBeenCalledWith(); expect(apiClient.fetchModelFolders).toHaveBeenCalledWith();
expect(manager.nonEmptyFolders).toEqual(new Set(['', 'full'])); expect(manager.nonEmptyFolders).toEqual(new Set(['', 'full']));
expect(manager.treeData).toEqual({ full: {}, empty: {} });
expect(manager.emptyFolderCount).toBe(1);
});
it('keeps the folder data loaded while empty folders are hidden', async () => {
const apiClient = createApiClient();
apiClient.fetchUnifiedFolderTree.mockResolvedValue({ tree: { full: {}, empty: {} } });
const manager = createManager(apiClient);
manager.showEmptyFolders = false;
await manager.loadFolderTree();
// The data is still fetched so the menu can report the count; only the
// rendering is gated by the preference.
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith({ includeEmpty: true });
expect(manager.emptyFolderCount).toBe(1);
}); });
it('passes includeEmpty to the folder list in list display mode', async () => { it('passes includeEmpty to the folder list in list display mode', async () => {
@@ -95,41 +121,46 @@ describe('SidebarManager empty folders toggle', () => {
.mockResolvedValueOnce({ folders: ['', 'full', 'empty'] }) .mockResolvedValueOnce({ folders: ['', 'full', 'empty'] })
.mockResolvedValueOnce({ folders: ['', 'full'] }); .mockResolvedValueOnce({ folders: ['', 'full'] });
const manager = createManager(apiClient, { displayMode: 'list' }); const manager = createManager(apiClient, { displayMode: 'list' });
manager.showEmptyFolders = true;
await manager.loadFolderTree(); await manager.loadFolderTree();
expect(apiClient.fetchModelFolders).toHaveBeenNthCalledWith(1, { includeEmpty: true }); expect(apiClient.fetchModelFolders).toHaveBeenNthCalledWith(1, { includeEmpty: true });
expect(apiClient.fetchModelFolders).toHaveBeenNthCalledWith(2);
expect(manager.foldersList).toEqual(['', 'full', 'empty']); expect(manager.foldersList).toEqual(['', 'full', 'empty']);
expect(manager.nonEmptyFolders).toEqual(new Set(['', 'full'])); expect(manager.nonEmptyFolders).toEqual(new Set(['', 'full']));
expect(manager.emptyFolderCount).toBe(1);
}); });
it('ignores the preference when the page does not support folder management', async () => { it('does not request empty folders when the page does not support folder management', async () => {
const apiClient = createApiClient(); const apiClient = createApiClient();
apiClient.apiConfig.config.supportsFolderManagement = false; apiClient.apiConfig.config.supportsFolderManagement = false;
const manager = createManager(apiClient); const manager = createManager(apiClient);
manager.showEmptyFolders = true;
await manager.loadFolderTree(); await manager.loadFolderTree();
expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith(); expect(apiClient.fetchUnifiedFolderTree).toHaveBeenCalledWith();
expect(apiClient.fetchModelFolders).not.toHaveBeenCalled();
expect(manager.nonEmptyFolders).toBeNull(); expect(manager.nonEmptyFolders).toBeNull();
expect(manager.emptyFolderCount).toBeNull();
}); });
it('persists the toggle and reloads the tree', async () => { it('persists the toggle and re-renders without refetching', () => {
const apiClient = createApiClient(); const apiClient = createApiClient();
const manager = createManager(apiClient); const manager = createManager(apiClient);
manager.showEmptyFolders = false;
manager.loadFolderTree = vi.fn(); manager.loadFolderTree = vi.fn();
manager.handleEmptyFoldersToggle({ stopPropagation: vi.fn() }); manager.handleEmptyFoldersToggle({ stopPropagation: vi.fn() });
expect(manager.showEmptyFolders).toBe(true); expect(manager.showEmptyFolders).toBe(true);
expect(getStorageItem('loras_showEmptyFolders')).toBe(true); expect(getStorageItem('loras_showEmptyFolders')).toBe(true);
expect(manager.loadFolderTree).toHaveBeenCalledTimes(1); expect(manager.loadFolderTree).not.toHaveBeenCalled();
expect(manager.renderFolderDisplay).toHaveBeenCalledTimes(1);
}); });
it('dims folders that contain no models', () => { it('dims folders that contain no models', () => {
const manager = createManager(createApiClient()); const manager = createManager(createApiClient());
manager.showEmptyFolders = true;
manager.treeData = { full: {}, empty: {} }; manager.treeData = { full: {}, empty: {} };
manager.nonEmptyFolders = new Set(['', 'full']); manager.nonEmptyFolders = new Set(['', 'full']);
@@ -142,6 +173,7 @@ describe('SidebarManager empty folders toggle', () => {
it('does not dim folders whose subtree contains models', () => { it('does not dim folders whose subtree contains models', () => {
const manager = createManager(createApiClient()); const manager = createManager(createApiClient());
manager.showEmptyFolders = true;
// Models live in "characters/anime" only; "characters" itself holds no // Models live in "characters/anime" only; "characters" itself holds no
// direct models but must not be dimmed. // direct models but must not be dimmed.
manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['characters/anime']); manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['characters/anime']);
@@ -156,6 +188,48 @@ describe('SidebarManager empty folders toggle', () => {
expect(animeNode[0]).not.toContain('empty'); expect(animeNode[0]).not.toContain('empty');
expect(emptyNode[0]).toContain('empty'); expect(emptyNode[0]).toContain('empty');
}); });
it('does not dim folders while the preference is off', () => {
const manager = createManager(createApiClient());
manager.showEmptyFolders = false;
manager.treeData = { full: {}, empty: {} };
manager.nonEmptyFolders = new Set(['', 'full']);
const html = manager.renderTreeNode(manager.treeData, '');
expect(html).not.toContain('sidebar-tree-node-content empty');
});
describe('list view', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
});
it('hides empty folders from the flat list while the preference is off', () => {
const manager = createManager(createApiClient(), { displayMode: 'list' });
manager.showEmptyFolders = false;
manager.foldersList = ['', 'full', 'empty'];
manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['full']);
manager.renderFolderList();
const html = document.getElementById('sidebarFolderTree').innerHTML;
expect(html).toContain('data-path="full"');
expect(html).not.toContain('data-path="empty"');
});
it('shows empty folders dimmed in the flat list when the preference is on', () => {
const manager = createManager(createApiClient(), { displayMode: 'list' });
manager.showEmptyFolders = true;
manager.foldersList = ['', 'full', 'empty'];
manager.nonEmptyFolders = manager._buildNonEmptyFolderSet(['full']);
manager.renderFolderList();
const html = document.getElementById('sidebarFolderTree').innerHTML;
expect(html).toContain('sidebar-node-content empty" data-path="empty"');
});
});
}); });
describe('SidebarManager view options menu', () => { describe('SidebarManager view options menu', () => {
@@ -164,13 +238,17 @@ describe('SidebarManager view options menu', () => {
<div class="context-menu-item" data-action="view-mode-tree"><i class="check-indicator" style="display:none"></i></div> <div class="context-menu-item" data-action="view-mode-tree"><i class="check-indicator" style="display:none"></i></div>
<div class="context-menu-item" data-action="view-mode-list"><i class="check-indicator" style="display:none"></i></div> <div class="context-menu-item" data-action="view-mode-list"><i class="check-indicator" style="display:none"></i></div>
<div class="context-menu-item" data-action="toggle-recursive"><i class="check-indicator" style="display:none"></i></div> <div class="context-menu-item" data-action="toggle-recursive"><i class="check-indicator" style="display:none"></i></div>
<div class="context-menu-item" data-action="toggle-empty-folders"><i class="check-indicator" style="display:none"></i></div> <div class="context-menu-item" data-action="toggle-empty-folders"><span id="sidebarEmptyFoldersCount"></span><i class="check-indicator" style="display:none"></i></div>
</div>`; </div>`;
function getCheck(action) { function getCheck(action) {
return document.querySelector(`#sidebarViewOptionsMenu [data-action="${action}"] .check-indicator`); return document.querySelector(`#sidebarViewOptionsMenu [data-action="${action}"] .check-indicator`);
} }
function getEmptyFoldersItem() {
return document.querySelector('[data-action="toggle-empty-folders"]');
}
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
document.body.innerHTML = MENU_HTML; document.body.innerHTML = MENU_HTML;
@@ -206,7 +284,35 @@ describe('SidebarManager view options menu', () => {
manager.updateViewOptionsMenu(); manager.updateViewOptionsMenu();
expect(document.querySelector('[data-action="toggle-empty-folders"]').style.display).toBe('none'); expect(getEmptyFoldersItem().style.display).toBe('none');
});
it('hides the empty-folders item when the library has no empty folders', () => {
const manager = createManager(createApiClient());
manager.emptyFolderCount = 0;
manager.updateViewOptionsMenu();
expect(getEmptyFoldersItem().style.display).toBe('none');
});
it('keeps the empty-folders item visible while the count is unknown', () => {
const manager = createManager(createApiClient());
manager.emptyFolderCount = null;
manager.updateViewOptionsMenu();
expect(getEmptyFoldersItem().style.display).not.toBe('none');
});
it('shows the empty-folder count next to the label', () => {
const manager = createManager(createApiClient());
manager.emptyFolderCount = 12;
manager.updateViewOptionsMenu();
expect(getEmptyFoldersItem().style.display).not.toBe('none');
expect(document.getElementById('sidebarEmptyFoldersCount').textContent).toBe('(12)');
}); });
it('switches display mode from the menu and closes it', () => { it('switches display mode from the menu and closes it', () => {
@@ -224,7 +330,7 @@ describe('SidebarManager view options menu', () => {
it('toggles empty folders from the menu and keeps it open', () => { it('toggles empty folders from the menu and keeps it open', () => {
const manager = createManager(createApiClient()); const manager = createManager(createApiClient());
manager.loadFolderTree = vi.fn(); manager.showEmptyFolders = false;
const menu = document.getElementById('sidebarViewOptionsMenu'); const menu = document.getElementById('sidebarViewOptionsMenu');
menu.style.display = 'block'; menu.style.display = 'block';
@@ -233,6 +339,7 @@ describe('SidebarManager view options menu', () => {
expect(manager.showEmptyFolders).toBe(true); expect(manager.showEmptyFolders).toBe(true);
expect(getCheck('toggle-empty-folders').style.display).toBe('block'); expect(getCheck('toggle-empty-folders').style.display).toBe('block');
expect(menu.style.display).toBe('block'); expect(menu.style.display).toBe('block');
expect(manager.renderFolderDisplay).toHaveBeenCalled();
}); });
it('collapses all folders from the header button', () => { it('collapses all folders from the header button', () => {
@@ -277,6 +384,29 @@ describe('SidebarManager view options menu', () => {
manager.handleViewOptionsButton({ stopPropagation: vi.fn(), currentTarget: button }); manager.handleViewOptionsButton({ stopPropagation: vi.fn(), currentTarget: button });
expect(menu.style.display).toBe('none'); expect(menu.style.display).toBe('none');
}); });
it('shows empty folders on a fresh library (default preference)', () => {
const manager = createManager(createApiClient());
manager.updateSearchRecursiveOption = vi.fn();
manager.updateFolderManagementButtons = vi.fn();
manager.updateCollapseAllButton = vi.fn();
manager.restoreSidebarState();
expect(manager.showEmptyFolders).toBe(true);
});
it('honours a stored preference to hide empty folders', () => {
setStorageItem('loras_showEmptyFolders', false);
const manager = createManager(createApiClient());
manager.updateSearchRecursiveOption = vi.fn();
manager.updateFolderManagementButtons = vi.fn();
manager.updateCollapseAllButton = vi.fn();
manager.restoreSidebarState();
expect(manager.showEmptyFolders).toBe(false);
});
}); });
describe('SidebarManager folder creation', () => { describe('SidebarManager folder creation', () => {
@@ -306,19 +436,34 @@ describe('SidebarManager folder creation', () => {
const apiClient = createApiClient(); const apiClient = createApiClient();
const manager = createManager(apiClient); const manager = createManager(apiClient);
manager.selectedPath = 'characters'; manager.selectedPath = 'characters';
manager.showEmptyFolders = false;
manager.refresh = vi.fn().mockResolvedValue(undefined); manager.refresh = vi.fn().mockResolvedValue(undefined);
const success = await manager._createFolder('characters/anime', 'characters'); const success = await manager._createFolder('characters/anime', 'characters');
expect(success).toBe(true); expect(success).toBe(true);
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/characters/anime'); expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/characters/anime');
// Empty-folder display is enabled so the new folder shows up immediately // The new folder is empty, so creating it turns empty-folder display back
// on to keep the folder visible in the tree.
expect(manager.showEmptyFolders).toBe(true); expect(manager.showEmptyFolders).toBe(true);
expect(getStorageItem('loras_showEmptyFolders')).toBe(true); expect(getStorageItem('loras_showEmptyFolders')).toBe(true);
expect(manager.expandedNodes.has('characters')).toBe(true); expect(manager.expandedNodes.has('characters')).toBe(true);
expect(manager.refresh).toHaveBeenCalledTimes(1); expect(manager.refresh).toHaveBeenCalledTimes(1);
}); });
it('re-enables empty folders when creating while the preference is off', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.showEmptyFolders = false;
manager.refresh = vi.fn().mockResolvedValue(undefined);
const success = await manager._createFolder('new-folder', '');
expect(success).toBe(true);
expect(manager.showEmptyFolders).toBe(true);
expect(getStorageItem('loras_showEmptyFolders')).toBe(true);
});
it('fails gracefully when no model root is configured', async () => { it('fails gracefully when no model root is configured', async () => {
const apiClient = createApiClient(); const apiClient = createApiClient();
apiClient.fetchModelRoots.mockResolvedValue({ roots: [] }); apiClient.fetchModelRoots.mockResolvedValue({ roots: [] });
@@ -332,22 +477,124 @@ describe('SidebarManager folder creation', () => {
expect(manager.refresh).not.toHaveBeenCalled(); expect(manager.refresh).not.toHaveBeenCalled();
}); });
it('opens the create-folder input for a context-menu folder', () => { it('opens the create-folder input as an inline row under the context-menu folder', () => {
const manager = createManager(createApiClient()); const manager = createManager(createApiClient());
document.body.innerHTML = '<div class="sidebar-tree-container"></div>'; document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.treeData = { characters: {} };
manager.renderTree();
manager._performFolderAction('create-subfolder', 'characters'); manager._performFolderAction('create-subfolder', 'characters');
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input'); const row = document.getElementById('sidebarCreateFolderInput');
expect(input).not.toBeNull(); expect(row).not.toBeNull();
expect(row.classList.contains('sidebar-create-folder-node')).toBe(true);
expect(manager._createFolderBasePath).toBe('characters'); expect(manager._createFolderBasePath).toBe('characters');
// The leaf parent is expanded and the row sits inside its children container
expect(manager.expandedNodes.has('characters')).toBe(true);
const parentNode = document.querySelector('.sidebar-tree-node[data-path="characters"]');
expect(parentNode.querySelector(':scope > .sidebar-tree-children').contains(row)).toBe(true);
});
it('inserts the row as the first child of an already-expanded parent', () => {
const manager = createManager(createApiClient());
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.treeData = { characters: { anime: {} } };
manager.expandedNodes = new Set(['characters']);
manager.renderTree();
manager.showCreateFolderInput('characters');
const children = document.querySelector('.sidebar-tree-node[data-path="characters"] > .sidebar-tree-children');
expect(children.firstElementChild.id).toBe('sidebarCreateFolderInput');
// Existing children container is reused, no temporary one is tracked
expect(manager._createFolderTempChildren).toBeNull();
});
it('appends the row at the top level for root creation', () => {
const manager = createManager(createApiClient());
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.treeData = { characters: {} };
manager.renderTree();
manager.showCreateFolderInput('');
const folderTree = document.getElementById('sidebarFolderTree');
const row = document.getElementById('sidebarCreateFolderInput');
expect(row.parentElement).toBe(folderTree);
expect(folderTree.lastElementChild).toBe(row);
});
it('inserts the row after the parent item in list mode', () => {
const manager = createManager(createApiClient(), { displayMode: 'list' });
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.foldersList = ['characters', 'characters/anime'];
manager.renderFolderList();
manager.showCreateFolderInput('characters');
const items = [...document.querySelectorAll('#sidebarFolderTree > div')];
const parentIndex = items.findIndex(el => el.dataset.path === 'characters');
expect(items[parentIndex + 1].id).toBe('sidebarCreateFolderInput');
// List-mode rows use the list content styling, not the tree one
expect(items[parentIndex + 1].querySelector('.sidebar-node-content')).not.toBeNull();
});
it('removes the temporary children container when creation is canceled', () => {
const manager = createManager(createApiClient());
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.treeData = { characters: {} };
manager.renderTree();
manager.showCreateFolderInput('characters');
manager.handleCreateFolderCancel();
expect(document.getElementById('sidebarCreateFolderInput')).toBeNull();
expect(manager.isCreatingFolder).toBe(false);
const parentNode = document.querySelector('.sidebar-tree-node[data-path="characters"]');
expect(parentNode.querySelector(':scope > .sidebar-tree-children')).toBeNull();
});
it('cancels creation when the input loses focus', () => {
vi.useFakeTimers();
try {
const manager = createManager(createApiClient());
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.showCreateFolderInput('');
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input');
input.dispatchEvent(new Event('blur'));
vi.advanceTimersByTime(150);
expect(document.getElementById('sidebarCreateFolderInput')).toBeNull();
expect(manager.isCreatingFolder).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('ignores tree clicks and context menus on the create row', () => {
const manager = createManager(createApiClient());
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.treeData = { characters: {} };
manager.renderTree();
manager.selectFolder = vi.fn();
const showMenu = vi.spyOn(manager, '_showFolderContextMenu').mockImplementation(() => {});
manager.showCreateFolderInput('characters');
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input');
manager.handleTreeClick({ target: input });
expect(manager.selectFolder).not.toHaveBeenCalled();
manager.handleTreeContextMenu({ target: input, preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(showMenu).not.toHaveBeenCalled();
}); });
it('submits a standalone folder creation when no drag is pending', async () => { it('submits a standalone folder creation when no drag is pending', async () => {
const apiClient = createApiClient(); const apiClient = createApiClient();
const manager = createManager(apiClient); const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined); manager.refresh = vi.fn().mockResolvedValue(undefined);
document.body.innerHTML = '<div class="sidebar-tree-container"></div>'; document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.showCreateFolderInput('characters'); manager.showCreateFolderInput('characters');
document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input').value = 'anime'; document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input').value = 'anime';
@@ -358,3 +605,440 @@ describe('SidebarManager folder creation', () => {
expect(document.getElementById('sidebarCreateFolderInput')).toBeNull(); expect(document.getElementById('sidebarCreateFolderInput')).toBeNull();
}); });
}); });
describe('SidebarManager folder deletion', () => {
const MODAL_HTML = `
<div id="deleteFolderModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<h2 data-role="title"></h2>
<p class="delete-message" data-role="message"></p>
<div class="delete-model-info" data-role="info"></div>
<div class="modal-actions">
<button class="cancel-btn" data-action="cancel-delete-folder">Cancel</button>
<button class="delete-btn" data-action="confirm-delete-folder">Delete folder</button>
</div>
</div>
</div>`;
function confirmBtn() {
return document.querySelector('#deleteFolderModal [data-action="confirm-delete-folder"]');
}
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = MODAL_HTML;
state.global.settings = {};
vi.clearAllMocks();
});
it('opens the confirm state for a folder whose subtree holds no models', () => {
const manager = createManager(createApiClient());
manager.nonEmptyFolders = new Set(['', 'full']);
manager.showDeleteFolderModal('empty');
const modal = document.getElementById('deleteFolderModal');
expect(modal.dataset.state).toBe('confirm');
expect(confirmBtn().style.display).toBe('');
expect(manager._pendingDeleteFolderPath).toBe('empty');
expect(modalManager.showModal).toHaveBeenCalledWith('deleteFolderModal');
});
it('explains the refusal when the subtree still holds models', () => {
const manager = createManager(createApiClient());
manager.nonEmptyFolders = new Set(['', 'full']);
manager.showDeleteFolderModal('full');
const modal = document.getElementById('deleteFolderModal');
expect(modal.dataset.state).toBe('blocked');
expect(confirmBtn().style.display).toBe('none');
expect(manager._pendingDeleteFolderPath).toBeNull();
});
it('treats an unknown folder as model-free when the models-only set is missing', () => {
// nonEmptyFolders is null outside the include-empty tree; the server still
// refuses a non-empty folder, so the client falls back to the confirm state.
const manager = createManager(createApiClient());
manager.nonEmptyFolders = null;
manager.showDeleteFolderModal('empty');
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('confirm');
});
it('deletes the folder and offers the undo affordance for an empty one', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
const success = await manager._deleteFolder('empty');
expect(success).toBe(true);
expect(apiClient.deleteFolder).toHaveBeenCalledWith('/models/loras/empty');
expect(manager.refresh).toHaveBeenCalledTimes(1);
expect(showActionToast).toHaveBeenCalledTimes(1);
expect(showToast).not.toHaveBeenCalled();
});
it('restores a deleted empty folder through the create-folder API', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
await manager._deleteFolder('empty');
const undo = showActionToast.mock.calls[0][3].onAction;
await undo();
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/empty');
expect(showToast).toHaveBeenCalledWith('sidebar.deleteFolderResult.restored', {}, 'success');
});
it('skips the undo affordance when non-model leftovers were removed', async () => {
const apiClient = createApiClient({
deleteFolder: vi.fn().mockResolvedValue({
success: true,
folder: 'empty',
file_count: 2,
dir_count: 1,
restorable: false,
}),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
await manager._deleteFolder('empty');
expect(showActionToast).not.toHaveBeenCalled();
expect(showToast).toHaveBeenCalledWith(
'sidebar.deleteFolderResult.successWithFiles',
{ name: 'empty', count: 3 },
'success'
);
});
it('surfaces the not_empty conflict when the tree was stale', async () => {
const conflict = Object.assign(new Error('still contains models'), { code: 'not_empty' });
const apiClient = createApiClient({
deleteFolder: vi.fn().mockRejectedValue(conflict),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
const success = await manager._deleteFolder('full');
expect(success).toBe(false);
expect(showToast).toHaveBeenCalledWith('sidebar.deleteFolderResult.notEmpty', {}, 'warning');
expect(manager.refresh).not.toHaveBeenCalled();
});
it('surfaces a busy folder with a staged delete', async () => {
const busy = Object.assign(new Error('staged delete pending'), { code: 'busy' });
const apiClient = createApiClient({
deleteFolder: vi.fn().mockRejectedValue(busy),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
await manager._deleteFolder('full');
expect(showToast).toHaveBeenCalledWith('sidebar.deleteFolderResult.busy', {}, 'warning');
});
it('drops the removed subtree from the persisted expand state', () => {
const manager = createManager(createApiClient());
manager.expandedNodes = new Set(['empty', 'empty/deep', 'other']);
manager.saveExpandedState = vi.fn();
manager._forgetRemovedFolder('empty');
expect([...manager.expandedNodes]).toEqual(['other']);
expect(manager.saveExpandedState).toHaveBeenCalledTimes(1);
});
it('leaves the expand state untouched when nothing matched', () => {
const manager = createManager(createApiClient());
manager.expandedNodes = new Set(['other']);
manager.saveExpandedState = vi.fn();
manager._forgetRemovedFolder('empty');
expect([...manager.expandedNodes]).toEqual(['other']);
expect(manager.saveExpandedState).not.toHaveBeenCalled();
});
it('routes the modal buttons to cancel and confirm', () => {
const manager = createManager(createApiClient());
manager._deleteFolder = vi.fn().mockResolvedValue(true);
manager._pendingDeleteFolderPath = 'empty';
manager._wireDeleteFolderModal();
confirmBtn().dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(modalManager.closeModal).toHaveBeenCalledWith('deleteFolderModal');
expect(manager._deleteFolder).toHaveBeenCalledWith('empty');
});
it('routes the context-menu action to the delete modal', () => {
const manager = createManager(createApiClient());
manager.showDeleteFolderModal = vi.fn();
manager._performFolderAction('delete-folder', 'empty');
expect(manager.showDeleteFolderModal).toHaveBeenCalledWith('empty');
});
it('hides the delete entry when folder management is unsupported', () => {
document.body.insertAdjacentHTML('beforeend', `
<div id="sidebarFolderContextMenu" class="context-menu">
<div class="context-menu-item" data-action="create-subfolder"></div>
<div class="context-menu-item delete-item" data-action="delete-folder"></div>
</div>`);
const apiClient = createApiClient();
apiClient.apiConfig.config.supportsFolderManagement = false;
const manager = createManager(apiClient);
manager._showFolderContextMenu(10, 10, 'empty');
const item = document.querySelector('#sidebarFolderContextMenu [data-action="delete-folder"]');
expect(item.style.display).toBe('none');
manager._closeFolderContextMenu();
});
});
describe('SidebarManager folder rename', () => {
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
state.global.settings = {};
vi.clearAllMocks();
});
function renameInput() {
return document.querySelector('#sidebarRenameFolderInput .sidebar-rename-folder-input');
}
it('turns the node into a prefilled inline row in tree mode', () => {
const manager = createManager(createApiClient());
manager.treeData = { characters: { anime: {} } };
manager.renderTree();
manager.showRenameFolderInput('characters/anime');
const row = document.getElementById('sidebarRenameFolderInput');
expect(row).not.toBeNull();
expect(renameInput().value).toBe('anime');
// The node is hidden in place, not removed: the row sits right before it
const node = document.querySelector('.sidebar-tree-node[data-path="characters/anime"]');
expect(node.style.display).toBe('none');
expect(row.nextElementSibling).toBe(node);
expect(manager._renameFolderPath).toBe('characters/anime');
});
it('inserts the row in place in list mode', () => {
const manager = createManager(createApiClient(), { displayMode: 'list' });
manager.foldersList = ['characters', 'characters/anime'];
manager.renderFolderList();
manager.showRenameFolderInput('characters/anime');
const row = document.getElementById('sidebarRenameFolderInput');
expect(row.querySelector('.sidebar-node-content')).not.toBeNull();
const item = document.querySelector('.sidebar-folder-item[data-path="characters/anime"]');
expect(row.nextElementSibling).toBe(item);
});
it('restores the node when the edit is canceled', () => {
const manager = createManager(createApiClient());
manager.treeData = { characters: { anime: {} } };
manager.renderTree();
manager.showRenameFolderInput('characters/anime');
manager.handleRenameFolderCancel();
expect(document.getElementById('sidebarRenameFolderInput')).toBeNull();
expect(manager._renameFolderPath).toBeNull();
const node = document.querySelector('.sidebar-tree-node[data-path="characters/anime"]');
expect(node.style.display).toBe('');
});
it('renames through the API and re-keys the persisted selection', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
manager.selectedPath = 'characters/anime';
manager.expandedNodes = new Set(['characters', 'characters/anime']);
manager.pageControls = { pageState: { activeFolder: 'characters/anime' } };
const success = await manager._renameFolder('characters/anime', 'animation');
expect(success).toBe(true);
expect(apiClient.renameFolder).toHaveBeenCalledWith('/models/loras/characters/anime', 'animation');
expect(manager.selectedPath).toBe('renamed');
expect(manager.pageControls.pageState.activeFolder).toBe('renamed');
expect(getStorageItem('loras_activeFolder')).toBe('renamed');
expect(manager.refresh).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith(
'sidebar.renameFolderResult.success', { name: 'animation' }, 'success'
);
});
it('re-keys the expanded subtree and the selection', () => {
const manager = createManager(createApiClient());
manager.expandedNodes = new Set(['a', 'a/b', 'a/b/c', 'x']);
manager.selectedPath = 'a/b/c';
manager.saveExpandedState = vi.fn();
manager._rekeyFolderPath('a/b', 'a/z');
expect([...manager.expandedNodes]).toEqual(['a', 'a/z', 'a/z/c', 'x']);
expect(manager.selectedPath).toBe('a/z/c');
expect(manager.saveExpandedState).toHaveBeenCalledTimes(1);
});
it('submits the inline edit and skips the API for an unchanged name', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
manager.treeData = { characters: { anime: {} } };
manager.renderTree();
manager.showRenameFolderInput('characters/anime');
renameInput().value = 'anime';
await manager.handleRenameFolderSubmit();
expect(apiClient.renameFolder).not.toHaveBeenCalled();
expect(document.getElementById('sidebarRenameFolderInput')).toBeNull();
});
it('rejects invalid names before calling the API', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.treeData = { characters: { anime: {} } };
manager.renderTree();
manager.showRenameFolderInput('characters/anime');
renameInput().value = 'bad/name';
await manager.handleRenameFolderSubmit();
expect(apiClient.renameFolder).not.toHaveBeenCalled();
expect(showToast).toHaveBeenCalledWith('sidebar.dragDrop.invalidFolderName', {}, 'error');
// The row stays open so the name can be corrected
expect(document.getElementById('sidebarRenameFolderInput')).not.toBeNull();
});
it('surfaces a name collision', async () => {
const conflict = Object.assign(new Error('already exists'), { code: 'target_exists' });
const apiClient = createApiClient({
renameFolder: vi.fn().mockRejectedValue(conflict),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
const success = await manager._renameFolder('characters/anime', 'animation');
expect(success).toBe(false);
expect(showToast).toHaveBeenCalledWith('sidebar.renameFolderResult.targetExists', {}, 'warning');
expect(manager.refresh).not.toHaveBeenCalled();
});
it('routes the context-menu action to the inline rename row', () => {
const manager = createManager(createApiClient());
manager.showRenameFolderInput = vi.fn();
manager._performFolderAction('rename-folder', 'characters/anime');
expect(manager.showRenameFolderInput).toHaveBeenCalledWith('characters/anime');
});
it('hides the rename entry when folder management is unsupported', () => {
document.body.insertAdjacentHTML('beforeend', `
<div id="sidebarFolderContextMenu" class="context-menu">
<div class="context-menu-item" data-action="rename-folder"></div>
</div>`);
const apiClient = createApiClient();
apiClient.apiConfig.config.supportsFolderManagement = false;
const manager = createManager(apiClient);
manager._showFolderContextMenu(10, 10, 'empty');
const item = document.querySelector('#sidebarFolderContextMenu [data-action="rename-folder"]');
expect(item.style.display).toBe('none');
manager._closeFolderContextMenu();
});
});
describe('SidebarManager folder context menu layout', () => {
// Mirrors templates/components/context_menu.html: the update check on top,
// the folder operations as one group, delete last behind its own divider.
const MENU_HTML = `
<div id="sidebarFolderContextMenu" class="context-menu">
<div class="context-menu-item" data-action="check-folder-updates"></div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="create-subfolder"></div>
<div class="context-menu-item" data-action="rename-folder"></div>
<div class="context-menu-separator"></div>
<div class="context-menu-item delete-item" data-action="delete-folder"></div>
</div>`;
const separators = () => [...document.querySelectorAll('#sidebarFolderContextMenu .context-menu-separator')];
const item = (action) => document.querySelector(`#sidebarFolderContextMenu [data-action="${action}"]`);
const visible = (el) => el.style.display !== 'none';
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = MENU_HTML;
state.global.settings = {};
vi.clearAllMocks();
});
it('keeps both dividers on a library page', () => {
const manager = createManager(createApiClient());
manager._showFolderContextMenu(10, 10, 'empty');
expect(separators().map(visible)).toEqual([true, true]);
expect(visible(item('create-subfolder'))).toBe(true);
expect(visible(item('rename-folder'))).toBe(true);
expect(visible(item('delete-folder'))).toBe(true);
manager._closeFolderContextMenu();
});
it('collapses both dividers when the page has no folder management', () => {
const apiClient = createApiClient();
apiClient.apiConfig.config.supportsFolderManagement = false;
const manager = createManager(apiClient);
manager._showFolderContextMenu(10, 10, 'empty');
expect(visible(item('check-folder-updates'))).toBe(true);
expect(visible(item('create-subfolder'))).toBe(false);
expect(visible(item('rename-folder'))).toBe(false);
expect(visible(item('delete-folder'))).toBe(false);
// Nothing left to divide: the update check stands alone
expect(separators().map(visible)).toEqual([false, false]);
manager._closeFolderContextMenu();
});
it('drops leading, trailing and doubled separators', () => {
document.body.innerHTML = `
<div id="sidebarFolderContextMenu" class="context-menu">
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="a"></div>
<div class="context-menu-separator"></div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="b"></div>
<div class="context-menu-separator"></div>
</div>`;
const manager = createManager(createApiClient());
manager._updateContextMenuSeparators(document.getElementById('sidebarFolderContextMenu'));
expect(separators().map(visible)).toEqual([false, true, false, false]);
});
});
@@ -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);
});
});
@@ -266,4 +266,101 @@ describe('DownloadManager external model source downloads', () => {
expect(manager._externalGroupKey(ms)).toBe('modelscope:u/r'); expect(manager._externalGroupKey(ms)).toBe('modelscope:u/r');
expect(manager._externalGroupKey(hf)).not.toBe(manager._externalGroupKey(ms)); 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');
});
});
@@ -26,6 +26,7 @@ describe('modelSourceHelpers', () => {
expect(MODEL_SOURCES.map((s) => s.platform)).toEqual([ expect(MODEL_SOURCES.map((s) => s.platform)).toEqual([
'huggingface', 'huggingface',
'modelscope', 'modelscope',
'modelscope-ai',
'tensorart', 'tensorart',
]); ]);
}); });
@@ -44,6 +45,16 @@ describe('modelSourceHelpers', () => {
expect(info.url).toBe('https://modelscope.cn/models/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', () => { it('recognises TensorArt URLs and keeps only the numeric id', () => {
const info = parseModelSourceUrl( const info = parseModelSourceUrl(
'https://tensor.art/models/827823520299086029/Vivid-Impressions-Storybook-Sstyle-V1.0' 'https://tensor.art/models/827823520299086029/Vivid-Impressions-Storybook-Sstyle-V1.0'
@@ -174,6 +174,54 @@ describe('DownloadManager.detectUrlType — external model source URLs', () => {
expect(result.platform).toBe('huggingface'); 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', () => { it('rejects path traversal in either platform', () => {
expect( expect(
DownloadManager.detectUrlType('https://modelscope.cn/models/../etc/passwd') DownloadManager.detectUrlType('https://modelscope.cn/models/../etc/passwd')
+240
View File
@@ -10,11 +10,23 @@ class FakeMoveService:
def __init__(self, result): def __init__(self, result):
self._result = result self._result = result
self.received_path = None self.received_path = None
self.received_dry_run = None
self.received_new_name = None
async def create_folder(self, folder_path): async def create_folder(self, folder_path):
self.received_path = folder_path self.received_path = folder_path
return self._result 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: class FakeRequest:
def __init__(self, payload): def __init__(self, payload):
@@ -91,3 +103,231 @@ async def test_create_folder_invalid_json_body():
assert response.status == 400 assert response.status == 400
payload = json.loads(response.text) payload = json.loads(response.text)
assert payload["success"] is False 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
+521 -6
View File
@@ -307,7 +307,12 @@ async def test_get_model_sources_lists_capabilities():
sources = _json_payload(response) sources = _json_payload(response)
by_platform = {s["platform"]: s for s in sources} by_platform = {s["platform"]: s for s in sources}
assert set(by_platform) == {"huggingface", "modelscope", "tensorart"} assert set(by_platform) == {
"huggingface",
"modelscope",
"modelscope-ai",
"tensorart",
}
assert by_platform["huggingface"]["supports_enrichment"] is True assert by_platform["huggingface"]["supports_enrichment"] is True
assert by_platform["modelscope"]["supports_enrichment"] is True assert by_platform["modelscope"]["supports_enrichment"] is True
# TensorArt is link-only: no accessible model card for the backend. # TensorArt is link-only: no accessible model card for the backend.
@@ -315,6 +320,12 @@ async def test_get_model_sources_lists_capabilities():
assert by_platform["modelscope"]["supports_download"] is True assert by_platform["modelscope"]["supports_download"] is True
assert by_platform["modelscope"]["default_revision"] == "master" assert by_platform["modelscope"]["default_revision"] == "master"
assert by_platform["tensorart"]["supports_download"] is False assert by_platform["tensorart"]["supports_download"] is False
# The international deployment is advertised with its own example URL, so
# the Link dialog names the host a user actually has open.
assert by_platform["modelscope-ai"]["supports_download"] is True
assert by_platform["modelscope-ai"]["example_url"].startswith(
"https://www.modelscope.ai/"
)
assert all(s["example_url"] for s in sources) assert all(s["example_url"] for s in sources)
@@ -492,6 +503,44 @@ async def test_download_model_source_modelscope_default_paths(tmp_path, monkeypa
) )
@pytest.mark.asyncio
async def test_download_model_source_modelscope_intl_uses_its_own_host(
tmp_path, monkeypatch
):
"""`.ai` is a separate catalogue, so the download must not go to `.cn`."""
captured = _stub_download_backend(monkeypatch)
saved = AsyncMock()
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", saved)
response = await ModelSourceHandler().download_model_source(
FakeRequest(
json_data={
"platform": "modelscope-ai",
"repo": "referall13/EM1",
"filename": "EM1_c1-st1000.safetensors",
"model_root": str(tmp_path),
"use_default_paths": True,
}
)
)
assert response.status == 200
assert captured["url"] == (
"https://www.modelscope.ai/models/referall13/EM1/resolve/master/"
"EM1_c1-st1000.safetensors"
)
# Its own default directory, so the same owner/name on both deployments
# cannot overwrite each other.
assert captured["save_path"] == str(
tmp_path / "modelscope-ai" / "referall13" / "EM1" / "EM1_c1-st1000.safetensors"
)
ref = saved.await_args.args[1]
assert ref.platform == "modelscope-ai"
assert ref.source_id == "referall13/EM1"
assert ref.url == "https://www.modelscope.ai/models/referall13/EM1"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_model_source_defaults_to_huggingface(tmp_path, monkeypatch): async def test_download_model_source_defaults_to_huggingface(tmp_path, monkeypatch):
"""The legacy /api/lm/download-hf-model payload has no `platform` key.""" """The legacy /api/lm/download-hf-model payload has no `platform` key."""
@@ -609,18 +658,19 @@ async def test_save_source_metadata_writes_platform_fields(
base_model="SDXL 1.0", base_model="SDXL 1.0",
preview_url="", preview_url="",
) )
monkeypatch.setattr( scanner = SimpleNamespace(
model_source_handlers.MetadataManager, # A real scanner owns metadata creation (see the lazy-hash test below).
"create_default_metadata", _create_default_metadata=AsyncMock(return_value=metadata),
AsyncMock(return_value=metadata), add_model_to_cache=AsyncMock(),
) )
scanner = SimpleNamespace(add_model_to_cache=AsyncMock())
monkeypatch.setattr( monkeypatch.setattr(
ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=scanner) ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=scanner)
) )
monkeypatch.setattr( monkeypatch.setattr(
model_source_handlers, "_infer_model_type", lambda _root: (LoraMetadata, "get_lora_scanner") model_source_handlers, "_infer_model_type", lambda _root: (LoraMetadata, "get_lora_scanner")
) )
hydrate = AsyncMock()
monkeypatch.setattr(model_source_handlers, "hydrate_from_source", hydrate)
ref = SourceRef(platform=platform, source_id="u/r", url=url) ref = SourceRef(platform=platform, source_id="u/r", url=url)
await model_source_handlers._save_source_metadata(str(model_path), ref, str(tmp_path)) await model_source_handlers._save_source_metadata(str(model_path), ref, str(tmp_path))
@@ -630,6 +680,471 @@ async def test_save_source_metadata_writes_platform_fields(
assert saved["source_url"] == url assert saved["source_url"] == url
assert bool(saved.get("hf_url", "")) is expect_hf_alias assert bool(saved.get("hf_url", "")) is expect_hf_alias
assert scanner._create_default_metadata.await_args.args == (str(model_path),)
cached = scanner.add_model_to_cache.await_args.args[0] cached = scanner.add_model_to_cache.await_args.args[0]
assert cached["source_platform"] == platform assert cached["source_platform"] == platform
assert cached["source_url"] == url assert cached["source_url"] == url
# The site's own API is consulted last, so the scanner-cache refresh it
# performs lands on the entry created above.
assert hydrate.await_args.args == (str(model_path),)
assert hydrate.await_args.kwargs["ref"] == ref
@pytest.mark.asyncio
async def test_checkpoint_download_defers_the_hash(tmp_path, monkeypatch):
"""A multi-GB checkpoint must not be hashed inside the download request.
``CheckpointScanner`` records ``hash_status="pending"`` and lets the hash be
computed on demand; going through the generic
``MetadataManager.create_default_metadata`` would read the whole file before
the download response could return, which is exactly the pause this code
path is supposed to avoid.
"""
from py.services.checkpoint_scanner import CheckpointScanner
from py.utils.models import CheckpointMetadata
model_path = tmp_path / "big_checkpoint.safetensors"
model_path.write_bytes(b"stub")
real_scanner = CheckpointScanner()
scanner = SimpleNamespace(
_create_default_metadata=real_scanner._create_default_metadata,
add_model_to_cache=AsyncMock(),
)
monkeypatch.setattr(
ServiceRegistry, "get_checkpoint_scanner", AsyncMock(return_value=scanner)
)
monkeypatch.setattr(
model_source_handlers,
"_infer_model_type",
lambda _root: (CheckpointMetadata, "get_checkpoint_scanner"),
)
generic = AsyncMock(
# Stands in for the eager helper: if the handler reaches for it, the
# sidecar ends up hashed and the assertions below say so plainly.
return_value=LoraMetadata(
file_name="big_checkpoint",
model_name="big_checkpoint",
file_path=str(model_path),
size=4,
modified=1.0,
sha256="d" * 64,
base_model="Unknown",
preview_url="",
)
)
monkeypatch.setattr(
model_source_handlers.MetadataManager, "create_default_metadata", generic
)
monkeypatch.setattr(model_source_handlers, "hydrate_from_source", AsyncMock())
ref = SourceRef(
platform="huggingface",
source_id="u/r",
url="https://huggingface.co/u/r",
)
await model_source_handlers._save_source_metadata(str(model_path), ref, str(tmp_path))
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
assert saved["sha256"] == ""
assert saved["hash_status"] == "pending"
assert saved["from_civitai"] is False
# The download link is still recorded on top of the deferred hash.
assert saved["source_platform"] == "huggingface"
assert saved["source_url"] == "https://huggingface.co/u/r"
# The scanner cache must carry the pending state too, or the cache fill
# would compute the hash after all.
cached = scanner.add_model_to_cache.await_args.args[0]
assert cached["hash_status"] == "pending"
assert cached["sha256"] == ""
generic.assert_not_awaited()
# ---------------------------------------------------------------------------
# Post-transfer phase reporting
# ---------------------------------------------------------------------------
def _stub_hydration_pipeline(tmp_path, monkeypatch):
"""Wire `_save_source_metadata`'s collaborators and record call order."""
model_path = tmp_path / "downloaded.safetensors"
model_path.write_bytes(b"x" * 32)
metadata = LoraMetadata(
file_name="downloaded",
model_name="Downloaded",
file_path=str(model_path),
size=32,
modified=1.0,
sha256="a" * 64,
base_model="SDXL 1.0",
preview_url="",
)
monkeypatch.setattr(
model_source_handlers.MetadataManager,
"create_default_metadata",
AsyncMock(return_value=metadata),
)
monkeypatch.setattr(
ServiceRegistry,
"get_lora_scanner",
AsyncMock(return_value=SimpleNamespace(add_model_to_cache=AsyncMock())),
)
monkeypatch.setattr(
model_source_handlers,
"_infer_model_type",
lambda _root: (LoraMetadata, "get_lora_scanner"),
)
events: list = []
async def fake_broadcast(download_id, data):
events.append(("broadcast", data["stage"], data, download_id))
async def fake_hydrate(*_args, **_kwargs):
events.append(("hydrate", None, None, None))
monkeypatch.setattr(
model_source_handlers.ws_manager, "broadcast_download_progress", fake_broadcast
)
monkeypatch.setattr(model_source_handlers, "hydrate_from_source", fake_hydrate)
return model_path, events
@pytest.mark.asyncio
async def test_save_source_metadata_reports_post_transfer_stages(tmp_path, monkeypatch):
"""The byte counter stops before indexing and the site fetch, so the UI has
to be told what is still running otherwise the bar looks stuck."""
model_path, events = _stub_hydration_pipeline(tmp_path, monkeypatch)
ref = SourceRef(
platform="modelscope", source_id="u/r", url="https://modelscope.cn/models/u/r"
)
await model_source_handlers._save_source_metadata(
str(model_path), ref, str(tmp_path), download_id="dl-1"
)
# Each stage is announced *before* its work starts, so the label is never
# describing something that already finished.
assert [event[:2] for event in events] == [
("broadcast", "indexing"),
("broadcast", "source"),
("hydrate", None),
]
for kind, stage, data, download_id in events:
if kind != "broadcast":
continue
assert download_id == "dl-1"
assert data["status"] == "metadata"
assert data["progress"] == 100
assert data["platform"] == "modelscope"
@pytest.mark.asyncio
async def test_save_source_metadata_is_silent_without_a_watcher(tmp_path, monkeypatch):
"""No `download_id` means no UI is watching; nothing should be broadcast."""
model_path, events = _stub_hydration_pipeline(tmp_path, monkeypatch)
ref = SourceRef(
platform="modelscope", source_id="u/r", url="https://modelscope.cn/models/u/r"
)
await model_source_handlers._save_source_metadata(str(model_path), ref, str(tmp_path))
assert events == [("hydrate", None, None, None)]
@pytest.mark.asyncio
async def test_report_phase_never_breaks_a_download(monkeypatch):
"""Progress reporting is cosmetic; a dead socket must not fail the file."""
monkeypatch.setattr(
model_source_handlers.ws_manager,
"broadcast_download_progress",
AsyncMock(side_effect=RuntimeError("socket gone")),
)
await model_source_handlers._report_phase("dl-1", "source", "modelscope")
@pytest.mark.asyncio
async def test_download_passes_its_watch_id_into_metadata_work(tmp_path, monkeypatch):
"""The stages are only visible if the handler hands its id down."""
_stub_download_backend(monkeypatch)
saved = AsyncMock()
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", saved)
await ModelSourceHandler().download_model_source(
FakeRequest(
json_data={
"platform": "modelscope",
"repo": "owner/name",
"filename": "model.safetensors",
"model_root": str(tmp_path),
"download_id": "dl-42",
}
)
)
assert saved.await_args.kwargs["download_id"] == "dl-42"
@pytest.mark.asyncio
async def test_skipped_download_still_reports_the_site_stage(tmp_path, monkeypatch):
"""An already-present file is hydrated too, so it needs the same signal."""
_stub_download_backend(monkeypatch)
hydrate = AsyncMock()
monkeypatch.setattr(model_source_handlers, "hydrate_from_source", hydrate)
broadcast = AsyncMock()
monkeypatch.setattr(
model_source_handlers.ws_manager, "broadcast_download_progress", broadcast
)
existing = tmp_path / "model.safetensors"
existing.write_bytes(b"x" * 32)
await ModelSourceHandler().download_model_source(
FakeRequest(
json_data={
"platform": "modelscope",
"repo": "owner/name",
"filename": "model.safetensors",
"model_root": str(tmp_path),
"download_id": "dl-7",
}
)
)
assert broadcast.await_args.args[1]["stage"] == "source"
@pytest.mark.asyncio
async def test_save_source_metadata_survives_a_hydration_failure(tmp_path, monkeypatch):
"""Metadata hydration must never be able to fail a completed download."""
model_path = tmp_path / "downloaded.safetensors"
model_path.write_bytes(b"x" * 32)
metadata = LoraMetadata(
file_name="downloaded",
model_name="Downloaded",
file_path=str(model_path),
size=32,
modified=1.0,
sha256="a" * 64,
base_model="SDXL 1.0",
preview_url="",
)
monkeypatch.setattr(
model_source_handlers.MetadataManager,
"create_default_metadata",
AsyncMock(return_value=metadata),
)
monkeypatch.setattr(
ServiceRegistry,
"get_lora_scanner",
AsyncMock(return_value=SimpleNamespace(add_model_to_cache=AsyncMock())),
)
monkeypatch.setattr(
model_source_handlers, "_infer_model_type", lambda _root: (LoraMetadata, "get_lora_scanner")
)
monkeypatch.setattr(
model_source_handlers,
"hydrate_from_source",
AsyncMock(side_effect=RuntimeError("site down")),
)
ref = SourceRef(
platform="modelscope", source_id="u/r", url="https://modelscope.cn/models/u/r"
)
await model_source_handlers._save_source_metadata(str(model_path), ref, str(tmp_path))
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
assert saved["source_platform"] == "modelscope"
@pytest.mark.asyncio
async def test_downloading_an_existing_file_still_hydrates(tmp_path, monkeypatch):
"""A pre-existing file may still be missing the site's metadata."""
_stub_download_backend(monkeypatch)
saved = AsyncMock()
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", saved)
hydrate = AsyncMock()
monkeypatch.setattr(model_source_handlers, "hydrate_from_source", hydrate)
existing = tmp_path / "model.safetensors"
existing.write_bytes(b"x" * 32)
response = await ModelSourceHandler().download_model_source(
FakeRequest(
json_data={
"platform": "modelscope",
"repo": "owner/name",
"filename": "model.safetensors",
"model_root": str(tmp_path),
}
)
)
assert response.status == 200
saved.assert_not_awaited()
assert hydrate.await_args.args == (str(existing),)
assert hydrate.await_args.kwargs["ref"].source_id == "owner/name"
# ---------------------------------------------------------------------------
# Download-time metadata hydration (end to end)
# ---------------------------------------------------------------------------
def _modelscope_card_payload() -> dict:
"""A trimmed ModelScope model-detail response for the hydration test."""
return {
"Code": 200,
"Data": {
"Name": "Krea-2-LORA",
"ChineseName": "krea脸模",
"AigcType": "LoRA",
"Description": "权重0.5-1.2。配合《风格滤镜》lora一起使用。",
"BaseModel": ["krea/Krea-2-Turbo"],
"OfficialTags": [{"Tag": "photography"}, {"Tag": "woman"}],
"ModelInfos": {
"safetensor": {
"files": [
{
"name": "Krea-2-LORA_c1-st1000.safetensors",
"sha256": "a" * 64,
}
]
}
},
"MuseInfo": {
"versions": [
{
"stats": {"fileList": ["Krea-2-LORA_c1-st1000.safetensors"]},
"modelVersion": {
"showName": "c1-st1000",
"triggerWords": '["kreaface","kreamodel"]',
},
"coverImages": [
{"url": "https://resources.modelscope.cn/cover-images/b.png"},
{"url": "https://resources.modelscope.cn/cover-images/c.png"},
],
}
]
},
},
}
@pytest.mark.asyncio
async def test_download_hydrates_the_card_from_the_site(tmp_path, monkeypatch):
"""A ModelScope download must land with a populated model card.
Only the network, the scanner and the file transfer are faked, so this
exercises the real handler, the real `ModelScopeSource` and the real
post-processor together. Breaking the wiring between them fails here even
when each half still passes its own unit tests.
"""
model_path = tmp_path / "Krea-2-LORA_c1-st1000.safetensors"
async def fake_download_file(**kwargs):
with open(kwargs["save_path"], "wb") as handle:
handle.write(b"stub")
return True, kwargs["save_path"]
class _Downloader:
download_file = staticmethod(fake_download_file)
class _Settings:
def get(self, key, default=None):
return default
async def fake_get_downloader():
return _Downloader()
monkeypatch.setattr(model_source_handlers, "get_downloader", fake_get_downloader)
monkeypatch.setattr(
model_source_handlers, "get_settings_manager", lambda: _Settings()
)
monkeypatch.setattr(
model_source_handlers,
"_infer_model_type",
lambda _root: (LoraMetadata, "get_lora_scanner"),
)
scanner = SimpleNamespace(
get_cached_data=AsyncMock(
return_value=SimpleNamespace(raw_data=[{"file_path": str(model_path)}])
),
add_model_to_cache=AsyncMock(),
update_single_model_cache=AsyncMock(),
)
monkeypatch.setattr(
ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=scanner)
)
async def fake_fetch_text(url, **_kwargs):
return "# Krea-2-LORA\n\n权重0.5-1.2。"
async def fake_fetch_json(url, **_kwargs):
return 200, _modelscope_card_payload()
monkeypatch.setattr(
"py.services.model_sources.modelscope.fetch_text", fake_fetch_text
)
monkeypatch.setattr(
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
)
monkeypatch.setattr(
"py.metadata_ops.list_base_models", AsyncMock(return_value=["Krea 2"])
)
monkeypatch.setattr(
"py.metadata_ops.download_preview",
AsyncMock(return_value=str(tmp_path / "preview.webp")),
)
response = await ModelSourceHandler().download_model_source(
FakeRequest(
json_data={
"platform": "modelscope",
"repo": "jj3550945163/Krea-2-LORA",
"filename": "Krea-2-LORA_c1-st1000.safetensors",
"model_root": str(tmp_path),
}
)
)
assert response.status == 200
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
# The download's own provenance is unchanged.
assert saved["source_platform"] == "modelscope"
assert saved["source_url"] == "https://modelscope.cn/models/jj3550945163/Krea-2-LORA"
assert saved["from_civitai"] is False
# The site's published metadata, with no LLM involved.
assert saved["model_name"] == "Krea-2-LORA"
assert saved["base_model"] == "Krea 2"
assert saved["tags"] == ["photography", "woman"]
assert saved["civitai"]["name"] == "c1-st1000"
assert saved["civitai"]["trainedWords"] == ["kreaface", "kreamodel"]
assert saved["civitai"]["description"] == "权重0.5-1.2。配合《风格滤镜》lora一起使用。"
assert [img["url"] for img in saved["civitai"]["images"]] == [
"https://resources.modelscope.cn/cover-images/b.png",
"https://resources.modelscope.cn/cover-images/c.png",
]
assert saved["preview_url"] == str(tmp_path / "preview.webp")
assert saved["usage_tips"] == (
'{"strength_min": 0.5, "strength_max": 1.2, "strength_range": "0.5-1.2"}'
)
assert saved["metadata_source"] == "source:modelscope"
# No provider answered, so claiming an AI enrichment would be a lie.
assert "llm_enriched_at" not in saved
# The enriched card reaches the scanner cache, not just the file.
assert scanner.update_single_model_cache.await_count == 1
cached = scanner.update_single_model_cache.await_args.args[2]
assert cached["model_name"] == "Krea-2-LORA"
+44 -1
View File
@@ -6,7 +6,7 @@ import pytest
from py.services.connectivity_guard import OFFLINE_COOLDOWN_ERROR, OFFLINE_FRIENDLY_MESSAGE from py.services.connectivity_guard import OFFLINE_COOLDOWN_ERROR, OFFLINE_FRIENDLY_MESSAGE
from py.services.errors import RateLimitError from py.services.errors import RateLimitError
from py.services.metadata_sync_service import MetadataSyncService from py.services.metadata_sync_service import MetadataSyncService, _merge_ordered_unique
class DummySettings: class DummySettings:
@@ -112,6 +112,49 @@ async def test_update_model_metadata_merges_and_persists():
) )
def test_merge_ordered_unique_keeps_first_seen_order():
assert _merge_ordered_unique(["b", "a"], ["a", "c", "b", "d"]) == [
"b",
"a",
"c",
"d",
]
assert _merge_ordered_unique([], ["x"]) == ["x"]
assert _merge_ordered_unique(["x"], []) == ["x"]
@pytest.mark.asyncio
async def test_update_model_metadata_preserves_trained_word_order():
"""Trigger word order (prompt order) must survive a metadata refresh."""
helpers = build_service()
local = {
"civitai": {"trainedWords": ["zeta style", "alpha", "beta"]},
"model_name": "Local",
}
remote = {
"source": "api",
"trainedWords": ["beta", "gamma", "alpha"],
"model": {"name": "Remote Model"},
}
result = await helpers.service.update_model_metadata(
"path/to/model.metadata.json",
local,
remote,
helpers.default_provider,
)
# Saved order first, newly discovered words appended, duplicates dropped
assert result["civitai"]["trainedWords"] == [
"zeta style",
"alpha",
"beta",
"gamma",
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_model_metadata_propagates_civitai_autov3(): async def test_update_model_metadata_propagates_civitai_autov3():
helpers = build_service() helpers = build_service()
+339
View File
@@ -14,6 +14,8 @@ class FakeScanner:
def __init__(self, roots: List[Path]) -> None: def __init__(self, roots: List[Path]) -> None:
self._roots = [str(root) for root in roots] self._roots = [str(root) for root in roots]
self.known_folders: List[str] = [] self.known_folders: List[str] = []
self.removed_folders: List[str] = []
self.renamed_folders: List[tuple] = []
def get_model_roots(self) -> List[str]: def get_model_roots(self) -> List[str]:
return list(self._roots) return list(self._roots)
@@ -21,6 +23,12 @@ class FakeScanner:
async def add_known_folder(self, folder: str) -> None: async def add_known_folder(self, folder: str) -> None:
self.known_folders.append(folder) self.known_folders.append(folder)
async def remove_known_folder(self, folder: str) -> None:
self.removed_folders.append(folder)
async def rename_known_folder(self, previous: str, current: str, **kwargs) -> None:
self.renamed_folders.append((previous, current, kwargs))
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_folder_creates_directory_and_registers_it(tmp_path: Path): async def test_create_folder_creates_directory_and_registers_it(tmp_path: Path):
@@ -87,3 +95,334 @@ async def test_create_folder_requires_path(tmp_path: Path):
result = await service.create_folder("") result = await service.create_folder("")
assert result["success"] is False assert result["success"] is False
def _make_nested(root: Path) -> Path:
target = root / "characters" / "anime"
target.mkdir(parents=True)
return target
@pytest.mark.asyncio
async def test_delete_folder_removes_empty_directory_and_forgets_it(tmp_path: Path):
target = _make_nested(tmp_path)
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is True
assert result["folder"] == "characters/anime"
assert result["model_count"] == 0
assert result["restorable"] is True
assert not target.exists()
assert scanner.removed_folders == ["characters/anime"]
@pytest.mark.asyncio
async def test_delete_folder_reports_non_model_leftovers_as_not_restorable(tmp_path: Path):
target = _make_nested(tmp_path)
(target / "notes.txt").write_text("keep me?", encoding="utf-8")
(target / "nested").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is True
assert result["file_count"] == 1
assert result["dir_count"] == 1
assert result["restorable"] is False
assert not target.exists()
@pytest.mark.asyncio
async def test_delete_folder_refuses_when_models_live_below(tmp_path: Path):
target = _make_nested(tmp_path)
model_file = target / "model.safetensors"
model_file.write_text("weights", encoding="utf-8")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is False
assert result["code"] == "not_empty"
assert result["manifest"]["model_count"] == 1
assert target.exists()
assert model_file.exists()
assert scanner.removed_folders == []
@pytest.mark.asyncio
async def test_delete_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is False
assert result["code"] == "busy"
assert target.exists()
@pytest.mark.asyncio
async def test_delete_folder_refuses_the_library_root_itself(tmp_path: Path):
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(tmp_path))
assert result["success"] is False
assert "root" in result["error"].lower()
assert tmp_path.exists()
@pytest.mark.asyncio
async def test_delete_folder_rejects_paths_outside_roots(tmp_path: Path):
root = tmp_path / "library"
root.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
scanner = FakeScanner([root])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(outside))
assert result["success"] is False
assert "error" in result
assert outside.exists()
assert scanner.removed_folders == []
@pytest.mark.asyncio
async def test_delete_folder_reports_missing_directory(tmp_path: Path):
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(tmp_path / "gone"))
assert result["success"] is False
assert "no longer exists" in result["error"]
@pytest.mark.asyncio
async def test_delete_folder_requires_path(tmp_path: Path):
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
result = await service.delete_folder("")
assert result["success"] is False
@pytest.mark.asyncio
async def test_delete_folder_dry_run_reports_without_removing(tmp_path: Path):
target = _make_nested(tmp_path)
(target / "leftover.webp").write_text("preview", encoding="utf-8")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target), dry_run=True)
assert result["success"] is True
assert result["dry_run"] is True
assert result["file_count"] == 1
assert target.exists()
assert scanner.removed_folders == []
@pytest.mark.asyncio
async def test_delete_folder_refuses_symlinked_directory(tmp_path: Path):
real = tmp_path / "real"
real.mkdir()
link = tmp_path / "link"
try:
link.symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError): # pragma: no cover - platform guard
pytest.skip("symlinks are not supported on this platform")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(link))
assert result["success"] is False
assert "symlink" in result["error"].lower()
assert link.is_symlink()
assert real.is_dir()
@pytest.mark.asyncio
async def test_delete_folder_counts_nested_symlinks_without_following_them(tmp_path: Path):
target = _make_nested(tmp_path)
real = tmp_path / "real"
real.mkdir()
(real / "model.safetensors").write_text("weights", encoding="utf-8")
try:
(target / "linked").symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError): # pragma: no cover - platform guard
pytest.skip("symlinks are not supported on this platform")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(target))
assert result["success"] is True
assert result["symlink_count"] == 1
# The linked model is not part of the subtree being deleted
assert result["model_count"] == 0
assert (real / "model.safetensors").exists()
@pytest.mark.asyncio
async def test_rename_folder_moves_directory_and_forwards_rekey(tmp_path: Path):
target = _make_nested(tmp_path)
(target / "model.safetensors").write_text("weights", encoding="utf-8")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(target), "animation")
renamed = tmp_path / "characters" / "animation"
assert result["success"] is True
assert result["renamed"] is True
assert result["folder"] == "characters/animation"
assert result["previous_folder"] == "characters/anime"
assert renamed.is_dir()
assert (renamed / "model.safetensors").exists()
assert not target.exists()
previous, current, kwargs = scanner.renamed_folders[0]
assert previous == "characters/anime"
assert current == "characters/animation"
assert kwargs["previous_path"] == target.as_posix()
assert kwargs["new_path"] == renamed.as_posix()
@pytest.mark.asyncio
async def test_rename_folder_noop_when_name_is_unchanged(tmp_path: Path):
target = _make_nested(tmp_path)
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(target), "anime")
assert result["success"] is True
assert result["renamed"] is False
assert target.is_dir()
assert scanner.renamed_folders == []
@pytest.mark.asyncio
async def test_rename_folder_refuses_existing_target(tmp_path: Path):
target = _make_nested(tmp_path)
(tmp_path / "characters" / "animation").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(target), "animation")
assert result["success"] is False
assert result["code"] == "target_exists"
assert target.is_dir()
assert scanner.renamed_folders == []
@pytest.mark.parametrize("new_name", ["", " ", "a/b", "..", ".", "bad:name", "back\\slash"])
@pytest.mark.asyncio
async def test_rename_folder_rejects_invalid_names(tmp_path: Path, new_name: str):
target = _make_nested(tmp_path)
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(target), new_name)
assert result["success"] is False
assert target.is_dir()
assert scanner.renamed_folders == []
@pytest.mark.asyncio
async def test_rename_folder_refuses_the_library_root_itself(tmp_path: Path):
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(tmp_path), "renamed-root")
assert result["success"] is False
assert "root" in result["error"].lower()
assert tmp_path.is_dir()
@pytest.mark.asyncio
async def test_rename_folder_rejects_paths_outside_roots(tmp_path: Path):
root = tmp_path / "library"
root.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
scanner = FakeScanner([root])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(outside), "renamed")
assert result["success"] is False
assert outside.is_dir()
assert scanner.renamed_folders == []
@pytest.mark.asyncio
async def test_rename_folder_reports_missing_directory(tmp_path: Path):
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(tmp_path / "gone"), "renamed")
assert result["success"] is False
assert "no longer exists" in result["error"]
@pytest.mark.asyncio
async def test_rename_folder_refuses_symlinked_directory(tmp_path: Path):
real = tmp_path / "real"
real.mkdir()
link = tmp_path / "link"
try:
link.symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError): # pragma: no cover - platform guard
pytest.skip("symlinks are not supported on this platform")
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(link), "renamed")
assert result["success"] is False
assert "symlink" in result["error"].lower()
assert link.is_symlink()
@pytest.mark.asyncio
async def test_rename_folder_refuses_while_a_staged_delete_is_pending(tmp_path: Path):
target = _make_nested(tmp_path)
(target / ".lm-pending-delete").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.rename_folder(str(target), "animation")
assert result["success"] is False
assert result["code"] == "busy"
assert target.is_dir()
assert scanner.renamed_folders == []
@pytest.mark.asyncio
async def test_rename_folder_requires_path(tmp_path: Path):
service = ModelMoveService(FakeScanner([tmp_path]), "lora")
result = await service.rename_folder("", "renamed")
assert result["success"] is False
+217
View File
@@ -1547,6 +1547,223 @@ async def test_add_known_folder_ignores_empty_input(tmp_path: Path):
assert cache.all_folders == before assert cache.all_folders == before
@pytest.mark.asyncio
async def test_remove_known_folder_drops_subtree_and_keeps_ancestors(tmp_path: Path):
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
await scanner.add_known_folder("nested/deep/leaf")
await scanner.remove_known_folder("nested/deep")
assert "nested/deep" not in cache.all_folders
assert "nested/deep/leaf" not in cache.all_folders
# The ancestor directory still exists on disk in its own right
assert "nested" in cache.all_folders
@pytest.mark.asyncio
async def test_remove_known_folder_purges_stale_cache_entries(tmp_path: Path):
_, second, _ = _create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
assert "nested" in cache.folders
await scanner.remove_known_folder("nested")
assert "nested" not in cache.all_folders
assert "nested" not in cache.folders
assert _normalize_path(second) not in {
item["file_path"] for item in cache.raw_data
}
@pytest.mark.asyncio
async def test_remove_known_folder_noop_without_recorded_folders(tmp_path: Path):
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
cache.all_folders = None
# Legacy snapshot without recorded folders: nothing to prune, and the
# scheduled backfill walk rebuilds the list from disk.
await scanner.remove_known_folder("nested")
assert cache.all_folders is None
@pytest.mark.asyncio
async def test_remove_known_folder_ignores_empty_input(tmp_path: Path):
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
before = list(cache.all_folders)
await scanner.remove_known_folder("")
await scanner.remove_known_folder("/")
assert cache.all_folders == before
@pytest.mark.asyncio
async def test_rename_known_folder_rekeys_folders_cache_and_sidecar(tmp_path: Path):
_, second, _ = _create_files(tmp_path)
nested = tmp_path / "nested"
preview = nested / "two.preview.png"
preview.write_text("png", encoding="utf-8")
(nested / "two.metadata.json").write_text(
json.dumps(
{
"file_path": _normalize_path(second),
"preview_url": _normalize_path(preview),
}
),
encoding="utf-8",
)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
entry = next(item for item in cache.raw_data if item["model_name"] == "two")
entry["preview_url"] = _normalize_path(preview)
renamed = tmp_path / "renamed"
old_abs = _normalize_path(nested)
new_abs = _normalize_path(renamed)
os.rename(nested, renamed)
changed = await scanner.rename_known_folder(
"nested", "renamed", previous_path=old_abs, new_path=new_abs
)
assert changed is True
assert "renamed" in cache.all_folders
assert "nested" not in cache.all_folders
assert "renamed" in cache.folders
assert "nested" not in cache.folders
assert entry["folder"] == "renamed"
assert entry["file_path"] == _normalize_path(renamed / "two.txt")
assert entry["preview_url"] == _normalize_path(renamed / "two.preview.png")
assert scanner._hash_index.get_path("hash-two") == _normalize_path(
renamed / "two.txt"
)
# The sidecar travelled with the directory and was re-pointed in place
payload = json.loads(
(renamed / "two.metadata.json").read_text(encoding="utf-8")
)
assert payload["file_path"] == _normalize_path(renamed / "two.txt")
assert payload["preview_url"] == _normalize_path(renamed / "two.preview.png")
@pytest.mark.asyncio
async def test_rename_known_folder_handles_nested_targets(tmp_path: Path):
(tmp_path / "a" / "b" / "c").mkdir(parents=True)
model = tmp_path / "a" / "b" / "c" / "m.txt"
model.write_text("m", encoding="utf-8")
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
old_abs = _normalize_path(tmp_path / "a" / "b")
new_abs = _normalize_path(tmp_path / "a" / "z")
os.rename(tmp_path / "a" / "b", tmp_path / "a" / "z")
await scanner.rename_known_folder(
"a/b", "a/z", previous_path=old_abs, new_path=new_abs
)
assert "a/b" not in cache.all_folders
assert "a/b/c" not in cache.all_folders
assert "a/z" in cache.all_folders
assert "a/z/c" in cache.all_folders
# The parent is an untouched directory in its own right
assert "a" in cache.all_folders
entry = next(item for item in cache.raw_data if item["model_name"] == "m")
assert entry["folder"] == "a/z/c"
assert entry["file_path"] == _normalize_path(tmp_path / "a" / "z" / "c" / "m.txt")
@pytest.mark.asyncio
async def test_rename_known_folder_keeps_unrelated_entries(tmp_path: Path):
first, _, _ = _create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
old_abs = _normalize_path(tmp_path / "nested")
new_abs = _normalize_path(tmp_path / "renamed")
os.rename(tmp_path / "nested", tmp_path / "renamed")
await scanner.rename_known_folder(
"nested", "renamed", previous_path=old_abs, new_path=new_abs
)
root_entry = next(item for item in cache.raw_data if item["model_name"] == "one")
assert root_entry["folder"] == ""
assert root_entry["file_path"] == _normalize_path(first)
@pytest.mark.asyncio
async def test_rename_known_folder_rekeys_excluded_models(tmp_path: Path):
nested = tmp_path / "nested"
nested.mkdir()
(nested / "one.txt").write_text("one", encoding="utf-8")
(nested / "skip-me.txt").write_text("skip", encoding="utf-8")
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
assert scanner._excluded_models == [_normalize_path(nested / "skip-me.txt")]
old_abs = _normalize_path(nested)
new_abs = _normalize_path(tmp_path / "renamed")
os.rename(nested, tmp_path / "renamed")
await scanner.rename_known_folder(
"nested", "renamed", previous_path=old_abs, new_path=new_abs
)
assert scanner._excluded_models == [
_normalize_path(tmp_path / "renamed" / "skip-me.txt")
]
@pytest.mark.asyncio
async def test_rename_known_folder_ignores_unchanged_or_empty_names(tmp_path: Path):
_create_files(tmp_path)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
before = list(cache.all_folders)
assert (
await scanner.rename_known_folder(
"nested",
"nested",
previous_path=_normalize_path(tmp_path / "nested"),
new_path=_normalize_path(tmp_path / "nested"),
)
is False
)
assert (
await scanner.rename_known_folder(
"",
"renamed",
previous_path=_normalize_path(tmp_path),
new_path=_normalize_path(tmp_path / "renamed"),
)
is False
)
assert cache.all_folders == before
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_all_folders_updated_after_move(tmp_path: Path): async def test_get_all_folders_updated_after_move(tmp_path: Path):
first, _, _ = _create_files(tmp_path) first, _, _ = _create_files(tmp_path)
+185 -2
View File
@@ -59,6 +59,17 @@ class TestDetectSource:
"modelscope", "modelscope",
"jj3550945163/Krea-2-LORA", "jj3550945163/Krea-2-LORA",
), ),
# modelscope.ai is a separate catalogue with its own platform id.
(
"https://www.modelscope.ai/models/referall13/EM1",
"modelscope-ai",
"referall13/EM1",
),
(
"https://modelscope.ai/models/ErLubu/krea2_style_260911_02/summary",
"modelscope-ai",
"ErLubu/krea2_style_260911_02",
),
( (
"https://tensor.art/models/827823520299086029/Vivid-Impressions-Storybook-Sstyle-V1.0", "https://tensor.art/models/827823520299086029/Vivid-Impressions-Storybook-Sstyle-V1.0",
"tensorart", "tensorart",
@@ -97,6 +108,21 @@ class TestDetectSource:
== "https://tensor.art/models/123" == "https://tensor.art/models/123"
) )
def test_modelscope_com_is_an_alias_of_the_mainland_site(self):
"""``.com`` 301-redirects to ``.cn``, so it is not a third catalogue."""
ref = detect_source("https://www.modelscope.com/models/u/r")
assert ref.platform == "modelscope"
assert ref.url == "https://modelscope.cn/models/u/r"
def test_the_two_modelscope_catalogues_do_not_cross_match(self):
"""A host must never be accepted by the other deployment's patterns."""
mainland = get_source("modelscope")
international = get_source("modelscope-ai")
assert mainland.parse("https://www.modelscope.ai/models/u/r") is None
assert international.parse("https://modelscope.cn/models/u/r") is None
assert international.parse("https://www.modelscope.com/models/u/r") is None
class TestStrictParsing: class TestStrictParsing:
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -106,6 +132,8 @@ class TestStrictParsing:
"https://huggingface.co/user/repo/", "https://huggingface.co/user/repo/",
"https://modelscope.cn/models/user/repo", "https://modelscope.cn/models/user/repo",
"https://modelscope.cn/models/user/repo/summary", "https://modelscope.cn/models/user/repo/summary",
"https://www.modelscope.ai/models/user/repo",
"https://www.modelscope.ai/models/user/repo/files",
"https://tensor.art/models/827823520299086029", "https://tensor.art/models/827823520299086029",
"https://tensor.art/models/827823520299086029/Vivid-Impressions", "https://tensor.art/models/827823520299086029/Vivid-Impressions",
], ],
@@ -145,6 +173,16 @@ class TestCapabilities:
assert source.default_revision == "master" assert source.default_revision == "master"
assert source.default_subdir == "modelscope" assert source.default_subdir == "modelscope"
def test_modelscope_intl_is_the_same_site_on_another_catalogue(self):
source = get_source("modelscope-ai")
assert source.supports_enrichment is True
assert source.supports_download is True
assert source.default_revision == "master"
# A distinct directory: the same owner/name can exist on both
# deployments with different content.
assert source.default_subdir == "modelscope-ai"
assert source.base_url == "https://www.modelscope.ai"
def test_tensorart_is_link_only(self): def test_tensorart_is_link_only(self):
source = get_source("tensorart") source = get_source("tensorart")
assert source.supports_enrichment is False assert source.supports_enrichment is False
@@ -152,11 +190,17 @@ class TestCapabilities:
def test_registry_lists_every_source(self): def test_registry_lists_every_source(self):
platforms = {s.platform for s in list_sources()} platforms = {s.platform for s in list_sources()}
assert platforms == {"huggingface", "modelscope", "tensorart"} assert platforms == {
"huggingface",
"modelscope",
"modelscope-ai",
"tensorart",
}
def test_labels_are_brand_names(self): def test_labels_are_brand_names(self):
assert source_label("huggingface") == "Hugging Face" assert source_label("huggingface") == "Hugging Face"
assert source_label("modelscope") == "ModelScope" assert source_label("modelscope") == "ModelScope"
assert source_label("modelscope-ai") == "ModelScope (International)"
assert source_label("tensorart") == "TensorArt" assert source_label("tensorart") == "TensorArt"
assert source_label("unknown", "fallback") == "fallback" assert source_label("unknown", "fallback") == "fallback"
@@ -311,6 +355,44 @@ class TestFetchModelCard:
in calls in calls
) )
@pytest.mark.asyncio
async def test_modelscope_intl_fetches_from_its_own_catalogue(self, monkeypatch):
"""The mainland site 404s for a `.ai`-only repository, so every fetch
has to stay on the host the URL came from."""
calls: list[str] = []
async def fake_fetch_text(url: str, **_kwargs) -> str:
calls.append(url)
return "# card"
json_calls: list[str] = []
async def fake_fetch_json(url: str, **_kwargs):
json_calls.append(url)
return 200, {"Data": {"Name": "EM1", "MuseInfo": {"versions": []}}}
monkeypatch.setattr(
"py.services.model_sources.modelscope.fetch_text", fake_fetch_text
)
monkeypatch.setattr(
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
)
source = get_source("modelscope-ai")
await source.fetch_model_card("referall13/EM1")
await source.fetch_model_card_context("referall13/EM1")
await source.list_files("referall13/EM1")
assert calls == [
"https://www.modelscope.ai/models/referall13/EM1/resolve/master/README.md"
]
assert json_calls == [
"https://www.modelscope.ai/api/v1/models/referall13/EM1",
"https://www.modelscope.ai/api/v1/models/referall13/EM1/repo/files"
"?Revision=master",
]
assert not any("modelscope.cn" in url for url in calls + json_calls)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tensorart_never_fetches(self): async def test_tensorart_never_fetches(self):
# TensorArt enrichment is disabled: the provider must not issue any # TensorArt enrichment is disabled: the provider must not issue any
@@ -335,6 +417,12 @@ class TestAssetBaseUrl:
== "https://modelscope.cn/models/u/r/resolve/master" == "https://modelscope.cn/models/u/r/resolve/master"
) )
def test_modelscope_intl_uses_master_revision(self):
assert (
get_source("modelscope-ai").asset_base_url("u/r")
== "https://www.modelscope.ai/models/u/r/resolve/master"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Model card context (site extras kept outside the README) # Model card context (site extras kept outside the README)
@@ -354,6 +442,9 @@ def _modelscope_detail_payload() -> dict:
"Data": { "Data": {
"Name": "Krea-2-LORA", "Name": "Krea-2-LORA",
"ChineseName": "krea脸模", "ChineseName": "krea脸模",
"AigcType": "LoRA",
"License": "Apache License 2.0",
"Tags": ["LoRA", "text-to-image", "portrait"],
"Description": "权重0.5-1.2。配合《风格滤镜》lora一起使用。", "Description": "权重0.5-1.2。配合《风格滤镜》lora一起使用。",
"BaseModel": ["krea/Krea-2-Turbo"], "BaseModel": ["krea/Krea-2-Turbo"],
"License": "Apache License 2.0", "License": "Apache License 2.0",
@@ -422,6 +513,81 @@ class TestFetchModelCardContext:
# OfficialTag values only, de-duplicated, order preserved. # OfficialTag values only, de-duplicated, order preserved.
assert context.official_tags == ["photography", "woman"] assert context.official_tags == ["photography", "woman"]
@pytest.mark.asyncio
async def test_modelscope_reads_site_identity_fields(self, monkeypatch):
async def fake_fetch_json(url, **_kwargs):
return 200, _modelscope_detail_payload()
monkeypatch.setattr(
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
)
context = await ModelScopeSource().fetch_model_card_context(
"u/r", "Krea-2-LORA_c1-st1000.safetensors"
)
assert context.model_name == "Krea-2-LORA"
assert context.model_name_localized == "krea脸模"
assert context.license == "Apache License 2.0"
assert context.model_type == "LoRA"
# The version label is taken from the file that was matched, not from
# whichever version happens to come first in the payload.
assert context.version_name == "c1-st1000"
@pytest.mark.asyncio
async def test_modelscope_version_label_is_empty_for_an_unknown_file(
self, monkeypatch
):
async def fake_fetch_json(url, **_kwargs):
return 200, _modelscope_detail_payload()
monkeypatch.setattr(
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
)
context = await ModelScopeSource().fetch_model_card_context(
"u/r", "other.safetensors"
)
assert context.version_name == ""
# The repository-wide fields are still published.
assert context.model_name == "Krea-2-LORA"
@pytest.mark.asyncio
async def test_modelscope_falls_back_to_plain_tags(self, monkeypatch):
"""An empty ``OfficialTags`` must not mean "no tags at all".
The plain ``Tags`` list mixes genuine content tags with library and
task categories; the latter are dropped so the card is not tagged
"lora" / "text-to-image".
"""
payload = _modelscope_detail_payload()
payload["Data"]["OfficialTags"] = None
async def fake_fetch_json(url, **_kwargs):
return 200, payload
monkeypatch.setattr(
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
)
context = await ModelScopeSource().fetch_model_card_context("u/r")
assert context.official_tags == ["portrait"]
@pytest.mark.asyncio
async def test_modelscope_curated_tags_win_over_plain_tags(self, monkeypatch):
async def fake_fetch_json(url, **_kwargs):
return 200, _modelscope_detail_payload()
monkeypatch.setattr(
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
)
context = await ModelScopeSource().fetch_model_card_context("u/r")
assert "portrait" not in context.official_tags
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_modelscope_matches_example_images_by_filename(self, monkeypatch): async def test_modelscope_matches_example_images_by_filename(self, monkeypatch):
async def fake_fetch_json(url, **_kwargs): async def fake_fetch_json(url, **_kwargs):
@@ -655,6 +821,22 @@ class TestDownloadUrls:
"https://modelscope.cn/models/u/r/resolve/master/sub/f.safetensors" "https://modelscope.cn/models/u/r/resolve/master/sub/f.safetensors"
) )
def test_modelscope_intl_builds_every_url_on_its_own_host(self):
"""The two deployments serve different catalogues, so a URL built for
one must never point at the other."""
source = get_source("modelscope-ai")
assert source.canonical_url("u/r") == "https://www.modelscope.ai/models/u/r"
assert source.file_download_url("u/r", "sub/f.safetensors") == (
"https://www.modelscope.ai/models/u/r/resolve/master/sub/f.safetensors"
)
assert source.asset_base_url("u/r") == (
"https://www.modelscope.ai/models/u/r/resolve/master"
)
assert source.page_url_for_file("u/r", "sub/f.safetensors") == (
"https://www.modelscope.ai/models/u/r/file/view/master/sub/f.safetensors"
)
def test_explicit_revision_wins(self): def test_explicit_revision_wins(self):
assert ModelScopeSource().file_download_url("u/r", "f.bin", "v1") == ( assert ModelScopeSource().file_download_url("u/r", "f.bin", "v1") == (
"https://modelscope.cn/models/u/r/resolve/v1/f.bin" "https://modelscope.cn/models/u/r/resolve/v1/f.bin"
@@ -701,12 +883,13 @@ class TestSourceIdValidation:
class TestDownloadSourceRegistry: class TestDownloadSourceRegistry:
def test_downloadable_sources_excludes_link_only_sites(self): def test_downloadable_sources_excludes_link_only_sites(self):
platforms = {source.platform for source in downloadable_sources()} platforms = {source.platform for source in downloadable_sources()}
assert platforms == {"huggingface", "modelscope"} assert platforms == {"huggingface", "modelscope", "modelscope-ai"}
def test_get_download_source_rejects_link_only_platform(self): def test_get_download_source_rejects_link_only_platform(self):
assert get_download_source("tensorart") is None assert get_download_source("tensorart") is None
assert get_download_source("nope") is None assert get_download_source("nope") is None
assert get_download_source("modelscope").platform == "modelscope" assert get_download_source("modelscope").platform == "modelscope"
assert get_download_source("modelscope-ai").platform == "modelscope-ai"
assert get_download_source("huggingface").platform == "huggingface" assert get_download_source("huggingface").platform == "huggingface"
+397
View File
@@ -0,0 +1,397 @@
"""Tests for download-time metadata hydration.
`py/services/model_sources/hydration.py` is the deterministic counterpart of
the `enrich_hf_metadata` skill: it turns a freshly downloaded ModelScope /
Hugging Face file into the populated model card a CivitAI download produces,
without an LLM and without the user running anything.
These tests cover the orchestration which source data is fetched, what is
handed to the post-processor, and that nothing here can fail a download. The
field-by-field mapping lives in `tests/services/test_post_processor.py`.
"""
from __future__ import annotations
import pytest
from py.services.model_sources import ModelCardContext, ModelSourceCache, SourceRef
from py.services.model_sources import hydration
from py.services.model_sources.base import ModelSource
from py.services.model_sources.hydration import (
SHARED_CACHE_MAX_ENTRIES,
hydrate_from_source,
load_model_card,
reset_shared_caches,
resolve_site_base_model,
shared_source_cache,
)
REF = SourceRef(
platform="modelscope",
source_id="user/repo",
url="https://modelscope.cn/models/user/repo",
)
SIDECAR = {
"sha256": "a" * 64,
"base_model": "Unknown",
# Written by the download handler just before hydration runs.
"source_platform": "modelscope",
"source_url": "https://modelscope.cn/models/user/repo",
}
class _FakeSource(ModelSource):
"""Minimal provider that records what hydration asked of it."""
platform = "modelscope"
label = "ModelScope"
supports_enrichment = True
def __init__(self, *, context=None, readme="", fail=False):
self.context = context if context is not None else ModelCardContext()
self.readme = readme
self.fail = fail
self.readme_calls = 0
self.context_calls = 0
self.context_kwargs: dict = {}
async def fetch_model_card(self, source_id):
self.readme_calls += 1
if self.fail:
raise RuntimeError("network down")
return self.readme
async def fetch_model_card_context(
self, source_id, filename="", *, sha256="", cache=None
):
self.context_calls += 1
self.context_kwargs = {"filename": filename, "sha256": sha256}
if self.fail:
raise RuntimeError("network down")
return self.context
@pytest.fixture(autouse=True)
def _isolated_shared_caches():
reset_shared_caches()
yield
reset_shared_caches()
def _async(value):
async def _call(*_args, **_kwargs):
return value
return _call
def _wire(monkeypatch, source, *, metadata=SIDECAR, result=None):
"""Patch hydration's collaborators; return the recorded process() calls."""
monkeypatch.setattr(hydration, "get_source", lambda _platform: source)
monkeypatch.setattr("py.metadata_ops.read_metadata", _async(metadata))
calls: list = []
class _Processor:
async def process(self, **kwargs):
calls.append(kwargs)
if result is not None:
return result
return {"success": True, "updated_fields": ["model_name"]}
monkeypatch.setattr("py.services.agent.post_processor.PostProcessor", _Processor)
return calls
# ---------------------------------------------------------------------------
# Orchestration
# ---------------------------------------------------------------------------
class TestHydrateFromSource:
@pytest.mark.asyncio
async def test_applies_the_site_card_without_an_llm(self, monkeypatch):
source = _FakeSource(
context=ModelCardContext(
model_name="Krea-2-LORA",
version_name="c1-st1000",
description="权重0.5-1.2。",
official_tags=["photography"],
),
readme="# Krea-2-LORA",
)
calls = _wire(monkeypatch, source)
updated = await hydrate_from_source("/models/lora.safetensors", ref=REF)
assert updated == ["model_name"]
assert len(calls) == 1
call = calls[0]
# No provider is consulted: everything applied is what the site published.
assert call["llm_output"] == {}
assert call["skill_name"] == "enrich_hf_metadata"
assert call["readme_content"] == "# Krea-2-LORA"
assert call["source_context"].model_name == "Krea-2-LORA"
assert call["metadata_source"] == "source:modelscope"
@pytest.mark.asyncio
async def test_matches_the_file_by_hash_and_basename(self, monkeypatch):
source = _FakeSource(context=ModelCardContext(model_name="X"))
_wire(monkeypatch, source)
await hydrate_from_source("/models/sub/Krea-2-LORA_c1-st1000.safetensors", ref=REF)
assert source.context_kwargs == {
"filename": "Krea-2-LORA_c1-st1000.safetensors",
"sha256": "a" * 64,
}
@pytest.mark.asyncio
async def test_returns_early_for_an_unknown_platform(self, monkeypatch):
source = _FakeSource(context=ModelCardContext(model_name="X"))
calls = _wire(monkeypatch, source)
monkeypatch.setattr(hydration, "get_source", lambda _platform: None)
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
assert calls == []
@pytest.mark.asyncio
async def test_returns_early_for_a_link_only_source(self, monkeypatch):
source = _FakeSource(context=ModelCardContext(model_name="X"))
source.supports_enrichment = False
calls = _wire(monkeypatch, source)
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
assert calls == []
@pytest.mark.asyncio
async def test_returns_early_without_a_sidecar(self, monkeypatch):
source = _FakeSource(context=ModelCardContext(model_name="X"))
calls = _wire(monkeypatch, source, metadata={})
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
assert calls == []
@pytest.mark.asyncio
async def test_returns_early_when_the_site_published_nothing(self, monkeypatch):
source = _FakeSource(context=ModelCardContext(), readme="")
calls = _wire(monkeypatch, source)
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
assert calls == []
@pytest.mark.asyncio
async def test_a_deferred_hash_still_matches_by_filename(self, monkeypatch):
"""Checkpoints and other large files are stored with
``hash_status="pending"`` and an empty ``sha256`` (see
``CheckpointScanner._create_default_metadata``), so hydration has to
work from the filename alone."""
source = _FakeSource(context=ModelCardContext(model_name="X"))
calls = _wire(
monkeypatch,
source,
metadata={**SIDECAR, "sha256": "", "hash_status": "pending"},
)
await hydrate_from_source("/models/big_checkpoint.safetensors", ref=REF)
assert calls[0]["source_context"].model_name == "X"
assert source.context_kwargs == {
"filename": "big_checkpoint.safetensors",
"sha256": "",
}
@pytest.mark.asyncio
async def test_returns_early_when_the_model_is_not_linked(self, monkeypatch):
"""A file that merely shares a name must not get another model's card."""
source = _FakeSource(context=ModelCardContext(model_name="X"))
calls = _wire(
monkeypatch, source, metadata={"sha256": "a" * 64, "base_model": "Unknown"}
)
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
assert calls == []
@pytest.mark.asyncio
async def test_returns_early_when_linked_to_another_repository(self, monkeypatch):
source = _FakeSource(context=ModelCardContext(model_name="X"))
calls = _wire(
monkeypatch,
source,
metadata={
**SIDECAR,
"source_url": "https://modelscope.cn/models/user/other",
},
)
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
assert calls == []
@pytest.mark.asyncio
async def test_returns_early_when_linked_to_another_platform(self, monkeypatch):
source = _FakeSource(context=ModelCardContext(model_name="X"))
calls = _wire(
monkeypatch,
source,
metadata={
"sha256": "a" * 64,
"source_platform": "huggingface",
"source_url": "https://huggingface.co/user/repo",
},
)
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
assert calls == []
@pytest.mark.asyncio
async def test_readme_alone_is_enough_to_run(self, monkeypatch):
source = _FakeSource(context=ModelCardContext(), readme="# hi")
calls = _wire(monkeypatch, source)
await hydrate_from_source("/models/lora.safetensors", ref=REF)
assert len(calls) == 1
assert calls[0]["readme_content"] == "# hi"
@pytest.mark.asyncio
async def test_a_failing_site_never_breaks_the_download(self, monkeypatch):
source = _FakeSource(fail=True)
calls = _wire(monkeypatch, source)
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
assert calls == []
@pytest.mark.asyncio
async def test_a_failing_post_processor_never_breaks_the_download(
self, monkeypatch
):
source = _FakeSource(context=ModelCardContext(model_name="X"))
_wire(monkeypatch, source, result={"success": False, "errors": ["boom"]})
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
@pytest.mark.asyncio
async def test_updates_are_reported_for_logging(self, monkeypatch):
source = _FakeSource(context=ModelCardContext(model_name="X"))
_wire(
monkeypatch,
source,
result={"success": True, "updated_fields": ["tags", "civitai"]},
)
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == [
"tags",
"civitai",
]
# ---------------------------------------------------------------------------
# Per-repository memo
# ---------------------------------------------------------------------------
class TestSharedSourceCache:
def test_same_repository_reuses_one_memo(self):
assert shared_source_cache("modelscope", "u/r") is shared_source_cache(
"modelscope", "u/r"
)
def test_different_repositories_get_different_memos(self):
assert shared_source_cache("modelscope", "u/r") is not shared_source_cache(
"modelscope", "u/other"
)
def test_entry_expires(self, monkeypatch):
clock = {"now": 1000.0}
monkeypatch.setattr(hydration.time, "monotonic", lambda: clock["now"])
first = shared_source_cache("modelscope", "u/r")
clock["now"] += hydration.SHARED_CACHE_TTL + 1
assert shared_source_cache("modelscope", "u/r") is not first
def test_cache_is_bounded(self):
for index in range(SHARED_CACHE_MAX_ENTRIES + 5):
shared_source_cache("modelscope", f"u/r{index}")
assert len(hydration._shared_caches) == SHARED_CACHE_MAX_ENTRIES
class TestLoadModelCard:
@pytest.mark.asyncio
async def test_successful_read_is_memoised(self):
source = _FakeSource(readme="# hi")
cache = ModelSourceCache()
assert await load_model_card(source, "u/r", cache) == "# hi"
assert await load_model_card(source, "u/r", cache) == "# hi"
assert source.readme_calls == 1
@pytest.mark.asyncio
async def test_empty_read_is_retried(self):
"""A transient failure must not be cached as "this repo has no card"."""
source = _FakeSource(readme="")
cache = ModelSourceCache()
await load_model_card(source, "u/r", cache)
await load_model_card(source, "u/r", cache)
assert source.readme_calls == 2
@pytest.mark.asyncio
async def test_works_without_a_cache(self):
source = _FakeSource(readme="# hi")
assert await load_model_card(source, "u/r") == "# hi"
assert source.readme_calls == 1
# ---------------------------------------------------------------------------
# Base-model resolution
# ---------------------------------------------------------------------------
class TestResolveSiteBaseModel:
@pytest.mark.asyncio
async def test_maps_the_sites_own_vocabulary(self, monkeypatch):
monkeypatch.setattr(
"py.metadata_ops.list_base_models",
_async(["Krea 2", "Flux.1 D"]),
)
context = ModelCardContext(
base_model="krea/Krea-2-Turbo",
base_model_aliases=["KREA_2_TURBO", "krea/Krea-2-Turbo"],
)
assert await resolve_site_base_model(context) == "Krea 2"
@pytest.mark.asyncio
async def test_unknown_hint_defers_instead_of_guessing(self, monkeypatch):
monkeypatch.setattr(
"py.metadata_ops.list_base_models", _async(["Flux.1 D"])
)
context = ModelCardContext(base_model="something/else")
assert await resolve_site_base_model(context) == ""
@pytest.mark.asyncio
async def test_no_hints_needs_no_vocabulary_lookup(self, monkeypatch):
async def _boom(*_args, **_kwargs): # pragma: no cover - must not run
raise AssertionError("list_base_models should not be called")
monkeypatch.setattr("py.metadata_ops.list_base_models", _boom)
assert await resolve_site_base_model(ModelCardContext()) == ""
@pytest.mark.asyncio
async def test_a_vocabulary_failure_is_not_fatal(self, monkeypatch):
async def _boom(*_args, **_kwargs):
raise RuntimeError("civitai down")
monkeypatch.setattr("py.metadata_ops.list_base_models", _boom)
assert await resolve_site_base_model(ModelCardContext(base_model="x")) == ""
+115
View File
@@ -1121,3 +1121,118 @@ pip install modelscope
) )
assert "modelDescription" not in mock_apply.call_args[0][1] assert "modelDescription" not in mock_apply.call_args[0][1]
# ======================================================================
# Site identity and provenance fields
# ======================================================================
class TestSiteIdentityFields:
"""The fields that make a source download look like a CivitAI one."""
METADATA = {
"from_civitai": False,
"source_platform": "modelscope",
"source_url": "https://modelscope.cn/models/user/repo",
"file_name": "Krea-2-LORA_c1-st1000",
"model_name": "Krea-2-LORA_c1-st1000",
"base_model": "Unknown",
}
@staticmethod
def _run(processor, *, metadata, context, llm_output=None, **kwargs):
async def _call():
with (
mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply,
mock.patch("py.metadata_ops.download_preview", return_value=None),
mock.patch("py.metadata_ops.refresh_cache"),
):
await processor.process(
skill_name="enrich_hf_metadata",
model_path="/p.safetensors",
llm_output=llm_output if llm_output is not None else {},
metadata=metadata,
source_context=context,
**kwargs,
)
return mock_apply.call_args[0][1]
return _call()
@pytest.mark.asyncio
async def test_model_name_is_taken_from_the_site(self, processor):
applied = await self._run(
processor,
metadata=dict(self.METADATA),
context=ModelCardContext(model_name="Krea-2-LORA"),
)
assert applied["model_name"] == "Krea-2-LORA"
@pytest.mark.asyncio
async def test_model_name_is_also_written_when_absent(self, processor):
metadata = {**self.METADATA, "model_name": ""}
applied = await self._run(
processor,
metadata=metadata,
context=ModelCardContext(model_name="Krea-2-LORA"),
)
assert applied["model_name"] == "Krea-2-LORA"
@pytest.mark.asyncio
async def test_renamed_model_keeps_the_users_name(self, processor):
metadata = {**self.METADATA, "model_name": "my own name"}
applied = await self._run(
processor,
metadata=metadata,
context=ModelCardContext(model_name="Krea-2-LORA"),
)
assert "model_name" not in applied
@pytest.mark.asyncio
async def test_version_label_becomes_the_civitai_name(self, processor):
applied = await self._run(
processor,
metadata=dict(self.METADATA),
context=ModelCardContext(
model_name="Krea-2-LORA",
version_name="c1-st1000",
description="权重0.5-1.2。",
),
)
assert applied["civitai"]["name"] == "c1-st1000"
# Every civitai branch contributes to one dict, so an earlier branch
# must survive a later one.
assert applied["civitai"]["description"] == "权重0.5-1.2。"
@pytest.mark.asyncio
async def test_llm_enriched_at_is_stamped_only_when_the_llm_answered(
self, processor
):
applied = await self._run(
processor,
metadata=dict(self.METADATA),
context=ModelCardContext(model_name="Krea-2-LORA"),
)
assert applied["metadata_source"] == "agent:enrich_hf_metadata"
assert "llm_enriched_at" not in applied
@pytest.mark.asyncio
async def test_llm_answer_stamps_llm_enriched_at(self, processor):
applied = await self._run(
processor,
metadata=dict(self.METADATA),
context=ModelCardContext(model_name="Krea-2-LORA"),
llm_output={"base_model": "", "confidence": "high"},
)
assert "llm_enriched_at" in applied
@pytest.mark.asyncio
async def test_metadata_source_can_be_overridden(self, processor):
applied = await self._run(
processor,
metadata=dict(self.METADATA),
context=ModelCardContext(model_name="Krea-2-LORA"),
metadata_source="source:modelscope",
)
assert applied["metadata_source"] == "source:modelscope"