Compare commits

...

20 Commits

Author SHA1 Message Date
Will Miao
d3bf0a164b fix(gitignore): add .reasonix/ to ignore list 2026-06-23 07:06:15 +08:00
Will Miao
afb6ca1b8d refactor(settings): rename update_flag_strategy to version_grouping with migration 2026-06-22 16:59:32 +08:00
Will Miao
94f43426d7 feat(ui): show version count in group-by-model cards, add versions_count sort, no-reload VLM
- group_by_model dedup now counts versions per group and attaches
  version_count; respects update_flag_strategy (same_base) by
  sub-grouping on base_model
- Card footer shows clickable 'x versions' link instead of version
  name when grouped (hides HIGH/LOW badges); clicking triggers
  View Local Versions without page reload
- Added 'Local Versions' sort option (versions_count), auto-hidden
  when group_by_model is off
- Sort preference is saved/restored separately for normal and
  grouped modes
- VLM flow (triggerVlmView, clearCustomFilter) uses resetAndReload()
  via API instead of window.location.reload()
- Fixed cache mutation bug: version_count is now set on a shallow
  copy, not the cached dict, preventing stale version_count leaking
  into VLM responses
- i18n: all 9 locale files translated
2026-06-22 16:02:12 +08:00
Will Miao
2b361f4f5d feat(ui): add group-by-model toggle to global context menu
Adds a 'Group by Model' toggle entry to the right-click global context
menu for quick access, complementing the existing setting in
Settings → Layout Settings. The menu item shows a checkmark indicator
reflecting the current state and immediately reloads the view on toggle.

Also fixes he.json translation that was mojibake (garbled characters).

Includes:
- Context menu HTML item with check-indicator
- JS toggle logic via settingsManager
- i18n for all 10 locales
- Hebrew translation fix
2026-06-22 11:31:15 +08:00
Will Miao
7438072f8c feat(save-image): add %batch_num% support in batch loop 2026-06-22 09:11:38 +08:00
Will Miao
26c54fd358 fix(versions): scope VLM custom filter per-page to prevent cross-page leak
Store the originating page type alongside VLM data in sessionStorage;
validate it on every page load before applying the filter or showing
the indicator. Stale data is auto-cleaned on mismatch.

This prevents the 'View all local versions' custom filter from leaking
into the checkpoints (or embeddings) page, which caused an empty grid.
2026-06-21 12:02:06 +08:00
Will Miao
7cb6b04c63 chore: remove duplicate _truncateText from LorasControls/CheckpointsControls, add backend test for civitai_model_id filter 2026-06-21 11:19:54 +08:00
Will Miao
fc29cde82a feat(versions): add View all local versions button to model versions tab
Clicking the button closes the modal, writes filter params to sessionStorage,
and reloads the page to show all local versions of the model as individual
cards (bypassing group-by-model dedup). The filter respects the update flag
strategy and the versions-filter-toggle state (same-base vs all versions).

Supporting changes:
- sessionStorage keys vlm_model_id / vlm_model_name / vlm_base_model
- BaseModelApiClient._addModelSpecificParams adds civitai_model_id param
- LoraApiClient calls super._addModelSpecificParams for VLM detection
- LorasControls / CheckpointsControls clearCustomFilter checks VLM first
- PageControls.checkVlmFilter shows customFilterIndicator with label
- Backend parses civitai_model_id, filters before group_by_model dedup
2026-06-21 11:13:53 +08:00
Will Miao
559ca946dc feat(models): add group-by-model option to collapse multiple versions into one card
Adds a 'Group by Model' toggle in Layout Settings. When enabled, only the
latest version (highest civitai.id) of each Civitai model is shown as a
single card — older versions sharing the same modelId are hidden.

Backend dedup runs in BaseModelService.get_paginated_data() before
filtering/pagination, ensuring correct paginated results. The setting
is persisted via the existing settings pipeline and passed as a query
parameter to the listing endpoint.

Includes:
- Backend: dedup logic, route param parsing, settings default
- Frontend: API param, SettingsManager wiring, toggle UI
- i18n: translations for all 10 locales
- Tests: unit test covering dedup on/off and standalone items
2026-06-21 08:48:42 +08:00
Will Miao
2b8e7c7504 fix(tests): update recipes page tests for unified controls template
- Inject #customFilterIndicator DOM in beforeEach (raw template
  renderer doesn't process Jinja2 {% include %} tags)
