Compare commits

...

29 Commits

Author SHA1 Message Date
Will Miao
908464bc0a docs: remove inline release notes from README (now maintained via GitHub Releases) 2026-05-06 22:40:06 +08:00
willmiao
0ffee3a854 docs: auto-update supporters list in README 2026-05-06 10:29:43 +00:00
Will Miao
8aa9739c44 data: refresh supporters from license server (739 supporters, includes Patreon data) 2026-05-06 18:29:21 +08:00
Will Miao
50739bbb43 fix(css): remove dead CSS properties causing Biome errors
- batch-import-modal.css: add generic font family fallback to Font Awesome
- card.css: remove dead margin-left overridden by shorthand margin: 0
- shared.css: remove duplicate position: absolute overridden by position: fixed
2026-05-06 09:33:15 +08:00
Will Miao
e849303763 fix(header): eliminate search input focus layout shift and reduce focus ring size
- Remove transform: translateY(-1px) that caused layout shift on focus
- Reduce box-shadow focus ring from 2px to 1px for subtler appearance
- Tone down drop-shadow from 4px/16px to 2px/8px (matches base state)
2026-05-06 09:33:04 +08:00
Will Miao
241b2e15d2 docs: update extension image URL 2026-05-05 22:26:40 +08:00
Will Miao
88da754504 docs: migrate wiki-images to wiki repo, remove stale docs
Moved wiki-images to the wiki repo (willmiao/ComfyUI-Lora-Manager.wiki). Updated README.md image reference to use wiki raw URL. Removed docs/LM-Extension-Wiki.md (superseded by wiki pages).
2026-05-05 22:20:19 +08:00
Will Miao
b4a706651f feat(delete-model-version): add GET endpoint to delete a model version by version ID 2026-05-05 21:25:08 +08:00
pixelpaws
ff7cc6d9bb Merge pull request #921 from 1756141021/fix/drag-strength-notify-setValue
fix: commit dragged strength through options.setValue at drag end
2026-05-05 16:20:48 +08:00
hein
454210a47c fix: commit dragged strength through options.setValue at drag end
During drag, handleStrengthDrag is called with updateWidget=false, which
mutates widgetValue in-place via parseLoraValue's direct array reference,
bypassing widget.value setter and options.setValue entirely.