- Fix selector from #customFilterText to .customFilterText
2026-06-20 06:55:47 +08:00
Will Miao
6816d75933 refactor(recipes): unify controls and breadcrumb UI with model pages
- Replace inline controls+breadcrumb in recipes.html with shared includes
- Add page_id conditionals in controls.html to adapt buttons per page type
- Unify customFilterText selector to class-based in recipes.js
- Add [data-action="find-duplicates"] event listener for unified button
- Fix i18n keys to use recipes-specific translations on recipes page
2026-06-19 22:41:50 +08:00
willmiao
b58abbad7c docs: auto-update supporters list in README 2026-06-19 10:31:18 +00:00
Will Miao
999814ca87 chore(release): bump version to v1.1.4 2026-06-19 18:31:03 +08:00
Will Miao
3c2760a803 fix(stats): sort Base Model Distribution X-axis labels alphabetically (#796) 2026-06-19 17:29:33 +08:00
Will Miao
0edbd7bcca fix(metadata): add LoraTextLoaderLM extractor so SaveImageLM records its loras (#801) 2026-06-19 17:13:48 +08:00
Will Miao
21e89fa7de fix(tags): normalize tag case on save and make filtering case-insensitive (#727)
- save_metadata_updates now trims/lowercases/dedupes tags on write
- ModelFilterSet tag matching is now case-insensitive (both include/exclude)
- Removed redundant .lower() calls in tag_update_service.py
2026-06-19 16:42:09 +08:00
Will Miao
968d6d1d1f feat(tags): unify recipe modal tag UI with model modal
- Replace recipe modal's custom tag display/edit with shared
  renderCompactTags/setupTagEditMode from ModelTags and utils
- Remove 300+ lines of duplicated tag display and editing code
- Parameterize setupTagEditMode with saveHandler/onSaved/showSuggestions
  options for recipe-specific save flow (updateRecipeMetadata + dirty state)
- Scope all DOM queries in ModelTags.js via options.container / this.closest
  to prevent cross-modal element conflicts
- Fix edit button alignment (justify-content: flex-start)
- Fix tag tooltip selector scoping in setupTagTooltip
- Add width: 100% to #recipeTagsContainer for edit container full width
2026-06-19 16:31:27 +08:00
Will Miao
cf0fd0e0ad feat(i18n): internationalize dynamic insights content with key/params architecture (#489) 2026-06-19 13:49:03 +08:00
Will Miao
16e5dcf7b2 feat(i18n): internationalize statistics page strings across all locales 2026-06-19 13:37:01 +08:00
Will Miao
ab6bb25d46 fix(example-images): skip hidden files in path validation, show offending items on failure (#807) 2026-06-19 11:54:55 +08:00
62 changed files with 2295 additions and 885 deletions

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@ model_cache/
.codex
.omo
reasonix.toml
.reasonix/
.codegraph/
# Vue widgets development cache (but keep build output)

File diff suppressed because one or more lines are too long

View File

@@ -11,14 +11,15 @@
"Insomnia Art Designs",
"2018cfh",
"Arlecchino Shion",
"Charles Blakemore",
"Rob Williams",
"W+K+White",
"$MetaSamsara",
"wackop",
"Phil",
"Carl G.",
"Charles Blakemore",
"stone9k",
"Rosenthal",
"itismyelement",
"Mozzel",
"Gingko Biloba",
@@ -28,7 +29,6 @@
"DM",
"Sen314",
"Estragon",
"Rosenthal",
"ClockDaemon",
"Francisco Tatis",
"Tobi_Swagg",
@@ -80,11 +80,13 @@
"Release Cabrakan",
"JW Sin",
"Alex",
"bh",
"carozzz",
"Marlon Daniels",
"James Dooley",
"zenbound",
"Buzzard",
"Aaron Bleuer",
"Adam Shaw",
"Mark Corneglio",
"SarcasticHashtag",
@@ -95,6 +97,7 @@
"James Todd",
"Wicked Choices by ASLPro3D",
"FinalyFree",
"Weasyl",
"Steven Pfeiffer",
"Timmy",
"Johnny",
@@ -105,7 +108,7 @@
"Luc Job",
"dl0901dm",
"corde",
"Nick Walker",
"nwalker94",
"Yushio",
"Vik71it",
"Bishoujoker",
@@ -118,9 +121,12 @@
"BadassArabianMofo",
"Pascal Dahle",
"Greg",
"Sangheili460",
"MagnaInsomnia",
"Akira_HentAI",
"lmsupporter",
"andrew.tappan",
"N/A",
"Greenmoustache",
"zounic",
"wfpearl",
@@ -128,20 +134,19 @@
"Jack B Nimble",
"JaxMax",
"contrite831",
"bh",
"Jwk0205",
"Starkselle",
"Olive",
"Aaron Bleuer",
"LacesOut!",
"greebles",
"Some Guy Named Barry",
"M Postkasse",
"Gooohokrbe",
"wamekukyouzin",
"OldBones",
"Jacob Hoehler",
"Dogmaster",
"Matt Wenzel",
"Weasyl",
"Lex Song",
"Cory Paza",
"Gonzalo Andre Allendes Lopez",
@@ -151,20 +156,18 @@
"Philip Hempel",
"dan",
"aai",
"Mouthlessman",
"otaku fra",
"jean jahren",
"MiraiKuriyamaSy",
"Ran C",
"ViperC",
"Penfore",
"Sangheili460",
"MagnaInsomnia",
"Karl P.",
"Gordon Cole",
"Adam Taylor",
"AbstractAss",
"Weird_With_A_Beard",
"N/A",
"The Spawn",
"graysock",
"Pozadine1",
@@ -187,15 +190,15 @@
"太郎 ゲーム",
"Roslynd",
"jinxedx",
"Neco28",
"Cosmosis",
"David Ortega",
"AELOX",
"Dankin",
"Nicfit23",
"FloPro4Sho",
"Cristian Vazquez",
"wamekukyouzin",
"drum matthieu",
"Dogmaster",
"Frank Nitty",
"Magic Noob",
"Christopher Michel",
@@ -210,7 +213,6 @@
"Kevin John Duck",
"Dustin Chen",
"Blackfish95",
"Mouthlessman",
"Paul Kroll",
"Bas Imagineer",
"John Statham",
@@ -232,8 +234,11 @@
"MJG",
"David LaVallee",
"linnfrey",
"ae",
"Tr4shP4nda",
"IamAyam",
"skaterb949",
"Brian M",
"Josef Lanzl",
"Nerezza",
"sanborondon",
@@ -248,11 +253,10 @@
"Tee Gee",
"Geolog",
"tarek helmi",
"Neco28",
"Eris3D",
"Max Marklund",
"David Ortega",
"Pronredn",
"Jamie Ogletree",
"a _",
"Jeff",
"lh qwe",
@@ -272,8 +276,6 @@
"George",
"dw",
"地獄の禄",
"ae",
"Tr4shP4nda",
"Gamalonia",
"WRL_SPR",
"capn",
@@ -289,13 +291,14 @@
"Hailshem",
"kudari",
"Naomi Hale Danchi",
"ken",
"epicgamer0020690",
"Joshua Porrata",
"SuBu",
"RedPIXel",
"Richard",
"奚明 刘",
"Andrew",
"Brian M",
"Robert Wegemund",
"Littlehuggy",
"준희 김",
@@ -303,6 +306,7 @@
"Thought2Form",
"Kevin Picco",
"Sadlip",
"Joey Callahan",
"Tomohiro Baba",
"m",
"Noora",
@@ -311,10 +315,10 @@
"Mattssn",
"Mikko Hemilä",
"Jacob McDaniel",
"Jamie Ogletree",
"Temikus",
"Artokun",
"Michael Taylor",
"Derek Baker",
"Martial",
"Michael Anthony Scott",
"Emil Andersson",
@@ -338,10 +342,8 @@
"momokai",
"starbugx",
"dc7431",
"ken",
"Crocket",
"keemun",
"RedPIXel",
"Wind",
"Nexus",
"Ramneek“Guy”Ashok",
@@ -370,12 +372,13 @@
"Vir",
"Skyfire83",
"Adam Rinehart",
"Pitpe11",
"TheD1rtyD03",
"gzmzmvp",
"Gregory Kozhemiak",
"Draven T",
"mrjuan",
"Eric Whitney",
"Joey Callahan",
"Aquatic Coffee",
"Ivan Tadic",
"Mike Simone",
@@ -389,13 +392,13 @@
"X",
"Sloan Steddy",
"hexxish",
"Derek Baker",
"Anthony Faxlandez",
"battu",
"Nathan",
"NICHOLAS BAXLEY",
"Pat Hen",
"Xeeosat",
"Saya",
"Ed Wang",
"Jordan Shaw",
"g unit",
@@ -411,8 +414,6 @@
"Raku",
"smart.edge5178",
"Menard",
"Pitpe11",
"TheD1rtyD03",
"moonpetal",
"SomeDude",
"g9p0o",
@@ -444,9 +445,11 @@
"Shock Shockor",
"ACTUALLY_the_Real_Willem_Dafoe",
"Михал Михалыч",
"Matt",
"Goldwaters",
"Kauffy",
"Zude",
"SPJ",
"Kyler",
"Edward Kennedy",
"Justin Blaylock",
@@ -467,7 +470,6 @@
"Distortik",
"Filippo Ferrari",
"Youguang",
"Saya",
"andrewzpong",
"BossGame",
"lrdchs",
@@ -479,6 +481,8 @@
"Whitepinetrader",
"POPPIN",
"nanana",
"D",
"Dark_Pest",
"Alex",
"Karru",
"ChaChanoKo",
@@ -506,18 +510,20 @@
"Kalli Core",
"Christian Schäfer",
"りん あめ",
"Matt",
"Joaquin Hierrezuelo",
"Locrospiel",
"Frogmilk",
"SPJ",
"Sean voets",
"Kor",
"Joseph Hanson",
"John Rednoulf",
"Kyron Mahan",
"Bryan Rutkowski",
"TBitz33",
"Anonym dkjglfleeoeldldldlkf",
"Ezokewn",
"SendingRavens",
"Steven",
"JackJohnnyJim",
"TenaciousD",
"Dmitry Ryzhov",
@@ -558,6 +564,9 @@
"Scott",
"Muratoraccio",
"D",
"Mobius2020",
"ExLightSaber",
"YaboiRay",
"nickname",
"Sildoren",
"Darv",
@@ -583,8 +592,6 @@
"Inkognito",
"G",
"Tan+Huynh",
"D",
"Dark_Pest",
"Jacky+Ho",
"generic404",
"abattoirblues",
@@ -604,12 +611,9 @@
"Doug Mason",
"Jeremy Townsend",
"Dave Abraham",
"Joaquin Hierrezuelo",
"Sean voets",
"Owen Gwosdz",
"Jarrid Lee",
"Poophead27 Blyat",
"John Rednoulf",
"Spire",
"AZ Party Oasis",
"Boba Smith",
@@ -619,11 +623,12 @@
"Jack Dole",
"matt",
"somethingtosay8",
"Terminuz",
"ivistorm",
"max blo",
"Sauv",
"Steven",
"CptNeo",
"Borte",
"Maso",
"Ted Cart",
"Sage Himeros",
@@ -642,6 +647,7 @@
"Teriak47",
"Just me",
"Raf Stahelin",
"Nacho Ferrando",
"Вячеслав Маринин",
"Marcos Tortosa Carmona",
"Dkommander22",
@@ -688,6 +694,8 @@
"SelfishMedic",
"adderleighn",
"EnragedAntelope",
"shw",
"Celestial+Kitten",
"bakeliteboy",
"TequiTequi",
"Homero+Banda",
@@ -717,9 +725,6 @@
"PoorStudent",
"lucites",
"Alex+Zaw",
"Mobius2020",
"ExLightSaber",
"YaboiRay",
"Drizzly",
"Nebuleux",
"Join+Chun",
@@ -745,6 +750,7 @@
"Nico",
"Maximilian Krischan",
"Banana Joe",
"proto merp",
"_ G3n",
"Donovan Jenkins",
"Hans Meier",
@@ -766,6 +772,7 @@
"jumpd",
"John C",
"Rim",
"yfx507",
"Room Light",
"Jairus Knudsen",
"Xan Dionysus",
@@ -783,19 +790,20 @@
"TheFusion",
"Jean-françois SEMA",
"3zS4QNQ4",
"Terminuz",
"Kurt",
"Matt M.",
"Ivan Imes",
"J M",
"Slacks",
"Bouya shaka",
"john Greene",
"Faburizu",
"Jack Lawfield",
"jimyjomson",
"Borte",
"JaeHyun Jang",
"Homero Banda",
"Chase Kwon",
"Bob Ling",
"yyuvuvu",
"Inyoshu",
"Chad Barnes",
@@ -821,5 +829,5 @@
"Somebody",
"CK"
],
"totalCount": 818
"totalCount": 826
}

View File

@@ -145,6 +145,10 @@
},
"usage": {
"timesUsed": "Verwendungsanzahl"
},
"footer": {
"versionCount": "{count} Versionen",
"viewAllVersions": "Alle lokalen Versionen anzeigen"
}
},
"globalContextMenu": {
@@ -183,6 +187,9 @@
},
"manageExcludedModels": {
"label": "Ausgeschlossene Modelle verwalten"
},
"groupByModel": {
"label": "Nach Modell gruppieren"
}
},
"header": {
@@ -325,7 +332,7 @@
"extraFolderPaths": "Zusätzliche Ordnerpfade",
"downloadPathTemplates": "Download-Pfad-Vorlagen",
"priorityTags": "Prioritäts-Tags",
"updateFlags": "Update-Markierungen",
"versionScope": "Update-Markierungen",
"exampleImages": "Beispielbilder",
"autoOrganize": "Auto-Organisierung",
"metadata": "Metadaten",
@@ -430,6 +437,8 @@
"help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe."
},
"layoutSettings": {
"groupByModel": "Nach Modell gruppieren",
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes Civitai-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
"displayDensity": "Anzeige-Dichte",
"displayDensityOptions": {
"default": "Standard",
@@ -586,7 +595,7 @@
"download": "Herunterladen",
"restartRequired": "Neustart erforderlich"
},
"updateFlagStrategy": {
"versionGrouping": {
"label": "Strategie für Update-Markierungen",
"help": "Entscheide, ob Update-Badges nur dann erscheinen, wenn eine neue Version dasselbe Basismodell wie deine lokalen Dateien verwendet, oder sobald es irgendein neueres Release für dieses Modell gibt.",
"options": {
@@ -670,7 +679,10 @@
"sizeAsc": "Kleinste",
"usage": "Anzahl Nutzung",
"usageDesc": "Meiste",
"usageAsc": "Wenigste"
"usageAsc": "Wenigste",
"versionsCount": "Lokale Versionen",
"versionsCountDesc": "Meiste Versionen zuerst",
"versionsCountAsc": "Wenigste Versionen zuerst"
},
"refresh": {
"title": "Modelliste aktualisieren",
@@ -1016,6 +1028,18 @@
"storage": "Speicher",
"insights": "Erkenntnisse"
},
"metrics": {
"totalModels": "Modelle gesamt",
"totalStorage": "Speicher gesamt",
"totalGenerations": "Generationen gesamt",
"usageRate": "Nutzungsrate",
"loras": "LoRAs",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"uniqueTags": "Einzigartige Tags",
"unusedModels": "Ungenutzte Modelle",
"avgUsesPerModel": "Ø Nutzungen/Modell"
},
"usage": {
"mostUsedLoras": "Meistgenutzte LoRAs",
"mostUsedCheckpoints": "Meistgenutzte Checkpoints",
@@ -1033,13 +1057,77 @@
},
"insights": {
"smartInsights": "Intelligente Erkenntnisse",
"recommendations": "Empfehlungen"
"recommendations": "Empfehlungen",
"noInsights": "Keine Erkenntnisse verfügbar",
"unusedLoras": {
"high": {
"title": "Hohe Anzahl ungenutzter LoRAs",
"description": "{percent}% Ihrer LoRAs ({count}/{total}) wurden noch nie verwendet.",
"suggestion": "Erwägen Sie, ungenutzte Modelle zu organisieren oder zu archivieren, um Speicherplatz freizugeben."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Ungenutzte Checkpoints erkannt",
"description": "{percent}% Ihrer Checkpoints ({count}/{total}) wurden noch nie verwendet.",
"suggestion": "Überprüfen Sie nicht mehr benötigte Checkpoints und erwägen Sie deren Entfernung."
}
},
"unusedEmbeddings": {
"high": {
"title": "Hohe Anzahl ungenutzter Embeddings",
"description": "{percent}% Ihrer Embeddings ({count}/{total}) wurden noch nie verwendet.",
"suggestion": "Organisieren oder archivieren Sie ungenutzte Embeddings, um Ihre Sammlung zu optimieren."
}
},
"collection": {
"large": {
"title": "Große Sammlung erkannt",
"description": "Ihre Modellsammlung verwendet {size} Speicher.",
"suggestion": "Erwägen Sie externe Speicher- oder Cloud-Lösungen für eine bessere Organisation."
}
},
"activity": {
"active": {
"title": "Aktiver Benutzer",
"description": "Sie haben {count} Generationen abgeschlossen!",
"suggestion": "Entdecken und erstellen Sie weiterhin großartige Inhalte mit Ihren Modellen."
}
}
},
"charts": {
"collectionOverview": "Sammlungsübersicht",
"baseModelDistribution": "Basis-Modell-Verteilung",
"usageTrends": "Nutzungstrends (Letzte 30 Tage)",
"usageDistribution": "Nutzungsverteilung"
"usageDistribution": "Nutzungsverteilung",
"date": "Datum",
"usageCount": "Nutzungsanzahl",
"fileSizeBytes": "Dateigröße (Bytes)",
"models": "Modelle",
"loraUsage": "LoRA-Nutzung",
"checkpointUsage": "Checkpoint-Nutzung",
"embeddingUsage": "Embedding-Nutzung"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusionsmodell",
"embedding": "Embeddings"
},
"placeholders": {
"loading": "Lädt...",
"noModels": "Keine Modelle gefunden",
"errorLoading": "Fehler beim Laden der Daten",
"noStorageData": "Keine Speicherdaten verfügbar",
"rootFolder": "Root",
"chartLibraryMissing": "Diagramm benötigt Chart.js-Bibliothek"
},
"tooltips": {
"tagCount": "{tag}: {count} Modelle",
"chartUsage": "{name}: {size}, {count} Nutzungen",
"chartPercentage": "{label}: {value} ({pct}%)"
}
},
"modals": {

View File

@@ -145,6 +145,10 @@
},
"usage": {
"timesUsed": "Times used"
},
"footer": {
"versionCount": "{count} versions",
"viewAllVersions": "View all local versions"
}
},
"globalContextMenu": {
@@ -183,6 +187,9 @@
},
"manageExcludedModels": {
"label": "Manage Excluded Models"
},
"groupByModel": {
"label": "Group by Model"
}
},
"header": {
@@ -325,7 +332,7 @@
"extraFolderPaths": "Extra Folder Paths",
"downloadPathTemplates": "Download Path Templates",
"priorityTags": "Priority Tags",
"updateFlags": "Update Flags",
"versionScope": "Version Scope",
"exampleImages": "Example Images",
"autoOrganize": "Auto-organize",
"metadata": "Metadata",
@@ -430,6 +437,8 @@
"help": "When enabled, versions downloaded before will be skipped."
},
"layoutSettings": {
"groupByModel": "Group by Model",
"groupByModelHelp": "When enabled, only the latest version of each Civitai model is shown as a single card. Older versions are hidden.",
"displayDensity": "Display Density",
"displayDensityOptions": {
"default": "Default",
@@ -586,12 +595,12 @@
"download": "Download",
"restartRequired": "Requires restart"
},
"updateFlagStrategy": {
"label": "Update Flag Strategy",
"help": "Decide whether update badges should only appear when a new release shares the same base model as your local files or whenever any newer version exists for that model.",
"versionGrouping": {
"label": "Version Grouping",
"help": "Decide how versions are grouped for display: by base model or all together. Also controls update badge logic and the VLM version list filtering.",
"options": {
"sameBase": "Match updates by base model",
"any": "Flag any available update"
"sameBase": "Group by base model (same_base)",
"any": "Show all versions (any)"
}
},
"hideEarlyAccessUpdates": {
@@ -670,7 +679,10 @@
"sizeAsc": "Smallest",
"usage": "Use Count",
"usageDesc": "Most",
"usageAsc": "Least"
"usageAsc": "Least",
"versionsCount": "Local Versions",
"versionsCountDesc": "Most versions first",
"versionsCountAsc": "Fewest versions first"
},
"refresh": {
"title": "Refresh model list",
@@ -1016,6 +1028,18 @@
"storage": "Storage",
"insights": "Insights"
},
"metrics": {
"totalModels": "Total Models",
"totalStorage": "Total Storage",
"totalGenerations": "Total Generations",
"usageRate": "Usage Rate",
"loras": "LoRAs",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"uniqueTags": "Unique Tags",
"unusedModels": "Unused Models",
"avgUsesPerModel": "Avg. Uses/Model"
},
"usage": {
"mostUsedLoras": "Most Used LoRAs",
"mostUsedCheckpoints": "Most Used Checkpoints",
@@ -1033,13 +1057,77 @@
},
"insights": {
"smartInsights": "Smart Insights",
"recommendations": "Recommendations"
"recommendations": "Recommendations",
"noInsights": "No insights available",
"unusedLoras": {
"high": {
"title": "High Number of Unused LoRAs",
"description": "{percent}% of your LoRAs ({count}/{total}) have never been used.",
"suggestion": "Consider organizing or archiving unused models to free up storage space."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Unused Checkpoints Detected",
"description": "{percent}% of your checkpoints ({count}/{total}) have never been used.",
"suggestion": "Review and consider removing checkpoints you no longer need."
}
},
"unusedEmbeddings": {
"high": {
"title": "High Number of Unused Embeddings",
"description": "{percent}% of your embeddings ({count}/{total}) have never been used.",
"suggestion": "Consider organizing or archiving unused embeddings to optimize your collection."
}
},
"collection": {
"large": {
"title": "Large Collection Detected",
"description": "Your model collection is using {size} of storage.",
"suggestion": "Consider using external storage or cloud solutions for better organization."
}
},
"activity": {
"active": {
"title": "Active User",
"description": "You've completed {count} generations so far!",
"suggestion": "Keep exploring and creating amazing content with your models."
}
}
},
"charts": {
"collectionOverview": "Collection Overview",
"baseModelDistribution": "Base Model Distribution",
"usageTrends": "Usage Trends (Last 30 Days)",
"usageDistribution": "Usage Distribution"
"usageDistribution": "Usage Distribution",
"date": "Date",
"usageCount": "Usage Count",
"fileSizeBytes": "File Size (bytes)",
"models": "Models",
"loraUsage": "LoRA Usage",
"checkpointUsage": "Checkpoint Usage",
"embeddingUsage": "Embedding Usage"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model",
"embedding": "Embeddings"
},
"placeholders": {
"loading": "Loading...",
"noModels": "No models found",
"errorLoading": "Error loading data",
"noStorageData": "No storage data available",
"rootFolder": "Root",
"chartLibraryMissing": "Chart requires Chart.js library"
},
"tooltips": {
"tagCount": "{tag}: {count} models",
"chartUsage": "{name}: {size}, {count} uses",
"chartPercentage": "{label}: {value} ({pct}%)"
}
},
"modals": {
@@ -1387,7 +1475,7 @@
"resumeModelUpdates": "Resume updates for this model",
"ignoreModelUpdates": "Ignore updates for this model",
"viewLocalVersions": "View all local versions",
"viewLocalTooltip": "Coming soon"
"viewLocalTooltip": "Show all local versions of this model on the main page"
},
"filters": {
"label": "Base filter",

View File

@@ -145,6 +145,10 @@
},
"usage": {
"timesUsed": "Veces usado"
},
"footer": {
"versionCount": "{count} versiones",
"viewAllVersions": "Ver todas las versiones locales"
}
},
"globalContextMenu": {
@@ -183,6 +187,9 @@
},
"manageExcludedModels": {
"label": "Gestionar modelos excluidos"
},
"groupByModel": {
"label": "Agrupar por modelo"
}
},
"header": {
@@ -325,7 +332,7 @@
"extraFolderPaths": "Rutas de carpetas adicionales",
"downloadPathTemplates": "Plantillas de rutas de descarga",
"priorityTags": "Etiquetas prioritarias",
"updateFlags": "Indicadores de actualización",
"versionScope": "Indicadores de actualización",
"exampleImages": "Imágenes de ejemplo",
"autoOrganize": "Organización automática",
"metadata": "Metadatos",
@@ -430,6 +437,8 @@
"help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga."
},
"layoutSettings": {
"groupByModel": "Agrupar por modelo",
"groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de Civitai como una tarjeta única. Las versiones anteriores están ocultas.",
"displayDensity": "Densidad de visualización",
"displayDensityOptions": {
"default": "Predeterminado",
@@ -586,7 +595,7 @@
"download": "Descargar",
"restartRequired": "Requiere reinicio"
},
"updateFlagStrategy": {
"versionGrouping": {
"label": "Estrategia de indicadores de actualización",
"help": "Decide si las insignias de actualización deben mostrarse solo cuando una nueva versión comparte el mismo modelo base que tus archivos locales o siempre que exista cualquier versión más reciente de ese modelo.",
"options": {
@@ -670,7 +679,10 @@
"sizeAsc": "Menor",
"usage": "Número de usos",
"usageDesc": "Más",
"usageAsc": "Menos"
"usageAsc": "Menos",
"versionsCount": "Versiones locales",
"versionsCountDesc": "Más versiones primero",
"versionsCountAsc": "Menos versiones primero"
},
"refresh": {
"title": "Actualizar lista de modelos",
@@ -1016,6 +1028,18 @@
"storage": "Almacenamiento",
"insights": "Perspectivas"
},
"metrics": {
"totalModels": "Total de modelos",
"totalStorage": "Almacenamiento total",
"totalGenerations": "Generaciones totales",
"usageRate": "Tasa de uso",
"loras": "LoRAs",
"checkpoints": "Puntos de control",
"embeddings": "Embeddings",
"uniqueTags": "Etiquetas únicas",
"unusedModels": "Modelos no usados",
"avgUsesPerModel": "Prom. usos/modelo"
},
"usage": {
"mostUsedLoras": "LoRAs más utilizados",
"mostUsedCheckpoints": "Checkpoints más utilizados",
@@ -1033,13 +1057,77 @@
},
"insights": {
"smartInsights": "Perspectivas inteligentes",
"recommendations": "Recomendaciones"
"recommendations": "Recomendaciones",
"noInsights": "No hay información disponible",
"unusedLoras": {
"high": {
"title": "Alta cantidad de LoRAs no utilizadas",
"description": "El {percent}% de tus LoRAs ({count}/{total}) nunca se han utilizado.",
"suggestion": "Considera organizar o archivar modelos no utilizados para liberar espacio."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Puntos de control no utilizados detectados",
"description": "El {percent}% de tus puntos de control ({count}/{total}) nunca se han utilizado.",
"suggestion": "Revisa y considera eliminar los puntos de control que ya no necesites."
}
},
"unusedEmbeddings": {
"high": {
"title": "Alta cantidad de Embeddings no utilizados",
"description": "El {percent}% de tus embeddings ({count}/{total}) nunca se han utilizado.",
"suggestion": "Considera organizar o archivar embeddings no utilizados para optimizar tu colección."
}
},
"collection": {
"large": {
"title": "Colección grande detectada",
"description": "Tu colección de modelos está usando {size} de almacenamiento.",
"suggestion": "Considera usar almacenamiento externo o soluciones en la nube para una mejor organización."
}
},
"activity": {
"active": {
"title": "Usuario activo",
"description": "¡Has completado {count} generaciones hasta ahora!",
"suggestion": "Sigue explorando y creando contenido increíble con tus modelos."
}
}
},
"charts": {
"collectionOverview": "Resumen de colección",
"baseModelDistribution": "Distribución de modelo base",
"usageTrends": "Tendencias de uso (Últimos 30 días)",
"usageDistribution": "Distribución de uso"
"usageDistribution": "Distribución de uso",
"date": "Fecha",
"usageCount": "Conteo de uso",
"fileSizeBytes": "Tamaño del archivo (bytes)",
"models": "Modelos",
"loraUsage": "Uso de LoRA",
"checkpointUsage": "Uso de Checkpoint",
"embeddingUsage": "Uso de Embedding"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Punto de control",
"diffusion_model": "Modelo de difusión",
"embedding": "Embeddings"
},
"placeholders": {
"loading": "Cargando...",
"noModels": "No se encontraron modelos",
"errorLoading": "Error al cargar datos",
"noStorageData": "No hay datos de almacenamiento disponibles",
"rootFolder": "Raíz",
"chartLibraryMissing": "El gráfico requiere la librería Chart.js"
},
"tooltips": {
"tagCount": "{tag}: {count} modelos",
"chartUsage": "{name}: {size}, {count} usos",
"chartPercentage": "{label}: {value} ({pct}%)"
}
},
"modals": {

View File

@@ -145,6 +145,10 @@
},
"usage": {
"timesUsed": "Nombre d'utilisations"
},
"footer": {
"versionCount": "{count} versions",
"viewAllVersions": "Voir toutes les versions locales"
}
},
"globalContextMenu": {
@@ -183,6 +187,9 @@
},
"manageExcludedModels": {
"label": "Gérer les modèles exclus"
},
"groupByModel": {
"label": "Grouper par modèle"
}
},
"header": {
@@ -325,7 +332,7 @@
"extraFolderPaths": "Chemins de dossiers supplémentaires",
"downloadPathTemplates": "Modèles de chemin de téléchargement",
"priorityTags": "Étiquettes prioritaires",
"updateFlags": "Indicateurs de mise à jour",
"versionScope": "Indicateurs de mise à jour",
"exampleImages": "Images d'exemple",
"autoOrganize": "Organisation automatique",
"metadata": "Métadonnées",
@@ -430,6 +437,8 @@
"help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement."
},
"layoutSettings": {
"groupByModel": "Grouper par modèle",
"groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle Civitai s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.",
"displayDensity": "Densité d'affichage",
"displayDensityOptions": {
"default": "Par défaut",
@@ -586,7 +595,7 @@
"download": "Télécharger",
"restartRequired": "Redémarrage requis"
},
"updateFlagStrategy": {
"versionGrouping": {
"label": "Stratégie des indicateurs de mise à jour",
"help": "Choisissez si les badges de mise à jour doivent apparaître uniquement lorsquune nouvelle version partage le même modèle de base que vos fichiers locaux, ou dès quil existe une version plus récente pour ce modèle.",
"options": {
@@ -670,7 +679,10 @@
"sizeAsc": "Plus petit",
"usage": "Nombre d'utilisations",
"usageDesc": "Plus",
"usageAsc": "Moins"
"usageAsc": "Moins",
"versionsCount": "Versions locales",
"versionsCountDesc": "Plus de versions d'abord",
"versionsCountAsc": "Moins de versions d'abord"
},
"refresh": {
"title": "Actualiser la liste des modèles",
@@ -1016,6 +1028,18 @@
"storage": "Stockage",
"insights": "Aperçus"
},
"metrics": {
"totalModels": "Total des modèles",
"totalStorage": "Stockage total",
"totalGenerations": "Générations totales",
"usageRate": "Taux d'utilisation",
"loras": "LoRAs",
"checkpoints": "Points de contrôle",
"embeddings": "Embeddings",
"uniqueTags": "Tags uniques",
"unusedModels": "Modèles inutilisés",
"avgUsesPerModel": "Moy. utilisations/modèle"
},
"usage": {
"mostUsedLoras": "LoRAs les plus utilisés",
"mostUsedCheckpoints": "Checkpoints les plus utilisés",
@@ -1033,13 +1057,77 @@
},
"insights": {
"smartInsights": "Aperçus intelligents",
"recommendations": "Recommandations"
"recommendations": "Recommandations",
"noInsights": "Aucun aperçu disponible",
"unusedLoras": {
"high": {
"title": "Nombre élevé de LoRAs inutilisées",
"description": "{percent}% de vos LoRAs ({count}/{total}) n'ont jamais été utilisées.",
"suggestion": "Envisagez d'organiser ou d'archiver les modèles inutilisés pour libérer de l'espace."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Points de contrôle inutilisés détectés",
"description": "{percent}% de vos points de contrôle ({count}/{total}) n'ont jamais été utilisés.",
"suggestion": "Examinez et envisagez de supprimer les points de contrôle dont vous n'avez plus besoin."
}
},
"unusedEmbeddings": {
"high": {
"title": "Nombre élevé d'Embeddings inutilisées",
"description": "{percent}% de vos embeddings ({count}/{total}) n'ont jamais été utilisées.",
"suggestion": "Envisagez d'organiser ou d'archiver les embeddings inutilisées pour optimiser votre collection."
}
},
"collection": {
"large": {
"title": "Grande collection détectée",
"description": "Votre collection de modèles utilise {size} de stockage.",
"suggestion": "Envisagez d'utiliser un stockage externe ou des solutions cloud pour une meilleure organisation."
}
},
"activity": {
"active": {
"title": "Utilisateur actif",
"description": "Vous avez effectué {count} générations jusqu'à présent !",
"suggestion": "Continuez à explorer et à créer du contenu formidable avec vos modèles."
}
}
},
"charts": {
"collectionOverview": "Aperçu de la collection",
"baseModelDistribution": "Distribution des modèles de base",
"usageTrends": "Tendances d'utilisation (30 derniers jours)",
"usageDistribution": "Distribution de l'utilisation"
"usageDistribution": "Distribution de l'utilisation",
"date": "Date",
"usageCount": "Nombre d'utilisations",
"fileSizeBytes": "Taille du fichier (octets)",
"models": "Modèles",
"loraUsage": "Utilisation LoRA",
"checkpointUsage": "Utilisation Checkpoint",
"embeddingUsage": "Utilisation Embedding"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Point de contrôle",
"diffusion_model": "Modèle de diffusion",
"embedding": "Embeddings"
},
"placeholders": {
"loading": "Chargement...",
"noModels": "Aucun modèle trouvé",
"errorLoading": "Erreur de chargement des données",
"noStorageData": "Aucune donnée de stockage disponible",
"rootFolder": "Racine",
"chartLibraryMissing": "Le graphique nécessite la bibliothèque Chart.js"
},
"tooltips": {
"tagCount": "{tag}: {count} modèles",
"chartUsage": "{name}: {size}, {count} utilisations",
"chartPercentage": "{label}: {value} ({pct}%)"
}
},
"modals": {

View File

@@ -145,6 +145,10 @@
},
"usage": {
"timesUsed": "מספר שימושים"
},
"footer": {
"versionCount": "{count} גרסאות",
"viewAllVersions": "הצג את כל הגרסאות המקומיות"
}
},
"globalContextMenu": {
@@ -183,6 +187,9 @@
},
"manageExcludedModels": {
"label": "ניהול מודלים מוחרגים"
},
"groupByModel": {
"label": "קיבוץ לפי דגם"
}
},
"header": {
@@ -325,7 +332,7 @@
"extraFolderPaths": "נתיבי תיקיות נוספים",
"downloadPathTemplates": "תבניות נתיב הורדה",
"priorityTags": "תגיות עדיפות",
"updateFlags": "תגי עדכון",
"versionScope": "תגי עדכון",
"exampleImages": "תמונות דוגמה",
"autoOrganize": "ארגון אוטומטי",
"metadata": "מטא-נתונים",
@@ -430,6 +437,8 @@
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
},
"layoutSettings": {
"groupByModel": "קיבוץ לפי דגם",
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
"displayDensity": "צפיפות תצוגה",
"displayDensityOptions": {
"default": "ברירת מחדל",
@@ -586,7 +595,7 @@
"download": "הורד",
"restartRequired": "דורש הפעלה מחדש"
},
"updateFlagStrategy": {
"versionGrouping": {
"label": "אסטרטגיית תגי עדכון",
"help": "בחרו אם תוויות העדכון יוצגו רק כאשר גרסה חדשה חולקת את אותו דגם בסיס כמו הקבצים המקומיים שלכם או בכל מקרה שבו קיימת גרסה חדשה עבור אותו דגם.",
"options": {
@@ -670,7 +679,10 @@
"sizeAsc": "הקטן ביותר",
"usage": "מספר שימושים",
"usageDesc": "הכי הרבה",
"usageAsc": "הכי פחות"
"usageAsc": "הכי פחות",
"versionsCount": "גרסאות מקומיות",
"versionsCountDesc": "הכי הרבה גרסאות ראשונות",
"versionsCountAsc": "הכי מעט גרסאות ראשונות"
},
"refresh": {
"title": "רענן רשימת מודלים",
@@ -1016,6 +1028,18 @@
"storage": "אחסון",
"insights": "תובנות"
},
"metrics": {
"totalModels": "סה\"כ דגמים",
"totalStorage": "סה\"כ אחסון",
"totalGenerations": "סה\"כ יצירות",
"usageRate": "שיעור שימוש",
"loras": "LoRA",
"checkpoints": "נקודות ביקורת",
"embeddings": "הטמעות",
"uniqueTags": "תגיות ייחודיות",
"unusedModels": "דגמים שאינם בשימוש",
"avgUsesPerModel": "ממוצע שימושים/דגם"
},
"usage": {
"mostUsedLoras": "LoRAs הנפוצים ביותר",
"mostUsedCheckpoints": "Checkpoints הנפוצים ביותר",
@@ -1033,13 +1057,77 @@
},
"insights": {
"smartInsights": "תובנות חכמות",
"recommendations": "המלצות"
"recommendations": "המלצות",
"noInsights": "אין תובנות זמינות",
"unusedLoras": {
"high": {
"title": "כמות גבוהה של LoRAs שאינן בשימוש",
"description": "{percent}% מה-LoRAs שלך ({count}/{total}) מעולם לא נעשה בהם שימוש.",
"suggestion": "שקול לארגן או לאחסן בארכיון מודלים שאינם בשימוש כדי לפנות שטח אחסון."
}
},
"unusedCheckpoints": {
"detected": {
"title": "התגלו נקודות ביקורת שאינן בשימוש",
"description": "{percent}% מנקודות הביקורת שלך ({count}/{total}) מעולם לא נעשה בהן שימוש.",
"suggestion": "בדוק ושקול להסיר נקודות ביקורת שאינך צריך עוד."
}
},
"unusedEmbeddings": {
"high": {
"title": "כמות גבוהה של Embeddings שאינם בשימוש",
"description": "{percent}% מה-Embeddings שלך ({count}/{total}) מעולם לא נעשה בהם שימוש.",
"suggestion": "שקול לארגן או לאחסן בארכיון Embeddings שאינם בשימוש כדי לייעל את האוסף."
}
},
"collection": {
"large": {
"title": "התגלה אוסף גדול",
"description": "אוסף המודלים שלך משתמש ב-{size} של אחסון.",
"suggestion": "שקול להשתמש באחסון חיצוני או בפתרונות ענן לארגון טוב יותר."
}
},
"activity": {
"active": {
"title": "משתמש פעיל",
"description": "השלמת {count} יצירות עד כה!",
"suggestion": "המשך לחקור וליצור תוכן מדהים עם המודלים שלך."
}
}
},
"charts": {
"collectionOverview": "סקירת אוסף",
"baseModelDistribution": "התפלגות מודלי בסיס",
"usageTrends": "מגמות שימוש (30 יום אחרונים)",
"usageDistribution": "התפלגות שימוש"
"usageDistribution": "התפלגות שימוש",
"date": "תאריך",
"usageCount": "מספר שימושים",
"fileSizeBytes": "גודל קובץ (בתים)",
"models": "דגמים",
"loraUsage": "שימוש ב-LoRA",
"checkpointUsage": "שימוש ב-Checkpoint",
"embeddingUsage": "שימוש ב-Embedding"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "נקודת ביקורת",
"diffusion_model": "מודל דיפוזיה",
"embedding": "הטמעות"
},
"placeholders": {
"loading": "טוען...",
"noModels": "לא נמצאו דגמים",
"errorLoading": "שגיאה בטעינת נתונים",
"noStorageData": "אין נתוני אחסון זמינים",
"rootFolder": "שורש",
"chartLibraryMissing": "הגרף דורש את ספריית Chart.js"
},
"tooltips": {
"tagCount": "{tag}: {count} דגמים",
"chartUsage": "{name}: {size}, {count} שימושים",
"chartPercentage": "{label}: {value} ({pct}%)"
}
},
"modals": {

View File

@@ -145,6 +145,10 @@
},
"usage": {
"timesUsed": "使用回数"
},
"footer": {
"versionCount": "{count} バージョン",
"viewAllVersions": "ローカルの全バージョンを表示"
}
},
"globalContextMenu": {
@@ -183,6 +187,9 @@
},
"manageExcludedModels": {
"label": "除外モデルを管理"
},
"groupByModel": {
"label": "モデルでグループ化"
}
},
"header": {
@@ -325,7 +332,7 @@
"extraFolderPaths": "追加フォルダーパス",
"downloadPathTemplates": "ダウンロードパステンプレート",
"priorityTags": "優先タグ",
"updateFlags": "アップデートフラグ",
"versionScope": "アップデートフラグ",
"exampleImages": "例画像",
"autoOrganize": "自動整理",
"metadata": "メタデータ",
@@ -430,6 +437,8 @@
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
},
"layoutSettings": {
"groupByModel": "モデルでグループ化",
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
"displayDensity": "表示密度",
"displayDensityOptions": {
"default": "デフォルト",
@@ -586,7 +595,7 @@
"download": "ダウンロード",
"restartRequired": "再起動が必要"
},
"updateFlagStrategy": {
"versionGrouping": {
"label": "アップデートフラグの表示戦略",
"help": "新リリースがローカルファイルと同じベースモデルを共有する場合にのみ更新バッジを表示するか、そのモデルに新しいバージョンがあれば常に表示するかを決めます。",
"options": {
@@ -670,7 +679,10 @@
"sizeAsc": "小さい順",
"usage": "使用回数",
"usageDesc": "多い",
"usageAsc": "少ない"
"usageAsc": "少ない",
"versionsCount": "ローカルバージョン数",
"versionsCountDesc": "バージョン数の多い順",
"versionsCountAsc": "バージョン数の少ない順"
},
"refresh": {
"title": "モデルリストを更新",
@@ -1016,6 +1028,18 @@
"storage": "ストレージ",
"insights": "インサイト"
},
"metrics": {
"totalModels": "モデル総数",
"totalStorage": "ストレージ合計",
"totalGenerations": "生成回数合計",
"usageRate": "使用率",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "ユニークタグ",
"unusedModels": "未使用モデル",
"avgUsesPerModel": "平均使用回数/モデル"
},
"usage": {
"mostUsedLoras": "最も使用されているLoRA",
"mostUsedCheckpoints": "最も使用されているCheckpoint",
@@ -1033,13 +1057,77 @@
},
"insights": {
"smartInsights": "スマートインサイト",
"recommendations": "推奨事項"
"recommendations": "推奨事項",
"noInsights": "インサイトはありません",
"unusedLoras": {
"high": {
"title": "未使用のLoRAが多数あります",
"description": "LoRAの{percent}%{count}/{total})が一度も使用されていません。",
"suggestion": "未使用のモデルを整理またはアーカイブしてストレージを解放してください。"
}
},
"unusedCheckpoints": {
"detected": {
"title": "未使用のCheckpointを検出",
"description": "Checkpointの{percent}%{count}/{total})が一度も使用されていません。",
"suggestion": "不要なCheckpointを確認して削除を検討してください。"
}
},
"unusedEmbeddings": {
"high": {
"title": "未使用のEmbeddingが多数あります",
"description": "Embeddingの{percent}%{count}/{total})が一度も使用されていません。",
"suggestion": "未使用のEmbeddingを整理またはアーカイブしてコレクションを最適化してください。"
}
},
"collection": {
"large": {
"title": "大規模コレクションを検出",
"description": "モデルコレクションが{size}のストレージを使用しています。",
"suggestion": "外部ストレージやクラウドソリューションの使用を検討してください。"
}
},
"activity": {
"active": {
"title": "アクティブユーザー",
"description": "これまでに{count}回の生成を完了しました!",
"suggestion": "モデルを使って素晴らしいコンテンツを作り続けてください。"
}
}
},
"charts": {
"collectionOverview": "コレクション概要",
"baseModelDistribution": "ベースモデル分布",
"usageTrends": "使用傾向過去30日",
"usageDistribution": "使用分布"
"usageDistribution": "使用分布",
"date": "日付",
"usageCount": "使用回数",
"fileSizeBytes": "ファイルサイズ(バイト)",
"models": "モデル",
"loraUsage": "LoRA 使用量",
"checkpointUsage": "Checkpoint 使用量",
"embeddingUsage": "Embedding 使用量"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "拡散モデル",
"embedding": "Embedding"
},
"placeholders": {
"loading": "読み込み中...",
"noModels": "モデルが見つかりません",
"errorLoading": "データ読み込みエラー",
"noStorageData": "ストレージデータがありません",
"rootFolder": "ルート",
"chartLibraryMissing": "Chart.js ライブラリが必要です"
},
"tooltips": {
"tagCount": "{tag}: {count} モデル",
"chartUsage": "{name}: {size}, {count} 回使用",
"chartPercentage": "{label}: {value} ({pct}%)"
}
},
"modals": {

View File

@@ -145,6 +145,10 @@
},
"usage": {
"timesUsed": "사용 횟수"
},
"footer": {
"versionCount": "{count}개 버전",
"viewAllVersions": "모든 로컬 버전 보기"
}
},
"globalContextMenu": {
@@ -183,6 +187,9 @@
},
"manageExcludedModels": {
"label": "제외된 모델 관리"
},
"groupByModel": {
"label": "모델별 그룹화"
}
},
"header": {
@@ -325,7 +332,7 @@
"extraFolderPaths": "추가 폴다 경로",
"downloadPathTemplates": "다운로드 경로 템플릿",
"priorityTags": "우선순위 태그",
"updateFlags": "업데이트 표시",
"versionScope": "업데이트 표시",
"exampleImages": "예시 이미지",
"autoOrganize": "자동 정리",
"metadata": "메타데이터",
@@ -430,6 +437,8 @@
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
},
"layoutSettings": {
"groupByModel": "모델별 그룹화",
"groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
"displayDensity": "표시 밀도",
"displayDensityOptions": {
"default": "기본",
@@ -586,7 +595,7 @@
"download": "다운로드",
"restartRequired": "재시작 필요"
},
"updateFlagStrategy": {
"versionGrouping": {
"label": "업데이트 표시 전략",
"help": "새 릴리스가 로컬 파일과 동일한 베이스 모델을 공유할 때만 업데이트 배지를 표시할지, 또는 해당 모델에 사용 가능한 새 버전이 있으면 항상 표시할지 결정합니다.",
"options": {
@@ -670,7 +679,10 @@
"sizeAsc": "작은 순서",
"usage": "사용 횟수",
"usageDesc": "많은 순",
"usageAsc": "적은 순"
"usageAsc": "적은 순",
"versionsCount": "로컬 버전 수",
"versionsCountDesc": "버전 수 많은 순",
"versionsCountAsc": "버전 수 적은 순"
},
"refresh": {
"title": "모델 목록 새로고침",
@@ -1016,6 +1028,18 @@
"storage": "저장소",
"insights": "인사이트"
},
"metrics": {
"totalModels": "모델 총계",
"totalStorage": "총 저장 공간",
"totalGenerations": "총 생성 횟수",
"usageRate": "사용률",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "고유 태그",
"unusedModels": "미사용 모델",
"avgUsesPerModel": "모델당 평균 사용"
},
"usage": {
"mostUsedLoras": "가장 많이 사용된 LoRA",
"mostUsedCheckpoints": "가장 많이 사용된 Checkpoint",
@@ -1033,13 +1057,77 @@
},
"insights": {
"smartInsights": "스마트 인사이트",
"recommendations": "추천"
"recommendations": "추천",
"noInsights": "인사이트 없음",
"unusedLoras": {
"high": {
"title": "사용하지 않은 LoRA가 많음",
"description": "LoRA의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
"suggestion": "사용하지 않는 모델을 정리하거나 보관하여 저장 공간을 확보하세요."
}
},
"unusedCheckpoints": {
"detected": {
"title": "사용하지 않은 Checkpoint 감지",
"description": "Checkpoint의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
"suggestion": "더 이상 필요하지 않은 Checkpoint를 검토하고 제거하세요."
}
},
"unusedEmbeddings": {
"high": {
"title": "사용하지 않은 Embedding이 많음",
"description": "Embedding의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
"suggestion": "사용하지 않는 Embedding을 정리하여 컬렉션을 최적화하세요."
}
},
"collection": {
"large": {
"title": "대규모 컬렉션 감지",
"description": "모델 컬렉션이 {size}의 저장 공간을 사용 중입니다.",
"suggestion": "더 나은 관리를 위해 외부 저장소나 클라우드 솔루션을 고려하세요."
}
},
"activity": {
"active": {
"title": "활성 사용자",
"description": "지금까지 {count}번의 생성을 완료했습니다!",
"suggestion": "모델로 계속해서 멋진 콘텐츠를 탐색하고 만들어보세요."
}
}
},
"charts": {
"collectionOverview": "컬렉션 개요",
"baseModelDistribution": "베이스 모델 분포",
"usageTrends": "사용량 트렌드 (최근 30일)",
"usageDistribution": "사용량 분포"
"usageDistribution": "사용량 분포",
"date": "날짜",
"usageCount": "사용 횟수",
"fileSizeBytes": "파일 크기(바이트)",
"models": "모델",
"loraUsage": "LoRA 사용량",
"checkpointUsage": "Checkpoint 사용량",
"embeddingUsage": "Embedding 사용량"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "확산 모델",
"embedding": "Embedding"
},
"placeholders": {
"loading": "로딩 중...",
"noModels": "모델을 찾을 수 없음",
"errorLoading": "데이터 로딩 오류",
"noStorageData": "저장 데이터 없음",
"rootFolder": "루트",
"chartLibraryMissing": "Chart.js 라이브러리가 필요합니다"
},
"tooltips": {
"tagCount": "{tag}: {count}개 모델",
"chartUsage": "{name}: {size}, {count}회 사용",
"chartPercentage": "{label}: {value}({pct}%)"
}
},
"modals": {

View File

@@ -145,6 +145,10 @@
},
"usage": {
"timesUsed": "Количество использований"
},
"footer": {
"versionCount": "{count} версий",
"viewAllVersions": "Показать все локальные версии"
}
},
"globalContextMenu": {
@@ -183,6 +187,9 @@
},
"manageExcludedModels": {
"label": "Управление исключёнными моделями"
},
"groupByModel": {
"label": "Группировать по модели"
}
},
"header": {
@@ -325,7 +332,7 @@
"extraFolderPaths": "Дополнительные пути к папкам",
"downloadPathTemplates": "Шаблоны путей загрузки",
"priorityTags": "Приоритетные теги",
"updateFlags": "Метки обновлений",
"versionScope": "Метки обновлений",
"exampleImages": "Примеры изображений",
"autoOrganize": "Автоорганизация",
"metadata": "Метаданные",
@@ -430,6 +437,8 @@
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
},
"layoutSettings": {
"groupByModel": "Группировать по модели",
"groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.",
"displayDensity": "Плотность отображения",
"displayDensityOptions": {
"default": "По умолчанию",
@@ -586,7 +595,7 @@
"download": "Загрузить",
"restartRequired": "Требует перезапуска"
},
"updateFlagStrategy": {
"versionGrouping": {
"label": "Стратегия меток обновлений",
"help": "Выберите, отображать ли значки обновления только когда новая версия имеет тот же базовый модель, что и локальные файлы, или всегда при наличии любого нового релиза для этой модели.",
"options": {
@@ -670,7 +679,10 @@
"sizeAsc": "Наименьшим",
"usage": "Число использований",
"usageDesc": "Больше",
"usageAsc": "Меньше"
"usageAsc": "Меньше",
"versionsCount": "Локальные версии",
"versionsCountDesc": "Сначала больше версий",
"versionsCountAsc": "Сначала меньше версий"
},
"refresh": {
"title": "Обновить список моделей",
@@ -1016,6 +1028,18 @@
"storage": "Хранение",
"insights": "Аналитика"
},
"metrics": {
"totalModels": "Всего моделей",
"totalStorage": "Всего хранилища",
"totalGenerations": "Всего генераций",
"usageRate": "Коэффициент использования",
"loras": "LoRA",
"checkpoints": "Контрольные точки",
"embeddings": "Эмбеддинги",
"uniqueTags": "Уникальные теги",
"unusedModels": "Неиспользуемые модели",
"avgUsesPerModel": "Сред. использований/модель"
},
"usage": {
"mostUsedLoras": "Наиболее используемые LoRAs",
"mostUsedCheckpoints": "Наиболее используемые Checkpoints",
@@ -1033,13 +1057,77 @@
},
"insights": {
"smartInsights": "Умная аналитика",
"recommendations": "Рекомендации"
"recommendations": "Рекомендации",
"noInsights": "Нет доступных данных",
"unusedLoras": {
"high": {
"title": "Большое количество неиспользуемых LoRA",
"description": "{percent}% ваших LoRA ({count}/{total}) никогда не использовались.",
"suggestion": "Рассмотрите возможность организации или архивирования неиспользуемых моделей для освобождения места."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Обнаружены неиспользуемые контрольные точки",
"description": "{percent}% ваших контрольных точек ({count}/{total}) никогда не использовались.",
"suggestion": "Проверьте и удалите ненужные контрольные точки."
}
},
"unusedEmbeddings": {
"high": {
"title": "Большое количество неиспользуемых эмбеддингов",
"description": "{percent}% ваших эмбеддингов ({count}/{total}) никогда не использовались.",
"suggestion": "Организуйте или архивируйте неиспользуемые эмбеддинги для оптимизации коллекции."
}
},
"collection": {
"large": {
"title": "Обнаружена большая коллекция",
"description": "Ваша коллекция моделей использует {size} хранилища.",
"suggestion": "Рассмотрите внешнее хранилище или облачные решения для лучшей организации."
}
},
"activity": {
"active": {
"title": "Активный пользователь",
"description": "Вы завершили {count} генераций!",
"suggestion": "Продолжайте исследовать и создавать удивительный контент с вашими моделями."
}
}
},
"charts": {
"collectionOverview": "Обзор коллекции",
"baseModelDistribution": "Распределение базовых моделей",
"usageTrends": "Тенденции использования (за последние 30 дней)",
"usageDistribution": "Распределение использования"
"usageDistribution": "Распределение использования",
"date": "Дата",
"usageCount": "Количество использований",
"fileSizeBytes": "Размер файла (байты)",
"models": "Модели",
"loraUsage": "Использование LoRA",
"checkpointUsage": "Использование Checkpoint",
"embeddingUsage": "Использование Embedding"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Контрольная точка",
"diffusion_model": "Диффузионная модель",
"embedding": "Эмбеддинги"
},
"placeholders": {
"loading": "Загрузка...",
"noModels": "Модели не найдены",
"errorLoading": "Ошибка загрузки данных",
"noStorageData": "Нет данных о хранилище",
"rootFolder": "Корень",
"chartLibraryMissing": "Для графика требуется библиотека Chart.js"
},
"tooltips": {
"tagCount": "{tag}: {count} моделей",
"chartUsage": "{name}: {size}, {count} использований",
"chartPercentage": "{label}: {value} ({pct}%)"
}
},
"modals": {

View File

@@ -145,6 +145,10 @@
},
"usage": {
"timesUsed": "使用次数"
},
"footer": {
"versionCount": "{count} 个版本",
"viewAllVersions": "查看所有本地版本"
}
},
"globalContextMenu": {
@@ -183,6 +187,9 @@
},
"manageExcludedModels": {
"label": "管理已排除的模型"
},
"groupByModel": {
"label": "按模型分组"
}
},
"header": {
@@ -325,7 +332,7 @@
"extraFolderPaths": "额外文件夹路径",
"downloadPathTemplates": "下载路径模板",
"priorityTags": "优先标签",
"updateFlags": "更新标记",
"versionScope": "版本范围",
"exampleImages": "示例图片",
"autoOrganize": "自动整理",
"metadata": "元数据",
@@ -430,6 +437,8 @@
"help": "启用后如果下载历史服务记录显示该版本已下载LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
},
"layoutSettings": {
"groupByModel": "按模型分组",
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
"displayDensity": "显示密度",
"displayDensityOptions": {
"default": "默认",
@@ -586,12 +595,12 @@
"download": "下载",
"restartRequired": "需要重启"
},
"updateFlagStrategy": {
"label": "更新标记策略",
"help": "决定更新徽章是否仅在新版本与本地文件共享相同基础模型时显示,或只要该模型有任何更新版本就显示。",
"versionGrouping": {
"label": "版本分组",
"help": "控制版本在 UI 中的分组方式:按基础模型分组或合并显示。同时影响更新徽章逻辑和版本列表的筛选行为。",
"options": {
"sameBase": "按基础模型匹配更新",
"any": "显示任何可用更新"
"sameBase": "按基础模型分组",
"any": "显示所有版本"
}
},
"hideEarlyAccessUpdates": {
@@ -670,7 +679,10 @@
"sizeAsc": "最小",
"usage": "使用次数",
"usageDesc": "最多",
"usageAsc": "最少"
"usageAsc": "最少",
"versionsCount": "本地版本数",
"versionsCountDesc": "版本数从多到少",
"versionsCountAsc": "版本数从少到多"
},
"refresh": {
"title": "刷新模型列表",
@@ -1016,6 +1028,18 @@
"storage": "存储",
"insights": "洞察"
},
"metrics": {
"totalModels": "模型总数",
"totalStorage": "总存储空间",
"totalGenerations": "总生成次数",
"usageRate": "使用率",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "唯一标签",
"unusedModels": "未使用模型",
"avgUsesPerModel": "平均使用次数/模型"
},
"usage": {
"mostUsedLoras": "最常用 LoRA",
"mostUsedCheckpoints": "最常用 Checkpoint",
@@ -1033,13 +1057,77 @@
},
"insights": {
"smartInsights": "智能洞察",
"recommendations": "推荐"
"recommendations": "推荐",
"noInsights": "暂无可用洞察",
"unusedLoras": {
"high": {
"title": "大量未使用的 LoRA",
"description": "你的 LoRA 中有 {percent}%{count}/{total})从未被使用过。",
"suggestion": "考虑整理或归档未使用的模型以释放存储空间。"
}
},
"unusedCheckpoints": {
"detected": {
"title": "检测到未使用的 Checkpoint",
"description": "你的 Checkpoint 中有 {percent}%{count}/{total})从未被使用过。",
"suggestion": "审查并考虑删除不再需要的 Checkpoint。"
}
},
"unusedEmbeddings": {
"high": {
"title": "大量未使用的 Embedding",
"description": "你的 Embedding 中有 {percent}%{count}/{total})从未被使用过。",
"suggestion": "考虑整理或归档未使用的 Embedding 以优化你的收藏。"
}
},
"collection": {
"large": {
"title": "检测到大型收藏",
"description": "你的模型收藏正在使用 {size} 的存储空间。",
"suggestion": "考虑使用外部存储或云解决方案以获得更好的组织。"
}
},
"activity": {
"active": {
"title": "活跃用户",
"description": "你已经完成了 {count} 次生成!",
"suggestion": "继续探索并用你的模型创作精彩内容。"
}
}
},
"charts": {
"collectionOverview": "收藏概览",
"baseModelDistribution": "基础模型分布",
"usageTrends": "使用趋势最近30天",
"usageDistribution": "使用分布"
"usageDistribution": "使用分布",
"date": "日期",
"usageCount": "使用次数",
"fileSizeBytes": "文件大小(字节)",
"models": "模型",
"loraUsage": "LoRA 使用量",
"checkpointUsage": "Checkpoint 使用量",
"embeddingUsage": "Embedding 使用量"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "扩散模型",
"embedding": "Embedding"
},
"placeholders": {
"loading": "加载中...",
"noModels": "未找到模型",
"errorLoading": "数据加载失败",
"noStorageData": "暂无存储数据",
"rootFolder": "根目录",
"chartLibraryMissing": "需要 Chart.js 库来显示图表"
},
"tooltips": {
"tagCount": "{tag}{count} 个模型",
"chartUsage": "{name}{size}{count} 次使用",
"chartPercentage": "{label}{value}{pct}%"
}
},
"modals": {

View File

@@ -145,6 +145,10 @@
},
"usage": {
"timesUsed": "使用次數"
},
"footer": {
"versionCount": "{count} 個版本",
"viewAllVersions": "檢視所有本地版本"
}
},
"globalContextMenu": {
@@ -183,6 +187,9 @@
},
"manageExcludedModels": {
"label": "管理已排除的模型"
},
"groupByModel": {
"label": "按模型分組"
}
},
"header": {
@@ -325,7 +332,7 @@
"extraFolderPaths": "額外資料夾路徑",
"downloadPathTemplates": "下載路徑範本",
"priorityTags": "優先標籤",
"updateFlags": "更新標記",
"versionScope": "版本範圍",
"exampleImages": "範例圖片",
"autoOrganize": "自動整理",
"metadata": "中繼資料",
@@ -430,6 +437,8 @@
"help": "啟用後如果下載歷史服務記錄顯示該版本已下載LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
},
"layoutSettings": {
"groupByModel": "按模型分組",
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
"displayDensity": "顯示密度",
"displayDensityOptions": {
"default": "預設",
@@ -586,7 +595,7 @@
"download": "下載",
"restartRequired": "需要重新啟動"
},
"updateFlagStrategy": {
"versionGrouping": {
"label": "更新標記策略",
"help": "決定更新徽章是否僅在新版本與本地檔案共享相同基礎模型時顯示,或只要該模型有任何更新版本就顯示。",
"options": {
@@ -670,7 +679,10 @@
"sizeAsc": "最小",
"usage": "使用次數",
"usageDesc": "最多",
"usageAsc": "最少"
"usageAsc": "最少",
"versionsCount": "本地版本數",
"versionsCountDesc": "版本數從多到少",
"versionsCountAsc": "版本數從少到多"
},
"refresh": {
"title": "重新整理模型列表",
@@ -1016,6 +1028,18 @@
"storage": "儲存空間",
"insights": "洞察"
},
"metrics": {
"totalModels": "模型總數",
"totalStorage": "總儲存空間",
"totalGenerations": "總生成次數",
"usageRate": "使用率",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "唯一標籤",
"unusedModels": "未使用模型",
"avgUsesPerModel": "平均使用次數/模型"
},
"usage": {
"mostUsedLoras": "最常用的 LoRA",
"mostUsedCheckpoints": "最常用的 Checkpoint",
@@ -1033,13 +1057,77 @@
},
"insights": {
"smartInsights": "智慧洞察",
"recommendations": "推薦"
"recommendations": "推薦",
"noInsights": "暫無可用洞察",
"unusedLoras": {
"high": {
"title": "大量未使用的 LoRA",
"description": "你的 LoRA 中有 {percent}%{count}/{total})從未被使用過。",
"suggestion": "考慮整理或封存未使用的模型以釋放儲存空間。"
}
},
"unusedCheckpoints": {
"detected": {
"title": "檢測到未使用的 Checkpoint",
"description": "你的 Checkpoint 中有 {percent}%{count}/{total})從未被使用過。",
"suggestion": "審查並考慮刪除不再需要的 Checkpoint。"
}
},
"unusedEmbeddings": {
"high": {
"title": "大量未使用的 Embedding",
"description": "你的 Embedding 中有 {percent}%{count}/{total})從未被使用過。",
"suggestion": "考慮整理或封存未使用的 Embedding 以優化你的收藏。"
}
},
"collection": {
"large": {
"title": "檢測到大型收藏",
"description": "你的模型收藏正在使用 {size} 的儲存空間。",
"suggestion": "考慮使用外部儲存或雲端解決方案以獲得更好的組織。"
}
},
"activity": {
"active": {
"title": "活躍用戶",
"description": "你已經完成了 {count} 次生成!",
"suggestion": "繼續探索並用你的模型創作精彩內容。"
}
}
},
"charts": {
"collectionOverview": "收藏總覽",
"baseModelDistribution": "基礎模型分布",
"usageTrends": "使用趨勢(最近 30 天)",
"usageDistribution": "使用分布"
"usageDistribution": "使用分布",
"date": "日期",
"usageCount": "使用次數",
"fileSizeBytes": "檔案大小(位元組)",
"models": "模型",
"loraUsage": "LoRA 使用量",
"checkpointUsage": "Checkpoint 使用量",
"embeddingUsage": "Embedding 使用量"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "擴散模型",
"embedding": "Embedding"
},
"placeholders": {
"loading": "載入中...",
"noModels": "找不到模型",
"errorLoading": "資料載入失敗",
"noStorageData": "暫無儲存資料",
"rootFolder": "根目錄",
"chartLibraryMissing": "需要 Chart.js 函式庫來顯示圖表"
},
"tooltips": {
"tagCount": "{tag}{count} 個模型",
"chartUsage": "{name}{size}{count} 次使用",
"chartPercentage": "{label}{value}{pct}%"
}
},
"modals": {

View File

@@ -901,6 +901,55 @@ class LoraLoaderManagerExtractor(NodeMetadataExtractor):
"node_id": node_id
}
class LoraTextLoaderManagerExtractor(NodeMetadataExtractor):
"""Extract LoRA metadata from LoraTextLoaderLM (LoRA Text Loader).
The node accepts a `lora_syntax` STRING containing <lora:name:strength> tags
(same format as the ComfyUI prompt), plus an optional `lora_stack`.
This extractor parses the syntax string using the same regex as the node.
"""
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
active_loras = []
# Process lora_stack if available (optional input)
if "lora_stack" in inputs:
lora_stack = inputs.get("lora_stack", [])
for item in lora_stack:
# lora_stack entries are (path, model_strength, clip_strength) tuples
if isinstance(item, (list, tuple)) and len(item) >= 2:
lora_path = item[0]
model_strength = item[1]
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
active_loras.append({
"name": lora_name,
"strength": round(float(model_strength), 2)
})
# Process lora_syntax string input
if "lora_syntax" in inputs:
lora_syntax = inputs.get("lora_syntax", "")
if lora_syntax and isinstance(lora_syntax, str):
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
matches = re.findall(pattern, lora_syntax, re.IGNORECASE)
for match in matches:
lora_name = match[0]
model_strength = float(match[1])
active_loras.append({
"name": lora_name,
"strength": round(model_strength, 2)
})
if active_loras:
metadata[LORAS][node_id] = {
"lora_list": active_loras,
"node_id": node_id
}
class FluxGuidanceExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
@@ -1146,6 +1195,7 @@ NODE_EXTRACTORS = {
"UNETLoaderLM": UNETLoaderExtractor, # LoRA Manager
"LoraLoader": LoraLoaderExtractor,
"LoraLoaderLM": LoraLoaderManagerExtractor,
"LoraTextLoaderLM": LoraTextLoaderManagerExtractor,
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
"TensorRTLoader": TensorRTLoaderExtractor,
# Conditioning

View File

@@ -608,7 +608,7 @@ class SaveImageLM:
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
# Generate filename with counter if needed
base_filename = filename
base_filename = filename.replace("%batch_num%", str(i))
if add_counter_to_filename:
# Use counter + i to ensure unique filenames for all images in batch
current_counter = counter + i

View File

@@ -49,7 +49,10 @@ from ...utils.constants import (
VALID_LORA_TYPES,
)
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.example_images_paths import is_valid_example_images_root
from ...utils.example_images_paths import (
find_non_compliant_items_in_example_images_root,
is_valid_example_images_root,
)
from ...utils.lora_metadata import extract_trained_words
from ...utils.session_logging import get_standalone_session_log_snapshot
from ...utils.usage_stats import UsageStats
@@ -1498,6 +1501,16 @@ class SettingsHandler:
if not os.path.isdir(folder_path):
return "Please set a dedicated folder for example images."
if not self._is_dedicated_example_images_folder(folder_path):
offending = find_non_compliant_items_in_example_images_root(folder_path)
if offending:
items_str = ", ".join(repr(item) for item in offending[:5])
if len(offending) > 5:
items_str += f" … and {len(offending) - 5} more"
return (
f"The folder contains items that are not valid example image "
f"folders: {items_str}. Please use a dedicated, empty folder "
f"for example images to prevent accidental data loss."
)
return "Please set a dedicated folder for example images."
return None

View File

@@ -233,6 +233,8 @@ class ModelListingHandler:
start_time = time.perf_counter()
try:
params = self._parse_common_params(request)
# group_by_model is meaningless for excluded view; strip it
params.pop("group_by_model", None)
result = await self._service.get_excluded_paginated_data(**params)
format_start = time.perf_counter()
@@ -366,6 +368,19 @@ class ModelListingHandler:
request.query.get("name_pattern_use_regex", "false").lower() == "true"
)
# Group-by-model flag: deduplicate versions sharing the same civitai modelId
group_by_model = (
request.query.get("group_by_model", "false").lower() == "true"
)
# View-local-versions filter: show all local versions of a specific model
civitai_model_id = request.query.get("civitai_model_id")
if civitai_model_id is not None:
try:
civitai_model_id = int(civitai_model_id)
except (TypeError, ValueError):
civitai_model_id = None
return {
"page": page,
"page_size": page_size,
@@ -389,6 +404,8 @@ class ModelListingHandler:
"name_pattern_include": name_pattern_include,
"name_pattern_exclude": name_pattern_exclude,
"name_pattern_use_regex": name_pattern_use_regex,
"group_by_model": group_by_model,
"civitai_model_id": civitai_model_id,
**self._parse_specific_params(request),
}

View File

@@ -477,9 +477,12 @@ class StatsRoutes:
if unused_lora_percent > 50:
insights.append({
'type': 'warning',
'title': 'High Number of Unused LoRAs',
'description': f'{unused_lora_percent:.1f}% of your LoRAs ({unused_loras}/{total_loras}) have never been used.',
'suggestion': 'Consider organizing or archiving unused models to free up storage space.'
'key': 'insights.unusedLoras.high',
'params': {
'percent': f'{unused_lora_percent:.1f}',
'count': str(unused_loras),
'total': str(total_loras)
}
})
if total_checkpoints > 0:
@@ -487,9 +490,12 @@ class StatsRoutes:
if unused_checkpoint_percent > 30:
insights.append({
'type': 'warning',
'title': 'Unused Checkpoints Detected',
'description': f'{unused_checkpoint_percent:.1f}% of your checkpoints ({unused_checkpoints}/{total_checkpoints}) have never been used.',
'suggestion': 'Review and consider removing checkpoints you no longer need.'
'key': 'insights.unusedCheckpoints.detected',
'params': {
'percent': f'{unused_checkpoint_percent:.1f}',
'count': str(unused_checkpoints),
'total': str(total_checkpoints)
}
})
if total_embeddings > 0:
@@ -497,9 +503,12 @@ class StatsRoutes:
if unused_embedding_percent > 50:
insights.append({
'type': 'warning',
'title': 'High Number of Unused Embeddings',
'description': f'{unused_embedding_percent:.1f}% of your embeddings ({unused_embeddings}/{total_embeddings}) have never been used.',
'suggestion': 'Consider organizing or archiving unused embeddings to optimize your collection.'
'key': 'insights.unusedEmbeddings.high',
'params': {
'percent': f'{unused_embedding_percent:.1f}',
'count': str(unused_embeddings),
'total': str(total_embeddings)
}
})
# Storage insights
@@ -510,18 +519,20 @@ class StatsRoutes:
if total_size > 100 * 1024 * 1024 * 1024: # 100GB
insights.append({
'type': 'info',
'title': 'Large Collection Detected',
'description': f'Your model collection is using {self._format_size(total_size)} of storage.',
'suggestion': 'Consider using external storage or cloud solutions for better organization.'
'key': 'insights.collection.large',
'params': {
'size': self._format_size(total_size)
}
})
# Recent activity insight
if usage_data.get('total_executions', 0) > 100:
insights.append({
'type': 'success',
'title': 'Active User',
'description': f'You\'ve completed {usage_data["total_executions"]} generations so far!',
'suggestion': 'Keep exploring and creating amazing content with your models.'
'key': 'insights.activity.active',
'params': {
'count': str(usage_data['total_executions'])
}
})
return web.json_response({

View File

@@ -104,6 +104,61 @@ class BaseModelService(ABC):
fetch_duration = time.perf_counter() - t0
initial_count = len(sorted_data)
# Optionally filter by civitai model ID (shows all local versions of a specific model)
civitai_model_id = kwargs.get("civitai_model_id")
if civitai_model_id is not None:
sorted_data = [
item for item in sorted_data
if self._extract_model_id(item) == civitai_model_id
]
# Optionally group by civitai modelId, showing only the latest version per model
dedup_lost = 0
if kwargs.get("group_by_model") and civitai_model_id is None:
# Determine whether to further sub-group by base model
# When version_grouping is "same_base", versions with different
# base models are effectively different groups — the dedup key
# needs to include base_model so the version count and VLM flow
# stay consistent (card shows correct count for its base model).
ufs = self.settings.get("version_grouping", "same_base")
group_by_base = ufs == "same_base"
dedup_map = {} # (modelId [,base_model]) -> (item, version_id)
version_counter = {} # same-key -> count
standalone = []
for item in sorted_data:
mid = self._extract_model_id(item)
if mid is None:
standalone.append(item)
continue
key = (mid, item.get("base_model") or "") if group_by_base else mid
# Count all versions per key
version_counter[key] = version_counter.get(key, 0) + 1
vid = self._extract_version_id(item) or 0
if key not in dedup_map or vid > dedup_map[key][1]:
dedup_map[key] = (item, vid)
# Attach version_count to each surviving grouped item (shallow copy
# to avoid mutating cached dicts — the cache is shared across requests)
for key, (item, vid) in dedup_map.items():
item = dict(item)
item["version_count"] = version_counter[key]
dedup_map[key] = (item, vid)
dedup_lost = len(sorted_data) - (len(dedup_map) + len(standalone))
sorted_data = [entry[0] for entry in dedup_map.values()] + standalone
# Re-sort by version_count after dedup (only makes sense in group_by_model mode)
is_group_by_active = kwargs.get("group_by_model") and civitai_model_id is None
if sort_params.key == "versions_count" and is_group_by_active:
reverse = sort_params.order == "desc"
sorted_data.sort(
key=lambda x: (
x.get("version_count", 0),
(x.get("model_name") or x.get("file_name") or "").lower(),
x.get("file_path", "").lower(),
),
reverse=reverse,
)
t1 = time.perf_counter()
if hash_filters:
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
@@ -172,7 +227,7 @@ class BaseModelService(ABC):
overall_duration = time.perf_counter() - overall_start
logger.debug(
"%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). "
"Counts: initial=%d, post_filter=%d, final=%d",
"Counts: initial=%d, dedup=%d, post_filter=%d, final=%d",
self.__class__.__name__,
overall_duration,
fetch_duration,
@@ -181,6 +236,7 @@ class BaseModelService(ABC):
pagination_duration,
annotate_duration,
initial_count,
dedup_lost,
post_filter_count,
final_count,
)
@@ -495,7 +551,7 @@ class BaseModelService(ABC):
if not ordered_ids:
return annotated
strategy_value = self.settings.get("update_flag_strategy")
strategy_value = self.settings.get("version_grouping")
if isinstance(strategy_value, str) and strategy_value.strip():
strategy = strategy_value.strip().lower()
else:

View File

@@ -48,6 +48,7 @@ class CheckpointService(BaseModelService):
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
"version_count": checkpoint_data.get("version_count"),
}
def find_duplicate_hashes(self) -> Dict:

View File

@@ -48,6 +48,7 @@ class EmbeddingService(BaseModelService):
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
"version_count": embedding_data.get("version_count"),
}
def find_duplicate_hashes(self) -> Dict:

View File

@@ -59,6 +59,7 @@ class LoraService(BaseModelService):
lora_data.get("civitai", {}), minimal=True
),
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
"version_count": lora_data.get("version_count"),
}
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:

View File

@@ -427,7 +427,18 @@ class MetadataSyncService:
metadata = await metadata_loader(metadata_path)
for key, value in updates.items():
if isinstance(value, dict) and isinstance(metadata.get(key), dict):
if key == "tags" and isinstance(value, list):
# Normalize tags: trim, lowercase, deduplicate
normalized = []
seen = set()
for tag in value:
if isinstance(tag, str):
t = tag.strip().lower()
if t and t not in seen:
normalized.append(t)
seen.add(t)
metadata[key] = normalized
elif isinstance(value, dict) and isinstance(metadata.get(key), dict):
metadata[key].update(value)
else:
metadata[key] = value

View File

@@ -18,6 +18,8 @@ SUPPORTED_SORT_MODES = [
('size', 'desc'),
('usage', 'asc'),
('usage', 'desc'),
('versions_count', 'asc'),
('versions_count', 'desc'),
]
# Is this in use?
@@ -263,6 +265,17 @@ class ModelCache:
),
reverse=reverse
)
elif sort_key == 'versions_count':
# Pre-dedup sort: fall back to name sort.
# Actual re-sort by version_count happens in get_paginated_data after dedup.
result = natsorted(
data,
key=lambda x: (
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse
)
else:
# Fallback: no sort
result = list(data)

View File

@@ -294,12 +294,14 @@ class ModelFilterSet:
for tag, state in tag_filters.items():
if not tag:
continue
# Normalize to lowercase for case-insensitive matching
normalized = tag.strip().lower()
if state == "exclude":
exclude_tags.add(tag)
exclude_tags.add(normalized)
else:
include_tags.add(tag)
include_tags.add(normalized)
else:
include_tags = {tag for tag in tag_filters if tag}
include_tags = {tag.strip().lower() for tag in tag_filters if tag}
if include_tags:
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
@@ -318,13 +320,17 @@ class ModelFilterSet:
return True
# Otherwise, check if all non-special tags match
if non_special_tags:
return all(tag in (item_tags or []) for tag in non_special_tags)
# Case-insensitive: normalize item tags too
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
return all(tag in normalized_item_tags for tag in non_special_tags)
return True
# Normal case: all tags must match
return all(tag in (item_tags or []) for tag in non_special_tags)
# Normal case: all tags must match (case-insensitive)
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
return all(tag in normalized_item_tags for tag in non_special_tags)
else:
# OR logic (default): item must have ANY include tag
return any(tag in include_tags for tag in (item_tags or []))
# OR logic (default): item must have ANY include tag (case-insensitive)
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
return bool(normalized_item_tags & include_tags)
items = [item for item in items if matches_include(item.get("tags"))]
@@ -333,7 +339,9 @@ class ModelFilterSet:
def matches_exclude(item_tags):
if not item_tags and "__no_tags__" in exclude_tags:
return True
return any(tag in exclude_tags for tag in (item_tags or []))
# Case-insensitive: normalize item tags
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
return bool(normalized_item_tags & exclude_tags)
items = [
item for item in items if not matches_exclude(item.get("tags"))

View File

@@ -98,7 +98,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"lora_syntax_format": "legacy",
"model_card_footer_action": "replace_preview",
"show_version_on_card": True,
"update_flag_strategy": "same_base",
"version_grouping": "same_base",
"auto_organize_exclusions": [],
"metadata_refresh_skip_paths": [],
"skip_previously_downloaded_model_versions": False,
@@ -106,6 +106,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"backup_auto_enabled": True,
"backup_retention_count": 5,
"use_new_license_icons": True,
"group_by_model": False,
}
@@ -744,6 +745,7 @@ class SettingsManager:
"includeTriggerWords": "include_trigger_words",
"compactMode": "compact_mode",
"modelCardFooterAction": "model_card_footer_action",
"update_flag_strategy": "version_grouping",
}
updated = False