endDrag only called renderFunction for a DOM refresh, but never flushed the
mutation through options.setValue. Any external observer that wraps
options.setValue (e.g. ComfyUI Mirror Panel's bidirectional sync) would
therefore never see the dragged value and would treat the widget as unchanged.

Fix: replace the explicit renderFunction call with widget.value = widget.value.
This flushes the in-place mutation through the setter (options.setValue), which
re-renders the DOM internally AND notifies all setValue wrappers. Also fire
widget.callback for parity with the updateWidget=true path in handleStrengthDrag.

Applies the same fix to initHeaderDrag (proportional all-LoRA header drag).
2026-05-04 22:40:30 +08:00
Will Miao
2d7c404ebb fix(recipes): preserve scroll position on filter, search, and folder-driven reloads
Five entry points that trigger recipe page reloads were not passing
preserveScroll: true, causing the page to snap back to top after
filtering, searching, or navigating folders — especially painful with
hundreds of recipes.

- RecipePageControls.resetAndReload() → refreshVirtualScroll() now
  passes { preserveScroll: true } (sidebar folder clicks/drag moves)
- FilterManager applyFilters/clearAllFilters → loadRecipes(true)
  changed to loadRecipes({ preserveScroll: true })
- SearchManager performSearch → loadRecipes(true) changed to
  loadRecipes({ preserveScroll: true })
- SettingsManager reloadContent → loadRecipes() changed to
  loadRecipes({ preserveScroll: true })

The normalizeLoadRecipesOptions boolean path always forces
preserveScroll: false — the object form is required to pass it.
2026-05-04 20:26:13 +08:00
Will Miao
e23d803ecf fix(layout): ensure refresh split-button dropdown renders above breadcrumb nav 2026-05-03 18:14:54 +08:00
Will Miao
0cc640cfaa fix(recipe): support ComfyUI-Easy-Use nodes in runtime metadata extraction (#920)
- Add EasyComfyLoaderExtractor for comfyLoader (easy comfyLoader):
  extracts checkpoint, optional_lora_stack as LoRA apply node,
  prompt text, clip_skip, and latent dimensions
- Add EasyPreSamplingExtractor for samplerSettings (easy preSampling):
  extracts steps, cfg, sampler_name, scheduler, denoise, seed
- Add EasySeedExtractor for easySeed
- Fix clip_skip hardcoded to '1' — now searched from SAMPLING metadata
- Lora Stacker nodes intentionally excluded from extraction to
  prevent double-counting; LoRAs only recorded at apply nodes
2026-05-02 23:21:51 +08:00
Will Miao
2ac0eb0f9d fix(wanvideo): resolve lora path resolution and name truncation for extra folder paths
- Use get_lora_info_absolute to obtain correct absolute paths for loras
  in LM extra folder paths, instead of folder_paths.get_full_path which
  only searches ComfyUI's standard loras directories (returned None)
- Fix name field truncation: str.split('.')[0] stopped at the first dot,
  replaced with os.path.splitext to only strip the file extension
- Add _relpath_within_loras helper to preserve subdirectory info in the
  name field, matching WanVideoWrapper's os.path.splitext(lora)[0] format
2026-05-02 14:55:12 +08:00
Will Miao
f028625ce9 feat(check-models-exist): add batch endpoint for checking multiple model IDs
New endpoint: GET /api/lm/check-models-exist?modelIds=1,2,3,...

Accepts comma-separated modelIds, returns a results array with one
entry per modelId. Uses a single scanner lookup batch - three
service-registry calls total, regardless of model count. Skips
history checks entirely (same rationale as the singleton endpoint:
when models exist locally, history is redundant).

Expected: reduces 231 HTTP round-trips to 1 for the browser
extension's model-card indicator flow. Combined with the prior
SQLite-connection and history-skip fixes, total wall-clock time
for a 175K-lora user's page load drops from ~9.4s to <10ms.
2026-05-02 13:43:53 +08:00
Will Miao
06acc7f576 fix(trigger-word-toggle): default group children to active regardless of default_active 2026-05-02 13:33:42 +08:00
Will Miao
d324b57274 perf(check-model-exists): eliminate SQLite connection-per-query overhead and skip redundant history checks
Root cause: 231 concurrent /check-model-exists requests on 175K-lora library
caused ~9.4s wall clock time. The bottleneck was two-fold:

1. DownloadedVersionHistoryService opened a new sqlite3.connect() for every
   query under asyncio.Lock. With a large WAL from 175K entries, each
   connect() took ~8ms. Serialized by the lock across 231 requests, the
   230th request waited ~1848ms just for lock acquisition.

2. check_model_exists always queried download history even when the model
   was found locally. The history result (hasBeenDownloaded /
   downloadedVersionIds) is only used by the UI when the model is NOT
   found locally; when found, the 'in library' indicator takes priority.

Changes:
- downloaded_version_history_service.py: added persistent _get_conn() that
  creates the SQLite connection once and reuses it across all queries
- misc_handlers.py: early-return from check_model_exists when the model
  exists locally, bypassing the history service entirely (lock skipped)

Expected: per-request wait time drops from ~1912ms to <3ms, wall clock
from ~9.4s to <0.3s for the 175K-lora user's 231-card page.
2026-05-02 13:31:20 +08:00
Will Miao
502b7eab31 fix(layout): correct breadcrumb sticky behavior and controls wrapping overflow
- Extract breadcrumb from controls template into sibling component
- Fix breadcrumb sticky positioning (top: 0, z-index: calc(--z-header - 1))
- Add 1500px breakpoint to wrap controls-right and prevent overflow
- Adjust breadcrumb padding-bottom to cover controls-right area when sticky
2026-05-01 22:53:40 +08:00
Will Miao
be75ad930e feat(layout): implement responsive edge-to-edge card grid with density-aware column calculation
- Add dynamic column calculation based on container width and min card width
- Prevent tiny cards on narrow windows by respecting density-based minimums:
  - Default: 240px, Medium: 200px, Compact: 170px
- Fix edge-to-edge layout with proper CSS selector (.virtual-scroll-item.model-card)
- Add hamburger menu for mobile/small screens with proper translations
- Update all locale files with 'common.actions.menu' key

Fixes: Cards becoming too small/overlapping on narrow window widths (e.g., 1156px)
Changes: 15 files, +569/-114 lines
2026-05-01 21:34:31 +08:00
Will Miao
763c4f4dad feat(usage-control): add support for Civitai usageControl field
Handle models that are only available for on-site generation (usageControl:
"Generation" or "InternalGeneration") rather than downloadable.

Backend changes:
- Add usage_control field to ModelVersionRecord dataclass
- Extract usageControl from Civitai API responses
- Filter non-downloadable versions from update availability checks
- Add database schema migration for usage_control column
- Include usageControl in version response JSON

Frontend changes:
- Add isDownloadAllowed() helper function
- Show disabled download button for non-downloadable versions
- Add "On-Site Only" badge for restricted versions
- Update resolveUpdateAvailability() to filter non-downloadable versions
- Add CSS styling for disabled action button

Internationalization:
- Add translations for onSiteOnly badge and downloadNotAllowedTooltip
- Complete translations for all 10 supported languages
2026-05-01 13:10:15 +08:00
Will Miao
d32c492bdb feat(scripts): add legacy metadata migration tool
Add script to migrate metadata from legacy sidecar JSON files to
LoRA Manager's metadata.json format.

Features:
- Auto-discovers model folders from settings.json
- Supports LoRA and Checkpoint model types
- Migrates activation text, preferred weight (LoRA only), and notes
- Dry-run mode for safe preview
- Idempotent migration (won't duplicate existing data)
2026-05-01 08:56:00 +08:00
Will Miao
5dcfde36ea feat(doctor): add duplicate filename conflict detection and one-click resolution
Detects when multiple model files share the same basename (causing
ambiguity in LoRA resolution), logs warnings during scanning, and
provides a "Resolve Conflicts" button in the Doctor panel. Resolution
renames duplicates with hash-prefixed unique filenames, migrates all
sidecar and preview files, and updates the cache and frontend scroller
in-place so the model modal immediately reflects the new filename.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 15:21:26 +08:00
Will Miao
1d035361a4 fix(download): accept Diffusion Model file type when selecting primary file from CivitAI metadata
CivitAI returns file type "Diffusion Model" for checkpoint files (e.g., Anima
models), but the file selection logic only accepted "Model" and "Negative",
causing "No suitable file found in metadata" errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 11:54:14 +08:00
Will Miao
25605c5e78 feat(ui): add setting to toggle version name display on model cards (#916) 2026-04-29 20:04:40 +08:00
Will Miao
f3268a6179 fix(autocomplete): prevent migrateWidgetsValues from dropping text widget values (#915)
shouldBypassAutocompleteWidgetMigration only matched inputs by widget name,
but ComfyUI's migrateWidgetsValues also matches forceInput inputs (like "seed").
This discrepancy meant the bypass never triggered for TextLM/PromptLM nodes,
causing migrateWidgetsValues to filter out real widget values by incorrectly
mapping forceInput flags onto saved autocomplete values.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:44:08 +08:00
Will Miao
055e94d77b fix(updates): chunk bulk queries to avoid SQLite variable limit (#914)
_split _get_records_bulk into 500-id batches so the WHERE IN clause
never exceeds SQLite's 999-parameter ceiling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:15:44 +08:00
Will Miao
47fcd530a0 feat(settings): add aria2 wiki help link to download backend setting 2026-04-28 18:37:59 +08:00
Will Miao
3c32b9e088 feat(example-images): add wiki help link and i18n keys for remote open mode
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 19:45:16 +08:00
Will Miao
ffe0670a27 feat(example-images): add remote open mode support 2026-04-27 14:05:21 +08:00
83 changed files with 3392 additions and 987 deletions

136
README.md

File diff suppressed because one or more lines are too long

View File

@@ -12,33 +12,39 @@
"2018cfh",
"W+K+White",
"wackop",
"Takkan",
"Phil",
"Carl G.",
"Arlecchino Shion",
"stone9k",
"$MetaSamsara",
"itismyelement",
"Gingko Biloba",
"onesecondinosaur",
"stone9k",
"Takkan",
"Charles Blakemore",
"Rob Williams",
"Rosenthal",
"Francisco Tatis",
"Tobi_Swagg",
"Andrew Wilson",
"Greybush",
"Gooohokrbe",
"Ricky Carter",
"JongWon Han",
"OldBones",
"VantAI",
"runte3221",
"Illrigger",
"FreelancerZ",
"Edgar Tejeda",
"Jorge Hussni",
"Liam MacDougal",
"Fraser Cross",
"Polymorphic Indeterminate",
"Birdy",
"Marc Whiffen",
"Jorge Hussni",
"Kiba",
"Birdy",
"Skalabananen",
"Kiba",
"Reno Lam",
"Mozzel",
"sig",
"Christian Byrne",
"DM",
@@ -46,39 +52,41 @@
"Estragon",
"J\\B/ 8r0wns0n",
"Snaggwort",
"Arlecchino Shion",
"Charles Blakemore",
"Rob Williams",
"ClockDaemon",
"Jonathan Ross",
"KD",
"Omnidex",
"Nazono_hito",
"Tyler Trebuchon",
"Release Cabrakan",
"Tobi_Swagg",
"contrite831",
"SG",
"carozzz",
"James Dooley",
"zenbound",
"Buzzard",
"jmack",
"Adam Shaw",
"Mark Corneglio",
"SarcasticHashtag",
"Cosmosis",
"Anthony Rizzo",
"iamresist",
"Gooohokrbe",
"RedrockVP",
"Wolffen",
"FloPro4Sho",
"James Todd",
"OldBones",
"Steven Pfeiffer",
"Tim",
"Timmy",
"Johnny",
"Lisster",
"Michael Wong",
"Illrigger",
"whudunit",
"Tom Corrigan",
"dl0901dm",
"JackieWang",
"fnkylove",
"Julian V",
"Steven Owens",
"Yushio",
"Vik71it",
"Echo",
@@ -86,147 +94,137 @@
"Robert Stacey",
"PM",
"Todd Keck",
"Mozzel",
"Gingko Biloba",
"Sterilized",
"Briton Heilbrun",
"Aleksander Wujczyk",
"BadassArabianMofo",
"Sterilized",
"Pascal Dahle",
"quarz",
"Greg",
"Penfore",
"Greg",
"JSST",
"esthe",
"lmsupporter",
"IamAyam",
"zounic",
"wfpearl",
"Baekdoosixt",
"Jonathan Ross",
"Jack B Nimble",
"Nazono_hito",
"Melville Parrish",
"daniel dove",
"Lustre",
"JW Sin",
"contrite831",
"Alex",
"bh",
"confiscated Zyra",
"Marlon Daniels",
"Starkselle",
"Aaron Bleuer",
"LacesOut!",
"greebles",
"Adam Shaw",
"Tee Gee",
"Anthony Rizzo",
"tarek helmi",
"Cosmosis",
"M Postkasse",
"FloPro4Sho",
"ASLPro3D",
"Jacob Hoehler",
"FinalyFree",
"Weasyl",
"Timmy",
"Johnny",
"Lex Song",
"Cory Paza",
"Tak",
"Gonzalo Andre Allendes Lopez",
"Zach Gonser",
"Big Red",
"whudunit",
"Jimmy Ledbetter",
"Luc Job",
"dl0901dm",
"Philip Hempel",
"corde",
"Nick Walker",
"lh qwe",
"Julian V",
"Steven Owens",
"Bishoujoker",
"conner",
"aai",
"Briton Heilbrun",
"Tori",
"wildnut",
"Princess Bright Eyes",
"AbstractAss",
"Felipe dos Santos",
"ViperC",
"jean jahren",
"Aleksander Wujczyk",
"AM Kuro",
"Markus",
"S Sang",
"ViperC",
"Ran C",
"Sangheili460",
"MagnaInsomnia",
"Karl P.",
"Akira_HentAI",
"MagnaInsomnia",
"Gordon Cole",
"yuxz69",
"Douglas Gaspar",
"AlexDuKaNa",
"George",
"esthe",
"andrew.tappan",
"dw",
"N/A",
"The Spawn",
"Phil",
"graysock",
"Pozadine1",
"Greenmoustache",
"zounic",
"fancypants",
"IamAyam",
"Eldithor",
"Joboshy",
"Digital",
"JaxMax",
"takyamtom",
"奚明 刘",
"Bohemian Corporal",
"Dan",
"confiscated Zyra",
"Jwk0205",
"Bro Xie",
"준희 김",
"yer fey",
"batblue",
"carey6409",
"Olive",
"太郎 ゲーム",
"Tee Gee",
"Some Guy Named Barry",
"jinxedx",
"tarek helmi",
"Max Marklund",
"Tomohiro Baba",
"David Ortega",
"AELOX",
"Dankin",
"Nicfit23",
"Noora",
"wamekukyouzin",
"drum matthieu",
"Dogmaster",
"Matt Wenzel",
"Mattssn",
"Lex Song",
"John Saveas",
"Frank Nitty",
"Pronredn",
"Christopher Michel",
"Serge Bekenkamp",
"Jimmy Ledbetter",
"DougPeterson",
"LeoZero",
"Antonio Pontes",
"ApathyJones",
"nahinahi9",
"lh qwe",
"Kevin John Duck",
"conner",
"Dustin Chen",
"dan",
"Yaboi",
"Blackfish95",
"Mouthlessman",
"Steam Steam",
"Damon Cunliffe",
"CryptoTraderJK",
"Davaitamin",
"Princess Bright Eyes",
"Paul Kroll",
"AbstractAss",
"otaku fra",
"Ran C",
"tedcor",
"Fotek Design",
"Felipe dos Santos",
"Bas Imagineer",
"Markus",
"MiraiKuriyamaSy",
"Adam Taylor",
"Douglas Gaspar",
"Weird_With_A_Beard",
"MadSpin",
"Pozadine1",
"AlexDuKaNa",
"George",
"dw",
"Qarob",
"AIGooner",
"inbijiburu",
"Luc",
"ProtonPrince",
"DiffDuck",
"elu3199",
"Nick “Loadstone” D",
"Hasturkun",
"Jon Sandman",
"Ubivis",
@@ -234,54 +232,45 @@
"thesoftwaredruid",
"wundershark",
"mr_dinosaur",
"Tyrswood",
"linnfrey",
"Gamalonia",
"Vir",
"Pkrsky",
"Joboshy",
"Bohemian Corporal",
"Dan",
"奚明 刘",
"Josef Lanzl",
"Seth Christensen",
"Nerezza",
"Griffin Dahlberg",
"Draven T",
"yer fey",
"준희 김",
"Error_Rule34_Not_found",
"Gerald Welly",
"Roslynd",
"Geolog",
"jinxedx",
"Neco28",
"Aquatic Coffee",
"Dankin",
"ethanfel",
"Tomohiro Baba",
"David Ortega",
"Noora",
"Cristian Vazquez",
"Frank Nitty",
"Mattssn",
"Magic Noob",
"Focuschannel",
"DougPeterson",
"Jeff",
"Bruce",
"Kevin John Duck",
"Anthony Faxlandez",
"Kevin Christopher",
"Ouro Boros",
"Blackfish95",
"Chad Idk",
"Yaboi",
"dd",
"Paul Kroll",
"MiraiKuriyamaSy",
"semicolon drainpipe",
"Thesharingbrother",
"Bas Imagineer",
"Pat Hen",
"Steam Steam",
"CryptoTraderJK",
"Davaitamin",
"Dušan Ryban",
"tedcor",
"Fotek Design",
"sjon kreutz",
"John Statham",
"ResidentDeviant",
"Nihongasuki",
"JC",
"Prompt Pirate",
"uwutismxd",
"MadSpin",
"Metryman55",
"inbijiburu",
"decoy",
"Tyrswood",
"Nick “Loadstone” D",
"Ray Wing",
"Ranzitho",
"Gus",
@@ -290,6 +279,7 @@
"David LaVallee",
"ae",
"Tr4shP4nda",
"Gamalonia",
"WRL_SPR",
"capn",
"Joseph",
@@ -302,77 +292,60 @@
"Moon Knight",
"몽타주",
"Kland",
"zenobeus",
"Jackthemind",
"ryoma",
"Stryker",
"raf8osz",
"ElitaSSJ4",
"blikkies",
"Chris",
"Hailshem",
"kudari",
"Naomi Hale Danchi",
"dc7431",
"Vir",
"Brian M",
"Nerezza",
"sanborondon",
"Seth Christensen",
"Draven T",
"Taylor Funk",
"aezin",
"Thought2Form",
"jcay015",
"Kevin Picco",
"Erik Lopez",
"Shock Shockor",
"Mateo Curić",
"Goldwaters",
"Zude",
"Aquatic Coffee",
"Eris3D",
"m",
"ethanfel",
"Pierce McBride",
"Joshua Gray",
"Kyler",
"Focuschannel",
"Mikko Hemilä",
"aRtFuL_DodGeR",
"Jamie Ogletree",
"a _",
"James Coleman",
"CrimsonDX",
"Martial",
"Anthony Faxlandez",
"battu",
"Emil Andersson",
"Chad Idk",
"DarkSunset",
"Billy Gladky",
"Yuji Kaneko",
"Probis",
"Dušan Ryban",
"ItsGeneralButtNaked",
"Pat Hen",
"semicolon drainpipe",
"Jordan Shaw",
"Rops Alot",
"Thesharingbrother",
"Sam",
"sjon kreutz",
"Nimess",
"SRDB",
"Ace Ventura",
"g unit",
"Youguang",
"Metryman55",
"andrewzpong",
"FrxzenSnxw",
"BossGame",
"lrdchs",
"ResidentDeviant",
"Nihongasuki",
"JC",
"Prompt Pirate",
"uwutismxd",
"momokai",
"Hailshem",
"kudari",
"Naomi Hale Danchi",
"dc7431",
"zenobeus",
"ken",
"Inversity",
"AIVORY3D",
"epicgamer0020690",
"Joshua Porrata",
"keemun",
"SuBu",
"RedPIXel",
"Kevinj",
"Wind",
"Jackthemind",
"Nexus",
"Ramneek“Guy”Ashok",
"squid_actually",
@@ -385,80 +358,81 @@
"emyth",
"chriphost",
"KitKatM",
"ryoma",
"socrasteeze",
"ResidentDeviant",
"OrganicArtifact",
"Stryker",
"MudkipMedkitz",
"gzmzmvp",
"Welkor",
"John Martin",
"raf8osz",
"ElitaSSJ4",
"Richard",
"blikkies",
"Andrew",
"Chris",
"Robert Wegemund",
"Littlehuggy",
"moranqianlong",
"Gregory Kozhemiak",
"mrjuan",
"Brian Buie",
"Shock Shockor",
"Sadlip",
"Haru Yotu",
"Goldwaters",
"Eric Whitney",
"Joey Callahan",
"Zude",
"Ivan Tadic",
"Mike Simone",
"John J Linehan",
"Kyler",
"Elliot E",
"Morgandel",
"Kyron Mahan",
"Matura Arbeit",
"Theerat Jiramate",
"aRtFuL_DodGeR",
"Noah",
"Jacob McDaniel",
"X",
"Sloan Steddy",
"TBitz33",
"Anonym dkjglfleeoeldldldlkf",
"Temikus",
"Artokun",
"Michael Taylor",
"SendingRavens",
"Derek Baker",
"CrimsonDX",
"Michael Anthony Scott",
"DarkSunset",
"Atilla Berke Pekduyar",
"Michael Docherty",
"Nathan",
"Billy Gladky",
"NICHOLAS BAXLEY",
"Decx _",
"Paul Hartsuyker",
"elitassj",
"Jacob Winter",
"Probis",
"Ed Wang",
"ItsGeneralButtNaked",
"Nimess",
"SRDB",
"g unit",
"Distortik",
"David",
"Meilo",
"Pen Bouryoung",
"Youguang",
"四糸凜音",
"shinonomeiro",
"Snille",
"MaartenAlbers",
"khanh duy",
"xybrightsummer",
"jreedatchison",
"PhilW",
"Saya",
"andrewzpong",
"FrxzenSnxw",
"BossGame",
"lrdchs",
"Tree Tagger",
"Janik",
"Inversity",
"Crocket",
"Cruel",
"MRBlack",
"AIVORY3D",
"Kevinj",
"Mitchell Robson",
"Kiyoe",
"humptynutz",
"michael.isaza",
"Kalnei",
"Whitepinetrader",
"OrganicArtifact",
"Scott",
"MudkipMedkitz",
"ResidentDeviant",
"deanbrian",
"POPPIN",
"Alex Wortman",
"Cody",
"Raku",
"smart.edge5178",
"emadsultan",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
@@ -466,76 +440,152 @@
"Menard",
"Skyfire83",
"Adam Rinehart",
"D",
"Pitpe11",
"TheD1rtyD03",
"moonpetal",
"SomeDude",
"g9p0o",
"nanana",
"TheHolySheep",
"Monte Won",
"SpringBootisTrash",
"carsten",
"ikok",
"Nathen+Choi",
"T",
"LarsesFPC",
"cocona",
"sfasdfasfdsa",
"Buecyb99",
"4IXplr0r3r",
"dfklsjfkljslfjd",
"hayden",
"ahoystan",
"Leland Saunders",
"Welkor",
"David Schenck",
"John Martin",
"Wolfe7D1",
"Ink Temptation",
"Bob Barker",
"edk",
"moranqianlong",
"Kalli Core",
"Aeternyx",
"elleshar666",
"YOU SINWOO",
"ja s",
"Doug Mason",
"ACTUALLY_the_Real_Willem_Dafoe",
"Haru Yotu",
"Kauffy",
"Jeremy Townsend",
"EpicElric",
"Sean voets",
"Owen Gwosdz",
"John J Linehan",
"Elliot E",
"Thomas Wanner",
"Theerat Jiramate",
"Kyron Mahan",
"Edward Kennedy",
"Justin Blaylock",
"Devil Lude",
"Matura Arbeit",
"Nick Kage",
"kevin stoddard",
"Jack Dole",
"TBitz33",
"Anonym dkjglfleeoeldldldlkf",
"Vane Holzer",
"psytrax",
"Cyrus Fett",
"Ezokewn",
"SendingRavens",
"hexxish",
"CptNeo",
"notedfakes",
"Maso",
"Eric Ketchum",
"NICHOLAS BAXLEY",
"Michael Docherty",
"Michael Scott",
"Kevin Wallace",
"Matheus Couto",
"Saya",
"ChicRic",
"mercur",
"J C",
"Ed Wang",
"Paul Hartsuyker",
"elitassj",
"Jacob Winter",
"Ryan Presley Ng",
"Wes Sims",
"Donor4115",
"Lyavph",
"David",
"Meilo",
"Filippo Ferrari",
"Pen Bouryoung",
"shinonomeiro",
"Snille",
"MaartenAlbers",
"khanh duy",
"xybrightsummer",
"jreedatchison",
"PhilW",
"Janik",
"Cruel",
"MRBlack",
"Kiyoe",
"humptynutz",
"michael.isaza",
"Kalnei",
"Scott",
"Muratoraccio",
"Ginnie",
"emadsultan",
"D",
"nanana",
"Fthehappy",
"rsamerica",
"Alan+Cano",
"FeralOpticsAI",
"Pavlaki",
"generic404",
"Doug+Rintoul",
"Noor",
"Yorunai",
"quantenmecha",
"abattoirblues",
"Jason+Nash",
"BillyBoy84",
"zounik",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"lrdchs2",
"Duk3+Rand0m",
"4IXplr0r3r",
"hayden",
"ahoystan",
"Leland Saunders",
"Bob Barker",
"edk",
"JBsuede",
"Time Valentine",
"Aeternyx",
"YOU SINWOO",
"りん あめ",
"ja s",
"Михал Михалыч",
"Matt",
"Doug Mason",
"Jeremy Townsend",
"Frogmilk",
"Sean voets",
"Owen Gwosdz",
"SPJ",
"Thomas Wanner",
"Bryan Rutkowski",
"Devil Lude",
"David Murcko",
"kevin stoddard",
"Jack Dole",
"max blo",
"Xenon Xue",
"CptNeo",
"JackJohnnyJim",
"Dmitry Ryzhov",
"Maso",
"Edward Ten Eyck",
"Eric Ketchum",
"Kevin Wallace",
"Matheus Couto",
"ChicRic",
"Henrique Faiolli",
"mercur",
"Solixer",
"J C",
"jinksta187",
"Andrew Wilkinson",
"Manu Thetug",
"Karlanx",
"Yves Poezevara",
"operationancut",
"Teriak47",
"Just me",
"Raf Stahelin",
"Вячеслав Маринин",
"Lyavph",
"Filippo Ferrari",
"Cola Matthew",
"OniNoKen",
"Iain Wisely",
@@ -576,98 +626,121 @@
"dg",
"Maarten Harms",
"Israel",
"Muratoraccio",
"SelfishMedic",
"Ginnie",
"adderleighn",
"EnragedAntelope",
"Alan+Cano",
"FeralOpticsAI",
"Pavlaki",
"generic404",
"lighthawke",
"Terraformer",
"GDS+DEV",
"4rt+r3d",
"low9",
"Winged",
"you+halo9",
"YassineKhaled",
"YK12",
"MatteKey",
"Flob",
"ShiroSenpai",
"Somebody",
"Inkognito",
"Somebody",
"Gramer+Gumbyte",
"Crescent~San",
"Tan+Huynh",
"AiGirlTS",
"D",
"datasl4ve",
"Somebody",
"Dark_Pest",
"Aza",
"Jacky+Ho",
"koopa990",
"Karru",
"ChaChanoKo",
"null",
"bo",
"The+Forgetful+Dev",
"redcarrot",
"powerbot99",
"Mateusz+Kosela",
"Doug+Rintoul",
"Noor",
"Yorunai",
"Bula",
"quantenmecha",
"abattoirblues",
"Jason+Nash",
"BillyBoy84",
"DarkRoast",
"zounik",
"letzte",
"Nasty+Hobbit",
"SgtFluffles",
"lrdchs2",
"Duk3+Rand0m",
"KUJYAKU",
"NathenChoi",
"Thomas+Reck",
"Larses",
"cocona",
"Coeur+de+cochon",
"David Schenck",
"han b",
"Nico",
"Banana Joe",
"_ G3n",
"Donovan Jenkins",
"JBsuede",
"Tú Nguyễn Lý Hoàng",
"Michael Eid",
"beersandbacon",
"Maximilian Pyko",
"Invis",
"Justin Houston",
"Time Valentine",
"Bob barker",
"Ben D",
"Garrett Wood",
"Ronan Delevacq",
"james",
"Christian Schäfer",
"OrochiNights",
"Michael Zhu",
"ACTUALLY_the_Real_Willem_Dafoe",
"gonzalo",
"Seraphy",
"Михал Михалыч",
"雨の心 落",
"Matt",
"AllTimeNoobie",
"jumpd",
"John C",
"Rim",
"Dave Abraham",
"Joaquin Hierrezuelo",
"Dismem",
"Frogmilk",
"SPJ",
"Locrospiel",
"Jairus Knudsen",
"Jarrid Lee",
"Xan Dionysus",
"Nathan lee",
"Kor",
"Joseph Hanson",
"Mewtora",
"Middo",
"Forbidden Atelier",
"Bryan Rutkowski",
"John Rednoulf",
"Spire",
"Adictedtohumping",
"Boba Smith",
"Towelie",
"Cyrus Fett",
"MR.Bear",
"dsffsdfsdfsdfsdfsdf",
"Jean-françois SEMA",
"Kurt",
"max blo",
"Xenon Xue",
"JackJohnnyJim",
"Edward Ten Eyck",
"ivistorm",
"Sauv",
"Steven",
"TenaciousD",
"Khánh Đặng",
"Chase Kwon",
"Ted Cart",
"Inyoshu",
"Goober719",
"Chad Barnes",
"Person Y",
"David Spearing",
"James Ming",
"vanditking",
"kripitonga",
"Rizzi",
"nimin",
"OMAR LUCIANO",
"Ken+Suzuki",
"hannibal",
"Jo+Example",
"BrentBertram",
"Tigon",
"eumelzocker",
"dxjaymz",
"L C",
"Dude"
"Dude",
"CK"
],
"totalCount": 666
"totalCount": 739
}

View File

@@ -1,183 +0,0 @@
## Overview
The **LoRA Manager Civitai Extension** is a Browser extension designed to work seamlessly with [LoRA Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) to significantly enhance your browsing experience on [Civitai](https://civitai.com). With this extension, you can:
✅ Instantly see which models are already present in your local library
✅ Download new models with a single click
✅ Manage downloads efficiently with queue and parallel download support
✅ Keep your downloaded models automatically organized according to your custom settings
![Civitai Models page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-models-page.png)
**Update:** It now also supports browsing on [CivArchive](https://civarchive.com/) (formerly CivitaiArchive).
![CivArchive Models page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civarchive-models-page.png)
---
## Why Supporter Access?
LoRA Manager is built with love for the Stable Diffusion and ComfyUI communities. Your support makes it possible for me to keep improving and maintaining the tool full-time.
Supporter-exclusive features help ensure the long-term sustainability of LoRA Manager, allowing continuous updates, new features, and better performance for everyone.
Every contribution directly fuels development and keeps the core LoRA Manager free and open-source. In addition to monthly supporters, one-time donation supporters will also receive a license key, with the duration scaling according to the contribution amount. Thank you for helping keep this project alive and growing. ❤️
---
## Installation
### Supported Browsers & Installation Methods
| Browser | Installation Method |
|--------------------|-------------------------------------------------------------------------------------|
| **Google Chrome** | [Chrome Web Store link](https://chromewebstore.google.com/detail/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) |
| **Microsoft Edge** | Install via Chrome Web Store (compatible) |
| **Brave Browser** | Install via Chrome Web Store (compatible) |
| **Opera** | Install via Chrome Web Store (compatible) |
| **Firefox** | <div id="firefox-install" class="install-ok"><a href="https://github.com/willmiao/lm-civitai-extension-firefox/releases/latest/download/extension.xpi">📦 Install Firefox Extension (reviewed and verified by Mozilla)</a></div> |
For non-Chrome browsers (e.g., Microsoft Edge), you can typically install extensions from the Chrome Web Store by following these steps: open the extensions Chrome Web Store page, click 'Get extension', then click 'Allow' when prompted to enable installations from other stores, and finally click 'Add extension' to complete the installation.
---
## Privacy & Security
I understand concerns around browser extensions and privacy, and I want to be fully transparent about how the **LM Civitai Extension** works:
- **Reviewed and Verified**
This extension has been **manually reviewed and approved by the Chrome Web Store**. The Firefox version uses the **exact same code** (only the packaging format differs) and has passed **Mozillas Add-on review**.
- **Minimal Network Access**
The only external server this extension connects to is:
**`https://willmiao.shop`** — used solely for **license validation**.
It does **not collect, transmit, or store any personal or usage data**.
No browsing history, no user IDs, no analytics, no hidden trackers.
- **Local-Only Model Detection**
Model detection and LoRA Manager communication all happen **locally** within your browser, directly interacting with your local LoRA Manager backend.
I value your trust and are committed to keeping your local setup private and secure. If you have any questions, feel free to reach out!
---
## How to Use
After installing the extension, you'll automatically receive a **7-day trial** to explore all features.
When the extension is correctly installed and your license is valid:
- Open **Civitai**, and you'll see visual indicators added by the extension on model cards, showing:
- ✅ Models already present in your local library
- ⬇️ A download button for models not in your library
Clicking the download button adds the corresponding model version to the download queue, waiting to be downloaded. You can set up to **5 models to download simultaneously**.
### Visual Indicators Appear On:
- **Home Page** — Featured models
- **Models Page**
- **Creator Profiles** — If the creator has set their models to be visible
- **Recommended Resources** — On individual model pages
### Version Buttons on Model Pages
On a specific model page, visual indicators also appear on version buttons, showing which versions are already in your local library.
**Starting from v0.4.8**, model pages use a dedicated download button for better compatibility. When switching to a specific version by clicking a version button:
- The new **dedicated download button** directly triggers download via **LoRA Manager**
- The **original download button** remains unchanged for standard browser downloads
![Civitai Model Page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-model-page.png)
### Hide Models Already in Library (Beta)
**New in v0.4.8**: A new **Hide models already in library (Beta)** option makes it easier to focus on models you haven't added yet. It can be enabled from Settings, or toggled quickly using **Ctrl + Shift + H** (macOS: **Command + Shift + H**).
### Resources on Image Pages — now shows in-library indicators for image resources plus one-click recipe import
- **One-Click Import Civitai Image as Recipe** — Import any Civitai image as a recipe with a single click in the Resources Used panel.
- **Auto-Queue Missing Assets** — In Settings you can decide if LoRAs or checkpoints referenced by that image should automatically be added to your download queue.
- **More Accurate Metadata** — Importing directly from the page is faster than copying inside LM and keeps on-site tags and other metadata perfectly aligned.
![Civitai Image Page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-image-page.jpg)
[![alt](url)](https://github.com/user-attachments/assets/41fd4240-c949-4f83-bde7-8f3124c09494)
---
## Model Download Location & LoRA Manager Settings
To use the **one-click download function**, you must first set:
- Your **Default LoRAs Root**
- Your **Default Checkpoints Root**
These are set within LoRA Manager's settings.
When everything is configured, downloaded model files will be placed in:
`<Default_Models_Root>/<Base_Model_of_the_Model>/<First_Tag_of_the_Model>`
### Update: Default Path Customization (2025-07-21)
A new setting to customize the default download path has been added in the nightly version. You can now personalize where models are saved when downloading via the LM Civitai Extension.
![Default Path Customization](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/default-path-customization.png)
The previous YAML path mapping file will be deprecated—settings will now be unified in settings.json to simplify configuration.
---
## Backend Port Configuration
If your **ComfyUI** or **LoRA Manager** backend is running on a port **other than the default 8188**, you must configure the backend port in the extension's settings.
After correctly setting and saving the port, you'll see in the extension's header area:
- A **Healthy** status with the tooltip: `Connected to LoRA Manager on port xxxx`
---
## Advanced Usage
### Connecting to a Remote LoRA Manager
If your LoRA Manager is running on another computer, you can still connect from your browser using port forwarding.
> **Why can't you set a remote IP directly?**
>
> For privacy and security, the extension only requests access to `http://127.0.0.1/*`. Supporting remote IPs would require much broader permissions, which may be rejected by browser stores and could raise user concerns.
**Solution: Port Forwarding with `socat`**
On your browser computer, run:
`socat TCP-LISTEN:8188,bind=127.0.0.1,fork TCP:REMOTE.IP.ADDRESS.HERE:8188`
- Replace `REMOTE.IP.ADDRESS.HERE` with the IP of the machine running LoRA Manager.
- Adjust the port if needed.
This lets the extension connect to `127.0.0.1:8188` as usual, with traffic forwarded to your remote server.
_Thanks to user **Temikus** for sharing this solution!_
---
## Roadmap
The extension will evolve alongside **LoRA Manager** improvements. Planned features include:
- [x] Support for **additional model types** (e.g., embeddings)
- [x] One-click **Recipe Import**
- [x] Display of in-library status for all resources in the **Resources Used** section of the image page
- [x] One-click **Auto-organize Models**
- [x] **Hide models already in library (Beta)** - Focus on models you haven't added yet
**Stay tuned — and thank you for your support!**
---

View File

@@ -15,7 +15,8 @@
"settings": "Einstellungen",
"help": "Hilfe",
"add": "Hinzufügen",
"close": "Schließen"
"close": "Schließen",
"menu": "Menü"
},
"status": {
"loading": "Wird geladen...",
@@ -276,6 +277,7 @@
"help": "Optionaler Pfad zur ausführbaren aria2c-Datei. Leer lassen, um aria2c aus dem System-PATH zu verwenden.",
"placeholder": "Leer lassen, um aria2c aus dem PATH zu verwenden"
},
"aria2HelpLink": "Erfahren Sie, wie Sie das aria2-Download-Backend einrichten",
"civitaiHostBanner": {
"title": "Civitai-Host-Einstellung verfügbar",
"content": "Civitai verwendet jetzt civitai.com für SFW-Inhalte und civitai.red für uneingeschränkte Inhalte. In den Einstellungen können Sie ändern, welche Seite standardmäßig geöffnet wird.",
@@ -427,6 +429,8 @@
"hover": "Bei Hover anzeigen"
},
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen",
"showVersionOnCard": "Version auf Karte anzeigen",
"showVersionOnCardHelp": "Den Versionsnamen auf Modellkarten ein- oder ausblenden",
"modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche",
"modelCardFooterActionOptions": {
"exampleImages": "Beispielbilder öffnen",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "Geben Sie den Ordnerpfad ein, wo Beispielbilder von Civitai gespeichert werden",
"autoDownload": "Beispielbilder automatisch herunterladen",
"autoDownloadHelp": "Beispielbilder automatisch für Modelle herunterladen, die keine haben (erfordert gesetzten Download-Speicherort)",
"openMode": "Aktion für Beispielbilder öffnen",
"openModeHelp": "Wählen Sie, ob die Aktion auf dem Server geöffnet, ein zugeordneter lokaler Pfad kopiert oder eine benutzerdefinierte URI gestartet werden soll.",
"openModeOptions": {
"system": "Auf Server öffnen",
"clipboard": "Lokalen Pfad kopieren",
"uriTemplate": "Benutzerdefinierte URI öffnen"
},
"localRoot": "Lokales Stammverzeichnis für Beispielbilder",
"localRootHelp": "Optionales lokales oder eingebundenes Stammverzeichnis, das das Beispielbild-Verzeichnis des Servers widerspiegelt. Wenn leer, wird der Serverpfad wiederverwendet.",
"localRootPlaceholder": "Beispiel: /Volumes/ComfyUI/example_images",
"uriTemplate": "URI-Vorlage öffnen",
"uriTemplateHelp": "Verwenden Sie einen benutzerdefinierten Deeplink wie eine Datei-URI oder einen Shortcuts-Link.",
"uriTemplatePlaceholder": "Beispiel: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "Verfügbare Platzhalter: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
"openModeWikiLink": "Mehr über Remote-Open-Modi erfahren",
"optimizeImages": "Heruntergeladene Bilder optimieren",
"optimizeImagesHelp": "Beispielbilder optimieren, um Dateigröße zu reduzieren und Ladegeschwindigkeit zu verbessern (Metadaten bleiben erhalten)",
"download": "Herunterladen",
@@ -1274,12 +1293,15 @@
"earlyAccess": "Früher Zugriff",
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
"ignored": "Ignoriert",
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert"
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
"onSiteOnly": "Nur On-Site",
"onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar"
},
"actions": {
"download": "Herunterladen",
"downloadTooltip": "Diese Version herunterladen",
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
"delete": "Löschen",
"deleteTooltip": "Diese lokale Version löschen",
"ignore": "Ignorieren",
@@ -1442,6 +1464,10 @@
"opened": "Beispielbilder-Ordner geöffnet",
"openingFolder": "Beispielbilder-Ordner wird geöffnet",
"failedToOpen": "Fehler beim Öffnen des Beispielbilder-Ordners",
"copiedPath": "Pfad in Zwischenablage kopiert: {{path}}",
"clipboardFallback": "Pfad: {{path}}",
"copiedUri": "Link in Zwischenablage kopiert: {{uri}}",
"uriClipboardFallback": "Link: {{uri}}",
"setupRequired": "Beispielbilder-Speicher",
"setupDescription": "Um benutzerdefinierte Beispielbilder hinzuzufügen, müssen Sie zuerst einen Download-Speicherort festlegen.",
"setupUsage": "Dieser Pfad wird sowohl für heruntergeladene als auch für benutzerdefinierte Beispielbilder verwendet.",
@@ -1884,7 +1910,9 @@
"repairSuccess": "Cache-Neuaufbau abgeschlossen.",
"repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}",
"exportSuccess": "Diagnosepaket exportiert.",
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}"
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}",
"conflictsResolved": "{count} Dateinamenskonflikt(e) gelöst.",
"conflictsResolveFailed": "Auflösung der Dateinamenskonflikte fehlgeschlagen: {message}"
}
},
"banners": {

View File

@@ -15,7 +15,8 @@
"settings": "Settings",
"help": "Help",
"add": "Add",
"close": "Close"
"close": "Close",
"menu": "Menu"
},
"status": {
"loading": "Loading...",
@@ -276,6 +277,7 @@
"help": "Optional path to the aria2c executable. Leave empty to use aria2c from your system PATH.",
"placeholder": "Leave empty to use aria2c from PATH"
},
"aria2HelpLink": "Learn how to set up the aria2 download backend",
"civitaiHostBanner": {
"title": "Civitai host preference available",
"content": "Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
@@ -427,6 +429,8 @@
"hover": "Reveal on Hover"
},
"cardInfoDisplayHelp": "Choose when to display model information and action buttons",
"showVersionOnCard": "Show Version on Card",
"showVersionOnCardHelp": "Show or hide the version name on model cards",
"modelCardFooterAction": "Model Card Button Action",
"modelCardFooterActionOptions": {
"exampleImages": "Open Example Images",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "Enter the folder path where example images from Civitai will be saved",
"autoDownload": "Auto Download Example Images",
"autoDownloadHelp": "Automatically download example images for models that don't have them (requires download location to be set)",
"openMode": "Open Example Images Action",
"openModeHelp": "Choose whether the action opens on the server, copies a mapped local path, or launches a custom URI.",
"openModeOptions": {
"system": "Open on server",
"clipboard": "Copy local path",
"uriTemplate": "Open custom URI"
},
"localRoot": "Local Example Images Root",
"localRootHelp": "Optional local or mounted root that mirrors the server example images directory. If blank, the server path is reused.",
"localRootPlaceholder": "Example: /Volumes/ComfyUI/example_images",
"uriTemplate": "Open URI Template",
"uriTemplateHelp": "Use a custom deep link such as a file URI or a Shortcuts link.",
"uriTemplatePlaceholder": "Example: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "Available placeholders: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
"openModeWikiLink": "Learn more about remote open modes",
"optimizeImages": "Optimize Downloaded Images",
"optimizeImagesHelp": "Optimize example images to reduce file size and improve loading speed (metadata will be preserved)",
"download": "Download",
@@ -1274,12 +1293,15 @@
"earlyAccess": "Early Access",
"earlyAccessTooltip": "This version currently requires Civitai early access",
"ignored": "Ignored",
"ignoredTooltip": "Update notifications are disabled for this version"
"ignoredTooltip": "Update notifications are disabled for this version",
"onSiteOnly": "On-Site Only",
"onSiteOnlyTooltip": "This version is only available for on-site generation on Civitai"
},
"actions": {
"download": "Download",
"downloadTooltip": "Download this version",
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
"delete": "Delete",
"deleteTooltip": "Delete this local version",
"ignore": "Ignore",
@@ -1442,6 +1464,10 @@
"opened": "Example images folder opened",
"openingFolder": "Opening example images folder",
"failedToOpen": "Failed to open example images folder",
"copiedPath": "Path copied to clipboard: {{path}}",
"clipboardFallback": "Path: {{path}}",
"copiedUri": "Link copied to clipboard: {{uri}}",
"uriClipboardFallback": "Link: {{uri}}",
"setupRequired": "Example Images Storage",
"setupDescription": "To add custom example images, you need to set a download location first.",
"setupUsage": "This path is used for both downloaded and custom example images.",
@@ -1884,7 +1910,9 @@
"repairSuccess": "Cache rebuild completed.",
"repairFailed": "Cache rebuild failed: {message}",
"exportSuccess": "Diagnostics bundle exported.",
"exportFailed": "Failed to export diagnostics bundle: {message}"
"exportFailed": "Failed to export diagnostics bundle: {message}",
"conflictsResolved": "{count} filename conflict(s) resolved.",
"conflictsResolveFailed": "Failed to resolve filename conflicts: {message}"
}
},
"banners": {

View File

@@ -15,7 +15,8 @@
"settings": "Configuración",
"help": "Ayuda",
"add": "Añadir",
"close": "Cerrar"
"close": "Cerrar",
"menu": "Menú"
},
"status": {
"loading": "Cargando...",
@@ -276,6 +277,7 @@
"help": "Ruta opcional al ejecutable aria2c. Déjalo vacío para usar aria2c desde el PATH del sistema.",
"placeholder": "Déjalo vacío para usar aria2c desde el PATH"
},
"aria2HelpLink": "Aprende a configurar el backend de descarga aria2",
"civitaiHostBanner": {
"title": "Preferencia de host de Civitai disponible",
"content": "Civitai ahora usa civitai.com para contenido SFW y civitai.red para contenido sin restricciones. Puedes cambiar en Ajustes qué sitio se abre por defecto.",
@@ -427,6 +429,8 @@
"hover": "Mostrar al pasar el ratón"
},
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción",
"showVersionOnCard": "Mostrar versión en la tarjeta",
"showVersionOnCardHelp": "Mostrar u ocultar el nombre de versión en las tarjetas de modelo",
"modelCardFooterAction": "Acción del botón de tarjeta de modelo",
"modelCardFooterActionOptions": {
"exampleImages": "Abrir imágenes de ejemplo",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "Introduce la ruta de la carpeta donde se guardarán las imágenes de ejemplo de Civitai",
"autoDownload": "Descargar automáticamente imágenes de ejemplo",
"autoDownloadHelp": "Descargar automáticamente imágenes de ejemplo para modelos que no las tengan (requiere que se establezca la ubicación de descarga)",
"openMode": "Acción al abrir imágenes de ejemplo",
"openModeHelp": "Elige si la acción se abre en el servidor, copia una ruta local asignada o lanza una URI personalizada.",
"openModeOptions": {
"system": "Abrir en el servidor",
"clipboard": "Copiar ruta local",
"uriTemplate": "Abrir URI personalizada"
},
"localRoot": "Raíz local de imágenes de ejemplo",
"localRootHelp": "Raíz local u montada opcional que refleja el directorio de imágenes de ejemplo del servidor. Si se deja en blanco, se reutiliza la ruta del servidor.",
"localRootPlaceholder": "Ejemplo: /Volumes/ComfyUI/example_images",
"uriTemplate": "Abrir plantilla de URI",
"uriTemplateHelp": "Usa un enlace profundo personalizado, como un URI de archivo o un enlace de Shortcuts.",
"uriTemplatePlaceholder": "Ejemplo: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "Marcadores disponibles: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
"openModeWikiLink": "Más información sobre los modos de apertura remota",
"optimizeImages": "Optimizar imágenes descargadas",
"optimizeImagesHelp": "Optimizar imágenes de ejemplo para reducir el tamaño del archivo y mejorar la velocidad de carga (se preservarán los metadatos)",
"download": "Descargar",
@@ -1274,12 +1293,15 @@
"earlyAccess": "Acceso temprano",
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
"ignored": "Ignorada",
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión"
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
"onSiteOnly": "Solo en Sitio",
"onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai"
},
"actions": {
"download": "Descargar",
"downloadTooltip": "Descargar esta versión",
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
"delete": "Eliminar",
"deleteTooltip": "Eliminar esta versión local",
"ignore": "Ignorar",
@@ -1442,6 +1464,10 @@
"opened": "Carpeta de imágenes de ejemplo abierta",
"openingFolder": "Abriendo carpeta de imágenes de ejemplo",
"failedToOpen": "Error al abrir carpeta de imágenes de ejemplo",
"copiedPath": "Ruta copiada al portapapeles: {{path}}",
"clipboardFallback": "Ruta: {{path}}",
"copiedUri": "Enlace copiado al portapapeles: {{uri}}",
"uriClipboardFallback": "Enlace: {{uri}}",
"setupRequired": "Almacenamiento de imágenes de ejemplo",
"setupDescription": "Para agregar imágenes de ejemplo personalizadas, primero necesita establecer una ubicación de descarga.",
"setupUsage": "Esta ruta se utiliza tanto para imágenes de ejemplo descargadas como personalizadas.",
@@ -1884,7 +1910,9 @@
"repairSuccess": "Reconstrucción de caché completada.",
"repairFailed": "Error al reconstruir la caché: {message}",
"exportSuccess": "Paquete de diagnósticos exportado.",
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}"
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}",
"conflictsResolved": "{count} conflicto(s) de nombre de archivo resuelto(s).",
"conflictsResolveFailed": "Error al resolver conflictos de nombre de archivo: {message}"
}
},
"banners": {

View File

@@ -15,7 +15,8 @@
"settings": "Paramètres",
"help": "Aide",
"add": "Ajouter",
"close": "Fermer"
"close": "Fermer",
"menu": "Menu"
},
"status": {
"loading": "Chargement...",
@@ -276,6 +277,7 @@
"help": "Chemin facultatif vers lexécutable aria2c. Laissez vide pour utiliser aria2c depuis le PATH système.",
"placeholder": "Laisser vide pour utiliser aria2c depuis le PATH"
},
"aria2HelpLink": "Apprenez à configurer le backend de téléchargement aria2",
"civitaiHostBanner": {
"title": "Préférence dhôte Civitai disponible",
"content": "Civitai utilise désormais civitai.com pour le contenu SFW et civitai.red pour le contenu sans restriction. Vous pouvez modifier dans les paramètres le site ouvert par défaut.",
@@ -427,6 +429,8 @@
"hover": "Révéler au survol"
},
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action",
"showVersionOnCard": "Afficher la version sur la carte",
"showVersionOnCardHelp": "Afficher ou masquer le nom de version sur les cartes de modèle",
"modelCardFooterAction": "Action du bouton de carte de modèle",
"modelCardFooterActionOptions": {
"exampleImages": "Ouvrir les images d'exemple",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "Entrez le chemin du dossier où les images d'exemple de Civitai seront sauvegardées",
"autoDownload": "Téléchargement automatique des images d'exemple",
"autoDownloadHelp": "Télécharger automatiquement les images d'exemple pour les modèles qui n'en ont pas (nécessite que l'emplacement de téléchargement soit défini)",
"openMode": "Action douverture des images dexemple",
"openModeHelp": "Choisissez si laction souvre sur le serveur, copie un chemin local mappé ou lance une URI personnalisée.",
"openModeOptions": {
"system": "Ouvrir sur le serveur",
"clipboard": "Copier le chemin local",
"uriTemplate": "Ouvrir une URI personnalisée"
},
"localRoot": "Racine locale des images dexemple",
"localRootHelp": "Racine locale ou montée facultative qui reflète le répertoire des images dexemple du serveur. Si vide, le chemin du serveur est réutilisé.",
"localRootPlaceholder": "Exemple : /Volumes/ComfyUI/example_images",
"uriTemplate": "Ouvrir le modèle dURI",
"uriTemplateHelp": "Utilisez un lien profond personnalisé, tel quune URI de fichier ou un lien Shortcuts.",
"uriTemplatePlaceholder": "Exemple : shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "Paramètres disponibles : {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
"openModeWikiLink": "En savoir plus sur les modes d'ouverture à distance",
"optimizeImages": "Optimiser les images téléchargées",
"optimizeImagesHelp": "Optimiser les images d'exemple pour réduire la taille du fichier et améliorer la vitesse de chargement (les métadonnées seront préservées)",
"download": "Télécharger",
@@ -1274,12 +1293,15 @@
"earlyAccess": "Accès anticipé",
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
"ignored": "Ignorée",
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version"
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
"onSiteOnly": "Uniquement sur Site",
"onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai"
},
"actions": {
"download": "Télécharger",
"downloadTooltip": "Télécharger cette version",
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
"delete": "Supprimer",
"deleteTooltip": "Supprimer cette version locale",
"ignore": "Ignorer",
@@ -1442,6 +1464,10 @@
"opened": "Dossier d'images d'exemple ouvert",
"openingFolder": "Ouverture du dossier d'images d'exemple",
"failedToOpen": "Échec de l'ouverture du dossier d'images d'exemple",
"copiedPath": "Chemin copié dans le presse-papiers : {{path}}",
"clipboardFallback": "Chemin : {{path}}",
"copiedUri": "Lien copié dans le presse-papiers : {{uri}}",
"uriClipboardFallback": "Lien : {{uri}}",
"setupRequired": "Stockage d'images d'exemple",
"setupDescription": "Pour ajouter des images d'exemple personnalisées, vous devez d'abord définir un emplacement de téléchargement.",
"setupUsage": "Ce chemin est utilisé pour les images d'exemple téléchargées et personnalisées.",
@@ -1884,7 +1910,9 @@
"repairSuccess": "Reconstruction du cache terminée.",
"repairFailed": "Échec de la reconstruction du cache : {message}",
"exportSuccess": "Lot de diagnostics exporté.",
"exportFailed": "Échec de l'export du lot de diagnostics : {message}"
"exportFailed": "Échec de l'export du lot de diagnostics : {message}",
"conflictsResolved": "{count} conflit(s) de nom de fichier résolu(s).",
"conflictsResolveFailed": "Échec de la résolution des conflits de nom de fichier : {message}"
}
},
"banners": {

View File

@@ -15,7 +15,8 @@
"settings": "הגדרות",
"help": "עזרה",
"add": "הוספה",
"close": "סגור"
"close": "סגור",
"menu": "תפריט"
},
"status": {
"loading": "טוען...",
@@ -276,6 +277,7 @@
"help": "נתיב אופציונלי לקובץ ההפעלה aria2c. השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH של המערכת.",
"placeholder": "השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH"
},
"aria2HelpLink": "למד כיצד להגדיר את מנוע ההורדה aria2",
"civitaiHostBanner": {
"title": "העדפת מארח Civitai זמינה",
"content": "Civitai משתמש כעת ב-civitai.com עבור תוכן SFW וב-civitai.red עבור תוכן ללא הגבלות. ניתן לשנות בהגדרות איזה אתר ייפתח כברירת מחדל.",
@@ -427,6 +429,8 @@
"hover": "חשוף בריחוף"
},
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה",
"showVersionOnCard": "הצג גרסה בכרטיס",
"showVersionOnCardHelp": "הצג או הסתר את שם הגרסה בכרטיסי המודל",
"modelCardFooterAction": "פעולת כפתור כרטיס מודל",
"modelCardFooterActionOptions": {
"exampleImages": "פתח תמונות דוגמה",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "הזן את נתיב התיקייה שבו יישמרו תמונות דוגמה מ-Civitai",
"autoDownload": "הורדה אוטומטית של תמונות דוגמה",
"autoDownloadHelp": "הורד אוטומטית תמונות דוגמה למודלים שאין להם (דורש הגדרת מיקום הורדה)",
"openMode": "פעולת פתיחת תמונות דוגמה",
"openModeHelp": "בחר אם הפעולה תיפתח בשרת, תעתיק נתיב מקומי ממופה או תפעיל URI מותאם אישית.",
"openModeOptions": {
"system": "פתח בשרת",
"clipboard": "העתק נתיב מקומי",
"uriTemplate": "פתח URI מותאם אישית"
},
"localRoot": "שורש מקומי לתמונות דוגמה",
"localRootHelp": "שורש מקומי או ממופה אופציונלי שמשקף את תיקיית תמונות הדוגמה בשרת. אם השדה ריק, ייעשה שימוש חוזר בנתיב השרת.",
"localRootPlaceholder": "דוגמה: /Volumes/ComfyUI/example_images",
"uriTemplate": "תבנית URI לפתיחה",
"uriTemplateHelp": "השתמש בקישור עומק מותאם אישית כמו URI של קובץ או קישור Shortcuts.",
"uriTemplatePlaceholder": "דוגמה: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "מצייני מקום זמינים: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
"openModeWikiLink": "למידע נוסף על מצבי פתיחה מרחוק",
"optimizeImages": "מטב תמונות שהורדו",
"optimizeImagesHelp": "מטב תמונות דוגמה כדי להקטין את גודל הקובץ ולשפר את מהירות הטעינה (מטא-דאטה תישמר)",
"download": "הורד",
@@ -1274,12 +1293,15 @@
"earlyAccess": "גישה מוקדמת",
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
"ignored": "התעלם",
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו"
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
"onSiteOnly": "רק באתר",
"onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai"
},
"actions": {
"download": "הורדה",
"downloadTooltip": "הורד את הגרסה הזו",
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
"delete": "מחיקה",
"deleteTooltip": "מחק את הגרסה המקומית הזו",
"ignore": "התעלם",
@@ -1442,6 +1464,10 @@
"opened": "תיקיית תמונות הדוגמה נפתחה",
"openingFolder": "פותח תיקיית תמונות דוגמה",
"failedToOpen": "פתיחת תיקיית תמונות הדוגמה נכשלה",
"copiedPath": "הנתיב הועתק ללוח: {{path}}",
"clipboardFallback": "נתיב: {{path}}",
"copiedUri": "הקישור הועתק ללוח: {{uri}}",
"uriClipboardFallback": "קישור: {{uri}}",
"setupRequired": "אחסון תמונות דוגמה",
"setupDescription": "כדי להוסיף תמונות דוגמה מותאמות אישית, עליך קודם להגדיר מיקום הורדה.",
"setupUsage": "נתיב זה משמש הן עבור תמונות דוגמה שהורדו והן עבור תמונות מותאמות אישית.",
@@ -1884,7 +1910,9 @@
"repairSuccess": "בניית המטמון מחדש הושלמה.",
"repairFailed": "בניית המטמון מחדש נכשלה: {message}",
"exportSuccess": "חבילת האבחון יוצאה.",
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}"
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}",
"conflictsResolved": "נפתרו {count} התנגשויות בשמות קבצים.",
"conflictsResolveFailed": "פתרון התנגשויות שמות קבצים נכשל: {message}"
}
},
"banners": {

View File

@@ -15,7 +15,8 @@
"settings": "設定",
"help": "ヘルプ",
"add": "追加",
"close": "閉じる"
"close": "閉じる",
"menu": "メニュー"
},
"status": {
"loading": "読み込み中...",
@@ -276,6 +277,7 @@
"help": "aria2c 実行ファイルへの任意のパスです。空欄のままにすると、システム PATH 上の aria2c を使用します。",
"placeholder": "空欄のままにすると PATH 上の aria2c を使用します"
},
"aria2HelpLink": "aria2 ダウンロードバックエンドの設定方法",
"civitaiHostBanner": {
"title": "Civitai ホスト設定を利用できます",
"content": "Civitai は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
@@ -427,6 +429,8 @@
"hover": "ホバー時に表示"
},
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
"showVersionOnCard": "カードにバージョンを表示",
"showVersionOnCardHelp": "モデルカード上のバージョン名の表示/非表示を切り替えます",
"modelCardFooterAction": "モデルカードボタンのアクション",
"modelCardFooterActionOptions": {
"exampleImages": "例画像を開く",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "Civitaiからの例画像を保存するフォルダパスを入力してください",
"autoDownload": "例画像の自動ダウンロード",
"autoDownloadHelp": "例画像がないモデルの例画像を自動的にダウンロードします(ダウンロード場所の設定が必要)",
"openMode": "サンプル画像を開く動作",
"openModeHelp": "サーバー上で開くか、対応するローカルパスをコピーするか、カスタム URI を起動するかを選択します。",
"openModeOptions": {
"system": "サーバー上で開く",
"clipboard": "ローカルパスをコピー",
"uriTemplate": "カスタム URI を開く"
},
"localRoot": "ローカルのサンプル画像ルート",
"localRootHelp": "サーバーのサンプル画像ディレクトリを反映する任意のローカルまたはマウント済みルートです。空欄の場合はサーバーのパスを再利用します。",
"localRootPlaceholder": "例: /Volumes/ComfyUI/example_images",
"uriTemplate": "URI テンプレートを開く",
"uriTemplateHelp": "ファイル URI や Shortcuts リンクなどのカスタムディープリンクを使用します。",
"uriTemplatePlaceholder": "例: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "使用可能なプレースホルダー: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
"openModeWikiLink": "リモートオープンモードの詳細",
"optimizeImages": "ダウンロード画像の最適化",
"optimizeImagesHelp": "例画像を最適化してファイルサイズを縮小し、読み込み速度を向上させます(メタデータは保持されます)",
"download": "ダウンロード",
@@ -1274,12 +1293,15 @@
"earlyAccess": "早期アクセス",
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
"ignored": "無視中",
"ignoredTooltip": "このバージョンの更新通知は無効です"
"ignoredTooltip": "このバージョンの更新通知は無効です",
"onSiteOnly": "サイト内のみ",
"onSiteOnlyTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません"
},
"actions": {
"download": "ダウンロード",
"downloadTooltip": "このバージョンをダウンロード",
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
"delete": "削除",
"deleteTooltip": "このローカルバージョンを削除",
"ignore": "無視",
@@ -1442,6 +1464,10 @@
"opened": "例画像フォルダが開かれました",
"openingFolder": "例画像フォルダを開いています",
"failedToOpen": "例画像フォルダを開くのに失敗しました",
"copiedPath": "パスをクリップボードにコピーしました: {{path}}",
"clipboardFallback": "パス: {{path}}",
"copiedUri": "リンクをクリップボードにコピーしました: {{uri}}",
"uriClipboardFallback": "リンク: {{uri}}",
"setupRequired": "例画像ストレージ",
"setupDescription": "カスタム例画像を追加するには、まずダウンロード場所を設定する必要があります。",
"setupUsage": "このパスは、ダウンロードした例画像とカスタム画像の両方に使用されます。",
@@ -1884,7 +1910,9 @@
"repairSuccess": "キャッシュの再構築が完了しました。",
"repairFailed": "キャッシュの再構築に失敗しました: {message}",
"exportSuccess": "診断パッケージをエクスポートしました。",
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}"
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}",
"conflictsResolved": "{count} 件のファイル名競合が解決されました。",
"conflictsResolveFailed": "ファイル名競合の解決に失敗しました: {message}"
}
},
"banners": {

View File

@@ -15,7 +15,8 @@
"settings": "설정",
"help": "도움말",
"add": "추가",
"close": "닫기"
"close": "닫기",
"menu": "메뉴"
},
"status": {
"loading": "로딩 중...",
@@ -276,6 +277,7 @@
"help": "aria2c 실행 파일의 선택적 경로입니다. 비워 두면 시스템 PATH의 aria2c를 사용합니다.",
"placeholder": "비워 두면 PATH의 aria2c를 사용합니다"
},
"aria2HelpLink": "aria2 다운로드 백엔드 설정 방법 알아보기",
"civitaiHostBanner": {
"title": "Civitai 호스트 기본 설정 사용 가능",
"content": "이제 Civitai는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
@@ -427,6 +429,8 @@
"hover": "호버 시 표시"
},
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
"showVersionOnCard": "카드에 버전 표시",
"showVersionOnCardHelp": "모델 카드에 버전 이름 표시 여부를 전환합니다",
"modelCardFooterAction": "모델 카드 버튼 동작",
"modelCardFooterActionOptions": {
"exampleImages": "예시 이미지 열기",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "Civitai의 예시 이미지가 저장될 폴더 경로를 입력하세요",
"autoDownload": "예시 이미지 자동 다운로드",
"autoDownloadHelp": "예시 이미지가 없는 모델의 예시 이미지를 자동으로 다운로드합니다 (다운로드 위치 설정 필요)",
"openMode": "예시 이미지 열기 동작",
"openModeHelp": "서버에서 열지, 매핑된 로컬 경로를 복사할지, 사용자 지정 URI를 실행할지 선택합니다.",
"openModeOptions": {
"system": "서버에서 열기",
"clipboard": "로컬 경로 복사",
"uriTemplate": "사용자 지정 URI 열기"
},
"localRoot": "로컬 예시 이미지 루트",
"localRootHelp": "서버 예시 이미지 디렉터리를 반영하는 선택적 로컬 또는 마운트된 루트입니다. 비워 두면 서버 경로를 재사용합니다.",
"localRootPlaceholder": "예: /Volumes/ComfyUI/example_images",
"uriTemplate": "URI 템플릿 열기",
"uriTemplateHelp": "파일 URI 또는 Shortcuts 링크 같은 사용자 지정 딥링크를 사용합니다.",
"uriTemplatePlaceholder": "예: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "사용 가능한 플레이스홀더: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
"openModeWikiLink": "원격 열기 모드에 대해 자세히 알아보기",
"optimizeImages": "다운로드된 이미지 최적화",
"optimizeImagesHelp": "파일 크기를 줄이고 로딩 속도를 향상시키기 위해 예시 이미지를 최적화합니다 (메타데이터는 보존됨)",
"download": "다운로드",
@@ -1274,12 +1293,15 @@
"earlyAccess": "얼리 액세스",
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
"ignored": "무시됨",
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다"
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
"onSiteOnly": "사이트 내 전용",
"onSiteOnlyTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다"
},
"actions": {
"download": "다운로드",
"downloadTooltip": "이 버전 다운로드",
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
"delete": "삭제",
"deleteTooltip": "이 로컬 버전 삭제",
"ignore": "무시",
@@ -1442,6 +1464,10 @@
"opened": "예시 이미지 폴더가 열렸습니다",
"openingFolder": "예시 이미지 폴더를 여는 중",
"failedToOpen": "예시 이미지 폴더 열기 실패",
"copiedPath": "경로를 클립보드에 복사했습니다: {{path}}",
"clipboardFallback": "경로: {{path}}",
"copiedUri": "링크를 클립보드에 복사했습니다: {{uri}}",
"uriClipboardFallback": "링크: {{uri}}",
"setupRequired": "예시 이미지 저장소",
"setupDescription": "사용자 지정 예시 이미지를 추가하려면 먼저 다운로드 위치를 설정해야 합니다.",
"setupUsage": "이 경로는 다운로드한 예시 이미지와 사용자 지정 이미지 모두에 사용됩니다.",
@@ -1884,7 +1910,9 @@
"repairSuccess": "캐시 재구성이 완료되었습니다.",
"repairFailed": "캐시 재구성 실패: {message}",
"exportSuccess": "진단 번들이 내보내졌습니다.",
"exportFailed": "진단 번들 내보내기 실패: {message}"
"exportFailed": "진단 번들 내보내기 실패: {message}",
"conflictsResolved": "{count}개 파일명 충돌이 해결되었습니다.",
"conflictsResolveFailed": "파일명 충돌 해결 실패: {message}"
}
},
"banners": {

View File

@@ -15,7 +15,8 @@
"settings": "Настройки",
"help": "Справка",
"add": "Добавить",
"close": "Закрыть"
"close": "Закрыть",
"menu": "Меню"
},
"status": {
"loading": "Загрузка...",
@@ -276,6 +277,7 @@
"help": "Необязательный путь к исполняемому файлу aria2c. Оставьте пустым, чтобы использовать aria2c из системного PATH.",
"placeholder": "Оставьте пустым, чтобы использовать aria2c из PATH"
},
"aria2HelpLink": "Узнайте, как настроить сервер загрузки aria2",
"civitaiHostBanner": {
"title": "Доступна настройка хоста Civitai",
"content": "Теперь Civitai использует civitai.com для контента SFW и civitai.red для контента без ограничений. В настройках можно изменить, какой сайт открывать по умолчанию.",
@@ -427,6 +429,8 @@
"hover": "Показать при наведении"
},
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
"showVersionOnCard": "Показывать версию на карточке",
"showVersionOnCardHelp": "Показать или скрыть название версии на карточках моделей",
"modelCardFooterAction": "Действие кнопки карточки модели",
"modelCardFooterActionOptions": {
"exampleImages": "Открыть примеры изображений",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "Введите путь к папке, где будут сохраняться примеры изображений с Civitai",
"autoDownload": "Автозагрузка примеров изображений",
"autoDownloadHelp": "Автоматически загружать примеры изображений для моделей, у которых их нет (требует настройки места загрузки)",
"openMode": "Действие открытия примеров изображений",
"openModeHelp": "Выберите, будет ли действие открывать папку на сервере, копировать сопоставленный локальный путь или запускать пользовательский URI.",
"openModeOptions": {
"system": "Открыть на сервере",
"clipboard": "Скопировать локальный путь",
"uriTemplate": "Открыть пользовательский URI"
},
"localRoot": "Локальный корень примеров изображений",
"localRootHelp": "Необязательный локальный или смонтированный корневой путь, отражающий каталог примеров изображений на сервере. Если оставить пустым, будет использован путь сервера.",
"localRootPlaceholder": "Пример: /Volumes/ComfyUI/example_images",
"uriTemplate": "Шаблон URI для открытия",
"uriTemplateHelp": "Используйте пользовательскую deep link-ссылку, например file URI или ссылку Shortcuts.",
"uriTemplatePlaceholder": "Пример: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "Доступные плейсхолдеры: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
"openModeWikiLink": "Подробнее об удаленных режимах открытия",
"optimizeImages": "Оптимизировать загруженные изображения",
"optimizeImagesHelp": "Оптимизировать примеры изображений для уменьшения размера файла и улучшения скорости загрузки (метаданные будут сохранены)",
"download": "Загрузить",
@@ -1274,12 +1293,15 @@
"earlyAccess": "Ранний доступ",
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
"ignored": "Игнорируется",
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены"
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
"onSiteOnly": "Только на Сайте",
"onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте Civitai"
},
"actions": {
"download": "Скачать",
"downloadTooltip": "Скачать эту версию",
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
"delete": "Удалить",
"deleteTooltip": "Удалить эту локальную версию",
"ignore": "Игнорировать",
@@ -1442,6 +1464,10 @@
"opened": "Папка с примерами изображений открыта",
"openingFolder": "Открытие папки с примерами изображений",
"failedToOpen": "Не удалось открыть папку с примерами изображений",
"copiedPath": "Путь скопирован в буфер обмена: {{path}}",
"clipboardFallback": "Путь: {{path}}",
"copiedUri": "Ссылка скопирована в буфер обмена: {{uri}}",
"uriClipboardFallback": "Ссылка: {{uri}}",
"setupRequired": "Хранилище примеров изображений",
"setupDescription": "Чтобы добавить собственные примеры изображений, сначала нужно установить место загрузки.",
"setupUsage": "Этот путь используется как для загруженных, так и для пользовательских примеров изображений.",
@@ -1884,7 +1910,9 @@
"repairSuccess": "Перестройка кэша завершена.",
"repairFailed": "Не удалось перестроить кэш: {message}",
"exportSuccess": "Диагностический пакет экспортирован.",
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}"
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}",
"conflictsResolved": "Разрешено конфликтов имён файлов: {count}.",
"conflictsResolveFailed": "Не удалось разрешить конфликты имён файлов: {message}"
}
},
"banners": {

View File

@@ -15,7 +15,8 @@
"settings": "设置",
"help": "帮助",
"add": "添加",
"close": "关闭"
"close": "关闭",
"menu": "菜单"
},
"status": {
"loading": "加载中...",
@@ -276,6 +277,7 @@
"help": "可选的 aria2c 可执行文件路径。留空则使用系统 PATH 中的 aria2c。",
"placeholder": "留空则使用 PATH 中的 aria2c"
},
"aria2HelpLink": "了解如何配置 aria2 下载后端",
"civitaiHostBanner": {
"title": "已提供 Civitai 站点偏好设置",
"content": "Civitai 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
@@ -427,6 +429,8 @@
"hover": "悬停时显示"
},
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
"showVersionOnCard": "在卡片上显示版本",
"showVersionOnCardHelp": "在模型卡片上显示或隐藏版本名称",
"modelCardFooterAction": "模型卡片按钮操作",
"modelCardFooterActionOptions": {
"exampleImages": "打开示例图片",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "输入保存从 Civitai 下载的示例图片的文件夹路径",
"autoDownload": "自动下载示例图片",
"autoDownloadHelp": "自动为没有示例图片的模型下载示例图片(需设置下载位置)",
"openMode": "打开示例图片操作",
"openModeHelp": "选择是在服务器上打开、复制映射后的本地路径,还是启动自定义 URI。",
"openModeOptions": {
"system": "在服务器上打开",
"clipboard": "复制本地路径",
"uriTemplate": "打开自定义 URI"
},
"localRoot": "本地示例图片根目录",
"localRootHelp": "可选的本地或挂载根目录,用于映射服务器上的示例图片目录。若留空,则复用服务器路径。",
"localRootPlaceholder": "例如:/Volumes/ComfyUI/example_images",
"uriTemplate": "打开 URI 模板",
"uriTemplateHelp": "使用自定义深链接,例如文件 URI 或 Shortcuts 链接。",
"uriTemplatePlaceholder": "例如shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "可用占位符:{{local_path}}、{{encoded_local_path}}、{{relative_path}}、{{encoded_relative_path}}、{{file_uri}}、{{encoded_file_uri}}",
"openModeWikiLink": "了解远程打开模式",
"optimizeImages": "优化下载图片",
"optimizeImagesHelp": "优化示例图片以减少文件大小并提升加载速度(保留元数据)",
"download": "下载",
@@ -1274,12 +1293,15 @@
"earlyAccess": "抢先体验",
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
"ignored": "已忽略",
"ignoredTooltip": "此版本已关闭更新通知"
"ignoredTooltip": "此版本已关闭更新通知",
"onSiteOnly": "仅站内生成",
"onSiteOnlyTooltip": "此版本仅在 Civitai 站内可用,无法下载"
},
"actions": {
"download": "下载",
"downloadTooltip": "下载此版本",
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
"delete": "删除",
"deleteTooltip": "删除此本地版本",
"ignore": "忽略",
@@ -1442,6 +1464,10 @@
"opened": "示例图片文件夹已打开",
"openingFolder": "正在打开示例图片文件夹",
"failedToOpen": "打开示例图片文件夹失败",
"copiedPath": "路径已复制到剪贴板:{{path}}",
"clipboardFallback": "路径:{{path}}",
"copiedUri": "链接已复制到剪贴板:{{uri}}",
"uriClipboardFallback": "链接:{{uri}}",
"setupRequired": "示例图片存储",
"setupDescription": "要添加自定义示例图片,您需要先设置下载位置。",
"setupUsage": "此路径用于存储下载的示例图片和自定义图片。",
@@ -1884,7 +1910,9 @@
"repairSuccess": "缓存重建完成。",
"repairFailed": "缓存重建失败:{message}",
"exportSuccess": "诊断包已导出。",
"exportFailed": "导出诊断包失败:{message}"
"exportFailed": "导出诊断包失败:{message}",
"conflictsResolved": "已解决 {count} 个文件名冲突。",
"conflictsResolveFailed": "解决文件名冲突失败:{message}"
}
},
"banners": {

View File

@@ -15,7 +15,8 @@
"settings": "設定",
"help": "說明",
"add": "新增",
"close": "關閉"
"close": "關閉",
"menu": "選單"
},
"status": {
"loading": "載入中...",
@@ -276,6 +277,7 @@
"help": "可選的 aria2c 可執行檔路徑。留空則使用系統 PATH 中的 aria2c。",
"placeholder": "留空則使用 PATH 中的 aria2c"
},
"aria2HelpLink": "了解如何設定 aria2 下載後端",
"civitaiHostBanner": {
"title": "已提供 Civitai 站點偏好設定",
"content": "Civitai 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。你可以在設定中變更預設開啟的站點。",
@@ -427,6 +429,8 @@
"hover": "滑鼠懸停顯示"
},
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
"showVersionOnCard": "在卡片上顯示版本",
"showVersionOnCardHelp": "在模型卡片上顯示或隱藏版本名稱",
"modelCardFooterAction": "模型卡片按鈕操作",
"modelCardFooterActionOptions": {
"exampleImages": "開啟範例圖片",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "輸入從 Civitai 下載範例圖片要儲存的資料夾路徑",
"autoDownload": "自動下載範例圖片",
"autoDownloadHelp": "自動為沒有範例圖片的模型下載範例圖片(需設定下載位置)",
"openMode": "開啟範例圖片動作",
"openModeHelp": "選擇是在伺服器上開啟、複製對應的本機路徑,或啟動自訂 URI。",
"openModeOptions": {
"system": "在伺服器上開啟",
"clipboard": "複製本機路徑",
"uriTemplate": "開啟自訂 URI"
},
"localRoot": "本機範例圖片根目錄",
"localRootHelp": "可選的本機或掛載根目錄,用於對應伺服器上的範例圖片目錄。若留白,則會重用伺服器路徑。",
"localRootPlaceholder": "例如:/Volumes/ComfyUI/example_images",
"uriTemplate": "開啟 URI 範本",
"uriTemplateHelp": "使用自訂深層連結,例如檔案 URI 或 Shortcuts 連結。",
"uriTemplatePlaceholder": "例如shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "可用佔位符:{{local_path}}、{{encoded_local_path}}、{{relative_path}}、{{encoded_relative_path}}、{{file_uri}}、{{encoded_file_uri}}",
"openModeWikiLink": "了解遠端開啟模式",
"optimizeImages": "最佳化下載圖片",
"optimizeImagesHelp": "最佳化範例圖片以減少檔案大小並提升載入速度(會保留原有的 metadata",
"download": "下載",
@@ -1274,12 +1293,15 @@
"earlyAccess": "搶先體驗",
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
"ignored": "已忽略",
"ignoredTooltip": "此版本已關閉更新通知"
"ignoredTooltip": "此版本已關閉更新通知",
"onSiteOnly": "僅站內生成",
"onSiteOnlyTooltip": "此版本僅在 Civitai 站內可用,無法下載"
},
"actions": {
"download": "下載",
"downloadTooltip": "下載此版本",
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
"delete": "刪除",
"deleteTooltip": "刪除此本地版本",
"ignore": "忽略",
@@ -1442,6 +1464,10 @@
"opened": "範例圖片資料夾已開啟",
"openingFolder": "正在開啟範例圖片資料夾",
"failedToOpen": "開啟範例圖片資料夾失敗",
"copiedPath": "路徑已複製到剪貼簿:{{path}}",
"clipboardFallback": "路徑:{{path}}",
"copiedUri": "連結已複製到剪貼簿:{{uri}}",
"uriClipboardFallback": "連結:{{uri}}",
"setupRequired": "範例圖片儲存",
"setupDescription": "要新增自訂範例圖片,您需要先設定下載位置。",
"setupUsage": "此路徑用於儲存下載的範例圖片和自訂圖片。",
@@ -1884,7 +1910,9 @@
"repairSuccess": "快取重建完成。",
"repairFailed": "快取重建失敗:{message}",
"exportSuccess": "診斷套件已匯出。",
"exportFailed": "匯出診斷套件失敗:{message}"
"exportFailed": "匯出診斷套件失敗:{message}",
"conflictsResolved": "已解決 {count} 個檔案名稱衝突。",
"conflictsResolveFailed": "解決檔案名稱衝突失敗:{message}"
}
},
"banners": {

View File

@@ -560,8 +560,14 @@ class MetadataProcessor:
params["loras"] = " ".join(lora_parts)
# Set default clip_skip value
params["clip_skip"] = "1" # Common default
# Extract clip_skip from any SAMPLING node that provides it
for sampler_info in metadata.get(SAMPLING, {}).values():
clip_skip = sampler_info.get("parameters", {}).get("clip_skip")
if clip_skip is not None:
params["clip_skip"] = clip_skip
break
if params["clip_skip"] is None:
params["clip_skip"] = "1"
return params

View File

@@ -144,6 +144,118 @@ class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
class EasyComfyLoaderExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
if "ckpt_name" in inputs:
_store_checkpoint_metadata(metadata, node_id, inputs["ckpt_name"])
# Only extract from optional_lora_stack — skip the single lora_name to
# avoid double-counting LoRAs that come through the LORA_STACK path.
active_loras = []
optional_lora_stack = inputs.get("optional_lora_stack")
if optional_lora_stack is not None and isinstance(optional_lora_stack, (list, tuple)):
for item in optional_lora_stack:
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": model_strength
})
if active_loras:
metadata[LORAS][node_id] = {
"lora_list": active_loras,
"node_id": node_id
}
positive_text = inputs.get("positive", "")
negative_text = inputs.get("negative", "")
if positive_text or negative_text:
if node_id not in metadata[PROMPTS]:
metadata[PROMPTS][node_id] = {"node_id": node_id}
metadata[PROMPTS][node_id]["positive_text"] = positive_text
metadata[PROMPTS][node_id]["negative_text"] = negative_text
if "clip_skip" in inputs:
clip_skip = inputs["clip_skip"]
if node_id not in metadata[SAMPLING]:
metadata[SAMPLING][node_id] = {"parameters": {}, "node_id": node_id}
metadata[SAMPLING][node_id]["parameters"]["clip_skip"] = clip_skip
width = inputs.get("empty_latent_width")
height = inputs.get("empty_latent_height")
if width is not None and height is not None:
if SIZE not in metadata:
metadata[SIZE] = {}
metadata[SIZE][node_id] = {
"width": int(width),
"height": int(height),
"node_id": node_id
}
@staticmethod
def update(node_id, outputs, metadata):
# outputs: [(pipe_dict, model, vae), ...]
if not outputs or not isinstance(outputs, list) or len(outputs) == 0:
return
first_output = outputs[0]
if not isinstance(first_output, tuple) or len(first_output) < 1:
return
pipe = first_output[0]
if not isinstance(pipe, dict):
return
positive_conditioning = pipe.get("positive")
negative_conditioning = pipe.get("negative")
if positive_conditioning is not None or negative_conditioning is not None:
if node_id not in metadata[PROMPTS]:
metadata[PROMPTS][node_id] = {"node_id": node_id}
if positive_conditioning is not None:
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
if negative_conditioning is not None:
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
class EasyPreSamplingExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
sampling_params = {}
for key in ("steps", "cfg", "sampler_name", "scheduler", "denoise", "seed"):
if key in inputs:
sampling_params[key] = inputs[key]
metadata[SAMPLING][node_id] = {
"parameters": sampling_params,
"node_id": node_id,
IS_SAMPLER: True
}
class EasySeedExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs or "seed" not in inputs:
return
metadata[SAMPLING][node_id] = {
"parameters": {"seed": inputs["seed"]},
"node_id": node_id,
IS_SAMPLER: False
}
class CLIPTextEncodeExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
@@ -1013,9 +1125,12 @@ NODE_EXTRACTORS = {
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
# ComfyUI-Easy-Use pre-sampling / seed
"samplerSettings": EasyPreSamplingExtractor, # easy preSampling
"easySeed": EasySeedExtractor, # easy seed
# Loaders
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
"comfyLoader": EasyComfyLoaderExtractor, # ComfyUI-Easy-Use easy comfyLoader
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku

View File

@@ -1,10 +1,22 @@
import folder_paths # type: ignore
from ..utils.utils import get_lora_info
import os
from ..utils.utils import get_lora_info_absolute
from ..config import config
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
import logging
logger = logging.getLogger(__name__)
def _relpath_within_loras(abs_path):
"""Return abs_path relative to the first matching lora root, or basename as fallback."""
all_roots = list(config.loras_roots or []) + list(config.extra_loras_roots or [])
for root in all_roots:
try:
return os.path.relpath(abs_path, root)
except ValueError:
continue
return os.path.basename(abs_path)
class WanVideoLoraSelectLM:
NAME = "WanVideo Lora Select (LoraManager)"
CATEGORY = "Lora Manager/stackers"
@@ -56,13 +68,13 @@ class WanVideoLoraSelectLM:
clip_strength = float(lora.get('clipStrength', model_strength))
# Get lora path and trigger words
lora_path, trigger_words = get_lora_info(lora_name)
lora_path, trigger_words = get_lora_info_absolute(lora_name)
# Create lora item for WanVideo format
lora_item = {
"path": folder_paths.get_full_path("loras", lora_path),
"path": lora_path,
"strength": model_strength,
"name": lora_path.split(".")[0],
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
"blocks": selected_blocks,
"layer_filter": layer_filter,
"low_mem_load": low_mem_load,

View File

@@ -1,11 +1,23 @@
import folder_paths # type: ignore
from ..utils.utils import get_lora_info
import os
from ..utils.utils import get_lora_info_absolute
from ..config import config
from .utils import any_type
import logging
# 初始化日志记录器
logger = logging.getLogger(__name__)
def _relpath_within_loras(abs_path):
"""Return abs_path relative to the first matching lora root, or basename as fallback."""
all_roots = list(config.loras_roots or []) + list(config.extra_loras_roots or [])
for root in all_roots:
try:
return os.path.relpath(abs_path, root)
except ValueError:
continue
return os.path.basename(abs_path)
# 定义新节点的类
class WanVideoLoraTextSelectLM:
# 节点在UI中显示的名称
@@ -87,12 +99,12 @@ class WanVideoLoraTextSelectLM:
else:
continue
lora_path, trigger_words = get_lora_info(lora_name_raw)
lora_path, trigger_words = get_lora_info_absolute(lora_name_raw)
lora_item = {
"path": folder_paths.get_full_path("loras", lora_path),
"path": lora_path,
"strength": model_strength,
"name": lora_path.split(".")[0],
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
"blocks": selected_blocks,
"layer_filter": layer_filter,
"low_mem_load": low_mem_load,

View File

@@ -251,7 +251,7 @@ class BaseModelRoutes(ABC):
def _find_model_file(self, files):
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
return next((file for file in files if file.get("type") == "Model" and file.get("primary") is True), None)
return next((file for file in files if file.get("type") in ("Model", "Diffusion Model") and file.get("primary") is True), None)
def get_handler(self, name: str) -> Callable[[web.Request], web.StreamResponse]:
"""Expose handlers for subclasses or tests."""

View File

@@ -33,15 +33,18 @@ from ...services.metadata_service import (
update_metadata_providers,
)
from ...services.service_registry import ServiceRegistry
from ...services.model_lifecycle_service import delete_model_artifacts
from ...services.settings_manager import get_settings_manager
from ...services.websocket_manager import ws_manager
from ...services.downloader import get_downloader
from ...services.errors import ResourceNotFoundError
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
from ...utils.models import BaseModelMetadata
from ...utils.constants import (
CIVITAI_USER_MODEL_TYPES,
DEFAULT_NODE_COLOR,
NODE_TYPES,
PREVIEW_EXTENSIONS,
SUPPORTED_MEDIA_EXTENSIONS,
VALID_LORA_TYPES,
)
@@ -617,6 +620,7 @@ class DoctorHandler:
diagnostics = [
await self._check_civitai_api_key(),
await self._check_cache_health(),
await self._check_filename_conflicts(),
self._check_ui_version(client_version, app_version),
]
@@ -681,6 +685,145 @@ class DoctorHandler:
status=status,
)
async def resolve_filename_conflicts(self, request: web.Request) -> web.Response:
renamed: list[dict[str, Any]] = []
try:
for model_type, label, factory in self._scanner_factories:
try:
scanner = await factory()
hash_index = getattr(scanner, "_hash_index", None)
if hash_index is None:
continue
duplicates = {
filename: list(paths)
for filename, paths in hash_index.get_duplicate_filenames().items()
}
if not duplicates:
continue
cache = await scanner.get_cached_data()
path_to_model = {m["file_path"]: m for m in cache.raw_data}
used_basenames: set[str] = set()
for paths in duplicates.values():
if paths:
used_basenames.add(
os.path.splitext(os.path.basename(paths[0]))[0]
)
for filename, paths in duplicates.items():
for idx, path in enumerate(paths):
if idx == 0:
continue
dirname = os.path.dirname(path)
base_name = os.path.splitext(os.path.basename(path))[0]
ext = os.path.splitext(path)[1]
if not ext:
continue
model_data = path_to_model.get(path)
sha256 = (
model_data.get("sha256", "") if model_data else ""
)
hash_provider = (
lambda s=sha256: s if s else "0000"
)
new_filename = (
BaseModelMetadata.generate_unique_filename(
dirname,
base_name,
ext,
hash_provider=hash_provider,
)
)
candidate_base = os.path.splitext(new_filename)[0]
counter = 1
original_base = candidate_base
while candidate_base in used_basenames:
candidate_base = f"{original_base}-{counter}"
new_filename = f"{candidate_base}{ext}"
counter += 1
used_basenames.add(candidate_base)
new_path = os.path.join(dirname, new_filename)
if new_filename == os.path.basename(path):
continue
if not os.path.exists(path):
continue
old_base_no_ext = os.path.splitext(path)[0]
new_base_no_ext = (
os.path.splitext(new_path)[0]
)
os.rename(path, new_path)
for suffix in (".metadata.json", ".civitai.info"):
old_sidecar = old_base_no_ext + suffix
new_sidecar = new_base_no_ext + suffix
if os.path.exists(old_sidecar):
os.rename(old_sidecar, new_sidecar)
for preview_ext in PREVIEW_EXTENSIONS:
old_preview = old_base_no_ext + preview_ext
new_preview = new_base_no_ext + preview_ext
if os.path.exists(old_preview):
os.rename(old_preview, new_preview)
entry = path_to_model.get(path)
if entry:
entry = dict(entry)
entry["file_name"] = os.path.splitext(new_filename)[0]
if entry.get("preview_url"):
old_preview_url = entry["preview_url"].replace("\\", "/")
preview_ext = os.path.splitext(old_preview_url)[1]
if preview_ext:
entry["preview_url"] = (new_base_no_ext + preview_ext).replace(os.sep, "/")
await scanner.update_single_model_cache(
path, new_path, entry
)
logger.info(
"Resolved duplicate filename '%s': "
"renamed '%s' to '%s'",
filename,
path,
new_path,
)
renamed.append({
"model_type": model_type,
"label": label,
"filename": filename,
"old_path": path,
"new_path": new_path,
"new_filename": new_filename,
})
except Exception as exc: # pragma: no cover - defensive
logger.error(
"Failed to resolve filename conflicts for %s: %s",
model_type,
exc,
exc_info=True,
)
return web.json_response({
"success": True,
"renamed": renamed,
"count": len(renamed),
})
except Exception as exc:
logger.error(
"Error resolving filename conflicts: %s", exc, exc_info=True
)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
async def export_doctor_bundle(self, request: web.Request) -> web.Response:
try:
payload = await request.json()
@@ -846,6 +989,79 @@ class DoctorHandler:
"actions": [{"id": "repair-cache", "label": "Rebuild Cache"}],
}
async def _check_filename_conflicts(self) -> dict[str, Any]:
all_conflicts: list[dict[str, Any]] = []
total_conflict_groups = 0
total_conflict_files = 0
for model_type, label, factory in self._scanner_factories:
try:
scanner = await factory()
hash_index = getattr(scanner, "_hash_index", None)
if hash_index is None:
continue
duplicates = hash_index.get_duplicate_filenames()
if not duplicates:
continue
total_conflict_groups += len(duplicates)
for filename, paths in duplicates.items():
total_conflict_files += len(paths)
all_conflicts.append({
"model_type": model_type,
"label": label,
"filename": filename,
"paths": paths,
})
except Exception as exc: # pragma: no cover - defensive
logger.error(
"Doctor filename conflict check failed for %s: %s",
model_type,
exc,
exc_info=True,
)
if not all_conflicts:
return {
"id": "filename_conflicts",
"title": "Duplicate Filename Conflicts",
"status": "ok",
"summary": "No duplicate filenames found across model directories.",
"details": [],
"actions": [],
}
summary = (
f"{total_conflict_groups} filename(s) shared by "
f"{total_conflict_files} files across your library. "
f"This causes ambiguity when loading LoRAs by name."
)
details: list[str | dict[str, Any]] = [
{
"conflict_groups": total_conflict_groups,
"total_conflict_files": total_conflict_files,
}
]
for conflict in all_conflicts:
details.append(
f"[{conflict['label']}] '{conflict['filename']}' "
f"found in {len(conflict['paths'])} locations"
)
return {
"id": "filename_conflicts",
"title": "Duplicate Filename Conflicts",
"status": "warning",
"summary": summary,
"details": details,
"actions": [
{
"id": "resolve-filename-conflicts",
"label": "Resolve Conflicts",
}
],
}
def _check_ui_version(self, client_version: str, app_version: str) -> dict[str, Any]:
if client_version and client_version != app_version:
return {
@@ -1576,15 +1792,19 @@ class ModelLibraryHandler:
exists = True
model_type = "embedding"
if exists:
return web.json_response(
{
"success": True,
"exists": True,
"modelType": model_type,
"hasBeenDownloaded": False,
}
)
history_service = await self._get_download_history_service()
has_been_downloaded = False
history_type = model_type
if history_type:
has_been_downloaded = await history_service.has_been_downloaded(
history_type,
model_version_id,
)
else:
history_type = None
for candidate_type in ("lora", "checkpoint", "embedding"):
if await history_service.has_been_downloaded(
candidate_type,
@@ -1597,8 +1817,8 @@ class ModelLibraryHandler:
return web.json_response(
{
"success": True,
"exists": exists,
"modelType": model_type if exists else history_type,
"exists": False,
"modelType": history_type,
"hasBeenDownloaded": has_been_downloaded,
}
)
@@ -1618,29 +1838,35 @@ class ModelLibraryHandler:
model_type = None
versions = []
downloaded_version_ids = []
history_service = await self._get_download_history_service()
if lora_versions:
model_type = "lora"
versions = self._with_downloaded_flag(lora_versions)
downloaded_version_ids = await history_service.get_downloaded_version_ids(
model_type,
model_id,
return web.json_response(
{
"success": True,
"modelType": "lora",
"versions": self._with_downloaded_flag(lora_versions),
"downloadedVersionIds": [],
}
)
elif checkpoint_versions:
model_type = "checkpoint"
versions = self._with_downloaded_flag(checkpoint_versions)
downloaded_version_ids = await history_service.get_downloaded_version_ids(
model_type,
model_id,
if checkpoint_versions:
return web.json_response(
{
"success": True,
"modelType": "checkpoint",
"versions": self._with_downloaded_flag(checkpoint_versions),
"downloadedVersionIds": [],
}
)
elif embedding_versions:
model_type = "embedding"
versions = self._with_downloaded_flag(embedding_versions)
downloaded_version_ids = await history_service.get_downloaded_version_ids(
model_type,
model_id,
if embedding_versions:
return web.json_response(
{
"success": True,
"modelType": "embedding",
"versions": self._with_downloaded_flag(embedding_versions),
"downloadedVersionIds": [],
}
)
else:
history_service = await self._get_download_history_service()
for candidate_type in ("lora", "checkpoint", "embedding"):
candidate_downloaded_version_ids = (
await history_service.get_downloaded_version_ids(
@@ -1665,6 +1891,86 @@ class ModelLibraryHandler:
logger.error("Failed to check model existence: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def check_models_exist(self, request: web.Request) -> web.Response:
try:
model_ids_raw = request.query.get("modelIds", "")
if not model_ids_raw:
return web.json_response(
{"success": True, "results": []}
)
raw_ids = model_ids_raw.split(",")
seen: set[int] = set()
model_ids: list[int] = []
for raw in raw_ids:
stripped = raw.strip()
if not stripped:
continue
try:
mid = int(stripped)
except ValueError:
continue
if mid not in seen:
seen.add(mid)
model_ids.append(mid)
if not model_ids:
return web.json_response(
{"success": True, "results": []}
)
lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner()
results: list[dict] = []
for model_id in model_ids:
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
if lora_versions:
results.append({
"modelId": model_id,
"modelType": "lora",
"versions": self._with_downloaded_flag(lora_versions),
"downloadedVersionIds": [],
})
continue
if checkpoint_scanner:
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(model_id)
if checkpoint_versions:
results.append({
"modelId": model_id,
"modelType": "checkpoint",
"versions": self._with_downloaded_flag(checkpoint_versions),
"downloadedVersionIds": [],
})
continue
if embedding_scanner:
embedding_versions = await embedding_scanner.get_model_versions_by_id(model_id)
if embedding_versions:
results.append({
"modelId": model_id,
"modelType": "embedding",
"versions": self._with_downloaded_flag(embedding_versions),
"downloadedVersionIds": [],
})
continue
results.append({
"modelId": model_id,
"modelType": None,
"versions": [],
"downloadedVersionIds": [],
})
return web.json_response(
{"success": True, "results": results}
)
except Exception as exc:
logger.error("Failed to check models existence: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_model_version_download_status(
self, request: web.Request
) -> web.Response:
@@ -1777,6 +2083,78 @@ class ModelLibraryHandler:
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def delete_model_version(self, request: web.Request) -> web.Response:
try:
model_version_id_str = request.query.get("modelVersionId")
if not model_version_id_str:
return web.json_response(
{"success": False, "error": "Missing required parameter: modelVersionId"},
status=400,
)
try:
model_version_id = int(model_version_id_str)
except ValueError:
return web.json_response(
{"success": False, "error": "Parameter modelVersionId must be an integer"},
status=400,
)
lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner()
found_type = None
file_path = None
found_cache = None
for model_type, scanner in (
("lora", lora_scanner),
("checkpoint", checkpoint_scanner),
("embedding", embedding_scanner),
):
cache = await scanner.get_cached_data()
if cache and model_version_id in cache.version_index:
found_type = model_type
found_cache = cache
entry = cache.version_index[model_version_id]
file_path = entry.get("file_path")
break
if not file_path:
return web.json_response(
{"success": False, "error": "Model version not found in any scanner cache"},
status=404,
)
target_dir = os.path.dirname(file_path)
base_name = os.path.basename(file_path)
file_name, extension = os.path.splitext(base_name)
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
if found_cache:
found_cache.raw_data = [
item
for item in found_cache.raw_data
if item.get("file_path") != file_path
]
await found_cache.resort()
history_service = await self._get_download_history_service()
await history_service.mark_not_downloaded(found_type, model_version_id)
return web.json_response(
{
"success": True,
"modelType": found_type,
"modelVersionId": model_version_id,
}
)
except Exception as exc:
logger.error(
"Failed to delete model version: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_model_versions_status(self, request: web.Request) -> web.Response:
try:
model_id_str = request.query.get("modelId")
@@ -2796,6 +3174,7 @@ class MiscHandlerSet:
"update_settings": self.settings.update_settings,
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
"repair_doctor_cache": self.doctor.repair_doctor_cache,
"resolve_doctor_filename_conflicts": self.doctor.resolve_filename_conflicts,
"export_doctor_bundle": self.doctor.export_doctor_bundle,
"get_priority_tags": self.settings.get_priority_tags,
"get_settings_libraries": self.settings.get_libraries,
@@ -2809,8 +3188,10 @@ class MiscHandlerSet:
"update_node_widget": self.node_registry.update_node_widget,
"get_registry": self.node_registry.get_registry,
"check_model_exists": self.model_library.check_model_exists,
"check_models_exist": self.model_library.check_models_exist,
"get_model_version_download_status": self.model_library.get_model_version_download_status,
"set_model_version_download_status": self.model_library.set_model_version_download_status,
"delete_model_version": self.model_library.delete_model_version,
"get_civitai_user_models": self.model_library.get_civitai_user_models,
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,

View File

@@ -2423,6 +2423,7 @@ class ModelUpdateHandler:
"shouldIgnore": version.should_ignore,
"earlyAccessEndsAt": version.early_access_ends_at,
"isEarlyAccess": is_early_access,
"usageControl": version.usage_control,
"filePath": context.get("file_path"),
"fileName": context.get("file_name"),
}

View File

@@ -24,6 +24,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"),
RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"),
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
@@ -42,6 +43,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),
RouteDefinition(
"GET",
"/api/lm/model-version-download-status",
@@ -89,6 +91,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
),
RouteDefinition(
"GET", "/api/lm/delete-model-version", "delete_model_version"
),
)

View File

@@ -1364,7 +1364,7 @@ class DownloadManager:
f
for f in files
if f.get("primary")
and f.get("type") in ("Model", "Negative")
and f.get("type") in ("Model", "Negative", "Diffusion Model")
),
None,
)
@@ -1395,7 +1395,7 @@ class DownloadManager:
(
f
for f in files
if f.get("primary") and f.get("type") in ("Model", "Negative")
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model")
),
None,
)

View File

@@ -64,6 +64,7 @@ class DownloadedVersionHistoryService:
self._db_path = db_path or _resolve_database_path()
self._settings = settings_manager or get_settings_manager()
self._lock = asyncio.Lock()
self._conn: sqlite3.Connection | None = None
self._schema_initialized = False
self._ensure_directory()
self._initialize_schema()
@@ -78,6 +79,12 @@ class DownloadedVersionHistoryService:
conn.row_factory = sqlite3.Row
return conn
def _get_conn(self) -> sqlite3.Connection:
if self._conn is None:
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
return self._conn
def _initialize_schema(self) -> None:
if self._schema_initialized:
return
@@ -116,7 +123,7 @@ class DownloadedVersionHistoryService:
timestamp = time.time()
async with self._lock:
with self._connect() as conn:
conn = self._get_conn()
conn.execute(
"""
INSERT INTO downloaded_model_versions (
@@ -180,7 +187,7 @@ class DownloadedVersionHistoryService:
return
async with self._lock:
with self._connect() as conn:
conn = self._get_conn()
conn.executemany(
"""
INSERT INTO downloaded_model_versions (
@@ -208,7 +215,7 @@ class DownloadedVersionHistoryService:
timestamp = time.time()
async with self._lock:
with self._connect() as conn:
conn = self._get_conn()
conn.execute(
"""
INSERT INTO downloaded_model_versions (
@@ -238,7 +245,7 @@ class DownloadedVersionHistoryService:
return False
async with self._lock:
with self._connect() as conn:
conn = self._get_conn()
row = conn.execute(
"""
SELECT is_deleted_override
@@ -258,7 +265,7 @@ class DownloadedVersionHistoryService:
return []
async with self._lock:
with self._connect() as conn:
conn = self._get_conn()
rows = conn.execute(
"""
SELECT version_id
@@ -291,7 +298,7 @@ class DownloadedVersionHistoryService:
params: list[object] = [normalized_type, *normalized_model_ids]
async with self._lock:
with self._connect() as conn:
conn = self._get_conn()
rows = conn.execute(
f"""
SELECT model_id, version_id

View File

@@ -79,6 +79,12 @@ class ModelHashIndex:
hash_val = h
break
if hash_val is None:
for h, paths in self._duplicate_hashes.items():
if file_path in paths:
hash_val = h
break
# If we didn't find a hash, nothing to do
if not hash_val:
return

View File

@@ -1072,14 +1072,6 @@ class ModelScanner:
excluded_models.append(model_data['file_path'])
return None
# Check for duplicate filename before adding to hash index
# filename = os.path.splitext(os.path.basename(file_path))[0]
# existing_hash = hash_index.get_hash_by_filename(filename)
# if existing_hash and existing_hash != model_data.get('sha256', '').lower():
# existing_path = hash_index.get_path(existing_hash)
# if existing_path and existing_path != file_path:
# logger.warning(f"Duplicate filename detected: '{filename}' - files: '{existing_path}' and '{file_path}'")
return model_data
async def _apply_scan_result(self, scan_result: CacheBuildResult) -> None:
@@ -1105,6 +1097,31 @@ class ModelScanner:
await self._cache.resort()
self._log_duplicate_filename_summary()
def _log_duplicate_filename_summary(self) -> None:
"""Log a batched summary of duplicate filename conflicts once per scan."""
if self._hash_index is None:
return
duplicates = self._hash_index.get_duplicate_filenames()
if not duplicates:
return
total_files = sum(len(paths) for paths in duplicates.values())
conflict_count = len(duplicates)
model_type_label = self.model_type or "model"
logger.warning(
"Duplicate filename conflict detected: %d %s filename(s) "
"are shared by %d files total, causing ambiguity in %s resolution. "
"Open the Doctor panel to resolve one-click.",
conflict_count,
model_type_label,
total_files,
model_type_label.capitalize(),
)
async def _sync_download_history(
self,
raw_data: List[Mapping[str, Any]],

View File

@@ -69,6 +69,7 @@ class ModelVersionRecord:
early_access_ends_at: Optional[str] = None
sort_index: int = 0
is_early_access: bool = False
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
@dataclass
@@ -101,11 +102,14 @@ class ModelUpdateRecord:
return [version.version_id for version in self.versions if version.is_in_library]
def has_update(self, hide_early_access: bool = False) -> bool:
def has_update(
self, hide_early_access: bool = False, hide_non_downloadable: bool = True
) -> bool:
"""Return True when a non-ignored remote version newer than the newest local copy is available.
Args:
hide_early_access: If True, exclude early access versions from update check.
hide_non_downloadable: If True, exclude versions that don't allow downloads.
"""
if self.should_ignore_model:
@@ -121,6 +125,7 @@ class ModelUpdateRecord:
not version.is_in_library
and not version.should_ignore
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
and not (hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version))
for version in self.versions
)
@@ -129,6 +134,8 @@ class ModelUpdateRecord:
continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue
if version.version_id > max_in_library:
return True
return False
@@ -155,11 +162,18 @@ class ModelUpdateRecord:
# Phase 1: Basic EA flag from bulk API
return version.is_early_access
@staticmethod
def _is_downloadable(version: ModelVersionRecord) -> bool:
if version.usage_control is None:
return True
return version.usage_control == "Download"
def has_update_for_base(
self,
local_version_id: Optional[int],
local_base_model: Optional[str],
hide_early_access: bool = False,
hide_non_downloadable: bool = True,
) -> bool:
"""Return True when a newer remote version with the same base model exists.
@@ -167,6 +181,7 @@ class ModelUpdateRecord:
local_version_id: The current local version id.
local_base_model: The base model to filter by.
hide_early_access: If True, exclude early access versions from update check.
hide_non_downloadable: If True, exclude versions that don't allow downloads.
"""
if self.should_ignore_model:
@@ -197,6 +212,8 @@ class ModelUpdateRecord:
continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue
version_base = _normalize_base_model(version.base_model)
if version_base != normalized_base:
continue
@@ -209,6 +226,8 @@ class ModelUpdateRecord:
class ModelUpdateService:
"""Persist and query remote model version metadata."""
_SQLITE_MAX_VARIABLES = 500
_SCHEMA = """
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS model_update_status (
@@ -228,6 +247,7 @@ class ModelUpdateService:
preview_url TEXT,
is_in_library INTEGER NOT NULL DEFAULT 0,
should_ignore INTEGER NOT NULL DEFAULT 0,
usage_control TEXT,
PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
);
@@ -463,6 +483,10 @@ class ModelUpdateService:
"ALTER TABLE model_update_versions "
"ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0"
),
"usage_control": (
"ALTER TABLE model_update_versions "
"ADD COLUMN usage_control TEXT"
),
}
for column, statement in migrations.items():
@@ -1335,6 +1359,7 @@ class ModelUpdateService:
# Check availability field from bulk API for basic EA detection
availability = _normalize_string(entry.get("availability"))
is_early_access = availability == "EarlyAccess"
usage_control = _normalize_string(entry.get("usageControl"))
return ModelVersionRecord(
version_id=version_id,
@@ -1348,6 +1373,7 @@ class ModelUpdateService:
early_access_ends_at=early_access_ends_at,
sort_index=index,
is_early_access=is_early_access,
usage_control=usage_control,
)
def _extract_size_bytes(self, files) -> Optional[int]:
@@ -1439,32 +1465,40 @@ class ModelUpdateService:
if not model_ids:
return {}
params = tuple(model_ids)
placeholders = ",".join("?" for _ in params)
ids = list(model_ids)
status_rows: list = []
version_rows: list = []
with self._connect() as conn:
status_rows = conn.execute(
for start in range(0, len(ids), self._SQLITE_MAX_VARIABLES):
chunk = tuple(ids[start : start + self._SQLITE_MAX_VARIABLES])
placeholders = ",".join("?" for _ in chunk)
chunk_status = conn.execute(
f"""
SELECT model_id, model_type, last_checked_at, should_ignore_model
FROM model_update_status
WHERE model_id IN ({placeholders})
""",
params,
chunk,
).fetchall()
if not status_rows:
return {}
status_rows.extend(chunk_status)
version_rows = conn.execute(
chunk_versions = conn.execute(
f"""
SELECT model_id, version_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access
is_early_access, usage_control
FROM model_update_versions
WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC
""",
params,
chunk,
).fetchall()
version_rows.extend(chunk_versions)
if not status_rows:
return {}
versions_by_model: Dict[int, List[ModelVersionRecord]] = {}
for row in version_rows:
@@ -1482,6 +1516,7 @@ class ModelUpdateService:
early_access_ends_at=row["early_access_ends_at"],
sort_index=_normalize_int(row["sort_index"]) or 0,
is_early_access=bool(row["is_early_access"]),
usage_control=row["usage_control"],
)
)
@@ -1538,8 +1573,8 @@ class ModelUpdateService:
INSERT INTO model_update_versions (
version_id, model_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
is_early_access, usage_control
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
version.version_id,
@@ -1554,6 +1589,7 @@ class ModelUpdateService:
1 if version.should_ignore else 0,
version.early_access_ends_at,
1 if version.is_early_access else 0,
version.usage_control,
),
)
conn.commit()

View File

@@ -81,6 +81,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"folder_paths": {},
"extra_folder_paths": {},
"example_images_path": "",
"example_images_open_mode": "system",
"example_images_local_root": "",
"example_images_open_uri_template": "",
"optimize_example_images": True,
"auto_download_example_images": False,
"blur_mature_content": True,
@@ -94,6 +97,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
"model_name_display": "model_name",
"model_card_footer_action": "replace_preview",
"show_version_on_card": True,
"update_flag_strategy": "same_base",
"auto_organize_exclusions": [],
"metadata_refresh_skip_paths": [],

View File

@@ -1,17 +1,81 @@
import logging
import os
import sys
import re
import subprocess
import sys
from urllib.parse import quote
from aiohttp import web
from ..services.settings_manager import get_settings_manager
from ..utils.example_images_paths import (
get_model_folder,
get_model_relative_path,
)
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
logger = logging.getLogger(__name__)
_WINDOWS_DRIVE_PATTERN = re.compile(r"^[A-Za-z]:/")
def _is_within_root(path: str, root: str) -> bool:
try:
return os.path.commonpath([os.path.abspath(path), os.path.abspath(root)]) == os.path.abspath(root)
except ValueError:
return False
def _join_local_example_path(local_root: str, relative_path: str) -> str:
separator = "\\" if "\\" in local_root and "/" not in local_root else "/"
normalized_root = local_root.rstrip("\\/")
normalized_relative = relative_path.replace("/", separator)
if not normalized_root:
return normalized_relative
return f"{normalized_root}{separator}{normalized_relative}"
def _build_file_uri(path: str) -> str:
normalized = path.replace("\\", "/")
if _WINDOWS_DRIVE_PATTERN.match(normalized):
return f"file:///{quote(normalized, safe='/:')}"
if normalized.startswith("/"):
return f"file://{quote(normalized, safe='/:')}"
return f"file:///{quote(normalized.lstrip('/'), safe='/:')}"
def _render_open_uri_template(template: str, local_path: str, relative_path: str) -> str:
file_uri = _build_file_uri(local_path)
replacements = {
"{{local_path}}": local_path,
"{{encoded_local_path}}": quote(local_path, safe=""),
"{{relative_path}}": relative_path,
"{{encoded_relative_path}}": quote(relative_path, safe=""),
"{{file_uri}}": file_uri,
"{{encoded_file_uri}}": quote(file_uri, safe=""),
}
rendered = template
for placeholder, value in replacements.items():
rendered = rendered.replace(placeholder, value)
return rendered
def _open_system_folder(model_folder: str) -> dict[str, object]:
if os.name == "nt": # Windows
os.startfile(model_folder)
elif os.name == "posix": # macOS and Linux
if sys.platform == "darwin": # macOS
subprocess.Popen(["open", model_folder])
else: # Linux
subprocess.Popen(["xdg-open", model_folder])
return {
"success": True,
"message": f"Opened example images folder for {model_folder}",
"path": model_folder,
}
class ExampleImagesFileManager:
"""Manages access and operations for example image files"""
@@ -54,7 +118,7 @@ class ExampleImagesFileManager:
}, status=500)
# Path validation: ensure model_folder is under example_images_path
if not model_folder.startswith(os.path.abspath(example_images_path)):
if not _is_within_root(model_folder, example_images_path):
return web.json_response({
'success': False,
'error': 'Invalid model folder path'
@@ -67,20 +131,40 @@ class ExampleImagesFileManager:
'error': 'No example images found for this model. Download example images first.'
}, status=404)
# Open folder in file explorer
if os.name == 'nt': # Windows
os.startfile(model_folder)
elif os.name == 'posix': # macOS and Linux
if sys.platform == 'darwin': # macOS
subprocess.Popen(['open', model_folder])
else: # Linux
subprocess.Popen(['xdg-open', model_folder])
root_path = os.path.abspath(example_images_path)
relative_path = os.path.relpath(model_folder, root_path).replace("\\", "/")
open_mode = settings_manager.get("example_images_open_mode") or "system"
if open_mode == "clipboard":
local_root = settings_manager.get("example_images_local_root") or root_path
local_path = _join_local_example_path(local_root, relative_path)
return web.json_response({
'success': True,
'message': f'Opened example images folder for model {model_hash}'
'mode': 'clipboard',
'path': local_path,
'relative_path': relative_path,
})
if open_mode == "uri_template":
local_root = settings_manager.get("example_images_local_root") or root_path
uri_template = settings_manager.get("example_images_open_uri_template") or ""
if not uri_template.strip():
return web.json_response({
'success': False,
'error': 'No example image open URI template configured.'
}, status=400)
local_path = _join_local_example_path(local_root, relative_path)
return web.json_response({
'success': True,
'mode': 'uri',
'path': local_path,
'relative_path': relative_path,
'uri': _render_open_uri_template(uri_template, local_path, relative_path),
})
return web.json_response(_open_system_folder(model_folder))
except Exception as e:
logger.error(f"Failed to open example images folder: {e}", exc_info=True)
return web.json_response({
@@ -143,7 +227,7 @@ class ExampleImagesFileManager:
file_ext = os.path.splitext(file)[1].lower()
if (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']):
relative_path = get_model_relative_path(model_hash)
relative_path = os.path.relpath(model_folder, os.path.abspath(example_images_path)).replace("\\", "/")
files.append({
'name': file,
'path': f'/example_images_static/{relative_path}/{file}',

View File

@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""
Migrate metadata from old sidecar JSON format to LoRA Manager's metadata.json format.
This script automatically discovers model folders from LoRA Manager's settings.json,
finds JSON files with the same basename as model files (e.g., `model.json` for
`model.safetensors`), and migrates their content to the corresponding `.metadata.json` files.
Fields migrated:
- "activation text" → civitai.trainedWords (array of trigger words)
- "preferred weight" → usage_tips.strength (LoRA only, skipped for Checkpoint)
- "notes" → notes (user-defined notes)
Supported model types: LoRA, Checkpoint
Usage:
python scripts/migrate_legacy_metadata.py [--dry-run] [--verbose]
The script will:
1. Read settings.json to find all configured model folders
2. Recursively scan for model files (.safetensors, .ckpt, .pt, .pth, .bin)
3. Find corresponding legacy metadata JSON files
4. Migrate data to .metadata.json files
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
APP_NAME = "ComfyUI-LoRA-Manager"
MODEL_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin"}
SECRET_PATTERN = re.compile(r"(key|token|secret|password|auth|credential)", re.IGNORECASE)
def resolve_settings_path() -> Path:
repo_root = Path(__file__).parent.parent.resolve()
portable = repo_root / "settings.json"
if portable.exists():
payload = load_json(portable)
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
return portable
config_home = os.environ.get("XDG_CONFIG_HOME")
if config_home:
return Path(config_home).expanduser() / APP_NAME / "settings.json"
return Path.home() / ".config" / APP_NAME / "settings.json"
def load_json(path: Path) -> dict[str, Any]:
try:
with path.open("r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return {}
except json.JSONDecodeError as exc:
logger.error(f"Invalid JSON in {path}: {exc}")
return {}
except OSError as exc:
logger.error(f"Cannot read {path}: {exc}")
return {}
def expand_path(value: str) -> str:
return str(Path(value).expanduser().resolve(strict=False))
def normalize_path_list(value: Any) -> list[str]:
if isinstance(value, str):
return [expand_path(value)] if value else []
if isinstance(value, list):
return [expand_path(item) for item in value if isinstance(item, str) and item]
return []
def dedupe(values: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for value in values:
if value not in seen:
result.append(value)
seen.add(value)
return result
def get_model_roots(settings: dict[str, Any]) -> dict[str, list[str]]:
roots: dict[str, list[str]] = {}
active_library = settings.get("active_library") or "default"
sources = [settings]
library = settings.get("libraries", {}).get(active_library)
if isinstance(library, dict):
sources.insert(0, library)
for source in sources:
folder_paths = source.get("folder_paths")
if isinstance(folder_paths, dict):
for key, value in folder_paths.items():
roots.setdefault(key, []).extend(normalize_path_list(value))
for default_key, folder_key in (
("default_lora_root", "loras"),
("default_checkpoint_root", "checkpoints"),
("default_embedding_root", "embeddings"),
("default_unet_root", "unet"),
):
value = settings.get(default_key)
if isinstance(value, str) and value:
roots.setdefault(folder_key, []).append(expand_path(value))
return {key: dedupe(values) for key, values in roots.items()}
def find_model_files(directory: Path) -> list[Path]:
model_files = []
for ext in MODEL_EXTENSIONS:
model_files.extend(directory.rglob(f"*{ext}"))
return model_files
def find_legacy_metadata(model_path: Path) -> Path | None:
base_name = model_path.stem
legacy_path = model_path.with_name(f"{base_name}.json")
if legacy_path.exists() and legacy_path.is_file():
return legacy_path
return None
def load_legacy_metadata(legacy_path: Path) -> dict[str, Any] | None:
try:
with open(legacy_path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in legacy file {legacy_path}: {e}")
return None
except Exception as e:
logger.error(f"Error reading legacy file {legacy_path}: {e}")
return None
def load_metadata(metadata_path: Path) -> dict[str, Any]:
if not metadata_path.exists():
return {}
try:
with open(metadata_path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.warning(f"Invalid JSON in metadata file {metadata_path}: {e}. Starting fresh.")
return {}
except Exception as e:
logger.error(f"Error reading metadata file {metadata_path}: {e}")
return {}
def save_metadata(metadata_path: Path, data: dict[str, Any], dry_run: bool = False) -> bool:
if dry_run:
logger.info(f"[DRY RUN] Would save metadata to: {metadata_path}")
return True
temp_path = metadata_path.with_suffix(".tmp")
try:
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
os.replace(temp_path, metadata_path)
return True
except Exception as e:
logger.error(f"Error saving metadata to {metadata_path}: {e}")
if temp_path.exists():
try:
temp_path.unlink()
except:
pass
return False
def migrate_metadata(
legacy_data: dict[str, Any],
existing_metadata: dict[str, Any],
model_type: str
) -> dict[str, Any] | None:
metadata = existing_metadata.copy()
changes_made = False
if "civitai" not in metadata:
metadata["civitai"] = {}
activation_text = legacy_data.get("activation text")
if activation_text and isinstance(activation_text, str):
trigger_words = [
word.strip()
for word in activation_text.replace("\n", ",").split(",")
if word.strip()
]
if trigger_words:
existing_trained = metadata["civitai"].get("trainedWords", [])
if not isinstance(existing_trained, list):
existing_trained = []
merged = list(dict.fromkeys(existing_trained + trigger_words))
if merged != existing_trained:
metadata["civitai"]["trainedWords"] = merged
changes_made = True
logger.debug(f" Migrated activation text: {trigger_words}")
if model_type == "lora":
preferred_weight = legacy_data.get("preferred weight")
if preferred_weight is not None:
try:
weight_value = float(preferred_weight)
usage_tips_str = metadata.get("usage_tips", "{}")
if isinstance(usage_tips_str, str):
try:
usage_tips = json.loads(usage_tips_str)
except json.JSONDecodeError:
usage_tips = {}
elif isinstance(usage_tips_str, dict):
usage_tips = usage_tips_str
else:
usage_tips = {}
if "strength" not in usage_tips:
usage_tips["strength"] = weight_value
metadata["usage_tips"] = json.dumps(usage_tips, ensure_ascii=False)
changes_made = True
logger.debug(f" Migrated preferred weight: {weight_value}")
except (ValueError, TypeError) as e:
logger.warning(f" Could not parse preferred weight '{preferred_weight}': {e}")
else:
if legacy_data.get("preferred weight") is not None:
logger.debug(" Skipping 'preferred weight' for non-LoRA model")
notes = legacy_data.get("notes")
if notes and isinstance(notes, str) and notes.strip():
existing_notes = metadata.get("notes", "")
if not existing_notes:
metadata["notes"] = notes.strip()
changes_made = True
logger.debug(" Migrated notes")
elif notes.strip() not in existing_notes:
metadata["notes"] = f"{existing_notes}\n\n{notes.strip()}".strip()
changes_made = True
logger.debug(" Appended notes")
return metadata if changes_made else None
def process_model(model_path: Path, model_type: str, dry_run: bool = False) -> bool:
legacy_path = find_legacy_metadata(model_path)
if not legacy_path:
return True
logger.info(f"Processing: {model_path.name} ({model_type})")
logger.info(f" Found legacy metadata: {legacy_path.name}")
legacy_data = load_legacy_metadata(legacy_path)
if legacy_data is None:
return False
metadata_path = model_path.with_suffix(".metadata.json")
existing_metadata = load_metadata(metadata_path)
migrated = migrate_metadata(legacy_data, existing_metadata, model_type)
if migrated is None:
logger.info(" No changes needed (fields already exist or no migratable data)")
return True
if save_metadata(metadata_path, migrated, dry_run):
logger.info(f" ✓ Successfully migrated metadata to: {metadata_path.name}")
return True
else:
logger.error(" ✗ Failed to save metadata")
return False
def main() -> int:
parser = argparse.ArgumentParser(
description="Migrate legacy metadata JSON files to LoRA Manager's metadata.json format.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python scripts/migrate_legacy_metadata.py
python scripts/migrate_legacy_metadata.py --dry-run
python scripts/migrate_legacy_metadata.py --verbose
"""
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview changes without modifying any files"
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Enable verbose output"
)
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
settings_path = resolve_settings_path()
logger.info(f"Using settings: {settings_path}")
settings = load_json(settings_path)
if not settings:
logger.error("Could not load settings.json. Please ensure LoRA Manager is configured.")
return 1
roots = get_model_roots(settings)
if not roots:
logger.error("No model folders configured in settings.json.")
return 1
lora_roots = roots.get("loras", [])
checkpoint_roots = roots.get("checkpoints", []) + roots.get("unet", [])
all_roots = []
for root_list in [lora_roots, checkpoint_roots]:
for root in root_list:
path = Path(root)
if path.exists() and path.is_dir():
all_roots.append((path, "lora" if root in lora_roots else "checkpoint"))
if not all_roots:
logger.error("No valid model folders found.")
return 1
logger.info(f"Found {len(lora_roots)} LoRA root(s), {len(checkpoint_roots)} Checkpoint root(s)")
processed = 0
migrated = 0
errors = 0
skipped = 0
lora_count = 0
checkpoint_count = 0
for root_path, model_type in all_roots:
logger.info(f"Scanning: {root_path} ({model_type})")
model_files = find_model_files(root_path)
logger.debug(f" Found {len(model_files)} model files")
for model_path in model_files:
legacy_path = find_legacy_metadata(model_path)
if not legacy_path:
skipped += 1
continue
processed += 1
if process_model(model_path, model_type, dry_run=args.dry_run):
migrated += 1
if model_type == "lora":
lora_count += 1
else:
checkpoint_count += 1
else:
errors += 1
logger.info("\n" + "=" * 50)
logger.info("Migration Summary:")
logger.info(f" Models with legacy metadata: {processed}")
logger.info(f" Successfully migrated: {migrated}")
logger.info(f" - LoRA models: {lora_count}")
logger.info(f" - Checkpoint models: {checkpoint_count}")
logger.info(f" Errors: {errors}")
logger.info(f" Skipped (no legacy file): {skipped}")
if args.dry_run:
logger.info("\n [DRY RUN MODE - No files were modified]")
return 0 if errors == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -15,5 +15,8 @@
"C:/path/to/another/embeddings_folder"
]
},
"example_images_open_mode": "system",
"example_images_local_root": "",
"example_images_open_uri_template": "",
"auto_organize_exclusions": []
}

View File

@@ -87,7 +87,7 @@
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
content: '\f00c';
font-family: 'Font Awesome 6 Free';
font-family: 'Font Awesome 6 Free', sans-serif;
font-weight: 900;
color: var(--lora-text);
font-size: 12px;

View File

@@ -22,6 +22,7 @@
transition: transform 160ms ease-out;
aspect-ratio: 896/1152; /* Preserve aspect ratio */
max-width: 260px; /* Base size */
min-width: 200px; /* Prevent cards from becoming too narrow */
width: 100%;
margin: 0 auto;
cursor: pointer;
@@ -328,7 +329,6 @@
}
.card-actions i {
margin-left: var(--space-1);
cursor: pointer;
color: white;
transition: opacity 0.2s, transform 0.15s ease;
@@ -370,7 +370,16 @@
text-shadow: 0 0 5px rgba(255, 193, 7, 0.5);
}
/* 响应式设计 */
@media (max-width: 1200px) {
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.model-card {
max-width: 240px;
min-width: 180px;
}
}
@media (max-width: 768px) {
.card-grid {
grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */
@@ -378,6 +387,7 @@
.model-card {
max-width: 100%; /* Allow cards to fill available space on mobile */
min-width: 200px;
}
}
@@ -507,6 +517,11 @@
font-size: 0.75em;
}
/* Hide civitai version name when setting is disabled */
body.hide-card-version .civitai-version {
display: none;
}
/* Prevent text selection on cards and interactive elements */
.model-card,
.model-card *,
@@ -558,8 +573,13 @@
position: absolute;
box-sizing: border-box;
transition: transform 160ms ease-out;
margin: 0; /* Remove margins, positioning is handled by VirtualScroller */
width: 100%; /* Allow width to be set by the VirtualScroller */
margin: 0;
width: 100%;
}
/* Allow cards to grow beyond 260px in virtual scroll mode */
.virtual-scroll-item.model-card {
max-width: none;
}
.virtual-scroll-item:hover {
@@ -571,11 +591,11 @@
.card-grid.virtual-scroll {
display: block;
position: relative;
margin: 0 auto;
margin: 0; /* Remove auto margins - positioning handled by VirtualScroller leftOffset */
padding: 4px 0; /* Add top/bottom padding equivalent to card padding */
height: auto;
width: 100%;
max-width: 1400px; /* Keep the max-width from original grid */
max-width: none; /* Remove max-width constraint - handled by VirtualScroller */
box-sizing: border-box; /* Include padding in width calculation */
overflow-x: hidden; /* Prevent horizontal overflow */
}

View File

@@ -22,6 +22,22 @@
gap: 1rem;
}
/* Left section: Logo + Navigation */
.header-left {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
/* Right section: Controls */
.header-right {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
/* Responsive header container for larger screens */
@media (min-width: 2150px) {
.header-container {
@@ -77,6 +93,7 @@
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
white-space: nowrap;
}
.nav-item:hover,
@@ -97,13 +114,99 @@
color: white;
}
/* Header search */
/* Header search - Centered with VS Code command palette style */
.header-search {
flex: 1;
max-width: 400px;
display: flex;
justify-content: center;
max-width: 600px;
margin: 0 auto;
transition: opacity 0.2s ease;
}
/* VS Code command palette style search container */
.header-search .search-container {
width: 100%;
max-width: 600px;
position: relative;
display: flex;
align-items: center;
background: var(--input-bg, var(--card-bg));
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm, 6px);
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.header-search .search-container:focus-within {
border-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px var(--lora-accent);
}
.header-search input {
flex: 1;
width: 100%;
padding: 0.5rem 0.75rem;
padding-left: 2.25rem !important;
padding-right: 5rem !important;
border: none;
background: transparent;
color: var(--text-color);
font-size: 0.95rem;
outline: none;
}
.header-search input::placeholder {
color: var(--text-muted);
}
.header-search .search-icon {
position: absolute;
left: 0.75rem;
color: var(--text-muted);
font-size: 0.9rem;
pointer-events: none;
}
.header-search .search-options-toggle,
.header-search .search-filter-toggle {
position: absolute;
right: 0.5rem;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--border-radius-xs, 4px);
transition: all 0.2s ease;
}
.header-search .search-options-toggle {
right: 2.25rem;
}
.header-search .search-options-toggle:hover,
.header-search .search-filter-toggle:hover {
background: var(--lora-surface-hover, oklch(95% 0.02 256));
color: var(--lora-accent);
}
.header-search .filter-badge {
position: absolute;
top: 2px;
right: 2px;
width: 8px;
height: 8px;
background: var(--lora-accent);
border-radius: 50%;
font-size: 0;
}
/* Disabled state for header search */
.header-search.disabled {
opacity: 0.5;
@@ -247,44 +350,207 @@
opacity: 1;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.app-title {
/* Hamburger menu button - hidden by default */
.hamburger-menu-btn {
display: none;
/* Hide text title on mobile */
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.hamburger-menu-btn:hover {
background: var(--lora-accent);
color: white;
}
/* Hamburger dropdown menu */
.hamburger-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm, 6px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
padding: 0.5rem;
min-width: 160px;
z-index: var(--z-dropdown, 200);
}
.hamburger-dropdown.active {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.hamburger-dropdown .dropdown-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--border-radius-xs, 4px);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem;
white-space: nowrap;
}
.hamburger-dropdown .dropdown-item:hover {
background: var(--lora-surface-hover, oklch(95% 0.02 256));
color: var(--lora-accent);
}
.hamburger-dropdown .dropdown-item i {
width: 20px;
text-align: center;
}
.hamburger-dropdown .dropdown-divider {
height: 1px;
background: var(--border-color);
margin: 0.25rem 0;
}
/* Responsive: Early optimization at 1200px - reduce gaps and padding */
@media (max-width: 1200px) {
.header-container {
gap: 0.75rem;
padding: 0 12px;
}
.main-nav {
gap: 0.25rem;
}
.nav-item {
padding: 0.25rem 0.5rem;
font-size: 0.85rem;
}
.header-controls {
gap: 4px;
gap: 6px;
}
.header-controls > div {
width: 28px;
height: 28px;
width: 30px;
height: 30px;
}
}
/* Responsive: Hide nav icons at 1100px to save space */
@media (max-width: 1100px) {
.nav-item {
gap: 0;
padding: 0.25rem 0.4rem;
}
.nav-item i {
display: none;
}
.header-search {
max-width: 450px;
}
}
@media (max-width: 950px) {
.app-title {
display: none !important;
}
.header-container {
padding: 0 10px;
gap: 0.5rem;
}
.header-controls {
display: none !important;
}
.hamburger-menu-btn {
display: flex !important;
}
.hamburger-dropdown {
display: none;
}
.hamburger-dropdown.active {
display: flex;
}
.header-search {
max-width: none;
margin: 0 0.5rem;
margin: 0;
flex: 1;
min-width: 200px;
}
.main-nav {
margin-right: 0.5rem;
gap: 0.25rem;
margin-right: 0;
}
.nav-item {
padding: 0.25rem 0.35rem;
font-size: 0.8rem;
}
}
/* For very small screens */
/* Responsive: Compact mode at 768px */
@media (max-width: 768px) {
.header-search input {
padding: 0.4rem 0.6rem;
padding-left: 2rem !important;
padding-right: 4.5rem !important;
font-size: 0.9rem;
}
.header-search .search-container {
border-radius: var(--border-radius-xs, 4px);
}
}
/* For very small screens - switch nav to icons only */
@media (max-width: 600px) {
.header-container {
padding: 0 8px;
gap: 0.4rem;
}
.main-nav {
display: none;
/* Hide navigation on very small screens */
display: flex;
gap: 0.15rem;
margin-right: 0;
}
.header-search {
flex: 1;
.nav-item {
padding: 0.25rem;
font-size: 0.75rem;
}
.nav-item span {
display: none;
}
.nav-item i {
display: block;
font-size: 1rem;
}
}
/* Position relative for hamburger menu positioning */
.header-right {
position: relative;
}

View File

@@ -374,6 +374,14 @@
background: color-mix(in oklch, var(--lora-surface) 35%, transparent);
}
.version-action-disabled {
background: transparent;
border-color: var(--border-color);
color: var(--text-muted);
opacity: 0.6;
cursor: not-allowed;
}
.version-action:disabled {
opacity: 0.6;
cursor: not-allowed;

View File

@@ -67,7 +67,6 @@
.early-access-info {
display: none;
position: absolute;
top: 100%;
right: 0;
background: var(--card-bg);
@@ -97,7 +96,6 @@
.local-path {
display: none;
position: absolute;
top: 100%;
right: 0;
background: var(--card-bg);

View File

@@ -271,11 +271,16 @@
/* Enhanced Sidebar Breadcrumb Styles */
.sidebar-breadcrumb-container {
margin-top: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
/* Sticky positioning to stick below header when scrolling
top: 0 means stick at the top of the scroll container (page-content)
which is at header height (48px) from the viewport */
position: sticky;
top: 0;
z-index: calc(var(--z-header) - 1);
}
.sidebar-breadcrumb-nav {
@@ -284,7 +289,6 @@
flex-wrap: wrap;
gap: 4px;
font-size: 0.85em;
padding: 0 8px;
}
.sidebar-breadcrumb-item {

View File

@@ -21,7 +21,7 @@
top: -54px;
z-index: calc(var(--z-header) - 1);
background: var(--bg-color);
padding: var(--space-2) 0;
padding: var(--space-1) 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
@@ -371,6 +371,14 @@
display: block;
}
/* Elevate the controls stacking context above breadcrumb nav when a dropdown is open,
so the dropdown menu isn't obscured. Only active when dropdown is shown to avoid
the entire controls bar (which can wrap to 2 rows on narrow viewports) covering
the sticky breadcrumb. */
.controls:has(.dropdown-group.active) {
z-index: var(--z-header);
}
.dropdown-item {
display: block;
padding: 6px 15px;
@@ -397,6 +405,33 @@
text-align: center;
}
/* Intermediate breakpoint: wrap controls-right to prevent overflow */
@media (max-width: 1500px) {
.actions {
flex-wrap: wrap;
gap: var(--space-2);
}
.action-buttons {
flex-wrap: wrap;
gap: var(--space-1);
}
.controls-right {
width: 100%;
justify-content: flex-end;
margin-top: 8px;
padding-left: 0;
}
/* Reduce button sizes to fit better */
.control-group button {
min-width: 80px;
padding: 4px 8px;
font-size: 0.8em;
}
}
@media (max-width: 768px) {
.actions {
flex-wrap: wrap;

View File

@@ -129,6 +129,126 @@ export class HeaderManager {
// Hide search functionality on Statistics page
this.updateHeaderForPage();
// Initialize hamburger menu for mobile
this.initializeHamburgerMenu();
}
initializeHamburgerMenu() {
const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
const hamburgerDropdown = document.getElementById('hamburgerDropdown');
if (!hamburgerBtn || !hamburgerDropdown) return;
// Toggle dropdown on hamburger button click
hamburgerBtn.addEventListener('click', (e) => {
e.stopPropagation();
hamburgerDropdown.classList.toggle('active');
const icon = hamburgerBtn.querySelector('i');
if (hamburgerDropdown.classList.contains('active')) {
icon.classList.remove('fa-bars');
icon.classList.add('fa-times');
} else {
icon.classList.remove('fa-times');
icon.classList.add('fa-bars');
}
});
// Handle dropdown item clicks
const dropdownItems = hamburgerDropdown.querySelectorAll('.dropdown-item');
dropdownItems.forEach(item => {
item.addEventListener('click', (e) => {
const action = item.dataset.action;
this.handleHamburgerAction(action);
hamburgerDropdown.classList.remove('active');
const icon = hamburgerBtn.querySelector('i');
icon.classList.remove('fa-times');
icon.classList.add('fa-bars');
});
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!hamburgerDropdown.contains(e.target) && !hamburgerBtn.contains(e.target)) {
hamburgerDropdown.classList.remove('active');
const icon = hamburgerBtn.querySelector('i');
if (icon) {
icon.classList.remove('fa-times');
icon.classList.add('fa-bars');
}
}
});
// Update theme icon in hamburger menu based on current theme
this.updateHamburgerThemeIcon();
}
handleHamburgerAction(action) {
switch (action) {
case 'theme':
if (typeof toggleTheme === 'function') {
const newTheme = toggleTheme();
// Update theme toggle in header if it exists
const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) {
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
themeToggle.classList.add(`theme-${newTheme}`);
this.updateThemeTooltip(themeToggle, newTheme);
}
this.updateHamburgerThemeIcon();
}
break;
case 'settings':
if (window.settingsManager) {
window.settingsManager.toggleSettings();
}
break;
case 'help':
const helpToggle = document.getElementById('helpToggleBtn');
if (helpToggle) {
helpToggle.click();
}
break;
case 'notifications':
updateService.toggleUpdateModal();
break;
case 'support':
if (window.modalManager) {
window.modalManager.toggleModal('supportModal');
renderSupporters().catch(error => {
console.error('Error loading supporters:', error);
});
}
break;
}
}
updateHamburgerThemeIcon() {
const themeItem = document.querySelector('.dropdown-item[data-action="theme"]');
if (!themeItem) return;
const currentTheme = getStorageItem('theme') || 'auto';
const icon = themeItem.querySelector('i');
const text = themeItem.querySelector('span');
if (icon) {
icon.classList.remove('fa-moon', 'fa-sun', 'fa-adjust');
if (currentTheme === 'light') {
icon.classList.add('fa-sun');
} else if (currentTheme === 'dark') {
icon.classList.add('fa-moon');
} else {
icon.classList.add('fa-adjust');
}
}
// Update text based on current theme
if (text) {
const key = currentTheme === 'light' ? 'header.theme.switchToDark' :
currentTheme === 'dark' ? 'header.theme.switchToLight' :
'header.theme.toggle';
updateElementAttribute(themeItem, 'aria-label', key, {}, '');
}
}
updateHeaderForPage() {

View File

@@ -645,7 +645,7 @@ export function createModelCard(model, modelType) {
<div class="model-info">
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '&quot;')}">${getDisplayName(model)}</span>
<div>
${model.civitai?.name ? `<span class="version-name">${model.civitai.name}</span>` : ''}
${model.civitai?.name ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : ''}
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
</div>
</div>

View File

@@ -181,6 +181,13 @@ function isEarlyAccessActive(version) {
}
}
function isDownloadAllowed(version) {
if (!version.usageControl) {
return true;
}
return version.usageControl === 'Download';
}
function buildMetaMarkup(version, options = {}) {
const segments = [];
if (version.baseModel) {
@@ -230,12 +237,17 @@ function buildBadge(label, tone, options = {}) {
function buildActionButton(label, variant, action, options = {}) {
const attributes = [
`class="version-action ${variant}"`,
`data-version-action="${escapeHtml(action)}"`,
];
if (action) {
attributes.push(`data-version-action="${escapeHtml(action)}"`);
}
if (options.title) {
attributes.push(`title="${escapeHtml(options.title)}"`);
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
}
if (options.disabled) {
attributes.push('disabled');
}
if (options.extraAttributes) {
attributes.push(options.extraAttributes);
}
@@ -371,6 +383,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
if (hideEarlyAccess && isEarlyAccessActive(version)) {
return false;
}
if (!isDownloadAllowed(version)) {
return false;
}
const versionBase = normalizeBaseModelName(version.baseModel);
if (versionBase !== normalizedBase) {
return false;
@@ -502,6 +517,17 @@ function renderRow(version, options) {
}));
}
if (!isDownloadAllowed(version)) {
const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only');
badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', {
title: translate(
'modals.model.versions.badges.onSiteOnlyTooltip',
{},
'This version is only available for on-site generation on Civitai'
),
}));
}
if (version.shouldIgnore) {
badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
title: translate(
@@ -524,25 +550,36 @@ function renderRow(version, options) {
const actions = [];
if (!version.isInLibrary) {
// Download button with optional EA bolt icon
const canDownload = isDownloadAllowed(version);
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
actions.push(buildActionButton(
downloadLabel,
'version-action-primary',
'download',
{
title: isEarlyAccess
? translate(
let downloadTitle;
if (!canDownload) {
downloadTitle = translate(
'modals.model.versions.actions.downloadNotAllowedTooltip',
{},
'This version is only available for on-site generation on Civitai'
);
} else if (isEarlyAccess) {
downloadTitle = translate(
'modals.model.versions.actions.downloadEarlyAccessTooltip',
{},
'Download this early access version from Civitai'
)
: translate(
);
} else {
downloadTitle = translate(
'modals.model.versions.actions.downloadTooltip',
{},
'Download this version'
),
);
}
actions.push(buildActionButton(
downloadLabel,
canDownload ? 'version-action-primary' : 'version-action-disabled',
canDownload ? 'download' : '',
{
title: downloadTitle,
iconMarkup: downloadIcon,
disabled: !canDownload,
}
));
} else if (version.filePath) {

View File

@@ -2,6 +2,7 @@ import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { escapeHtml } from '../components/shared/utils.js';
import { state } from '../state/index.js';
const MAX_CONSOLE_ENTRIES = 200;
@@ -258,6 +259,15 @@ export class DoctorManager {
}
renderInlineDetail(detail) {
if (detail.conflict_groups || detail.total_conflict_files) {
return `
<div class="doctor-inline-detail">
<strong>${escapeHtml(translate('doctor.status.warning', {}, 'Conflicts'))}</strong>
<div>${escapeHtml(`${detail.conflict_groups || 0} filenames, ${detail.total_conflict_files || 0} files`)}</div>
</div>
`;
}
if (detail.client_version || detail.server_version) {
return `
<div class="doctor-inline-detail">
@@ -317,6 +327,9 @@ export class DoctorManager {
case 'repair-cache':
await this.repairCache();
break;
case 'resolve-filename-conflicts':
await this.resolveFilenameConflicts();
break;
case 'reload-page':
this.reloadUi();
break;
@@ -345,6 +358,47 @@ export class DoctorManager {
}
}
async resolveFilenameConflicts() {
try {
this.setLoading(true);
const response = await fetch('/api/lm/doctor/resolve-filename-conflicts', { method: 'POST' });
const payload = await response.json();
if (!response.ok || payload.success === false) {
throw new Error(payload.error || 'Failed to resolve filename conflicts.');
}
const renamedCount = payload.count || 0;
showToast(
'doctor.toast.conflictsResolved',
{ count: renamedCount },
'success'
);
// Update scroller items so model cards reflect new filenames immediately
if (state.virtualScroller && payload.renamed) {
for (const renamed of payload.renamed) {
const baseName = renamed.new_filename.replace(/\.[^.]+$/, '');
state.virtualScroller.updateSingleItem(renamed.old_path, {
file_name: baseName,
file_path: renamed.new_path,
});
}
}
await this.refreshDiagnostics({ silent: true });
} catch (error) {
console.error('Doctor filename conflict resolution failed:', error);
showToast(
'doctor.toast.conflictsResolveFailed',
{ message: error.message },
'error'
);
} finally {
this.setLoading(false);
}
}
async exportBundle() {
try {
this.setLoading(true);

View File

@@ -599,7 +599,7 @@ export class FilterManager {
// Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true);
await window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
// For models page, reset the page and reload
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
@@ -682,7 +682,7 @@ export class FilterManager {
// Reload data using the appropriate method for the current page
if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true);
await window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
}

View File

@@ -301,7 +301,7 @@ export class SearchManager {
// Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) {
window.recipeManager.loadRecipes(true); // true to reset pagination
window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
// For models page, reset the page and reload
getModelApiClient().loadMoreWithVirtualScroll(true, false);

View File

@@ -879,6 +879,12 @@ export class SettingsManager {
modelCardFooterActionSelect.value = state.global.settings.model_card_footer_action || 'example_images';
}
// Set show version on card
const showVersionOnCardCheckbox = document.getElementById('showVersionOnCard');
if (showVersionOnCardCheckbox) {
showVersionOnCardCheckbox.checked = state.global.settings.show_version_on_card !== false;
}
// Set model name display setting
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
if (modelNameDisplaySelect) {
@@ -914,6 +920,23 @@ export class SettingsManager {
autoDownloadExampleImagesCheckbox.checked = state.global.settings.auto_download_example_images || false;
}
const exampleImagesOpenModeSelect = document.getElementById('exampleImagesOpenMode');
if (exampleImagesOpenModeSelect) {
exampleImagesOpenModeSelect.value = state.global.settings.example_images_open_mode || 'system';
}
const exampleImagesLocalRootInput = document.getElementById('exampleImagesLocalRoot');
if (exampleImagesLocalRootInput) {
exampleImagesLocalRootInput.value = state.global.settings.example_images_local_root || '';
}
const exampleImagesOpenUriTemplateInput = document.getElementById('exampleImagesOpenUriTemplate');
if (exampleImagesOpenUriTemplateInput) {
exampleImagesOpenUriTemplateInput.value = state.global.settings.example_images_open_uri_template || '';
}
this.updateExampleImagesOpenSettingsVisibility();
// Load download path templates
this.loadDownloadPathTemplates();
@@ -2015,6 +2038,25 @@ export class SettingsManager {
}
}
updateExampleImagesOpenSettingsVisibility() {
const openMode = state.global.settings.example_images_open_mode || 'system';
const localRootSetting = document.getElementById('exampleImagesLocalRootSetting');
const uriTemplateSetting = document.getElementById('exampleImagesUriTemplateSetting');
if (localRootSetting) {
localRootSetting.style.display = openMode === 'system' ? 'none' : 'block';
}
if (uriTemplateSetting) {
uriTemplateSetting.style.display = openMode === 'uri_template' ? 'block' : 'none';
}
}
async handleExampleImagesOpenModeChange() {
await this.saveSelectSetting('exampleImagesOpenMode', 'example_images_open_mode');
this.updateExampleImagesOpenSettingsVisibility();
}
async loadMetadataArchiveSettings() {
try {
// Load current settings from state
@@ -2821,7 +2863,7 @@ export class SettingsManager {
await resetAndReload(false);
} else if (this.currentPage === 'recipes') {
// Reload the recipes without updating folders
await window.recipeManager.loadRecipes();
await window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'checkpoints') {
// Reload the checkpoints without updating folders
await resetAndReload(false);
@@ -2854,6 +2896,10 @@ export class SettingsManager {
const cardInfoDisplay = state.global.settings.card_info_display || 'always';
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
// Apply show version on card setting
const showVersionOnCard = state.global.settings.show_version_on_card !== false;
document.body.classList.toggle('hide-card-version', !showVersionOnCard);
const shouldShowSidebar = state.global.settings.show_folder_sidebar !== false;
if (sidebarManager && typeof sidebarManager.setSidebarEnabled === 'function') {
sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {

View File

@@ -19,7 +19,7 @@ class RecipePageControls {
}
async resetAndReload() {
refreshVirtualScroll();
await refreshVirtualScroll({ preserveScroll: true });
}
async refreshModels(fullRebuild = false) {

View File

@@ -25,6 +25,9 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
base_model_path_mappings: {},
download_path_templates: {},
example_images_path: '',
example_images_open_mode: 'system',
example_images_local_root: '',
example_images_open_uri_template: '',
optimize_example_images: true,
auto_download_example_images: false,
blur_mature_content: true,
@@ -35,6 +38,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
show_folder_sidebar: true,
model_name_display: 'model_name',
model_card_footer_action: 'example_images',
show_version_on_card: true,
include_trigger_words: false,
compact_mode: false,
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },

View File

@@ -104,69 +104,74 @@ export class VirtualScroller {
// Get display density setting
const displayDensity = state.global.settings?.display_density || 'default';
// Set exact column counts and grid widths to match CSS container widths
let maxColumns, maxGridWidth;
// Base gap between cards
const baseGap = 12;
this.columnGap = baseGap;
// Match exact column counts and CSS container width values based on density
// Define minimum card width based on density setting to ensure usability
// Cards smaller than this become hard to interact with and view
const minCardWidths = {
'default': 240, // Default: comfortable minimum
'medium': 200, // Medium: slightly smaller
'compact': 170 // Compact: smallest usable size
};
const minCardWidth = minCardWidths[displayDensity] || 240;
// Calculate maximum possible columns that fit in available width
// Formula: maxColumns = floor((availableWidth + gap) / (minCardWidth + gap))
const maxPossibleColumns = Math.floor((availableContentWidth + this.columnGap) / (minCardWidth + this.columnGap));
// Ensure at least 1 column
const maxColumns = Math.max(1, maxPossibleColumns);
// Define preferred maximum columns based on display density and screen size
// These are upper limits to prevent too many columns on ultra-wide screens
let preferredMaxColumns;
if (window.innerWidth >= 3000) { // 4K
if (displayDensity === 'default') {
maxColumns = 8;
preferredMaxColumns = 8;
} else if (displayDensity === 'medium') {
maxColumns = 9;
preferredMaxColumns = 10;
} else { // compact
maxColumns = 10;
preferredMaxColumns = 12;
}
maxGridWidth = 2400; // Match exact CSS container width for 4K
} else if (window.innerWidth >= 2150) { // 2K/1440p
if (displayDensity === 'default') {
maxColumns = 6;
preferredMaxColumns = 6;
} else if (displayDensity === 'medium') {
maxColumns = 7;
preferredMaxColumns = 8;
} else { // compact
maxColumns = 8;
preferredMaxColumns = 10;
}
maxGridWidth = 1800; // Match exact CSS container width for 2K
} else {
// 1080p
} else { // 1080p and smaller
if (displayDensity === 'default') {
maxColumns = 5;
preferredMaxColumns = 5;
} else if (displayDensity === 'medium') {
maxColumns = 6;
preferredMaxColumns = 6;
} else { // compact
maxColumns = 7;
preferredMaxColumns = 8;
}
maxGridWidth = 1400; // Match exact CSS container width for 1080p
}
// Calculate baseCardWidth based on desired column count and available space
// Formula: (maxGridWidth - (columns-1)*gap) / columns
const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns;
// Use the smaller of: max columns that fit, or preferred max
// This ensures cards are never smaller than minCardWidth
this.columnsCount = Math.min(maxColumns, preferredMaxColumns);
// Use the smaller of available content width or max grid width
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth);
// Calculate card width to perfectly fill available space
// Formula: (availableWidth - totalGap) / columns
const totalGap = (this.columnsCount - 1) * this.columnGap;
this.itemWidth = (availableContentWidth - totalGap) / this.columnsCount;
// Set exact column count based on screen size and mode
this.columnsCount = maxColumns;
// When available width is smaller than maxGridWidth, recalculate columns
if (availableContentWidth < maxGridWidth) {
// Calculate how many columns can fit in the available space
this.columnsCount = Math.max(1, Math.floor(
(availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap)
));
}
// Calculate actual item width
this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount;
// Calculate height based on aspect ratio
// Calculate height based on aspect ratio (896/1152)
this.itemHeight = this.itemWidth / this.itemAspectRatio;
// Calculate the left offset to center the grid within the content area
this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2);
// Edge-to-edge layout: no offset, grid fills container
this.leftOffset = 0;
const actualGridWidth = this.itemWidth * this.columnsCount + totalGap;
// Update grid element max-width to match available width
// Update grid element to fill available width
this.gridElement.style.maxWidth = `${actualGridWidth}px`;
this.gridElement.style.width = `${actualGridWidth}px`;
// Add or remove density classes for style adjustments
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
@@ -478,6 +483,12 @@ export class VirtualScroller {
element.style.width = `${this.itemWidth}px`;
element.style.height = `${this.itemHeight}px`;
// Remove max-width constraint from model-card to allow dynamic sizing
const modelCard = element.querySelector('.model-card');
if (modelCard) {
modelCard.style.maxWidth = 'none';
}
return element;
}

View File

@@ -64,6 +64,33 @@ export function openCivitaiUrl(url) {
return window.open(url, '_blank', 'noopener,noreferrer');
}
async function copyExampleImagesValue(value, successKey, fallbackKey, paramsKey = 'path') {
if (!value) {
return false;
}
const params = { [paramsKey]: value };
try {
await navigator.clipboard.writeText(value);
showToast(successKey, params, 'success');
return true;
} catch (clipboardErr) {
console.warn('Clipboard API not available:', clipboardErr);
showToast(fallbackKey, params, 'info');
return false;
}
}
function tryOpenExternalUri(uri) {
try {
const openedWindow = window.open(uri, '_blank', 'noopener,noreferrer');
return openedWindow !== null;
} catch (error) {
console.warn('Failed to open external URI:', error);
return false;
}
}
/**
* Utility function to copy text to clipboard with fallback for older browsers
* @param {string} text - The text to copy to clipboard
@@ -1088,7 +1115,31 @@ export async function openExampleImagesFolder(modelHash) {
const result = await response.json();
if (result.success) {
const message = translate('uiHelpers.exampleImages.openingFolder', {}, 'Opening example images folder');
if (result.mode === 'clipboard' && result.path) {
await copyExampleImagesValue(
result.path,
'uiHelpers.exampleImages.copiedPath',
'uiHelpers.exampleImages.clipboardFallback',
'path'
);
return true;
}
if (result.mode === 'uri' && result.uri) {
const opened = tryOpenExternalUri(result.uri);
if (!opened) {
await copyExampleImagesValue(
result.uri,
'uiHelpers.exampleImages.copiedUri',
'uiHelpers.exampleImages.uriClipboardFallback',
'uri'
);
} else {
showToast('uiHelpers.exampleImages.opened', {}, 'success');
}
return true;
}
showToast('uiHelpers.exampleImages.opened', {}, 'success');
return true;
} else {

View File

@@ -28,6 +28,7 @@
{% block content %}
{% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
{% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %}

View File

@@ -0,0 +1,5 @@
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
<!-- Breadcrumbs will be populated by JavaScript -->
</nav>
</div>

View File

@@ -129,11 +129,4 @@
</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>

View File

@@ -1,5 +1,7 @@
<header class="app-header">
<div class="header-container">
<!-- Left section: Logo + Navigation -->
<div class="header-left">
<div class="header-branding">
<a href="/loras" class="logo-link">
<img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
@@ -18,10 +20,6 @@
{% else %}
{% set current_page = 'loras' %}
{% endif %}
{% set search_disabled = current_page == 'statistics' %}
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
current_page %}
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
<nav class="main-nav">
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
@@ -43,8 +41,13 @@
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
</a>
</nav>
</div>
<!-- Context-aware search container -->
<!-- Center section: Search -->
{% set search_disabled = current_page == 'statistics' %}
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
current_page %}
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
<div class="{{ header_search_class }}" id="headerSearch">
<div class="search-container">
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
@@ -62,9 +65,9 @@
</div>
</div>
<div class="header-actions">
<!-- Integrated corner controls -->
<div class="header-controls">
<!-- Right section: Controls -->
<div class="header-right">
<div class="header-controls" id="headerControls">
<div class="theme-toggle" title="{{ t('header.theme.toggle') }}">
<i class="fas fa-moon dark-icon"></i>
<i class="fas fa-sun light-icon"></i>
@@ -85,6 +88,34 @@
<i class="fas fa-heart"></i>
</div>
</div>
<!-- Hamburger menu button (visible on mobile) -->
<button class="hamburger-menu-btn" id="hamburgerMenuBtn" title="{{ t('common.actions.menu') }}">
<i class="fas fa-bars"></i>
</button>
<!-- Hamburger dropdown menu -->
<div class="hamburger-dropdown" id="hamburgerDropdown">
<div class="dropdown-item theme-toggle-item" data-action="theme">
<i class="fas fa-moon"></i>
<span>{{ t('header.theme.toggle') }}</span>
</div>
<div class="dropdown-item" data-action="settings">
<i class="fas fa-cog"></i>
<span>{{ t('common.actions.settings') }}</span>
</div>
<div class="dropdown-item" data-action="help">
<i class="fas fa-question-circle"></i>
<span>{{ t('common.actions.help') }}</span>
</div>
<div class="dropdown-item" data-action="notifications">
<i class="fas fa-bell"></i>
<span>{{ t('header.actions.notifications') }}</span>
</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item" data-action="support">
<i class="fas fa-heart"></i>
<span>{{ t('header.actions.support') }}</span>
</div>
</div>
</div>
</div>
</header>

View File

@@ -138,6 +138,9 @@
<div class="setting-info">
<label for="downloadBackend">{{ t('settings.downloadBackend.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadBackend.help') }}"></i>
<a class="settings-action-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Aria2-Download-Backend-(Experimental)" target="_blank" rel="noopener" aria-label="{{ t('settings.aria2HelpLink') }}" title="{{ t('settings.aria2HelpLink') }}">
<i class="fas fa-question-circle" aria-hidden="true"></i>
</a>
</div>
<div class="setting-control select-control">
<select id="downloadBackend" onchange="settingsManager.saveSelectSetting('downloadBackend', 'download_backend')">
@@ -551,6 +554,24 @@
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="showVersionOnCard">
{{ t('settings.layoutSettings.showVersionOnCard') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.showVersionOnCardHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="showVersionOnCard"
onchange="settingsManager.saveToggleSetting('showVersionOnCard', 'show_version_on_card')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
@@ -1099,6 +1120,63 @@
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="exampleImagesOpenMode">
{{ t('settings.exampleImages.openMode') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.exampleImages.openModeHelp') }}"></i>
<a class="settings-action-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Remote-Open-for-Example-Images" target="_blank" rel="noopener" title="{{ t('settings.exampleImages.openModeWikiLink') }}">
<i class="fas fa-question-circle" aria-hidden="true"></i>
</a>
</label>
</div>
<div class="setting-control select-control">
<select id="exampleImagesOpenMode" onchange="settingsManager.handleExampleImagesOpenModeChange()">
<option value="system">{{ t('settings.exampleImages.openModeOptions.system') }}</option>
<option value="clipboard">{{ t('settings.exampleImages.openModeOptions.clipboard') }}</option>
<option value="uri_template">{{ t('settings.exampleImages.openModeOptions.uriTemplate') }}</option>
</select>
</div>
</div>
</div>
<div class="setting-item" id="exampleImagesLocalRootSetting" style="display: none;">
<div class="setting-row">
<div class="setting-info">
<label for="exampleImagesLocalRoot">
{{ t('settings.exampleImages.localRoot') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.exampleImages.localRootHelp') }}"></i>
</label>
</div>
<div class="setting-control path-control">
<input
type="text"
id="exampleImagesLocalRoot"
placeholder="{{ t('settings.exampleImages.localRootPlaceholder') }}"
onchange="settingsManager.saveInputSetting('exampleImagesLocalRoot', 'example_images_local_root')" />
</div>
</div>
</div>
<div class="setting-item" id="exampleImagesUriTemplateSetting" style="display: none;">
<div class="setting-row">
<div class="setting-info">
<label for="exampleImagesOpenUriTemplate">
{{ t('settings.exampleImages.uriTemplate') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.exampleImages.uriTemplateHelp') }} {{ t('settings.exampleImages.uriTemplatePlaceholders') }}"></i>
</label>
</div>
<div class="setting-control path-control">
<input
type="text"
id="exampleImagesOpenUriTemplate"
placeholder="{{ t('settings.exampleImages.uriTemplatePlaceholder') }}"
onchange="settingsManager.saveInputSetting('exampleImagesOpenUriTemplate', 'example_images_open_uri_template')" />
</div>
</div>
</div>
</div>
<!-- Auto-organize -->

View File

@@ -26,6 +26,7 @@
{% block content %}
{% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
{% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %}

View File

@@ -9,6 +9,7 @@
{% block content %}
{% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
{% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %}

View File

@@ -340,4 +340,52 @@ describe('SettingsManager library controls', () => {
expect(aria2PathSetting.style.display).toBe('none');
expect(saveSpy).toHaveBeenCalledWith('downloadBackend', 'download_backend');
});
it('loads example image remote-open settings and updates field visibility', async () => {
const manager = createManager();
document.body.innerHTML = `
<select id="exampleImagesOpenMode">
<option value="system">System</option>
<option value="clipboard">Clipboard</option>
<option value="uri_template">URI</option>
</select>
<div id="exampleImagesLocalRootSetting" style="display: none;"></div>
<div id="exampleImagesUriTemplateSetting" style="display: none;"></div>
<input id="exampleImagesLocalRoot" />
<input id="exampleImagesOpenUriTemplate" />
`;
vi.spyOn(manager, 'loadMetadataArchiveSettings').mockResolvedValue();
vi.spyOn(manager, 'loadBackupSettings').mockResolvedValue();
vi.spyOn(manager, 'loadLibraries').mockResolvedValue();
vi.spyOn(manager, 'loadLoraRoots').mockResolvedValue();
vi.spyOn(manager, 'loadCheckpointRoots').mockResolvedValue();
vi.spyOn(manager, 'loadUnetRoots').mockResolvedValue();
vi.spyOn(manager, 'loadEmbeddingRoots').mockResolvedValue();
state.global.settings = {
example_images_open_mode: 'uri_template',
example_images_local_root: '/Volumes/ComfyUI/examples',
example_images_open_uri_template: 'shortcuts://run-shortcut?text={{encoded_local_path}}',
};
await manager.loadSettingsToUI();
expect(document.getElementById('exampleImagesOpenMode').value).toBe('uri_template');
expect(document.getElementById('exampleImagesLocalRoot').value).toBe('/Volumes/ComfyUI/examples');
expect(document.getElementById('exampleImagesOpenUriTemplate').value)
.toBe('shortcuts://run-shortcut?text={{encoded_local_path}}');
expect(document.getElementById('exampleImagesLocalRootSetting').style.display).toBe('block');
expect(document.getElementById('exampleImagesUriTemplateSetting').style.display).toBe('block');
state.global.settings.example_images_open_mode = 'clipboard';
manager.updateExampleImagesOpenSettingsVisibility();
expect(document.getElementById('exampleImagesLocalRootSetting').style.display).toBe('block');
expect(document.getElementById('exampleImagesUriTemplateSetting').style.display).toBe('none');
state.global.settings.example_images_open_mode = 'system';
manager.updateExampleImagesOpenSettingsVisibility();
expect(document.getElementById('exampleImagesLocalRootSetting').style.display).toBe('none');
expect(document.getElementById('exampleImagesUriTemplateSetting').style.display).toBe('none');
});
});

View File

@@ -84,6 +84,8 @@ describe('UI helper DOM utilities', () => {
afterEach(() => {
vi.useRealTimers();
delete global.fetch;
delete navigator.clipboard;
delete window.open;
});
it('creates toast elements and cleans them up after timeout', async () => {
@@ -230,4 +232,49 @@ describe('UI helper DOM utilities', () => {
'noopener,noreferrer'
);
});
it('copies mapped local example-image paths when the backend requests clipboard mode', async () => {
global.fetch = vi.fn().mockResolvedValue({
json: async () => ({
success: true,
mode: 'clipboard',
path: '/Volumes/ComfyUI/examples/demo',
}),
});
navigator.clipboard = {
writeText: vi.fn().mockResolvedValue(),
};
const { openExampleImagesFolder } = await import(UI_HELPERS_MODULE);
const result = await openExampleImagesFolder('abc123');
expect(result).toBe(true);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('/Volumes/ComfyUI/examples/demo');
expect(global.fetch).toHaveBeenCalledWith('/api/lm/open-example-images-folder', expect.objectContaining({
method: 'POST',
}));
});
it('opens custom URIs for example-image folders when requested by the backend', async () => {
global.fetch = vi.fn().mockResolvedValue({
json: async () => ({
success: true,
mode: 'uri',
uri: 'shortcuts://run-shortcut?name=OpenFinder',
}),
});
window.open = vi.fn(() => ({}));
const { openExampleImagesFolder } = await import(UI_HELPERS_MODULE);
const result = await openExampleImagesFolder('abc123');
expect(result).toBe(true);
expect(window.open).toHaveBeenCalledWith(
'shortcuts://run-shortcut?name=OpenFinder',
'_blank',
'noopener,noreferrer'
);
});
});

View File

@@ -10,6 +10,7 @@ from unittest.mock import patch, MagicMock
import pytest
from aiohttp import web
from py.services.model_hash_index import ModelHashIndex
from py.routes.handlers.misc_handlers import (
BackupHandler,
DoctorHandler,
@@ -78,10 +79,11 @@ async def dummy_downloader_factory():
class DummyDoctorScanner:
def __init__(self, *, model_type='lora', raw_data=None, rebuild_error=None):
def __init__(self, *, model_type='lora', raw_data=None, rebuild_error=None, hash_index=None):
self.model_type = model_type
self._raw_data = list(raw_data or [])
self._rebuild_error = rebuild_error
self._hash_index = hash_index
self._persistent_cache = SimpleNamespace(
load_cache=lambda _model_type: SimpleNamespace(raw_data=list(self._raw_data))
)
@@ -91,6 +93,16 @@ class DummyDoctorScanner:
raise self._rebuild_error
return SimpleNamespace(raw_data=list(self._raw_data))
async def update_single_model_cache(self, original_path, new_path, metadata):
for item in self._raw_data:
if item.get("file_path") == original_path:
item["file_path"] = new_path
item["file_name"] = metadata.get("file_name", item.get("file_name", ""))
if metadata.get("preview_url"):
item["preview_url"] = metadata["preview_url"]
break
return True
class DummyCivitaiClient:
def __init__(self, *, success=True, result=None):
@@ -1582,3 +1594,107 @@ def test_wsl_to_windows_path_returns_none_on_subprocess_error(tmp_path):
):
result = _wsl_to_windows_path("/mnt/c/test")
assert result is None
# ── DoctorHandler filename conflict tests ──────────────────────────────
@pytest.mark.asyncio
async def test_check_filename_conflicts_returns_ok_when_no_duplicates():
hash_index = ModelHashIndex()
async def scanner_factory():
return DummyDoctorScanner(
model_type="lora", raw_data=[], hash_index=hash_index
)
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "token"}),
scanner_factories=(("lora", "LoRAs", scanner_factory),),
)
response = await handler.get_doctor_diagnostics(FakeRequest(method="GET"))
payload = json.loads(response.text)
diagnostic_map = {item["id"]: item for item in payload["diagnostics"]}
assert diagnostic_map["filename_conflicts"]["status"] == "ok"
assert "No duplicate filenames" in diagnostic_map["filename_conflicts"]["summary"]
@pytest.mark.asyncio
async def test_check_filename_conflicts_detects_duplicates():
hash_index = ModelHashIndex()
hash_index.add_entry("abc123", "/a/lora.safetensors")
hash_index.add_entry("def456", "/b/lora.safetensors")
async def scanner_factory():
return DummyDoctorScanner(
model_type="lora",
raw_data=[
{"file_path": "/a/lora.safetensors"},
{"file_path": "/b/lora.safetensors"},
],
hash_index=hash_index,
)
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "token"}),
scanner_factories=(("lora", "LoRAs", scanner_factory),),
)
response = await handler.get_doctor_diagnostics(FakeRequest(method="GET"))
payload = json.loads(response.text)
diagnostic_map = {item["id"]: item for item in payload["diagnostics"]}
conflict_diag = diagnostic_map["filename_conflicts"]
assert conflict_diag["status"] == "warning"
assert "1 filename(s)" in conflict_diag["summary"]
assert any("resolve-filename-conflicts" in str(a) for a in conflict_diag.get("actions", []))
@pytest.mark.asyncio
async def test_resolve_filename_conflicts_returns_renamed_list():
hash_index = ModelHashIndex()
hash_index.add_entry("abc123", "lora.safetensors")
hash_index.add_entry("def456", "lora.safetensors")
async def scanner_factory():
return DummyDoctorScanner(
model_type="lora",
raw_data=[],
hash_index=hash_index,
)
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "token"}),
scanner_factories=(("lora", "LoRAs", scanner_factory),),
)
response = await handler.resolve_filename_conflicts(FakeRequest(method="POST"))
payload = json.loads(response.text)
assert payload["success"] is True
# Files don't exist on disk, so nothing gets renamed
assert payload["count"] == 0
@pytest.mark.asyncio
async def test_resolve_filename_conflicts_handles_scanner_error_gracefully():
class ErrorScanner:
model_type = "lora"
async def get_cached_data(self):
raise RuntimeError("scanner unavailable")
async def scanner_factory():
return ErrorScanner()
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "token"}),
scanner_factories=(("lora", "LoRAs", scanner_factory),),
)
response = await handler.resolve_filename_conflicts(FakeRequest(method="POST"))
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["count"] == 0

View File

@@ -0,0 +1,113 @@
import pytest
from py.services.model_hash_index import ModelHashIndex
class TestModelHashIndexRemoveByPath:
def test_remove_by_path_finds_hash_in_hash_to_path(self):
index = ModelHashIndex()
index.add_entry("abc123", "/models/lora.safetensors")
index.remove_by_path("/models/lora.safetensors")
assert len(index) == 0
assert not index.get_duplicate_filenames()
def test_remove_by_path_falls_back_to_duplicate_hashes(self):
"""When a path is only tracked in _duplicate_hashes, remove_by_path
should still find and remove it."""
index = ModelHashIndex()
index.add_entry("abc123", "/models/lora_v1.safetensors")
index.add_entry("abc123", "/models/lora_v2.safetensors")
# lora_v1 is the primary (_hash_to_path), lora_v2 is in _duplicate_hashes
index.remove_by_path("/models/lora_v2.safetensors")
assert len(index) == 1
assert index._hash_to_path.get("abc123") == "/models/lora_v1.safetensors"
assert "abc123" not in index._duplicate_hashes
def test_remove_by_path_cleans_up_duplicate_filenames(self):
"""After removing a path, _duplicate_filenames should be updated."""
index = ModelHashIndex()
index.add_entry("abc123", "/models/mylora.safetensors")
index.add_entry("def456", "/other/mylora.safetensors")
assert "mylora" in index.get_duplicate_filenames()
assert len(index.get_duplicate_filenames()["mylora"]) == 2
index.remove_by_path("/other/mylora.safetensors")
# After removing one duplicate, only one path remains — no longer a duplicate
assert "mylora" not in index.get_duplicate_filenames()
def test_remove_by_path_keeps_duplicate_filenames_with_three_entries(self):
"""With 3 entries for the same filename, removing one should leave 2."""
index = ModelHashIndex()
index.add_entry("abc123", "/models/mylora.safetensors")
index.add_entry("def456", "/other/mylora.safetensors")
index.add_entry("ghi789", "/third/mylora.safetensors")
index.remove_by_path("/other/mylora.safetensors")
assert "mylora" in index.get_duplicate_filenames()
paths = index.get_duplicate_filenames()["mylora"]
assert len(paths) == 2
assert "/other/mylora.safetensors" not in paths
def test_remove_by_path_noop_on_unknown_path(self):
index = ModelHashIndex()
index.add_entry("abc123", "/models/lora.safetensors")
# Should not raise
index.remove_by_path("/nonexistent/lora.safetensors")
assert len(index) == 1
def test_remove_by_path_handles_hash_from_duplicate_hashes_only(self):
"""Remove a path whose hash exists ONLY in _duplicate_hashes,
not in _hash_to_path (edge case from index rebuilds)."""
index = ModelHashIndex()
index.add_entry("abc123", "/a/model.safetensors")
index.add_entry("abc123", "/b/model.safetensors")
# Manually remove the primary entry to simulate edge case
del index._hash_to_path["abc123"]
# Now the path is only referenced in _duplicate_hashes
assert "abc123" in index._duplicate_hashes
index.remove_by_path("/b/model.safetensors")
# The remaining path is promoted to _hash_to_path, duplicates cleared
assert "abc123" not in index._duplicate_hashes
assert index._hash_to_path.get("abc123") == "/a/model.safetensors"
class TestModelHashIndexGetDuplicateFilenames:
def test_empty_index_returns_empty_dict(self):
index = ModelHashIndex()
assert index.get_duplicate_filenames() == {}
def test_no_duplicates_returns_empty_dict(self):
index = ModelHashIndex()
index.add_entry("abc123", "/models/lora.safetensors")
index.add_entry("def456", "/models/other.safetensors")
assert index.get_duplicate_filenames() == {}
def test_duplicate_filenames_detected(self):
index = ModelHashIndex()
index.add_entry("abc123", "/a/mylora.safetensors")
index.add_entry("def456", "/b/mylora.safetensors")
dupes = index.get_duplicate_filenames()
assert "mylora" in dupes
assert len(dupes["mylora"]) == 2
def test_same_hash_same_name_not_a_filename_duplicate(self):
"""Same hash with same filename = hash duplicate, not filename conflict."""
index = ModelHashIndex()
index.add_entry("abc123", "/a/lora.safetensors")
# Same hash, same filename — this is a true duplicate (hash collision)
# but the filename index only tracks different files with same name
# Currently add_entry for same hash+path would update, not create duplicate
# This is correct behavior — filename dupes are for different files
def test_add_entry_idempotent_for_same_path_and_hash(self):
index = ModelHashIndex()
index.add_entry("abc123", "/a/lora.safetensors")
index.add_entry("abc123", "/a/lora.safetensors")
assert len(index) == 1
assert index.get_duplicate_filenames() == {}

View File

@@ -624,3 +624,42 @@ async def test_reconcile_cache_removes_duplicate_alias_when_same_real_file_seen_
cache = await scanner.get_cached_data()
cached_paths = {item["file_path"] for item in cache.raw_data}
assert cached_paths == {_normalize_path(loras_root / "link" / "one.txt")}
@pytest.mark.asyncio
async def test_log_duplicate_filename_summary_logs_warning(tmp_path: Path, caplog):
"""When duplicate filenames exist, _log_duplicate_filename_summary should emit
a single warning log with the conflict count and total file count."""
import logging
caplog.set_level(logging.WARNING)
root = tmp_path / "loras"
root.mkdir()
scanner = DummyScanner(root)
# Simulate duplicate filenames in the hash index
scanner._hash_index.add_entry("aaa111", str(root / "model.safetensors"))
scanner._hash_index.add_entry("bbb222", str(root / "dir" / "model.safetensors"))
scanner._log_duplicate_filename_summary()
assert len(caplog.records) >= 1
log_msg = caplog.records[-1].message
assert "Duplicate filename conflict detected" in log_msg
assert "1 dummy filename(s)" in log_msg
assert "2 files total" in log_msg
@pytest.mark.asyncio
async def test_log_duplicate_filename_summary_silent_when_no_duplicates(tmp_path: Path, caplog):
import logging
caplog.set_level(logging.WARNING)
root = tmp_path / "loras"
root.mkdir()
scanner = DummyScanner(root)
scanner._log_duplicate_filename_summary()
# No warning should be logged when there are no duplicates
for record in caplog.records:
assert "Duplicate filename conflict detected" not in record.message

View File

@@ -442,6 +442,42 @@ async def test_has_updates_bulk_returns_mapping(tmp_path):
assert await service.has_update("lora", 9) is True
@pytest.mark.asyncio
async def test_has_updates_bulk_handles_more_than_sqlite_max_variables(tmp_path):
"""Bulk query with >999 model IDs must not raise 'too many SQL variables'."""
db_path = tmp_path / "updates.sqlite"
service = ModelUpdateService(str(db_path), ttl_seconds=3600)
model_ids = list(range(1, 1201))
with sqlite3.connect(str(db_path)) as conn:
conn.execute("INSERT INTO model_update_status (model_id, model_type) VALUES (?, ?)", (1, "lora"))
conn.execute("INSERT INTO model_update_versions (model_id, version_id, sort_index, name) VALUES (?, ?, ?, ?)", (1, 10, 0, "v1"))
mapping = await service.has_updates_bulk("lora", model_ids)
assert mapping[1] is True
assert len(mapping) == len(model_ids)
assert all(v is False for k, v in mapping.items() if k != 1)
@pytest.mark.asyncio
async def test_get_records_bulk_handles_more_than_sqlite_max_variables(tmp_path):
"""Bulk record fetch with >999 model IDs must not raise 'too many SQL variables'."""
db_path = tmp_path / "updates.sqlite"
service = ModelUpdateService(str(db_path), ttl_seconds=3600)
model_ids = list(range(1, 1201))
with sqlite3.connect(str(db_path)) as conn:
conn.execute("INSERT INTO model_update_status (model_id, model_type) VALUES (?, ?)", (1, "lora"))
conn.execute("INSERT INTO model_update_versions (model_id, version_id, sort_index, name) VALUES (?, ?, ?, ?)", (1, 10, 0, "v1"))
records = await service.get_records_bulk("lora", model_ids)
assert 1 in records
assert records[1].model_id == 1
assert len(records) == 1
@pytest.mark.asyncio
async def test_refresh_allows_duplicate_version_ids_across_models(tmp_path):
db_path = tmp_path / "updates.sqlite"

View File

@@ -66,6 +66,97 @@ async def test_open_folder_requires_existing_model_directory(monkeypatch: pytest
assert model_hash in popen_calls[0][-1]
async def test_open_folder_returns_clipboard_mode_with_mapped_local_path(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
settings_manager = get_settings_manager()
settings_manager.settings["example_images_path"] = str(tmp_path)
settings_manager.settings["example_images_open_mode"] = "clipboard"
settings_manager.settings["example_images_local_root"] = "/Volumes/ComfyUI/examples"
model_hash = "d" * 64
model_folder = tmp_path / "library-a" / model_hash
model_folder.mkdir(parents=True)
(model_folder / "image.png").write_text("data", encoding="utf-8")
popen_calls: list[list[str]] = []
class DummyPopen:
def __init__(self, cmd, *_args, **_kwargs):
popen_calls.append(cmd)
monkeypatch.setattr("subprocess.Popen", DummyPopen)
monkeypatch.setattr("py.utils.example_images_file_manager.get_model_folder", lambda _hash: str(model_folder))
request = JsonRequest({"model_hash": model_hash})
response = await ExampleImagesFileManager.open_folder(request)
body = json.loads(response.text)
assert response.status == 200
assert body == {
"success": True,
"mode": "clipboard",
"path": f"/Volumes/ComfyUI/examples/library-a/{model_hash}",
"relative_path": f"library-a/{model_hash}",
}
assert popen_calls == []
async def test_open_folder_returns_uri_mode_with_rendered_template(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
settings_manager = get_settings_manager()
settings_manager.settings["example_images_path"] = str(tmp_path)
settings_manager.settings["example_images_open_mode"] = "uri_template"
settings_manager.settings["example_images_local_root"] = "/Volumes/ComfyUI/examples"
settings_manager.settings["example_images_open_uri_template"] = (
"shortcuts://run-shortcut?name=OpenFinder&input=text&text={{encoded_local_path}}"
)
model_hash = "e" * 64
model_folder = tmp_path / model_hash
model_folder.mkdir()
(model_folder / "image.png").write_text("data", encoding="utf-8")
popen_calls: list[list[str]] = []
class DummyPopen:
def __init__(self, cmd, *_args, **_kwargs):
popen_calls.append(cmd)
monkeypatch.setattr("subprocess.Popen", DummyPopen)
request = JsonRequest({"model_hash": model_hash})
response = await ExampleImagesFileManager.open_folder(request)
body = json.loads(response.text)
assert response.status == 200
assert body["success"] is True
assert body["mode"] == "uri"
assert body["path"] == f"/Volumes/ComfyUI/examples/{model_hash}"
assert body["relative_path"] == model_hash
assert body["uri"] == (
"shortcuts://run-shortcut?name=OpenFinder&input=text&text="
f"%2FVolumes%2FComfyUI%2Fexamples%2F{model_hash}"
)
assert popen_calls == []
async def test_open_folder_rejects_missing_uri_template(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
settings_manager = get_settings_manager()
settings_manager.settings["example_images_path"] = str(tmp_path)
settings_manager.settings["example_images_open_mode"] = "uri_template"
model_hash = "f" * 64
model_folder = tmp_path / model_hash
model_folder.mkdir()
(model_folder / "image.png").write_text("data", encoding="utf-8")
response = await ExampleImagesFileManager.open_folder(JsonRequest({"model_hash": model_hash}))
body = json.loads(response.text)
assert response.status == 400
assert body["success"] is False
assert body["error"] == "No example image open URI template configured."
async def test_open_folder_rejects_invalid_paths(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
settings_manager = get_settings_manager()
settings_manager.settings["example_images_path"] = str(tmp_path)

View File

@@ -186,8 +186,22 @@ onMounted(() => {
(container as any).__widgetInputEl.inputEl = textareaRef.value
}
// Initialize hasText state
// Apply pending value from setValue if exists (workflow loading before Vue mount)
const pendingValue = (props.widget as any)._pendingValue
if (pendingValue !== undefined) {
textareaRef.value.value = pendingValue
hasText.value = pendingValue.length > 0
delete (props.widget as any)._pendingValue
// Dispatch event to notify autocomplete of value change
textareaRef.value.dispatchEvent(new CustomEvent('lora-manager:autocomplete-value-changed', {
detail: { value: pendingValue }
}))
}
// Initialize hasText state (already done if pendingValue was applied, but safe to re-check)
if (pendingValue === undefined) {
hasText.value = textareaRef.value.value.length > 0
}
// Listen for external value change events from setValue
textareaRef.value.addEventListener('lora-manager:autocomplete-value-changed', onExternalValueChange as EventListener)

View File

@@ -432,7 +432,7 @@ function shouldBypassAutocompleteWidgetMigration(
}
const originalWidgetsInputs = Object.values(inputDefs).filter((input: any) =>
widgetNames.has(input.name)
widgetNames.has(input.name) || input.forceInput
)
const widgetIndexHasForceInput = originalWidgetsInputs.flatMap((input: any) =>
@@ -441,10 +441,12 @@ function shouldBypassAutocompleteWidgetMigration(
: [!!input.forceInput]
)
return (
const result = (
widgetIndexHasForceInput.some(Boolean) &&
widgetIndexHasForceInput.length === widgetValues.length
)
return result
}
function remapWidgetValuesByName(
@@ -459,6 +461,7 @@ function remapWidgetValuesByName(
}
})
const currentWidgetNameSet = new Set(currentWidgetNames)
const remappedValues: unknown[] = []
for (const name of currentWidgetNames) {
if (valueByName.has(name)) {
@@ -466,6 +469,18 @@ function remapWidgetValuesByName(
}
}
// Append values for saved widget names that are NOT in the current widget
// list (e.g. forceInput widgets like "seed" that haven't been converted
// back to DOM widgets yet at configure time). Without these, the
// resulting array may accidentally match the length of ComfyUI's
// widgetIndexHasForceInput array, causing migrateWidgetsValues to
// incorrectly filter out the wrong values and drop real widget content.
for (const name of savedWidgetNames) {
if (!currentWidgetNameSet.has(name) && valueByName.has(name)) {
remappedValues.push(valueByName.get(name))
}
}
return remappedValues
}
@@ -498,6 +513,7 @@ function normalizeAutocompleteWidgetValues(node: any, info: any) {
}
const currentWidgetNames = getSerializableWidgetNames(node)
if (currentWidgetNames.length === 0) {
return
}
@@ -615,6 +631,8 @@ function createAutocompleteTextWidgetFactory(
inputEl.dispatchEvent(new CustomEvent('lora-manager:autocomplete-value-changed', {
detail: { value: v ?? '' }
}))
} else {
;(widget as any)._pendingValue = v ?? ''
}
// Also call onSetValue if defined (for Vue component integration)
if (typeof widget.onSetValue === 'function') {
@@ -751,10 +769,16 @@ app.registerExtension({
nodeType.prototype.configure = function (info: any) {
normalizeAutocompleteWidgetValues(this, info)
if (shouldBypassAutocompleteWidgetMigration(this, info?.widgets_values ?? [])) {
const bypassResult = shouldBypassAutocompleteWidgetMigration(this, info?.widgets_values ?? [])
if (bypassResult) {
info.widgets_values = [...(info.widgets_values ?? []), null]
}
return originalConfigure?.apply(this, arguments)
const result = originalConfigure?.apply(this, arguments)
return result
}
}

View File

@@ -232,9 +232,13 @@ export function initDrag(
onDragEnd();
}
// Now do the re-render after drag is complete
if (renderFunction) {
renderFunction(widget.value, widget);
// Commit final value through options.setValue so external observers are notified.
// During drag, handleStrengthDrag mutates widgetValue in-place (updateWidget=false),
// bypassing widget.value setter and options.setValue entirely. This assignment
// flushes the in-place mutation through the setter so any setValue wrappers fire.
widget.value = widget.value;
if (typeof widget.callback === 'function') {
widget.callback(widget.value);
}
}
};
@@ -349,8 +353,12 @@ export function initHeaderDrag(headerEl, widget, renderFunction) {
document.body.classList.remove('lm-lora-strength-dragging');
// Only re-render if we actually dragged
if (wasDragging && renderFunction) {
renderFunction(widget.value, widget);
if (wasDragging) {
// Commit final value through options.setValue so external observers are notified.
widget.value = widget.value;
if (typeof widget.callback === 'function') {
widget.callback(widget.value);
}
}
};

View File

@@ -413,7 +413,7 @@ app.registerExtension({
const savedItem = consumeQueuedState(itemState, itemText);
return {
text: itemText,
active: savedItem ? savedItem.active : defaultActive,
active: savedItem ? savedItem.active : true,
highlighted: false,
strength: null,
};

View File

@@ -2118,14 +2118,14 @@ to { transform: rotate(360deg);
padding: 20px 0;
}
.autocomplete-text-widget[data-v-76ce0f19] {
.autocomplete-text-widget[data-v-5514bf46] {
background: transparent;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.input-wrapper[data-v-76ce0f19] {
.input-wrapper[data-v-5514bf46] {
position: relative;
flex: 1;
display: flex;
@@ -2133,7 +2133,7 @@ to { transform: rotate(360deg);
}
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
.text-input[data-v-76ce0f19] {
.text-input[data-v-5514bf46] {
flex: 1;
width: 100%;
background-color: var(--comfy-input-bg, #222);
@@ -2150,7 +2150,7 @@ to { transform: rotate(360deg);
}
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
.text-input.vue-dom-mode[data-v-76ce0f19] {
.text-input.vue-dom-mode[data-v-5514bf46] {
background-color: var(--color-charcoal-400, #313235);
color: #fff;
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
@@ -2159,12 +2159,12 @@ to { transform: rotate(360deg);
font-size: 12px;
font-family: inherit;
}
.text-input[data-v-76ce0f19]:focus {
.text-input[data-v-5514bf46]:focus {
outline: none;
}
/* Clear button styles */
.clear-button[data-v-76ce0f19] {
.clear-button[data-v-5514bf46] {
position: absolute;
right: 6px;
bottom: 6px; /* Changed from top to bottom */
@@ -2187,31 +2187,31 @@ to { transform: rotate(360deg);
}
/* Show clear button when hovering over input wrapper */
.input-wrapper:hover .clear-button[data-v-76ce0f19] {
.input-wrapper:hover .clear-button[data-v-5514bf46] {
opacity: 0.7;
pointer-events: auto;
}
.clear-button[data-v-76ce0f19]:hover {
.clear-button[data-v-5514bf46]:hover {
opacity: 1;
background: rgba(255, 100, 100, 0.8);
}
.clear-button svg[data-v-76ce0f19] {
.clear-button svg[data-v-5514bf46] {
width: 12px;
height: 12px;
}
/* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button[data-v-76ce0f19] {
.text-input.vue-dom-mode ~ .clear-button[data-v-5514bf46] {
right: 8px;
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
width: 20px;
height: 20px;
background: rgba(107, 114, 128, 0.6);
}
.text-input.vue-dom-mode ~ .clear-button[data-v-76ce0f19]:hover {
.text-input.vue-dom-mode ~ .clear-button[data-v-5514bf46]:hover {
background: oklch(62% 0.18 25);
}
.text-input.vue-dom-mode ~ .clear-button svg[data-v-76ce0f19] {
.text-input.vue-dom-mode ~ .clear-button svg[data-v-5514bf46] {
width: 14px;
height: 14px;
}`));
@@ -14864,7 +14864,18 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
if (container && container.__widgetInputEl) {
container.__widgetInputEl.inputEl = textareaRef.value;
}
const pendingValue = props.widget._pendingValue;
if (pendingValue !== void 0) {
textareaRef.value.value = pendingValue;
hasText.value = pendingValue.length > 0;
delete props.widget._pendingValue;
textareaRef.value.dispatchEvent(new CustomEvent("lora-manager:autocomplete-value-changed", {
detail: { value: pendingValue }
}));
}
if (pendingValue === void 0) {
hasText.value = textareaRef.value.value.length > 0;
}
textareaRef.value.addEventListener("lora-manager:autocomplete-value-changed", onExternalValueChange);
}
if (textareaRef.value && typeof props.widget.callback === "function") {
@@ -14932,7 +14943,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
};
}
});
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-76ce0f19"]]);
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-5514bf46"]]);
function createVueWidgetCleanup(vueApp, onCleanup) {
let didUnmount = false;
return () => {
@@ -15573,12 +15584,13 @@ function shouldBypassAutocompleteWidgetMigration(node, widgetValues) {
return false;
}
const originalWidgetsInputs = Object.values(inputDefs).filter(
(input) => widgetNames.has(input.name)
(input) => widgetNames.has(input.name) || input.forceInput
);
const widgetIndexHasForceInput = originalWidgetsInputs.flatMap(
(input) => input.control_after_generate ? [!!input.forceInput, false] : [!!input.forceInput]
);
return widgetIndexHasForceInput.some(Boolean) && widgetIndexHasForceInput.length === widgetValues.length;
const result = widgetIndexHasForceInput.some(Boolean) && widgetIndexHasForceInput.length === widgetValues.length;
return result;
}
function remapWidgetValuesByName(widgetValues, savedWidgetNames, currentWidgetNames) {
const valueByName = /* @__PURE__ */ new Map();
@@ -15587,12 +15599,18 @@ function remapWidgetValuesByName(widgetValues, savedWidgetNames, currentWidgetNa
valueByName.set(name, widgetValues[index]);
}
});
const currentWidgetNameSet = new Set(currentWidgetNames);
const remappedValues = [];
for (const name of currentWidgetNames) {
if (valueByName.has(name)) {
remappedValues.push(valueByName.get(name));
}
}
for (const name of savedWidgetNames) {
if (!currentWidgetNameSet.has(name) && valueByName.has(name)) {
remappedValues.push(valueByName.get(name));
}
}
return remappedValues;
}
function injectDefaultAutocompleteMetadataValues(widgetValues, currentWidgetNames) {
@@ -15707,6 +15725,8 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
inputEl.dispatchEvent(new CustomEvent("lora-manager:autocomplete-value-changed", {
detail: { value: v2 ?? "" }
}));
} else {
widget._pendingValue = v2 ?? "";
}
if (typeof widget.onSetValue === "function") {
widget.onSetValue(v2 ?? "");
@@ -15823,10 +15843,12 @@ app$1.registerExtension({
};
nodeType.prototype.configure = function(info) {
normalizeAutocompleteWidgetValues(this, info);
if (shouldBypassAutocompleteWidgetMigration(this, (info == null ? void 0 : info.widgets_values) ?? [])) {
const bypassResult = shouldBypassAutocompleteWidgetMigration(this, (info == null ? void 0 : info.widgets_values) ?? []);
if (bypassResult) {
info.widgets_values = [...info.widgets_values ?? [], null];
}
return originalConfigure == null ? void 0 : originalConfigure.apply(this, arguments);
const result = originalConfigure == null ? void 0 : originalConfigure.apply(this, arguments);
return result;
};
}
if (LORA_CHAIN_NODE_TYPES$1.includes(comfyClass)) {

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 872 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 529 KiB