View File

@@ -36,9 +36,9 @@ class TagUpdateService:
if isinstance(tag, str) and tag.strip():
# Convert all tags to lowercase to avoid case sensitivity issues on Windows
normalized = tag.strip().lower()
if normalized.lower() not in existing_lower:
if normalized not in existing_lower:
existing_tags.append(normalized)
existing_lower.append(normalized.lower())
existing_lower.append(normalized)
tags_added.append(normalized)
metadata["tags"] = existing_tags

View File

@@ -12,6 +12,18 @@ from ..services.settings_manager import get_settings_manager
_HEX_PATTERN = re.compile(r"[a-fA-F0-9]{64}")
# Filesystem/metadata files that are never created by the example images system
# and are safe to ignore during validation. The cleanup service only operates on
# directories, so these files pose no data-loss risk.
_SAFE_FILENAMES: frozenset[str] = frozenset({
".DS_Store", # macOS folder metadata
"Thumbs.db", # Windows thumbnail cache
"desktop.ini", # Windows folder customization
".localized", # macOS folder name localization
".gitkeep", # Placeholder to keep empty dirs in git
".gitignore", # Git ignore rules
})
logger = logging.getLogger(__name__)
@@ -180,6 +192,22 @@ def is_hash_folder(name: str) -> bool:
return bool(_HEX_PATTERN.fullmatch(name or ""))
def _is_safe_ignorable_entry(item: str, item_path: str) -> bool:
"""Return True if *item* is a harmless system/hidden file we can skip.
These files are never created by the example images system and are safe to
ignore because the cleanup/delete operations only act on **directories**,
never on individual files (other than ``.download_progress.json``).
"""
if item in _SAFE_FILENAMES:
return True
# Hide Unix hidden files (dotfiles) that are regular files,
# since the cleanup system never deletes or moves files.
if item.startswith(".") and os.path.isfile(item_path):
return True
return False
def is_valid_example_images_root(folder_path: str) -> bool:
"""Check whether a folder looks like a dedicated example images root."""
@@ -190,9 +218,16 @@ def is_valid_example_images_root(folder_path: str) -> bool:
for item in items:
item_path = os.path.join(folder_path, item)
# .download_progress.json is an expected metadata file — check before
# the generic dotfile rule so it stays explicitly documented.
if item == ".download_progress.json" and os.path.isfile(item_path):
continue
# Skip harmless system/hidden files — cleanup only touches directories
if _is_safe_ignorable_entry(item, item_path):
continue
if os.path.isdir(item_path):
if is_hash_folder(item):
continue
@@ -211,6 +246,41 @@ def is_valid_example_images_root(folder_path: str) -> bool:
return True
def find_non_compliant_items_in_example_images_root(folder_path: str) -> list[str]:
"""Return the names of items that prevent *folder_path* from being a valid
example images root, or an empty list if the folder is valid.
This mirrors ``is_valid_example_images_root`` but **returns** the offending
names instead of a boolean, so callers can produce actionable error messages.
"""
try:
items = os.listdir(folder_path)
except OSError as exc:
return [f"<cannot list directory: {exc}>"]
offending: list[str] = []
for item in items:
item_path = os.path.join(folder_path, item)
# Same skip rules as is_valid_example_images_root
if item == ".download_progress.json" and os.path.isfile(item_path):
continue
if _is_safe_ignorable_entry(item, item_path):
continue
if os.path.isdir(item_path):
if is_hash_folder(item):
continue
if item == "_deleted":
continue
if _library_folder_has_only_hash_dirs(item_path):
continue
offending.append(item)
return offending
def _library_folder_has_only_hash_dirs(path: str) -> bool:
"""Return True when a library subfolder only contains hash folders or metadata files."""

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.1.3"
version = "1.1.4"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",

View File

@@ -509,6 +509,50 @@
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
}
/* Clickable version count link (shown in group-by-model mode) */
.version-count-link {
display: inline-block;
color: var(--color-accent);
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
font-size: 0.85em;
line-height: 1.4;
margin-top: 2px;
border: 1px solid var(--color-accent-border);
border-radius: var(--border-radius-xs);
padding: 1px 6px;
background: var(--color-accent-subtle);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.version-count-link:hover {
background: var(--color-accent-border);
border-color: var(--color-accent-transparent);
}
/* Medium density adjustments for version count link */
.medium-density .version-count-link {
font-size: 0.8em;
}
.medium-density .badge-version-unit .version-count-link {
max-width: 90px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Compact density adjustments for version count link */
.compact-density .version-count-link {
font-size: 0.75em;
}
.compact-density .badge-version-unit .version-count-link {
max-width: 70px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Version row — flex container for badges + version names */
.version-row {
display: flex;

View File

@@ -17,6 +17,8 @@
flex-wrap: nowrap;
gap: 6px;
align-items: center;
min-width: 0;
overflow: hidden;
}
.model-tag-compact {
@@ -28,6 +30,9 @@
font-size: 0.75em;
color: var(--text-color);
white-space: nowrap;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Style for empty tags placeholder */
@@ -118,8 +123,9 @@
/* Model Tags Edit Mode */
.model-tags-header {
display: flex;
justify-content: space-between;
justify-content: flex-start;
align-items: center;
overflow: hidden;
}
.edit-tags-btn {
@@ -132,6 +138,7 @@
border-radius: var(--border-radius-xs);
transition: var(--transition-base);
margin-left: var(--space-1);
flex-shrink: 0;
}
.edit-tags-btn.visible,

View File

@@ -9,6 +9,10 @@
position: relative;
}
#recipeTagsContainer {
width: 100%;
}
.recipe-modal-header h2 {
margin: 0 0 var(--space-1);
padding: var(--space-1);
@@ -95,127 +99,11 @@
min-width: 0;
}
.content-editor.tags-editor input {
font-size: 0.9em;
}
/* Remove obsolete button styles */
.editor-actions {
display: none;
}
/* Special styling for tags content */
.tags-content {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 8px;
}
.tags-display {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
flex: 1;
min-width: 0;
overflow: hidden;
}
.no-tags {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.6;
font-style: italic;
}
/* Recipe Tags styles */
.recipe-tags-container {
position: relative;
margin-top: 0;
margin-bottom: 10px;
}
.recipe-tags-compact {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
}
.recipe-tag-compact {
background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs);
padding: 2px 8px;
font-size: 0.75em;
color: var(--text-color);
white-space: nowrap;
}
[data-theme="dark"] .recipe-tag-compact {
background: var(--surface-subtle);
border: 1px solid var(--lora-border);
}
.recipe-tag-more {
background: var(--lora-accent);
color: var(--lora-text);
border-radius: var(--border-radius-xs);
padding: 2px 8px;
font-size: 0.75em;
cursor: pointer;
white-space: nowrap;
font-weight: 500;
}
.recipe-tags-tooltip {
position: absolute;
top: calc(100% + 8px);
left: 0;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
box-shadow: var(--shadow-dropdown);
padding: 10px 14px;
max-width: 400px;
z-index: 10;
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition: var(--transition-base);
pointer-events: none;
}
.recipe-tags-tooltip.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
.tooltip-content {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 200px;
overflow-y: auto;
}
.tooltip-tag {
background: var(--surface-hover);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs);
padding: 3px 8px;
font-size: 0.75em;
color: var(--text-color);
}
[data-theme="dark"] .tooltip-tag {
background: var(--surface-hover);
border: 1px solid var(--lora-border);
}
#recipeModal .modal-content {
display: flex;
flex-direction: column;
@@ -1153,7 +1041,7 @@
max-height: 2.4em;
}
.recipe-tags-container {
#recipeTagsContainer {
margin-bottom: 6px;
}

View File

@@ -59,3 +59,8 @@
.initialization-notice .loading-spinner {
margin-bottom: var(--space-2);
}
/* Hide versions_count sort option when group-by-model is off */
body:not(.group-by-model) .sort-option-versions-count {
display: none;
}

View File

@@ -1,7 +1,7 @@
import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
import { getStorageItem, getSessionItem, removeSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
import {
getCompleteApiConfig,
getCurrentModelType,
@@ -1271,6 +1271,12 @@ export class BaseModelApiClient {
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
// Pass group-by-model mode to backend (skip when showing all versions of a specific model)
const vlmModelId = getSessionItem('vlm_model_id');
if (state.global.settings.group_by_model && !vlmModelId) {
params.append('group_by_model', 'true');
}
if (!isExcludedView && pageState.filters) {
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
@@ -1352,6 +1358,24 @@ export class BaseModelApiClient {
}
_addModelSpecificParams(params, pageState) {
// Check for View Local Versions filter (takes priority over recipe filters)
const vlmModelId = getSessionItem('vlm_model_id');
const vlmPageType = getSessionItem('vlm_page_type');
if (vlmModelId && vlmPageType === this.modelType) {
params.append('civitai_model_id', vlmModelId);
const vlmBaseModel = getSessionItem('vlm_base_model');
if (vlmBaseModel) {
params.append('base_model', vlmBaseModel);
}
return;
} else if (vlmModelId && vlmPageType !== this.modelType) {
// Stale VLM data from a different page type — clean up
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
}
if (this.modelType === 'loras') {
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');

View File

@@ -9,6 +9,13 @@ export class LoraApiClient extends BaseModelApiClient {
* Add LoRA-specific parameters to query
*/
_addModelSpecificParams(params, pageState) {
// Let parent handle View Local Versions filter first
super._addModelSpecificParams(params, pageState);
// If VLM filter was applied, skip recipe-specific filters
if (params.has('civitai_model_id')) {
return;
}
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');

View File

@@ -24,6 +24,14 @@ export class GlobalContextMenu extends BaseContextMenu {
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
// Update check indicator for group-by-model
if (groupByModelCheck) {
const isEnabled = !!state.global.settings.group_by_model;
groupByModelCheck.style.display = isEnabled ? 'block' : 'none';
}
if (isRecipesPage) {
modelUpdateItem?.classList.add('hidden');
@@ -31,6 +39,7 @@ export class GlobalContextMenu extends BaseContextMenu {
downloadExamplesItem?.classList.add('hidden');
cleanupExamplesItem?.classList.add('hidden');
excludedModelsItem?.classList.add('hidden');
groupByModelItem?.classList.add('hidden');
repairRecipesItem?.classList.remove('hidden');
} else {
modelUpdateItem?.classList.remove('hidden');
@@ -38,6 +47,7 @@ export class GlobalContextMenu extends BaseContextMenu {
downloadExamplesItem?.classList.remove('hidden');
cleanupExamplesItem?.classList.remove('hidden');
excludedModelsItem?.classList.remove('hidden');
groupByModelItem?.classList.remove('hidden');
repairRecipesItem?.classList.add('hidden');
}
@@ -74,6 +84,9 @@ export class GlobalContextMenu extends BaseContextMenu {
case 'manage-excluded-models':
this.manageExcludedModels();
break;
case 'toggle-group-by-model':
this.toggleGroupByModel();
break;
default:
console.warn(`Unhandled global context menu action: ${action}`);
break;
@@ -86,6 +99,30 @@ export class GlobalContextMenu extends BaseContextMenu {
});
}
toggleGroupByModel() {
const sm = window.settingsManager;
if (!sm) {
console.error('settingsManager not available on window');
return;
}
const newValue = !state.global.settings.group_by_model;
state.global.settings.group_by_model = newValue;
// Save/restore sort preference when toggling group_by_model
if (window.pageControls?.onGroupByModelToggled) {
window.pageControls.onGroupByModelToggled(newValue);
}
sm.saveSetting('group_by_model', newValue).catch((error) => {
console.error('Failed to save group_by_model setting:', error);
// Revert state on failure
state.global.settings.group_by_model = !newValue;
});
sm.applyFrontendSettings();
sm.reloadContent();
}
async downloadExampleImages(menuItem) {
const downloadPath = state?.global?.settings?.example_images_path;
if (!downloadPath) {

View File

@@ -7,6 +7,8 @@ import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
import { downloadManager } from '../managers/DownloadManager.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { openMediaViewer } from './shared/MediaViewer.js';
import { renderCompactTags, setupTagTooltip } from './shared/utils.js';
import { setupTagEditMode } from './shared/ModelTags.js';
const ALLOWED_GEN_PARAM_KEYS = new Set([
'prompt',
@@ -139,14 +141,6 @@ class RecipeModal {
this.saveTitleEdit();
}
// Handle tags edit
const tagsEditor = document.getElementById('recipeTagsEditor');
if (tagsEditor && tagsEditor.classList.contains('active') &&
!tagsEditor.contains(event.target) &&
!event.target.closest('.edit-icon')) {
this.saveTagsEdit();
}
// Handle reconnect input
const reconnectContainers = document.querySelectorAll('.lora-reconnect-container');
reconnectContainers.forEach(container => {
@@ -236,98 +230,10 @@ class RecipeModal {
this.filePath = hydratedRecipe.file_path;
this.listFilePath = hydratedRecipe.file_path;
// Set recipe tags if they exist
const tagsCompactElement = document.getElementById('recipeTagsCompact');
const tagsTooltipContent = document.getElementById('recipeTagsTooltipContent');
if (tagsCompactElement) {
// Add tags container with edit functionality
tagsCompactElement.innerHTML = `
<div class="editable-content tags-content">
<div class="tags-display"></div>
<button class="edit-icon" title="Edit tags"><i class="fas fa-pencil-alt"></i></button>
</div>
<div id="recipeTagsEditor" class="content-editor tags-editor">
<input type="text" class="tags-input" placeholder="Enter tags separated by commas">
</div>
`;
const tagsDisplay = tagsCompactElement.querySelector('.tags-display');
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
// Limit displayed tags to 5, show a "+X more" button if needed
const maxVisibleTags = 5;
const visibleTags = hydratedRecipe.tags.slice(0, maxVisibleTags);
const remainingTags = hydratedRecipe.tags.length > maxVisibleTags ? hydratedRecipe.tags.slice(maxVisibleTags) : [];
// Add visible tags
visibleTags.forEach(tag => {
const tagElement = document.createElement('div');
tagElement.className = 'recipe-tag-compact';
tagElement.textContent = tag;
tagsDisplay.appendChild(tagElement);
});
// Add "more" button if needed
if (remainingTags.length > 0) {
const moreButton = document.createElement('div');
moreButton.className = 'recipe-tag-more';
moreButton.textContent = `+${remainingTags.length} more`;
tagsDisplay.appendChild(moreButton);
// Add tooltip functionality
moreButton.addEventListener('mouseenter', () => {
document.getElementById('recipeTagsTooltip').classList.add('visible');
});
moreButton.addEventListener('mouseleave', () => {
setTimeout(() => {
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
document.getElementById('recipeTagsTooltip').classList.remove('visible');
}
}, 300);
});
document.getElementById('recipeTagsTooltip').addEventListener('mouseleave', () => {
document.getElementById('recipeTagsTooltip').classList.remove('visible');
});
// Add all tags to tooltip
if (tagsTooltipContent) {
tagsTooltipContent.innerHTML = '';
hydratedRecipe.tags.forEach(tag => {
const tooltipTag = document.createElement('div');
tooltipTag.className = 'tooltip-tag';
tooltipTag.textContent = tag;
tagsTooltipContent.appendChild(tooltipTag);
});
}
}
} else {
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
}
// Add event listeners for tags editing
const editTagsIcon = tagsCompactElement.querySelector('.edit-icon');
const tagsInput = tagsCompactElement.querySelector('.tags-input');
// Set current tags in the input
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
tagsInput.value = hydratedRecipe.tags.join(', ');
}
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
// Add key event listener for Enter key
tagsInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.saveTagsEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
this.cancelTagsEdit();
}
});
// Render tags using shared utility
const tagsContainer = document.getElementById('recipeTagsContainer');
if (tagsContainer) {
this.updateTagsDisplay(tagsContainer, hydratedRecipe.tags || []);
}
// Set recipe image
@@ -609,17 +515,35 @@ class RecipeModal {
}
syncTagsDisplay(tags) {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (!tagsContainer) {
return;
}
const container = document.getElementById('recipeTagsContainer');
if (!container) return;
this.updateTagsDisplay(container, tags || []);
}
this.updateTagsDisplay(tagsContainer, tags || []);
// Re-render tags display using shared utility, wire edit mode with ModelTags
updateTagsDisplay(container, tags) {
const filePath = this.filePath || '';
const tagsInput = tagsContainer.querySelector('.tags-input');
if (tagsInput) {
tagsInput.value = tags && tags.length > 0 ? tags.join(', ') : '';
}
container.innerHTML = renderCompactTags(tags, filePath);
// Setup tooltip for all tags
setupTagTooltip(container);
// Wire edit button using shared tag editing (no suggestions for recipes)
setupTagEditMode(null, {
container: container,
showSuggestions: false,
normalizeTag: false,
saveHandler: async (filePath, tags) => {
await updateRecipeMetadata(filePath, { tags }, this.getMetadataUpdateOptions());
},
onSaved: (tags) => {
this.currentRecipe.tags = tags;
this.commitField('tags');
const c = document.getElementById('recipeTagsContainer');
if (c) this.updateTagsDisplay(c, tags);
},
});
}
syncPromptField(field, value, placeholder) {
@@ -976,139 +900,6 @@ class RecipeModal {
}
}
// Tags editing methods
showTagsEditor() {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (tagsContainer) {
tagsContainer.querySelector('.editable-content').classList.add('hide');
const editor = tagsContainer.querySelector('#recipeTagsEditor');
editor.classList.add('active');
const input = editor.querySelector('input');
input.oninput = () => this.markFieldDirty('tags');
input.focus();
}
}
saveTagsEdit() {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (tagsContainer) {
const editor = tagsContainer.querySelector('#recipeTagsEditor');
const input = editor.querySelector('input');
const tagsText = input.value.trim();
// Parse tags
let newTags = [];
if (tagsText) {
newTags = tagsText.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0);
}
// Check if tags changed
const oldTags = this.currentRecipe.tags || [];
const tagsChanged =
newTags.length !== oldTags.length ||
newTags.some((tag, index) => tag !== oldTags[index]);
if (tagsChanged) {
// Update the recipe on the server
updateRecipeMetadata(this.filePath, { tags: newTags }, this.getMetadataUpdateOptions())
.then(data => {
// Show success toast
showToast('toast.recipes.tagsUpdated', {}, 'success');
// Update the current recipe object
this.currentRecipe.tags = newTags;
this.commitField('tags');
// Update tags in the UI
this.updateTagsDisplay(tagsContainer, newTags);
})
.catch(error => {
// Error is handled in the API function
this.clearFieldDirty('tags');
});
} else {
this.clearFieldDirty('tags');
}
// Hide editor
editor.classList.remove('active');
tagsContainer.querySelector('.editable-content').classList.remove('hide');
}
}
// Helper method to update tags display
updateTagsDisplay(tagsContainer, tags) {
const tagsDisplay = tagsContainer.querySelector('.tags-display');
tagsDisplay.innerHTML = '';
if (tags.length > 0) {
// Limit displayed tags to 5, show a "+X more" button if needed
const maxVisibleTags = 5;
const visibleTags = tags.slice(0, maxVisibleTags);
const remainingTags = tags.length > maxVisibleTags ? tags.slice(maxVisibleTags) : [];
// Add visible tags
visibleTags.forEach(tag => {
const tagElement = document.createElement('div');
tagElement.className = 'recipe-tag-compact';
tagElement.textContent = tag;
tagsDisplay.appendChild(tagElement);
});
// Add "more" button if needed
if (remainingTags.length > 0) {
const moreButton = document.createElement('div');
moreButton.className = 'recipe-tag-more';
moreButton.textContent = `+${remainingTags.length} more`;
tagsDisplay.appendChild(moreButton);
// Update tooltip content
const tooltipContent = document.getElementById('recipeTagsTooltipContent');
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const tooltipTag = document.createElement('div');
tooltipTag.className = 'tooltip-tag';
tooltipTag.textContent = tag;
tooltipContent.appendChild(tooltipTag);
});
}
// Re-add tooltip functionality
moreButton.addEventListener('mouseenter', () => {
document.getElementById('recipeTagsTooltip').classList.add('visible');
});
moreButton.addEventListener('mouseleave', () => {
setTimeout(() => {
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
document.getElementById('recipeTagsTooltip').classList.remove('visible');
}
}, 300);
});
}
} else {
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
}
}
cancelTagsEdit() {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (tagsContainer) {
// Reset input value
const editor = tagsContainer.querySelector('#recipeTagsEditor');
const input = editor.querySelector('input');
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
this.clearFieldDirty('tags');
// Hide editor
editor.classList.remove('active');
tagsContainer.querySelector('.editable-content').classList.remove('hide');
}
}
setupPromptEditors() {
const promptConfigs = [
{

View File

@@ -95,6 +95,22 @@ export class CheckpointsControls extends PageControls {
* Clear checkpoint custom filter and reload
*/
async clearCustomFilter() {
// Check for View Local Versions filter first
const vlmModelId = getSessionItem('vlm_model_id');
if (vlmModelId) {
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
// Hide the indicator
const indicator = document.getElementById('customFilterIndicator');
if (indicator) {
indicator.classList.add('hidden');
}
await resetAndReload();
return;
}
removeSessionItem('recipe_to_checkpoint_filterHash');
removeSessionItem('recipe_to_checkpoint_filterHashes');
removeSessionItem('filterCheckpointRecipeName');
@@ -106,14 +122,4 @@ export class CheckpointsControls extends PageControls {
await resetAndReload();
}
/**
* Helper to truncate text with ellipsis
* @param {string} text
* @param {number} maxLength
* @returns {string}
*/
_truncateText(text, maxLength) {
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
}
}

View File

@@ -112,6 +112,21 @@ export class LorasControls extends PageControls {
* Clear the custom filter and reload the page
*/
async clearCustomFilter() {
// Check for View Local Versions filter first (handles VLM and reloads)
const vlmModelId = getSessionItem('vlm_model_id');
if (vlmModelId) {
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
const indicator = document.getElementById('customFilterIndicator');
if (indicator) {
indicator.classList.add('hidden');
}
await resetAndReload();
return;
}
console.log("Clearing custom filter...");
// Remove filter parameters from session storage
removeSessionItem('recipe_to_lora_filterLoraHash');
@@ -134,16 +149,6 @@ export class LorasControls extends PageControls {
await resetAndReload();
}
/**
* Helper to truncate text with ellipsis
* @param {string} text - Text to truncate
* @param {number} maxLength - Maximum length before truncating
* @returns {string} - Truncated text
*/
_truncateText(text, maxLength) {
return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text;
}
/**
* Initialize the alphabet bar component
*/

View File

@@ -1,6 +1,6 @@
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js';
@@ -129,6 +129,9 @@ export class PageControls {
clearFilterBtn.addEventListener('click', () => this.clearCustomFilter());
}
// Check for View Local Versions filter
this.checkVlmFilter();
// Page-specific event listeners
this.initPageSpecificListeners();
}
@@ -459,15 +462,138 @@ export class PageControls {
this.api.toggleBulkMode();
}
/**
* Clear custom filter
*/
/**
* Trigger View Local Versions without page reload
* Sets sessionStorage and reloads data via the API.
*/
triggerVlmView(modelId, modelName, baseModel, pageType) {
const targetPageType = pageType || this.pageType;
setSessionItem('vlm_model_id', String(modelId));
setSessionItem('vlm_model_name', modelName || String(modelId));
setSessionItem('vlm_page_type', targetPageType);
if (baseModel) {
setSessionItem('vlm_base_model', baseModel);
} else {
removeSessionItem('vlm_base_model');
}
// Reload data via API (no page reload)
this.resetAndReload(true).then(() => {
// Show the VLM indicator after data loads
this.checkVlmFilter();
});
}
/**
* Called when group_by_model is toggled.
* Saves current sort when entering grouped mode, restores normal sort
* when leaving — prevents "Most versions first" persisting after exit.
*/
onGroupByModelToggled(isEnabled) {
const normalKey = `${this.pageType}_sort_normal`;
const groupedKey = `${this.pageType}_sort_grouped`;
if (isEnabled) {
// Entering group mode: save current sort for later restoration
setStorageItem(normalKey, this.pageState.sortBy);
// Restore previously saved grouped sort, if any
const savedGroupedSort = getStorageItem(groupedKey);
if (savedGroupedSort) {
this.pageState.sortBy = savedGroupedSort;
this.saveSortPreference(savedGroupedSort);
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = savedGroupedSort;
}
}
} else {
// Leaving group mode: save current grouped sort aside, restore normal
const currentSort = this.pageState.sortBy;
if (currentSort && currentSort.startsWith('versions_count')) {
setStorageItem(groupedKey, currentSort);
}
const savedNormalSort = getStorageItem(normalKey);
if (savedNormalSort) {
removeStorageItem(normalKey);
this.pageState.sortBy = savedNormalSort;
this.saveSortPreference(savedNormalSort);
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = savedNormalSort;
}
}
}
}
/**
* Check for View Local Versions filter in sessionStorage (page-type-scoped)
*/
checkVlmFilter() {
const vlmModelId = getSessionItem('vlm_model_id');
const vlmPageType = getSessionItem('vlm_page_type');
// Only show VLM indicator when it belongs to the current page type
if (vlmModelId && vlmPageType !== this.pageType) {
// Stale VLM data from a different page — clean up
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
return;
}
const vlmModelName = getSessionItem('vlm_model_name');
const vlmBaseModel = getSessionItem('vlm_base_model');
if (vlmModelId && vlmModelName) {
const indicator = document.getElementById('customFilterIndicator');
const filterText = indicator?.querySelector('.customFilterText');
if (indicator && filterText) {
indicator.classList.remove('hidden');
const prefix = vlmBaseModel
? 'Showing same-base versions from'
: 'Showing all versions from';
const displayText = `${prefix}: ${vlmModelName}`;
filterText.textContent = this._truncateText(displayText, 40);
filterText.setAttribute('title', displayText);
}
}
}
/**
* Clear custom filter
*/
async clearCustomFilter() {
// Check for View Local Versions filter first
const vlmModelId = getSessionItem('vlm_model_id');
if (vlmModelId) {
removeSessionItem('vlm_model_id');
removeSessionItem('vlm_model_name');
removeSessionItem('vlm_base_model');
removeSessionItem('vlm_page_type');
// Hide the indicator
const indicator = document.getElementById('customFilterIndicator');
if (indicator) {
indicator.classList.add('hidden');
}
// Reload data via API (no page reload)
await this.resetAndReload(true);
return;
}
// Otherwise delegate to subclass for recipe filters
if (!this.api) {
console.error('API methods not registered');
return;
}
try {
await this.api.clearCustomFilter();
} catch (error) {
@@ -475,6 +601,14 @@ export class PageControls {
showToast('toast.controls.clearFilterFailed', { message: error.message }, 'error');
}
}
/**
* Truncate text with ellipsis
*/
_truncateText(text, maxLength) {
if (!text) return '';
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
}
/**
* Initialize the favorites filter button state

View File

@@ -100,6 +100,12 @@ function handleModelCardEvent_internal(event, modelType) {
return true; // Stop propagation
}
if (event.target.closest('.version-count-link')) {
event.stopPropagation();
handleViewLocalVersionsFromCard(card, modelType);
return true;
}
// If no specific element was clicked, handle the card click (show modal or toggle selection)
handleCardClick(card, modelType);
return false; // Continue with other handlers (e.g., bulk selection)
@@ -265,6 +271,22 @@ async function handleExampleImagesAccess(card, modelType) {
}
}
function handleViewLocalVersionsFromCard(card, modelType) {
const modelId = card.dataset.modelId;
const modelName = card.dataset.name;
if (!modelId) return;
// Respect version_grouping: only filter by base model when the strategy says so
const strategy = state.global?.settings?.version_grouping;
const shouldFilterByBase = strategy === 'same_base';
const baseModel = shouldFilterByBase && card.dataset.base_model !== 'Unknown'
? card.dataset.base_model
: undefined;
// Use the no-reload VLM flow via PageControls
if (window.pageControls && typeof window.pageControls.triggerVlmView === 'function') {
window.pageControls.triggerVlmView(modelId, modelName, baseModel, modelType);
}
}
function handleCardClick(card, modelType) {
const pageState = getCurrentPageState();
@@ -448,6 +470,10 @@ export function createModelCard(model, modelType) {
const hasUpdateAvailable = Boolean(model.update_available);
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
// Store version_count for group-by-model display
if (model.version_count !== undefined) {
card.dataset.version_count = model.version_count;
}
// To only show usage_count when sorting by usage.
const pageState = getCurrentPageState();
@@ -659,16 +685,28 @@ export function createModelCard(model, modelType) {
const autoTags = model.auto_tags || [];
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
const hasVersionName = model.civitai?.name;
if (!hlTags.length && !hasVersionName) return '';
// When group_by_model is active and model has multiple versions,
// show clickable version count instead of version name (and hide badges)
const isGroupByModel = state.global.settings.group_by_model;
const versionCount = model.version_count;
const showVersionCount = isGroupByModel && versionCount > 1;
if (!hlTags.length && !hasVersionName && !showVersionCount) return '';
const density = state.global.settings.display_density || 'default';
const shortLabels = density === 'medium' || density === 'compact';
const badges = hlTags.map(t => {
// Don't show HIGH/LOW badges when showing version count (confusing in grouped mode)
const badges = !showVersionCount ? hlTags.map(t => {
const cls = t === 'HIGH' ? 'hl-badge hl-badge--high' : 'hl-badge hl-badge--low';
const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t;
const titleAttr = shortLabels ? ` title="${t}"` : '';
return `<span class="${cls}"${titleAttr}>${label}</span>`;
}).join('');
const versionHtml = hasVersionName ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : '';
}).join('') : '';
let versionHtml = '';
if (showVersionCount) {
const countLabel = translate('modelCard.footer.versionCount', { count: versionCount }, `${versionCount} versions`);
versionHtml = `<span class="version-count-link" title="${translate('modelCard.footer.viewAllVersions', {}, 'View all local versions')}">${countLabel}</span>`;
} else if (hasVersionName) {
versionHtml = `<span class="version-name civitai-version">${model.civitai.name}</span>`;
}
return `<span class="badge-version-unit">${badges}${versionHtml}</span>`;
})()}
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}

View File

@@ -752,6 +752,7 @@ export async function showModelModal(model, modelType) {
modelId: civitaiModelId,
currentVersionId: civitaiVersionId,
currentBaseModel: modelWithFullData.base_model,
modelName: model.model_name,
onUpdateStatusChange: handleUpdateStatusChange,
});
setupEditableFields(modelWithFullData.file_path, modelType);

View File

@@ -29,6 +29,14 @@ let priorityTagSuggestionsLoaded = false;
let priorityTagSuggestionsPromise = null;
let activeTagDragState = null;
// Configurable options for tag editing (set by setupTagEditMode)
let tagEditOptions = {
showSuggestions: true,
saveHandler: null,
onSaved: null,
normalizeTag: true,
};
function normalizeModelTypeKey(modelType) {
if (!modelType) {
return '';
@@ -140,13 +148,30 @@ let saveTagsHandler = null;
/**
* Set up tag editing mode
* @param {string|null} modelType - Model type for suggestions (e.g. 'loras', 'checkpoints')
* @param {Object} [options] - Optional configuration
* @param {boolean} [options.showSuggestions=true] - Show priority tag suggestions dropdown
* @param {Function} [options.saveHandler] - Custom save function, async (filePath, tags) => {}
* @param {Function} [options.onSaved] - Called after successful save, (tags) => {}
* @param {boolean} [options.normalizeTag=true] - Lowercase tag on add
*/
export function setupTagEditMode(modelType = null) {
const editBtn = document.querySelector('.edit-tags-btn');
export function setupTagEditMode(modelType = null, options = {}) {
// Store options for use by saveTags and addNewTag
tagEditOptions = {
showSuggestions: options.showSuggestions !== false,
saveHandler: options.saveHandler || null,
onSaved: options.onSaved || null,
normalizeTag: options.normalizeTag !== false,
};
const root = options.container || document;
const editBtn = root.querySelector('.edit-tags-btn');
if (!editBtn) return;
setActiveModelTypeKey(modelType);
ensurePriorityTagSuggestions();
if (tagEditOptions.showSuggestions) {
setActiveModelTypeKey(modelType);
ensurePriorityTagSuggestions();
}
// Store original tags for restoring on cancel
let originalTags = [];
@@ -158,7 +183,8 @@ export function setupTagEditMode(modelType = null) {
// Create new handler and store reference
const editBtnClickHandler = function() {
const tagsSection = document.querySelector('.model-tags-container');
const tagsSection = this.closest('.model-tags-container');
if (!tagsSection) return;
const isEditMode = tagsSection.classList.toggle('edit-mode');
const filePath = this.dataset.filePath;
@@ -193,16 +219,18 @@ export function setupTagEditMode(modelType = null) {
tagsSection.appendChild(editContainer);
// Setup the tag input field behavior
setupTagInput();
setupTagInput(tagsSection);
// Create and add preset suggestions dropdown
const tagForm = editContainer.querySelector('.metadata-add-form');
const suggestionsDropdown = createSuggestionsDropdown(originalTags);
tagForm.appendChild(suggestionsDropdown);
if (tagEditOptions.showSuggestions) {
const tagForm = editContainer.querySelector('.metadata-add-form');
const suggestionsDropdown = createSuggestionsDropdown(originalTags);
tagForm.appendChild(suggestionsDropdown);
}
// Setup delete buttons for existing tags
setupDeleteButtons();
setupTagDragAndDrop();
setupTagDragAndDrop(tagsSection);
// Transfer click event from original button to the cloned one
const newEditBtn = editContainer.querySelector('.metadata-header-btn');
@@ -218,7 +246,7 @@ export function setupTagEditMode(modelType = null) {
// Just show the existing edit container
tagsEditContainer.style.display = 'block';
editBtn.style.display = 'none';
setupTagDragAndDrop();
setupTagDragAndDrop(tagsSection);
}
} else {
// Exit edit mode
@@ -255,7 +283,7 @@ export function setupTagEditMode(modelType = null) {
saveTagsHandler = function(e) {
if (e.target.classList.contains('save-tags-btn') ||
e.target.closest('.save-tags-btn')) {
saveTags();
saveTags(e.target);
}
};
@@ -267,19 +295,28 @@ export function setupTagEditMode(modelType = null) {
/**
* Save tags
* @param {Element} [triggerElement] - The element that triggered the save (e.g. save button)
*/
async function saveTags() {
const editBtn = document.querySelector('.edit-tags-btn');
if (!editBtn) return;
async function saveTags(triggerElement = null) {
let editBtn;
let scope;
if (triggerElement) {
scope = triggerElement.closest('.model-tags-container');
editBtn = scope ? scope.querySelector('.edit-tags-btn') : document.querySelector('.edit-tags-btn');
} else {
scope = document.querySelector('.model-tags-container');
editBtn = scope ? scope.querySelector('.edit-tags-btn') : null;
}
if (!editBtn || !scope) return;
const filePath = editBtn.dataset.filePath;
const tagElements = document.querySelectorAll('.metadata-item');
const tagElements = scope.querySelectorAll('.metadata-item');
let tags = Array.from(tagElements).map(tag => tag.dataset.tag);
// Flush uncommitted input as a tag so it's not silently lost on save
const tagInput = document.querySelector('.metadata-input');
const tagInput = scope.querySelector('.metadata-input');
if (tagInput) {
const pendingTag = tagInput.value.trim().toLowerCase();
const pendingTag = tagEditOptions.normalizeTag ? tagInput.value.trim().toLowerCase() : tagInput.value.trim();
if (pendingTag && !tags.includes(pendingTag)) {
tags.push(pendingTag);
}
@@ -287,7 +324,7 @@ async function saveTags() {
}
// Get original tags to compare
const originalTagElements = document.querySelectorAll('.tooltip-tag');
const originalTagElements = scope.querySelectorAll('.tooltip-tag');
const originalTags = Array.from(originalTagElements).map(tag => tag.textContent);
// Check if tags have actually changed
@@ -301,59 +338,68 @@ async function saveTags() {
}
try {
// Save tags metadata
await getModelApiClient().saveModelMetadata(filePath, { tags: tags });
// Use custom save handler if provided, otherwise default model API
if (tagEditOptions.saveHandler) {
await tagEditOptions.saveHandler(filePath, tags);
} else {
await getModelApiClient().saveModelMetadata(filePath, { tags: tags });
}
// Set flag to skip restoring original tags when exiting edit mode
editBtn.dataset.skipRestore = "true";
// Update the compact tags display
const compactTagsContainer = document.querySelector('.model-tags-container');
if (compactTagsContainer) {
// Generate new compact tags HTML
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
if (compactTagsDisplay) {
// Clear current tags
compactTagsDisplay.innerHTML = '';
// Use custom onSaved if provided (e.g. for recipe dirty state + re-render)
if (tagEditOptions.onSaved) {
tagEditOptions.onSaved(tags);
} else {
// Update the compact tags display
const compactTagsContainer = scope;
if (compactTagsContainer) {
// Generate new compact tags HTML
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
// Add visible tags (up to 5)
const visibleTags = tags.slice(0, 5);
visibleTags.forEach(tag => {
const span = document.createElement('span');
span.className = 'model-tag-compact';
span.textContent = tag;
compactTagsDisplay.appendChild(span);
});
if (compactTagsDisplay) {
// Clear current tags
compactTagsDisplay.innerHTML = '';
// Add visible tags (up to 5)
const visibleTags = tags.slice(0, 5);
visibleTags.forEach(tag => {
const span = document.createElement('span');
span.className = 'model-tag-compact';
span.textContent = tag;
compactTagsDisplay.appendChild(span);
});
// Add more indicator if needed
const remainingCount = Math.max(0, tags.length - 5);
if (remainingCount > 0) {
const more = document.createElement('span');
more.className = 'model-tag-more';
more.dataset.count = remainingCount;
more.textContent = `+${remainingCount}`;
compactTagsDisplay.appendChild(more);
}
}
// Add more indicator if needed
const remainingCount = Math.max(0, tags.length - 5);
if (remainingCount > 0) {
const more = document.createElement('span');
more.className = 'model-tag-more';
more.dataset.count = remainingCount;
more.textContent = `+${remainingCount}`;
compactTagsDisplay.appendChild(more);
// Update tooltip content
const tooltipContent = compactTagsContainer.querySelector('.tooltip-content');
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const span = document.createElement('span');
span.className = 'tooltip-tag';
span.textContent = tag;
tooltipContent.appendChild(span);
});
}
}
// Update tooltip content
const tooltipContent = compactTagsContainer.querySelector('.tooltip-content');
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const span = document.createElement('span');
span.className = 'tooltip-tag';
span.textContent = tag;
tooltipContent.appendChild(span);
});
}
// Exit edit mode
editBtn.click();
}
// Exit edit mode
editBtn.click();
showToast('modelTags.messages.updated', {}, 'success');
} catch (error) {
console.error('Error saving tags:', error);
@@ -470,16 +516,19 @@ function renderPriorityTagSuggestions(container, existingTags = []) {
/**
* Set up tag input behavior
* @param {Element} scopeContainer - The .model-tags-container element
*/
function setupTagInput() {
const tagInput = document.querySelector('.metadata-input');
function setupTagInput(scopeContainer) {
const tagInput = scopeContainer
? scopeContainer.querySelector('.metadata-input')
: document.querySelector('.metadata-input');
if (tagInput) {
tagInput.focus();
tagInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addNewTag(this.value);
addNewTag(this.value, this);
this.value = ''; // Clear input after adding
}
});
@@ -504,9 +553,12 @@ function setupDeleteButtons() {
/**
* Enable drag-and-drop sorting for tag items
* @param {Element} [scopeContainer] - Optional scoped .model-tags-container element
*/
function setupTagDragAndDrop() {
const container = document.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR);
function setupTagDragAndDrop(scopeContainer) {
const container = scopeContainer
? scopeContainer.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR)
: document.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR);
if (!container) {
return;
}
@@ -712,12 +764,14 @@ function finishPointerDrag() {
/**
* Add a new tag
* @param {string} tag - Tag to add
* @param {Element} [scopeElement] - Element within the correct .model-tags-container for scoping
*/
function addNewTag(tag) {
tag = tag.trim().toLowerCase();
function addNewTag(tag, scopeElement = null) {
tag = tagEditOptions.normalizeTag ? tag.trim().toLowerCase() : tag.trim();
if (!tag) return;
const tagsContainer = document.querySelector('.metadata-items');
const scope = scopeElement ? scopeElement.closest('.model-tags-container') : document;
const tagsContainer = scope.querySelector('.metadata-items');
if (!tagsContainer) return;
// Validation: Check length
@@ -762,7 +816,7 @@ function addNewTag(tag) {
});
tagsContainer.appendChild(newTag);
setupTagDragAndDrop();
setupTagDragAndDrop(scope);
// Update status of items in the suggestions dropdown
updateSuggestionsDropdown();

View File

@@ -6,6 +6,7 @@ import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js';
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
import { formatFileSize } from './utils.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
@@ -306,7 +307,7 @@ function getToggleTooltipText(mode) {
}
function getDefaultDisplayMode() {
const strategy = state?.global?.settings?.update_flag_strategy;
const strategy = state?.global?.settings?.version_grouping;
return strategy === DISPLAY_FILTER_MODES.SAME_BASE
? DISPLAY_FILTER_MODES.SAME_BASE
: DISPLAY_FILTER_MODES.ANY;
@@ -338,7 +339,7 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
return false;
}
const strategy = state?.global?.settings?.update_flag_strategy;
const strategy = state?.global?.settings?.version_grouping;
const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
@@ -744,7 +745,7 @@ function renderToolbar(record, toolbarState = {}) {
<button class="versions-toolbar-btn versions-toolbar-btn-primary" data-versions-action="toggle-model-ignore">
${escapeHtml(ignoreText)}
</button>
<button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Coming soon'))}" disabled>
<button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Show all local versions of this model on the main page'))}">
${escapeHtml(viewLocalText)}
</button>
</div>
@@ -792,6 +793,7 @@ export function initVersionsTab({
modelId,
currentVersionId,
currentBaseModel,
modelName,
onUpdateStatusChange,
}) {
const pane = document.querySelector(`#${modalId} #versions-tab`);
@@ -1019,6 +1021,39 @@ export function initVersionsTab({
render(controller.record);
}
function handleViewLocalVersions() {
if (!controller.record || !modelId) {
return;
}
// Determine base model filter based on current display mode
const baseModelInfo = getCurrentVersionBaseModel(controller.record, normalizedCurrentVersionId);
const isFilteringActive =
displayMode === DISPLAY_FILTER_MODES.SAME_BASE &&
Boolean(baseModelInfo.normalized);
// Write filter params to sessionStorage (page-scoped)
setSessionItem('vlm_model_id', String(modelId));
setSessionItem('vlm_model_name', modelName || String(modelId));
setSessionItem('vlm_page_type', modelType);
if (isFilteringActive) {
// Use raw (non-normalized) base model for exact backend matching
setSessionItem('vlm_base_model', baseModelInfo.raw);
} else {
removeSessionItem('vlm_base_model');
}
// Close the modal and navigate via no-reload VLM flow
modalManager.closeModal(modalId);
if (window.pageControls && typeof window.pageControls.triggerVlmView === 'function') {
window.pageControls.triggerVlmView(
modelId,
modelName || String(modelId),
isFilteringActive ? baseModelInfo.raw : undefined,
modelType
);
}
}
async function handleToggleVersionIgnore(button, versionId) {
if (!controller.record) {
return;
@@ -1348,6 +1383,10 @@ export function initVersionsTab({
event.preventDefault();
handleToggleVersionDisplayMode();
break;
case 'view-local':
event.preventDefault();
handleViewLocalVersions();
break;
default:
break;
}

View File

@@ -78,10 +78,12 @@ export function renderCompactTags(tags, filePath = '') {
/**
* Set up tag tooltip functionality
* @param {Element} [scopeContainer] - Optional container to scope the querySelector
*/
export function setupTagTooltip() {
const tagsContainer = document.querySelector('.model-tags-container');
const tooltip = document.querySelector('.model-tags-tooltip');
export function setupTagTooltip(scopeContainer = null) {
const root = scopeContainer || document;
const tagsContainer = root.querySelector('.model-tags-container');
const tooltip = root.querySelector('.model-tags-tooltip');
if (tagsContainer && tooltip) {
tagsContainer.addEventListener('mouseenter', () => {

View File

@@ -905,15 +905,21 @@ export class SettingsManager {
showVersionOnCardCheckbox.checked = state.global.settings.show_version_on_card !== false;
}
// Set group by model
const groupByModelCheckbox = document.getElementById('groupByModel');
if (groupByModelCheckbox) {
groupByModelCheckbox.checked = !!state.global.settings.group_by_model;
}
// Set model name display setting
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
if (modelNameDisplaySelect) {
modelNameDisplaySelect.value = state.global.settings.model_name_display || 'model_name';
}
const updateFlagStrategySelect = document.getElementById('updateFlagStrategy');
if (updateFlagStrategySelect) {
updateFlagStrategySelect.value = state.global.settings.update_flag_strategy || 'same_base';
const versionGroupingSelect = document.getElementById('versionGrouping');
if (versionGroupingSelect) {
versionGroupingSelect.value = state.global.settings.version_grouping || 'same_base';
}
// Set hide early access updates setting
@@ -2011,7 +2017,11 @@ export class SettingsManager {
}
}
if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content') {
if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content' || settingKey === 'group_by_model') {
// Save/restore sort preference when toggling group_by_model
if (settingKey === 'group_by_model' && window.pageControls?.onGroupByModelToggled) {
window.pageControls.onGroupByModelToggled(value);
}
this.reloadContent();
}
@@ -2060,7 +2070,7 @@ export class SettingsManager {
if (
settingKey === 'model_name_display'
|| settingKey === 'model_card_footer_action'
|| settingKey === 'update_flag_strategy'
|| settingKey === 'version_grouping'
|| settingKey === 'mature_blur_level'
) {
this.reloadContent();
@@ -3046,6 +3056,10 @@ export class SettingsManager {
const useNewLicenseIcons = state.global.settings.use_new_license_icons !== false;
document.body.classList.toggle('use-new-license-icons', useNewLicenseIcons);
// Apply group-by-model mode
const groupByModel = !!state.global.settings.group_by_model;
document.body.classList.toggle('group-by-model', groupByModel);
}
}

View File

@@ -149,9 +149,10 @@ class RecipeManager {
_showCustomFilterIndicator() {
const indicator = document.getElementById('customFilterIndicator');
const textElement = document.getElementById('customFilterText');
if (!indicator) return;
const textElement = indicator.querySelector('.customFilterText');
if (!indicator || !textElement) return;
if (!textElement) return;
// Update text based on filter type
let filterText = '';
@@ -250,6 +251,11 @@ class RecipeManager {
bulkButton.addEventListener('click', () => window.bulkManager?.toggleBulkMode());
}
const duplicatesButton = document.querySelector('[data-action="find-duplicates"]');
if (duplicatesButton) {
duplicatesButton.addEventListener('click', () => this.findDuplicateRecipes());
}
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
if (favoriteFilterBtn) {
favoriteFilterBtn.addEventListener('click', () => {

View File

@@ -44,7 +44,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
include_trigger_words: false,
compact_mode: false,
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
update_flag_strategy: 'same_base',
version_grouping: 'same_base',
hide_early_access_updates: false,
auto_organize_exclusions: [],
metadata_refresh_skip_paths: [],
@@ -54,6 +54,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
backup_retention_count: 5,
strip_lora_on_copy: false,
use_new_license_icons: true,
group_by_model: false,
});
export function createDefaultSettings() {

View File

@@ -1,6 +1,8 @@
// Statistics page functionality
import { appCore } from './core.js';
import { showToast } from './utils/uiHelpers.js';
import { translate } from './utils/i18nHelpers.js';
import { i18n } from './i18n/index.js';
// Chart.js import (assuming it's available globally or via CDN)
// If Chart.js isn't available, we'll need to add it to the project
@@ -124,43 +126,43 @@ export class StatisticsManager {
{
icon: 'fas fa-magic',
value: this.data.collection.total_models,
label: 'Total Models',
label: translate('statistics.metrics.totalModels'),
format: 'number'
},
{
icon: 'fas fa-database',
value: this.data.collection.total_size,
label: 'Total Storage',
label: translate('statistics.metrics.totalStorage'),
format: 'size'
},
{
icon: 'fas fa-play-circle',
value: this.data.collection.total_generations,
label: 'Total Generations',
label: translate('statistics.metrics.totalGenerations'),
format: 'number'
},
{
icon: 'fas fa-chart-line',
value: this.calculateUsageRate(),
label: 'Usage Rate',
label: translate('statistics.metrics.usageRate'),
format: 'percentage'
},
{
icon: 'fas fa-layer-group',
value: this.data.collection.lora_count,
label: 'LoRAs',
label: translate('statistics.metrics.loras'),
format: 'number'
},
{
icon: 'fas fa-check-circle',
value: this.data.collection.checkpoint_count,
label: 'Checkpoints',
label: translate('statistics.metrics.checkpoints'),
format: 'number'
},
{
icon: 'fas fa-code',
value: this.data.collection.embedding_count,
label: 'Embeddings',
label: translate('statistics.metrics.embeddings'),
format: 'number'
}
];
@@ -189,18 +191,14 @@ export class StatisticsManager {
case 'size':
return this.formatFileSize(value);
case 'percentage':
return `${value.toFixed(1)}%`;
return new Intl.NumberFormat(i18n.getCurrentLocale(), { style: 'percent', maximumFractionDigits: 1 }).format(value / 100);
default:
return value;
}
}
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
return i18n.formatFileSize(bytes);
}
calculateUsageRate() {
@@ -250,7 +248,7 @@ export class StatisticsManager {
if (!ctx || !this.data.collection) return;
const data = {
labels: ['LoRAs', 'Checkpoints', 'Embeddings'],
labels: [translate('statistics.metrics.loras'), translate('statistics.metrics.checkpoints'), translate('statistics.metrics.embeddings')],
datasets: [{
data: [
this.data.collection.lora_count,
@@ -290,28 +288,28 @@ export class StatisticsManager {
const checkpointData = this.data.baseModels.checkpoints;
const embeddingData = this.data.baseModels.embeddings;
const allModels = new Set([
const allModels = Array.from(new Set([
...Object.keys(loraData),
...Object.keys(checkpointData),
...Object.keys(embeddingData)
]);
])).sort();
const data = {
labels: Array.from(allModels),
labels: allModels,
datasets: [
{
label: 'LoRAs',
data: Array.from(allModels).map(model => loraData[model] || 0),
label: translate('statistics.metrics.loras'),
data: allModels.map(model => loraData[model] || 0),
backgroundColor: 'oklch(68% 0.28 256 / 0.7)'
},
{
label: 'Checkpoints',
data: Array.from(allModels).map(model => checkpointData[model] || 0),
label: translate('statistics.metrics.checkpoints'),
data: allModels.map(model => checkpointData[model] || 0),
backgroundColor: 'oklch(68% 0.28 200 / 0.7)'
},
{
label: 'Embeddings',
data: Array.from(allModels).map(model => embeddingData[model] || 0),
label: translate('statistics.metrics.embeddings'),
data: allModels.map(model => embeddingData[model] || 0),
backgroundColor: 'oklch(68% 0.28 120 / 0.7)'
}
]
@@ -345,21 +343,21 @@ export class StatisticsManager {
labels: timeline.map(item => new Date(item.date).toLocaleDateString()),
datasets: [
{
label: 'LoRA Usage',
label: translate('statistics.charts.loraUsage'),
data: timeline.map(item => item.lora_usage),
borderColor: 'oklch(68% 0.28 256)',
backgroundColor: 'oklch(68% 0.28 256 / 0.1)',
fill: true
},
{
label: 'Checkpoint Usage',
label: translate('statistics.charts.checkpointUsage'),
data: timeline.map(item => item.checkpoint_usage),
borderColor: 'oklch(68% 0.28 200)',
backgroundColor: 'oklch(68% 0.28 200 / 0.1)',
fill: true
},
{
label: 'Embedding Usage',
label: translate('statistics.charts.embeddingUsage'),
data: timeline.map(item => item.embedding_usage),
borderColor: 'oklch(68% 0.28 120)',
backgroundColor: 'oklch(68% 0.28 120 / 0.1)',
@@ -383,14 +381,14 @@ export class StatisticsManager {
display: true,
title: {
display: true,
text: 'Date'
text: translate('statistics.charts.date')
}
},
y: {
display: true,
title: {
display: true,
text: 'Usage Count'
text: translate('statistics.charts.usageCount')
}
}
}
@@ -416,7 +414,7 @@ export class StatisticsManager {
const data = {
labels: allModels.map(model => model.name),
datasets: [{
label: 'Usage Count',
label: translate('statistics.charts.usageCount'),
data: allModels.map(model => model.usage_count),
backgroundColor: allModels.map(model => {
switch(model.type) {
@@ -450,7 +448,7 @@ export class StatisticsManager {
if (!ctx || !this.data.collection) return;
const data = {
labels: ['LoRAs', 'Checkpoints', 'Embeddings'],
labels: [translate('statistics.metrics.loras'), translate('statistics.metrics.checkpoints'), translate('statistics.metrics.embeddings')],
datasets: [{
data: [
this.data.collection.lora_size,
@@ -504,7 +502,7 @@ export class StatisticsManager {
const data = {
datasets: [{
label: 'Models',
label: translate('statistics.charts.models'),
data: allData.map(item => ({
x: item.size,
y: item.usage_count,
@@ -532,14 +530,14 @@ export class StatisticsManager {
x: {
title: {
display: true,
text: 'File Size (bytes)'
text: translate('statistics.charts.fileSizeBytes')
},
type: 'logarithmic'
},
y: {
title: {
display: true,
text: 'Usage Count'
text: translate('statistics.charts.usageCount')
}
}
},
@@ -548,7 +546,7 @@ export class StatisticsManager {
callbacks: {
label: (context) => {
const point = context.raw;
return `${point.name}: ${this.formatFileSize(point.x)}, ${point.y} uses`;
return translate('statistics.tooltips.chartUsage', { name: point.name, size: this.formatFileSize(point.x), count: point.y });
}
}
}
@@ -563,12 +561,12 @@ export class StatisticsManager {
const distribution = this.data.collection.model_types_distribution;
const typeDisplayNames = {
lora: 'LoRA',
locon: 'LyCORIS',
dora: 'DoRA',
checkpoint: 'Checkpoint',
diffusion_model: 'Diffusion Model',
embedding: 'Embeddings'
lora: translate('statistics.modelTypes.lora'),
locon: translate('statistics.modelTypes.locon'),
dora: translate('statistics.modelTypes.dora'),
checkpoint: translate('statistics.modelTypes.checkpoint'),
diffusion_model: translate('statistics.modelTypes.diffusion_model'),
embedding: translate('statistics.modelTypes.embedding')
};
const colorPalette = {
@@ -610,7 +608,7 @@ export class StatisticsManager {
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const value = context.parsed;
const pct = ((value / total) * 100).toFixed(1);
return ` ${context.label}: ${value} (${pct}%)`;
return translate('statistics.tooltips.chartPercentage', { label: context.label, value, pct });
}
}
}
@@ -654,7 +652,7 @@ export class StatisticsManager {
// Show loading indicator on initial load
if (state.offset === 0) {
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-spinner fa-spin"></i> ' + translate('statistics.placeholders.loading') + '</div>';
}
try {
@@ -670,7 +668,7 @@ export class StatisticsManager {
}
if (items.length === 0 && state.offset === 0) {
container.innerHTML = '<div class="loading-placeholder">No models found</div>';
container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.placeholders.noModels') + '</div>';
state.hasMore = false;
} else if (items.length < state.limit) {
state.hasMore = false;
@@ -683,7 +681,7 @@ export class StatisticsManager {
onerror="this.src='/loras_static/images/no-preview.png'">
<div class="model-info">
<div class="model-name" title="${model.name}">${model.name}</div>
<div class="model-meta">${model.base_model}${model.folder || 'Root'}</div>
<div class="model-meta">${model.base_model}${model.folder || translate('statistics.placeholders.rootFolder')}</div>
</div>
<div class="model-usage">${model.usage_count}</div>
</div>
@@ -695,7 +693,7 @@ export class StatisticsManager {
} catch (error) {
console.error(`Error loading ${type} list:`, error);
if (state.offset === 0) {
container.innerHTML = '<div class="loading-placeholder">Error loading data</div>';
container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.placeholders.errorLoading') + '</div>';
}
} finally {
state.isLoading = false;
@@ -718,7 +716,7 @@ export class StatisticsManager {
].sort((a, b) => b.size - a.size).slice(0, 10);
if (allModels.length === 0) {
container.innerHTML = '<div class="loading-placeholder">No storage data available</div>';
container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.placeholders.noStorageData') + '</div>';
return;
}
@@ -726,7 +724,7 @@ export class StatisticsManager {
<div class="model-item">
<div class="model-info">
<div class="model-name" title="${model.name}">${model.name}</div>
<div class="model-meta">${model.type}${model.base_model}</div>
<div class="model-meta">${translate('statistics.modelTypes.' + model.type.toLowerCase())}${model.base_model}</div>
</div>
<div class="model-usage">${this.formatFileSize(model.size)}</div>
</div>
@@ -744,7 +742,7 @@ export class StatisticsManager {
const size = Math.ceil((tagData.count / maxCount) * 5);
return `
<span class="tag-cloud-item size-${size}"
title="${tagData.tag}: ${tagData.count} models">
title="${translate('statistics.tooltips.tagCount', { tag: tagData.tag, count: tagData.count })}">
${tagData.tag}
</span>
`;
@@ -758,17 +756,30 @@ export class StatisticsManager {
const insights = this.data.insights.insights;
if (insights.length === 0) {
container.innerHTML = '<div class="loading-placeholder">No insights available</div>';
container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.insights.noInsights') + '</div>';
return;
}
container.innerHTML = insights.map(insight => `
container.innerHTML = insights.map(insight => {
const params = insight.params || {};
let title, description, suggestion;
if (insight.key) {
title = translate('statistics.' + insight.key + '.title', params);
description = translate('statistics.' + insight.key + '.description', params);
suggestion = translate('statistics.' + insight.key + '.suggestion', params);
} else {
// Backward compatibility for insights without key/params
title = insight.title || '';
description = insight.description || '';
suggestion = insight.suggestion || '';
}
return `
<div class="insight-card type-${insight.type}">
<div class="insight-title">${insight.title}</div>
<div class="insight-description">${insight.description}</div>
<div class="insight-suggestion">${insight.suggestion}</div>
<div class="insight-title">${title}</div>
<div class="insight-description">${description}</div>
<div class="insight-suggestion">${suggestion}</div>
</div>
`).join('');
`}).join('');
// Render collection analysis cards
this.renderCollectionAnalysis();
@@ -782,25 +793,25 @@ export class StatisticsManager {
{
icon: 'fas fa-percentage',
value: this.calculateUsageRate(),
label: 'Usage Rate',
label: translate('statistics.metrics.usageRate'),
format: 'percentage'
},
{
icon: 'fas fa-tags',
value: this.data.tags?.total_unique_tags || 0,
label: 'Unique Tags',
label: translate('statistics.metrics.uniqueTags'),
format: 'number'
},
{
icon: 'fas fa-clock',
value: this.data.collection.unused_loras + this.data.collection.unused_checkpoints,
label: 'Unused Models',
label: translate('statistics.metrics.unusedModels'),
format: 'number'
},
{
icon: 'fas fa-chart-line',
value: this.calculateAverageUsage(),
label: 'Avg. Uses/Model',
label: translate('statistics.metrics.avgUsesPerModel'),
format: 'decimal'
}
];
@@ -829,7 +840,7 @@ export class StatisticsManager {
const chartCanvases = document.querySelectorAll('canvas');
chartCanvases.forEach(canvas => {
const container = canvas.parentElement;
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-chart-bar"></i> Chart requires Chart.js library</div>';
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-chart-bar"></i> ' + translate('statistics.placeholders.chartLibraryMissing') + '</div>';
});
}

View File

@@ -158,6 +158,11 @@
<div class="context-menu-item" data-action="manage-excluded-models">
<i class="fas fa-eye-slash"></i> <span>{{ t('globalContextMenu.manageExcludedModels.label', default='Manage Excluded Models') }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="toggle-group-by-model">
<i class="fas fa-layer-group"></i> <span>{{ t('globalContextMenu.groupByModel.label') }}</span>
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
</div>
<div class="context-menu-item" data-action="repair-recipes">
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
</div>

View File

@@ -1,4 +1,5 @@
<div class="controls">
{% if page_id != 'recipes' %}
<div id="excludedViewBanner" class="excluded-view-banner hidden">
<div class="excluded-view-banner__content">
<div class="excluded-view-banner__title">
@@ -11,42 +12,58 @@
</button>
</div>
</div>
{% endif %}
<div class="actions">
<div class="action-buttons">
<div title="{{ t('loras.controls.sort.title') }}" class="control-group">
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.title') }}{% else %}{{ t('loras.controls.sort.title') }}{% endif %}" class="control-group">
<select id="sortSelect">
<optgroup label="{{ t('loras.controls.sort.name') }}">
<option value="name:asc">{{ t('loras.controls.sort.nameAsc') }}</option>
<option value="name:desc">{{ t('loras.controls.sort.nameDesc') }}</option>
</optgroup>
<optgroup label="{{ t('loras.controls.sort.date') }}">
<optgroup label="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.date') }}{% else %}{{ t('loras.controls.sort.date') }}{% endif %}">
<option value="date:desc">{{ t('loras.controls.sort.dateDesc') }}</option>
<option value="date:asc">{{ t('loras.controls.sort.dateAsc') }}</option>
</optgroup>
{% if page_id != 'recipes' %}
<optgroup label="{{ t('loras.controls.sort.size') }}">
<option value="size:desc">{{ t('loras.controls.sort.sizeDesc') }}</option>
<option value="size:asc">{{ t('loras.controls.sort.sizeAsc') }}</option>
</optgroup>
{% if page_id != 'embeddings' %}
{% endif %}
{% if page_id != 'embeddings' and page_id != 'recipes' %}
<optgroup label="{{ t('loras.controls.sort.usage', default='Usage') }}">
<option value="usage:desc">{{ t('loras.controls.sort.usageDesc', default='Times used (high to low)') }}</option>
<option value="usage:asc">{{ t('loras.controls.sort.usageAsc', default='Times used (low to high)') }}</option>
</optgroup>
{% endif %}
{% if page_id != 'recipes' %}
<optgroup class="sort-option-versions-count" label="{{ t('loras.controls.sort.versionsCount', default='Local Versions') }}">
<option value="versions_count:desc">{{ t('loras.controls.sort.versionsCountDesc', default='Most versions first') }}</option>
<option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option>
</optgroup>
{% endif %}
{% if page_id == 'recipes' %}
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
</optgroup>
{% endif %}
</select>
</div>
<div title="{{ t('loras.controls.refresh.title') }}" class="control-group dropdown-group">
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{ t('common.actions.refresh') }}</span></button>
<button class="dropdown-toggle" aria-label="Show refresh options">
<i class="fas fa-caret-down"></i>
</button>
<div class="dropdown-menu">
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('loras.controls.refresh.fullTooltip') }}">
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full') }}</span>
<div class="dropdown-item" data-action="full-rebuild" title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}{% else %}{{ t('loras.controls.refresh.fullTooltip') }}{% endif %}">
<i class="fas fa-tools"></i> <span>{% if page_id == 'recipes' %}{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}{% else %}{{ t('loras.controls.refresh.full') }}{% endif %}</span>
</div>
</div>
</div>
{% if page_id != 'recipes' %}
<div class="control-group">
<button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span>{{ t('loras.controls.fetch.action') }}</span></button>
</div>
@@ -55,6 +72,15 @@
<i class="fas fa-cloud-download-alt"></i> <span>{{ t('loras.controls.download.action') }}</span>
</button>
</div>
{% endif %}
{% if page_id == 'recipes' %}
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{ t('recipes.controls.import.action') }}</button>
</div>
<div title="{{ t('recipes.batchImport.title') }}" class="control-group">
<button onclick="batchImportManager.showModal()"><i class="fas fa-layer-group"></i> {{ t('recipes.batchImport.action') }}</button>
</div>
{% endif %}
<div class="control-group">
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span> <div class="shortcut-key">B</div></span>
@@ -71,6 +97,7 @@
<i class="fas fa-star"></i> <span>{{ t('loras.controls.favorites.action') }}</span>
</button>
</div>
{% if page_id != 'recipes' %}
<div class="control-group dropdown-group update-filter-group">
<button id="updateFilterBtn" data-action="toggle-updates" class="dropdown-main update-filter" title="{{ t('loras.controls.updates.title') }}">
<i class="fas fa-exclamation-circle"></i> <span>{{ t('loras.controls.updates.action') }}</span>
@@ -84,6 +111,7 @@
</div>
</div>
</div>
{% endif %}
<div id="customFilterIndicator" class="control-group hidden">
<div class="filter-active">
<i class="fas fa-filter"></i> <span class="customFilterText" title=""></span>

View File

@@ -536,6 +536,25 @@
</div>
</div>
<!-- Group by model toggle -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="groupByModel">
{{ t('settings.layoutSettings.groupByModel') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.groupByModelHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="groupByModel"
onchange="settingsManager.saveToggleSetting('groupByModel', 'group_by_model')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
@@ -1081,23 +1100,23 @@
</div>
</div>
<!-- Update Flags -->
<!-- Version Scope -->
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>{{ t('settings.sections.updateFlags') }}</h4>
<h4>{{ t('settings.sections.versionScope') }}</h4>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="updateFlagStrategy">
{{ t('settings.updateFlagStrategy.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.updateFlagStrategy.help') }}"></i>
<label for="versionGrouping">
{{ t('settings.versionGrouping.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.versionGrouping.help') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="updateFlagStrategy" onchange="settingsManager.saveSelectSetting('updateFlagStrategy', 'update_flag_strategy')">
<option value="same_base">{{ t('settings.updateFlagStrategy.options.sameBase') }}</option>
<option value="any">{{ t('settings.updateFlagStrategy.options.any') }}</option>
<select id="versionGrouping" onchange="settingsManager.saveSelectSetting('versionGrouping', 'version_grouping')">
<option value="same_base">{{ t('settings.versionGrouping.options.sameBase') }}</option>
<option value="any">{{ t('settings.versionGrouping.options.any') }}</option>
</select>
</div>
</div>

View File

@@ -6,13 +6,8 @@
<h2 id="recipeModalTitle">Recipe Details</h2>
<!-- Header Actions: populated dynamically in RecipeModal.js -->
<div class="recipe-header-actions" id="recipeHeaderActions"></div>
<!-- Recipe Tags Container -->
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
<!-- Recipe Tags Container (rendered by renderCompactTags) -->
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">

View File

@@ -62,91 +62,9 @@
{% block content %}
<!-- Recipe controls -->
<div class="controls">
<div class="actions">
<div class="action-buttons">
<div class="control-group">
<select id="sortSelect" title="{{ t('recipes.controls.sort.title') }}">
<optgroup label="{{ t('recipes.controls.sort.name') }}">
<option value="name:asc">{{ t('recipes.controls.sort.nameAsc') }}</option>
<option value="name:desc">{{ t('recipes.controls.sort.nameDesc') }}</option>
</optgroup>
<optgroup label="{{ t('recipes.controls.sort.date') }}">
<option value="date:desc">{{ t('recipes.controls.sort.dateDesc') }}</option>
<option value="date:asc">{{ t('recipes.controls.sort.dateAsc') }}</option>
</optgroup>
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
</optgroup>
</select>
</div>
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group dropdown-group">
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{
t('common.actions.refresh') }}</span></button>
<button class="dropdown-toggle" aria-label="Show refresh options">
<i class="fas fa-caret-down"></i>
</button>
<div class="dropdown-menu">
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}">
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span>
</div>
</div>
</div>
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{
t('recipes.controls.import.action') }}</button>
</div>
<div title="{{ t('recipes.batchImport.title') }}" class="control-group">
<button onclick="batchImportManager.showModal()"><i class="fas fa-layer-group"></i> {{
t('recipes.batchImport.action') }}</button>
</div>
<div class="control-group" title="{{ t('loras.controls.bulk.title') }}">
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span>
<div class="shortcut-key">B</div>
</span>
</button>
</div>
<!-- Add duplicate detection button -->
<div title="{{ t('loras.controls.duplicates.title') }}" class="control-group">
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{
t('loras.controls.duplicates.action') }}</button>
</div>
<div class="control-group">
<button id="favoriteFilterBtn" data-action="toggle-favorites" class="favorite-filter"
title="{{ t('recipes.controls.favorites.title') }}">
<i class="fas fa-star"></i> <span>{{ t('recipes.controls.favorites.action') }}</span>
</button>
</div>
<!-- Custom filter indicator button (hidden by default) -->
<div id="customFilterIndicator" class="control-group hidden">
<div class="filter-active">
<i class="fas fa-filter"></i> <span id="customFilterText">{{ t('recipes.controls.filteredByLora')
}}</span>
<i class="fas fa-times-circle clear-filter"></i>
</div>
</div>
</div>
<div class="controls-right">
<div class="control-group doctor-control-group">
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
<i class="fas fa-stethoscope"></i>
<span>{{ t('doctor.title', default='Doctor') }}</span>
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
</button>
</div>
</div>
</div>
<!-- Breadcrumb Navigation -->
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
<!-- Breadcrumbs will be populated by JavaScript -->
</nav>
</div>
</div>
{% include 'components/controls.html' %}
<!-- Breadcrumb Navigation -->
{% include 'components/breadcrumb.html' %}
<!-- Duplicates banner (hidden by default) -->
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">

View File

@@ -246,12 +246,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
@@ -375,12 +370,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
@@ -474,12 +464,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
@@ -588,12 +573,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
@@ -682,12 +662,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
@@ -790,12 +765,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
@@ -873,12 +843,10 @@ describe('Interaction-level regression coverage', () => {
});
recipeModal.markFieldDirty('title');
recipeModal.markFieldDirty('tags');
recipeModal.markFieldDirty('prompt');
recipeModal.markFieldDirty('negative_prompt');
document.querySelector('#recipeTitleEditor .title-input').value = 'Local Title';
document.querySelector('#recipeTagsEditor .tags-input').value = 'local-tag-1, local-tag-2';
document.getElementById('recipePromptInput').value = 'local prompt';
document.getElementById('recipeNegativePromptInput').value = 'local negative';
@@ -899,7 +867,6 @@ describe('Interaction-level regression coverage', () => {
await flushAsyncTasks();
expect(document.querySelector('#recipeTitleEditor .title-input').value).toBe('Local Title');
expect(document.querySelector('#recipeTagsEditor .tags-input').value).toBe('local-tag-1, local-tag-2');
expect(document.getElementById('recipePromptInput').value).toBe('local prompt');
expect(document.getElementById('recipeNegativePromptInput').value).toBe('local negative');
expect(recipeModal.currentRecipe.title).toBe('Hydrated Title');
@@ -918,12 +885,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
@@ -1057,12 +1019,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
@@ -1170,8 +1127,7 @@ describe('Interaction-level regression coverage', () => {
<div id="recipeModal" class="modal">
<div id="recipeModalTitle"></div>
<div id="recipePreviewContainer"></div>
<div id="recipeTagsCompact"></div>
<div id="recipeTagsTooltip"><div id="recipeTagsTooltipContent"></div></div>
<div id="recipeTagsContainer"></div>
<div id="recipePrompt"></div>
<textarea id="recipePromptInput"></textarea>
<div id="recipeNegativePrompt"></div>
@@ -1224,8 +1180,7 @@ describe('Interaction-level regression coverage', () => {
<div id="recipeModal" class="modal">
<div id="recipeModalTitle"></div>
<div id="recipePreviewContainer"></div>
<div id="recipeTagsCompact"></div>
<div id="recipeTagsTooltip"><div id="recipeTagsTooltipContent"></div></div>
<div id="recipeTagsContainer"></div>
<div id="recipePrompt"></div>
<textarea id="recipePromptInput"></textarea>
<div id="recipeNegativePrompt"></div>
@@ -1300,12 +1255,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
@@ -1418,12 +1368,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
@@ -1541,12 +1486,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
@@ -1654,12 +1594,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
@@ -1776,12 +1711,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
@@ -1878,12 +1808,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
@@ -2007,12 +1932,7 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">

View File

@@ -33,7 +33,7 @@ const stateMock = {
global: {
settings: {
autoplay_on_hover: false,
update_flag_strategy: 'any',
version_grouping: 'any',
},
},
};
@@ -67,7 +67,7 @@ describe('ModelVersionsTab media rendering', () => {
</div>
`;
stateMock.global.settings.autoplay_on_hover = false;
stateMock.global.settings.update_flag_strategy = 'any';
stateMock.global.settings.version_grouping = 'any';
({ getModelApiClient } = await import(API_FACTORY_MODULE));
fetchModelUpdateVersions = vi.fn();
getModelApiClient.mockReturnValue({
@@ -157,7 +157,7 @@ describe('ModelVersionsTab media rendering', () => {
});
it('shows a stable label with a short state indicator', async () => {
stateMock.global.settings.update_flag_strategy = 'any';
stateMock.global.settings.version_grouping = 'any';
fetchModelUpdateVersions.mockResolvedValue({
success: true,
record: {
@@ -192,7 +192,7 @@ describe('ModelVersionsTab media rendering', () => {
});
it('filters versions to the current base model when strategy is same_base', async () => {
stateMock.global.settings.update_flag_strategy = 'same_base';
stateMock.global.settings.version_grouping = 'same_base';
fetchModelUpdateVersions.mockResolvedValue({
success: true,
record: {
@@ -235,7 +235,7 @@ describe('ModelVersionsTab media rendering', () => {
});
it('toggle button can switch to display all versions', async () => {
stateMock.global.settings.update_flag_strategy = 'same_base';
stateMock.global.settings.version_grouping = 'same_base';
fetchModelUpdateVersions.mockResolvedValue({
success: true,
record: {
@@ -286,7 +286,7 @@ describe('ModelVersionsTab media rendering', () => {
});
it('shows a newer version badge when viewing same-base results', async () => {
stateMock.global.settings.update_flag_strategy = 'same_base';
stateMock.global.settings.version_grouping = 'same_base';
fetchModelUpdateVersions.mockResolvedValue({
success: true,
record: {

View File

@@ -143,6 +143,19 @@ describe('RecipeManager', () => {
renderRecipesPage();
// Inject controls DOM that would normally come from components/controls.html
// (raw template rendering doesn't process Jinja2 {% include %} tags)
const customFilterIndicator = document.createElement('div');
customFilterIndicator.id = 'customFilterIndicator';
customFilterIndicator.className = 'control-group hidden';
customFilterIndicator.innerHTML = `
<div class="filter-active">
<i class="fas fa-filter"></i> <span class="customFilterText" title=""></span>
<i class="fas fa-times-circle clear-filter"></i>
</div>
`;
document.body.appendChild(customFilterIndicator);
({ RecipeManager } = await import('../../../static/js/recipes.js'));
});
@@ -288,7 +301,7 @@ describe('RecipeManager', () => {
});
const indicator = document.getElementById('customFilterIndicator');
const filterText = indicator.querySelector('#customFilterText');
const filterText = indicator.querySelector('.customFilterText');
expect(filterText.innerHTML).toContain('Recipes using checkpoint:');
expect(filterText.innerHTML).toContain('Flux Base');

View File

@@ -80,6 +80,8 @@ FALSE_POSITIVES = {
"array",
"object",
"non.existent.key",
"statistics.modelTypes.",
"statistics.",
}
SPECIAL_UI_HELPER_KEYS = {

View File

@@ -733,6 +733,65 @@ def test_lora_manager_cache_updates_when_loras_removed(metadata_registry):
assert "lora_node" not in metadata[LORAS]
def test_lora_text_loader_extracts_loras_from_syntax(metadata_registry):
"""LoraTextLoaderLM extractor parses <lora:name:strength> tags from lora_syntax string."""
metadata_registry.start_collection("prompt1")
metadata_registry.record_node_execution(
"text_loader",
"LoraTextLoaderLM",
{"lora_syntax": ["<lora:foo:0.8> <lora:bar:1.0>"]},
None,
)
metadata = metadata_registry.get_metadata("prompt1")
assert "text_loader" in metadata[LORAS]
lora_list = metadata[LORAS]["text_loader"]["lora_list"]
assert len(lora_list) == 2
assert lora_list[0] == {"name": "foo", "strength": 0.8}
assert lora_list[1] == {"name": "bar", "strength": 1.0}
def test_lora_text_loader_extracts_loras_from_lora_stack(metadata_registry):
"""LoraTextLoaderLM extractor also processes the optional lora_stack input."""
metadata_registry.start_collection("prompt1")
metadata_registry.record_node_execution(
"stack_loader",
"LoraTextLoaderLM",
{
"lora_syntax": [""],
"lora_stack": (("/models/loras/my-lora.safetensors", 0.6, 0.5),),
},
None,
)
metadata = metadata_registry.get_metadata("prompt1")
assert "stack_loader" in metadata[LORAS]
lora_list = metadata[LORAS]["stack_loader"]["lora_list"]
assert len(lora_list) == 1
assert lora_list[0] == {"name": "my-lora", "strength": 0.6}
def test_lora_text_loader_handles_empty_syntax(metadata_registry):
"""LoraTextLoaderLM extractor produces no metadata when no loras are provided."""
metadata_registry.start_collection("prompt1")
metadata_registry.record_node_execution(
"empty_loader",
"LoraTextLoaderLM",
{"lora_syntax": [""]},
None,
)
metadata = metadata_registry.get_metadata("prompt1")
assert "empty_loader" not in metadata[LORAS]
def test_lora_manager_checkpoint_and_unet_loaders_extract_models(metadata_registry):
metadata_registry.start_collection("prompt1")

View File

@@ -302,15 +302,15 @@ async def test_get_insights(stats_routes):
insights = payload["data"]["insights"]
assert len(insights) == 3
titles = {entry["title"] for entry in insights}
assert "High Number of Unused LoRAs" in titles
assert "Unused Checkpoints Detected" in titles
assert "High Number of Unused Embeddings" in titles
keys = {entry["key"] for entry in insights}
assert "insights.unusedLoras.high" in keys
assert "insights.unusedCheckpoints.detected" in keys
assert "insights.unusedEmbeddings.high" in keys
descriptions = {entry["description"] for entry in insights}
assert any("2/3" in desc for desc in descriptions)
assert any("1/2" in desc for desc in descriptions)
assert any("1/1" in desc for desc in descriptions)
params_list = [entry["params"] for entry in insights]
assert any(p["total"] == "3" for p in params_list)
assert any(p["total"] == "2" for p in params_list)
assert any(p["total"] == "1" for p in params_list)
@pytest.mark.asyncio

View File

@@ -482,7 +482,7 @@ async def test_get_paginated_data_annotates_update_flags_with_bulk_dedup():
@pytest.mark.asyncio
async def test_update_flag_strategy_same_base_prefers_matching_base():
async def test_version_grouping_same_base_prefers_matching_base():
items = [
{
"model_name": "Pony Version",
@@ -551,7 +551,7 @@ async def test_update_flag_strategy_same_base_prefers_matching_base():
should_ignore_model=False,
)
update_service = StubUpdateServiceWithRecords({1: record})
settings = StubSettings({"update_flag_strategy": "same_base"})
settings = StubSettings({"version_grouping": "same_base"})
service = DummyService(
model_type="stub",
@@ -579,7 +579,7 @@ async def test_update_flag_strategy_same_base_prefers_matching_base():
@pytest.mark.asyncio
async def test_update_flag_strategy_same_base_honors_latest_local_version():
async def test_version_grouping_same_base_honors_latest_local_version():
items = [
{
"model_name": "Pony v0.1",
@@ -648,7 +648,7 @@ async def test_update_flag_strategy_same_base_honors_latest_local_version():
should_ignore_model=False,
)
update_service = StubUpdateServiceWithRecords({1: record})
settings = StubSettings({"update_flag_strategy": "same_base"})
settings = StubSettings({"version_grouping": "same_base"})
service = DummyService(
model_type="stub",
@@ -746,6 +746,134 @@ async def test_get_paginated_data_update_available_only_without_update_service()
assert response["total_pages"] == 0
@pytest.mark.asyncio
async def test_get_paginated_data_group_by_model_dedup():
"""group_by_model deduplicates items sharing the same civitai modelId,
keeping only the item with the highest version (civitai.id)."""
items = [
# Two versions of the same model (modelId=1)
{"model_name": "SameModel", "folder": "root", "civitai": {"modelId": 1, "id": 100}},
{"model_name": "SameModel", "folder": "root", "civitai": {"modelId": 1, "id": 200}},
# Another model with two versions
{"model_name": "AnotherModel", "folder": "root", "civitai": {"modelId": 2, "id": 50}},
{"model_name": "AnotherModel", "folder": "root", "civitai": {"modelId": 2, "id": 99}},
# A standalone item with no civitai metadata (no modelId)
{"model_name": "Standalone", "folder": "root"},
]
repository = StubRepository(items)
filter_set = PassThroughFilterSet()
search_strategy = NoSearchStrategy()
settings = StubSettings({})
service = DummyService(
model_type="stub",
scanner=object(),
metadata_class=BaseModelMetadata,
cache_repository=repository,
filter_set=filter_set,
search_strategy=search_strategy,
settings_provider=settings,
)
# With group_by_model=True — modelId=1 keeps id=200, modelId=2 keeps id=99
response = await service.get_paginated_data(
page=1,
page_size=10,
sort_by="name:asc",
group_by_model=True,
)
names = {item["model_name"] for item in response["items"]}
assert names == {"SameModel", "AnotherModel", "Standalone"}
assert response["total"] == 3
# Verify the kept items have the highest version id
for item in response["items"]:
if item.get("civitai", {}).get("modelId") == 1:
assert item["civitai"]["id"] == 200
# version_count should reflect total versions for this model
assert item.get("version_count") == 2, f"Expected version_count=2, got {item.get('version_count')}"
elif item.get("civitai", {}).get("modelId") == 2:
assert item["civitai"]["id"] == 99
assert item.get("version_count") == 2, f"Expected version_count=2, got {item.get('version_count')}"
else:
# Standalone item should NOT have version_count
assert "version_count" not in item, f"Standalone should not have version_count"
# With group_by_model=False (default) — all 5 items pass through
response_all = await service.get_paginated_data(
page=1,
page_size=10,
sort_by="name:asc",
)
assert response_all["total"] == 5
async def test_get_paginated_data_filters_by_civitai_model_id():
"""civitai_model_id filter returns only items matching the given modelId,
and bypasses group_by_model dedup so all versions appear."""
items = [
# Two versions of modelId=1
{"model_name": "Model1_v1", "folder": "root", "civitai": {"modelId": 1, "id": 100}},
{"model_name": "Model1_v2", "folder": "root", "civitai": {"modelId": 1, "id": 200}},
# One version of modelId=2
{"model_name": "Model2", "folder": "root", "civitai": {"modelId": 2, "id": 50}},
# Standalone (no civitai data)
{"model_name": "Standalone", "folder": "root"},
]
repository = StubRepository(items)
filter_set = PassThroughFilterSet()
search_strategy = NoSearchStrategy()
settings = StubSettings({})
service = DummyService(
model_type="stub",
scanner=object(),
metadata_class=BaseModelMetadata,
cache_repository=repository,
filter_set=filter_set,
search_strategy=search_strategy,
settings_provider=settings,
)
# Filter by modelId=1 — both versions should appear
response = await service.get_paginated_data(
page=1,
page_size=10,
sort_by="name:asc",
civitai_model_id=1,
)
names = {item["model_name"] for item in response["items"]}
assert names == {"Model1_v1", "Model1_v2"}
assert response["total"] == 2
# Filter by modelId=2 — single version
response2 = await service.get_paginated_data(
page=1,
page_size=10,
sort_by="name:asc",
civitai_model_id=2,
)
assert response2["total"] == 1
assert response2["items"][0]["model_name"] == "Model2"
# civitai_model_id + group_by_model=True — still shows all versions (no dedup)
response_dedup = await service.get_paginated_data(
page=1,
page_size=10,
sort_by="name:asc",
civitai_model_id=1,
group_by_model=True,
)
assert response_dedup["total"] == 2
# Verify both versions are present (dedup was skipped)
version_ids = {item["civitai"]["id"] for item in response_dedup["items"]}
assert version_ids == {100, 200}
def test_model_filter_set_handles_include_and_exclude_tag_filters():
settings = StubSettings({})
filter_set = ModelFilterSet(settings)

View File

@@ -9,6 +9,7 @@ import pytest
from py.services.settings_manager import get_settings_manager
from py.utils.example_images_paths import (
ensure_library_root_exists,
find_non_compliant_items_in_example_images_root,
get_model_folder,
get_model_relative_path,
is_valid_example_images_root,
@@ -140,3 +141,68 @@ def test_is_valid_example_images_root_accepts_legacy_library_structure(tmp_path,
(hash_folder / 'image.png').write_text('data', encoding='utf-8')
assert is_valid_example_images_root(str(tmp_path)) is True
def test_find_non_compliant_items_returns_empty_for_valid_root(tmp_path, settings_manager):
"""An empty folder or one with only hash dirs should return []."""
settings_manager.settings['example_images_path'] = str(tmp_path)
# Empty folder
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
# Only hash folders
hash_folder = tmp_path / ('f' * 64)
hash_folder.mkdir()
(hash_folder / 'image.png').write_text('data', encoding='utf-8')
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
def test_find_non_compliant_items_returns_offending_names(tmp_path, settings_manager):
"""A folder with non-hash items should return their names."""
settings_manager.settings['example_images_path'] = str(tmp_path)
# Create a valid hash folder so the root is otherwise acceptable
hash_folder = tmp_path / ('a' * 64)
hash_folder.mkdir()
# Add an offending file
(tmp_path / 'readme.txt').write_text('hello', encoding='utf-8')
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == ['readme.txt']
# Add an offending directory with content (empty dirs are accepted as
# potential legacy library folders by _library_folder_has_only_hash_dirs)
offending_dir = tmp_path / 'not_a_hash'
offending_dir.mkdir()
(offending_dir / 'some_file.txt').write_text('data', encoding='utf-8')
items = find_non_compliant_items_in_example_images_root(str(tmp_path))
assert 'readme.txt' in items
assert 'not_a_hash' in items
def test_find_non_compliant_items_ignores_hidden_files(tmp_path, settings_manager):
"""Hidden/system files should not appear in offending list."""
settings_manager.settings['example_images_path'] = str(tmp_path)
# .DS_Store is an allowed file
(tmp_path / '.DS_Store').write_text('', encoding='utf-8')
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
# Thumbs.db too
(tmp_path / 'Thumbs.db').write_text('', encoding='utf-8')
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
def test_find_non_compliant_items_accepts_download_progress_json(tmp_path, settings_manager):
""".download_progress.json should be recognised as a valid metadata file."""
settings_manager.settings['example_images_path'] = str(tmp_path)
(tmp_path / '.download_progress.json').write_text('{}', encoding='utf-8')
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
def test_find_non_compliant_items_reports_directory_error(tmp_path):
"""When the directory cannot be listed, return an explanatory message."""
non_existent = tmp_path / 'does-not-exist'
result = find_non_compliant_items_in_example_images_root(str(non_existent))
assert len(result) == 1
assert 'cannot list directory' in result[0]