Compare commits

...

23 Commits

Author SHA1 Message Date
Will Miao
94e1a8ac7b chore(release): bump version to v1.0.7 2026-05-17 20:40:13 +08:00
Will Miao
cc20d3b992 feat(ui): auto-detect HIGH/LOW badges and auto-tag filters (#918)
- Backend auto-tag extraction service: detect HIGH/LOW (Wan-only), I2V/T2V/TI2V,
  Lightning/Turbo from filename, base_model, and CivitAI version name
- HIGH/LOW badge in card footer (inline before version name), color-coded:
  blue for HIGH, teal for LOW; abbreviated to H/L in medium/compact density
- Auto-tag filter panel (I2V, T2V, TI2V, Lightning, Turbo) with tri-state
  include/exclude filtering
- Full filter pipeline: FilterCriteria → ModelFilterSet → baseModelApi params
- AUTO_TAG_GROUPS exported for frontend use
- 19 unit tests for auto-tag extraction edge cases
2026-05-17 17:45:12 +08:00
Will Miao
a74cbe7aa2 fix(test): sync civitai bulk test with nsfw param 2026-05-16 22:15:55 +08:00
Will Miao
94edfaa190 fix(import): discover all resources from CivitAI modelVersionIds
CivitAI image API returns modelVersionIds at the root level of the
response (not inside meta), containing ALL model version IDs across
all resources (checkpoint + LoRAs). Two bugs prevented LoRAs from
being discovered:

1. _download_remote_media only extracted the first modelVersionId for
   enrichment, dropping the rest.
2. CivitAI API meta parsing only ran as an EXIF fallback, but most
   images have embedded EXIF metadata (prompt, steps, etc.), so the
   fallback was never triggered.
3. When civitai_meta_raw itself has a nested 'meta' key, unwrapping
   it stripped the injected modelVersionIds.

Also fixed gen_params merge: API gen_params now overlays EXIF at the
field level instead of full replacement, preserving EXIF-only fields
like detailed generation parameters.
2026-05-16 22:12:30 +08:00
Will Miao
31c54ff068 fix(civitai): add nsfw param to user-models and batch-ids queries (#930)
The CivitAI /api/v1/models endpoint defaults to filtering out NSFW
content when the nsfw query parameter is omitted. Both get_user_models()
and get_model_versions_bulk() hit this endpoint without passing nsfw=true,
causing models whose nsfwLevel doesn't include the PG bit to be silently
dropped from results.

Add nsfw=true to both call sites so all browsing levels are returned.
2026-05-16 20:15:03 +08:00
Will Miao
21872a8e9e fix(ui): default_active in group mode should not propagate to children; hide group badge/edit for single-child groups (#929) 2026-05-16 16:52:06 +08:00
Will Miao
612612f1c7 feat(ui): add Open Source URL action to recipe modal header, align header styles with model modal 2026-05-16 16:11:14 +08:00
Will Miao
ff240db5b1 chore: reduce remote recipe import log verbosity, demote detail fields to debug 2026-05-15 21:04:09 +08:00
Will Miao
bcfed4b874 feat(ui): use recipes terminology in bulk delete confirmation for recipes page
The bulk delete confirmation modal always displayed "models" in its
text (title, message, countMessage) regardless of the current page
type. On the recipes page this is misleading since users are managing
recipes, not models.

- Add bulkDeleteRecipes i18n keys to all 10 locale files
- Update showBulkDeleteModal() to detect currentPageType and use
  recipes-specific wording when on the recipes page
2026-05-15 20:55:02 +08:00
Will Miao
1352c6ecbe fix(recipes): fall back to Civitai API meta when EXIF is empty, enrich checkpoint in analyze_remote_image
- When downloaded Civitai image has no embedded EXIF, parse the
  already-fetched Civitai API meta (resources, hashes) directly
  instead of skipping parser altogether.
- Extract loras and model from parser output to fill metadata gaps
  when the primary import path doesn't provide them.
- Read modelVersionIds[0] as fallback when modelVersionId is None
  (Civitai API returns both but the singular form can be absent).
- Run RecipeEnricher in analyze_remote_image before returning, so
  the LM UI receives complete metadata including checkpoint with
  zero additional API calls (reuses the image_info already fetched).
2026-05-15 20:31:34 +08:00
Will Miao
30b01b8a92 fix(recipes): offload EXIF to thread pool, throttle concurrent imports, eliminate duplicate Civitai API call
- Wrap ExifUtils.extract_image_metadata() with asyncio.to_thread() in
  both import handlers and analysis_service to prevent Pillow/piexif
  from blocking ComfyUI's event loop during batch imports.
- Add asyncio.Semaphore(2) to import_remote_recipe and import_from_url
  endpoints to cap concurrent heavy work and prevent event loop starvation.
- Pre-fetch Civitai image_info during download and pass it to the recipe
  enricher, eliminating a redundant get_image_info() API round-trip.
2026-05-15 18:29:54 +08:00
Will Miao
a105cb322b fix(metadata): prune stale example-image entries when files are deleted on disk (#927) 2026-05-14 20:51:33 +08:00
Will Miao
3bf396d003 feat(recipes): add toggle to strip <lora:> tags when copying prompt/negative_prompt
Adds a compact inline toggle in the Generation Parameters section of the
Recipe Modal that, when enabled, strips <lora:name:weight> tags and
cleans up residual punctuation before copying to clipboard. The setting
persists across sessions via localStorage.
2026-05-13 11:47:02 +08:00
Will Miao
60cfb3b8e0 chore: add .sisyphus/ to .gitignore 2026-05-13 09:30:26 +08:00
Will Miao
6763abb83c fix(test): update test recipes to use source_path instead of source_url
Follow-up to 86118d06 which consolidated on source_path but missed updating these two tests.
2026-05-13 09:27:05 +08:00
Will Miao
5c53968caa refactor(download-history): rename mark_not_downloaded to mark_as_deleted
The method mark_not_downloaded() was misleading — it doesn't negate
'downloaded' history (the model was indeed downloaded before), but
rather sets is_deleted_override = 1 to indicate the version was
downloaded and subsequently deleted. This flag allows re-download when
the 'skip previously downloaded' setting is enabled.

Rename to mark_as_deleted() to accurately reflect its semantics.
2026-05-12 22:50:30 +08:00
Will Miao
b4f7dd75af fix(persistent-cache): persist scanner cache after model deletion
After deleting a model, the in-memory scanner cache was updated but the
SQLite persistent cache was not. On server restart, the stale persistent
cache caused check_model_version_exists() to return True, blocking
re-download with 'Model version already exists'.

Add _persist_current_cache() calls in both deletion paths:
- ModelLifecycleService.delete_model() (used by versions tab delete)
- delete_model_version handler in MiscHandlers
2026-05-12 22:50:10 +08:00
Will Miao
86118d0654 fix(recipes): persist source_path in SQLite cache and eliminate source_url redundancy
- Add source_path column to PersistentRecipeCache SQLite schema with
  migration for existing databases (ALTER TABLE ADD COLUMN)
- Backfill source_path from recipe JSON files on first startup after
  migration to avoid requiring manual cache rebuild
- Remove all source_url recipe field references (import_remote_recipe,
  import_from_url, check_image_exists, enrichment, batch_import)
  and consolidate on source_path as the single source of truth
- Add civitai.green to supported Civitai page hosts
- Register check-image-exists and import-from-url recipe endpoints
2026-05-12 20:39:09 +08:00
Will Miao
df1410535e fix(ui): remove redundant Quick Refresh from Refresh split button dropdown
The main Refresh button and Quick Refresh dropdown item both called refreshModels(false). Split button dropdowns should only contain alternative actions (Hick's Law). Dropdown now has only Rebuild Cache (fullRebuild=true). Removed from 2 templates, 2 JS files, 1 test fixture, and 10 locale files.
2026-05-12 07:50:54 +08:00
Will Miao
75f74d54d8 feat(bulk): reorganize context menu with sections and submenu for workflow actions
Group 15 flat menu items into 5 logical sections (Workflow, Metadata,
Attributes, Organize, Download) with section headers to reduce cognitive
load. Nest the three workflow-related actions (Append, Replace, Copy
Syntax) into a single "Send to Workflow" hover-triggered submenu.

Add submenu infrastructure to BaseContextMenu with mouseover/mouseout
boundary detection, 250ms close delay, and viewport-aware positioning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 21:06:47 +08:00
Will Miao
ab6100f596 feat(bulk): add "Download Example Images" to bulk select context menu (#923)
Allows downloading example images only for selected models instead of
the entire library. Reuses the existing /api/lm/force-download-example-images
endpoint which already accepts an array of model hashes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 18:05:00 +08:00
Will Miao
5d3ab3bbf8 feat(showcase): click-to-view full-size image/video in recipe and model modals (#926)
- Add MediaViewer overlay for full-size image/video display with prev/next
  navigation, direction keys, counter, and adjacent preloading
- Recipe modal: click preview image/video opens full-size viewer
- Model showcase: click any example image/video opens viewer with full
  gallery navigation; blurred NSFW content opens directly to clear view
- Use Map<Element, number> for DOM-index mapping instead of URL comparison
  to avoid index mismatch from lazy-loaded vs data-attribute URLs
2026-05-10 22:22:24 +08:00
Will Miao
d9dc0dba8d perf(startup): load extra model paths during Config init to avoid double symlink scan
Move extra folder path resolution from _initialize_services (app.on_startup)
into Config.__init__ via new _load_extra_paths_from_settings() method.
This eliminates a redundant second symlink scan and consolidates all
'Found roots' / 'Found extra roots' logs into one contiguous block
during custom node import, before the ComfyUI server starts.
2026-05-08 14:55:53 +08:00
67 changed files with 2668 additions and 662 deletions

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@ model_cache/
# agent # agent
.opencode/ .opencode/
.claude/ .claude/
.sisyphus/
.codex .codex
# Vue widgets development cache (but keep build output) # Vue widgets development cache (but keep build output)

View File

@@ -15,215 +15,222 @@
"Phil", "Phil",
"Carl G.", "Carl G.",
"Arlecchino Shion", "Arlecchino Shion",
"stone9k",
"$MetaSamsara", "$MetaSamsara",
"Rob Williams",
"stone9k",
"runte3221",
"Kiba",
"Mozzel",
"itismyelement", "itismyelement",
"Gingko Biloba", "Gingko Biloba",
"onesecondinosaur", "onesecondinosaur",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"Takkan", "Takkan",
"Charles Blakemore", "Charles Blakemore",
"Rob Williams",
"Rosenthal", "Rosenthal",
"ClockDaemon",
"Francisco Tatis", "Francisco Tatis",
"Tobi_Swagg", "Tobi_Swagg",
"SG",
"jmack",
"Andrew Wilson", "Andrew Wilson",
"Greybush", "Greybush",
"iamresist",
"Wolffen",
"Ricky Carter", "Ricky Carter",
"JongWon Han", "JongWon Han",
"VantAI", "VantAI",
"runte3221", "Tim",
"Michael Wong",
"Illrigger", "Illrigger",
"Tom Corrigan",
"JackieWang",
"FreelancerZ", "FreelancerZ",
"fnkylove",
"Echo",
"Lilleman",
"Robert Stacey",
"PM",
"Edgar Tejeda", "Edgar Tejeda",
"Jorge Hussni", "Jorge Hussni",
"Liam MacDougal", "Liam MacDougal",
"Sterilized",
"Fraser Cross", "Fraser Cross",
"Polymorphic Indeterminate", "Polymorphic Indeterminate",
"Marc Whiffen", "Marc Whiffen",
"Birdy", "Birdy",
"Skalabananen", "Skalabananen",
"Kiba", "quarz",
"Reno Lam", "Reno Lam",
"Mozzel", "JSST",
"sig", "sig",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"J\\B/ 8r0wns0n", "J\\B/ 8r0wns0n",
"Snaggwort", "Snaggwort",
"ClockDaemon", "Baekdoosixt",
"Jonathan Ross", "Jonathan Ross",
"KD", "KD",
"Omnidex", "Omnidex",
"Nazono_hito", "Nazono_hito",
"Melville Parrish",
"daniel dove",
"Lustre",
"Tyler Trebuchon", "Tyler Trebuchon",
"Release Cabrakan", "Release Cabrakan",
"JW Sin",
"contrite831", "contrite831",
"SG", "Alex",
"carozzz", "carozzz",
"Marlon Daniels",
"James Dooley", "James Dooley",
"zenbound", "zenbound",
"Buzzard", "Buzzard",
"jmack",
"Adam Shaw", "Adam Shaw",
"Mark Corneglio", "Mark Corneglio",
"SarcasticHashtag", "SarcasticHashtag",
"Anthony Rizzo", "Anthony Rizzo",
"iamresist",
"Gooohokrbe", "Gooohokrbe",
"RedrockVP", "RedrockVP",
"Wolffen",
"James Todd", "James Todd",
"ASLPro3D",
"OldBones", "OldBones",
"FinalyFree",
"Steven Pfeiffer", "Steven Pfeiffer",
"Tim",
"Timmy", "Timmy",
"Johnny", "Johnny",
"Lisster",
"Michael Wong",
"whudunit",
"Tom Corrigan",
"dl0901dm",
"JackieWang",
"fnkylove",
"Yushio",
"Vik71it",
"Echo",
"Lilleman",
"Robert Stacey",
"PM",
"Todd Keck",
"Briton Heilbrun",
"Aleksander Wujczyk",
"BadassArabianMofo",
"Sterilized",
"Pascal Dahle",
"quarz",
"Penfore",
"Greg",
"JSST",
"lmsupporter",
"zounic",
"wfpearl",
"Baekdoosixt",
"Jack B Nimble",
"Melville Parrish",
"daniel dove",
"Lustre",
"JW Sin",
"Alex",
"bh",
"Marlon Daniels",
"Starkselle",
"Aaron Bleuer",
"LacesOut!",
"greebles",
"Cosmosis",
"M Postkasse",
"FloPro4Sho",
"ASLPro3D",
"Jacob Hoehler",
"FinalyFree",
"Weasyl",
"Lex Song",
"Cory Paza",
"Tak", "Tak",
"Gonzalo Andre Allendes Lopez", "Lisster",
"Zach Gonser", "Zach Gonser",
"Big Red", "Big Red",
"Jimmy Ledbetter", "whudunit",
"Luc Job", "Luc Job",
"Philip Hempel", "dl0901dm",
"corde", "corde",
"Nick Walker", "Nick Walker",
"Julian V", "Yushio",
"Steven Owens", "Vik71it",
"Bishoujoker", "Bishoujoker",
"aai", "Todd Keck",
"Briton Heilbrun",
"Tori", "Tori",
"wildnut", "wildnut",
"jean jahren", "jean jahren",
"Aleksander Wujczyk",
"AM Kuro", "AM Kuro",
"ViperC", "BadassArabianMofo",
"Ran C", "Pascal Dahle",
"Sangheili460", "Penfore",
"Greg",
"MagnaInsomnia", "MagnaInsomnia",
"Karl P.",
"Akira_HentAI", "Akira_HentAI",
"Gordon Cole", "Gordon Cole",
"yuxz69", "AbstractAss",
"esthe", "lmsupporter",
"andrew.tappan", "andrew.tappan",
"N/A", "N/A",
"Greenmoustache",
"zounic",
"wfpearl",
"Eldithor",
"Jack B Nimble",
"JaxMax",
"bh",
"Jwk0205",
"Starkselle",
"Olive",
"Aaron Bleuer",
"LacesOut!",
"greebles",
"Some Guy Named Barry",
"Cosmosis",
"M Postkasse",
"FloPro4Sho",
"wamekukyouzin",
"Jacob Hoehler",
"Matt Wenzel",
"Weasyl",
"Lex Song",
"Cory Paza",
"Gonzalo Andre Allendes Lopez",
"Serge Bekenkamp",
"Jimmy Ledbetter",
"Philip Hempel",
"ApathyJones",
"Julian V",
"Steven Owens",
"dan",
"aai",
"Mouthlessman",
"otaku fra",
"ViperC",
"Ran C",
"MiraiKuriyamaSy",
"Sangheili460",
"Karl P.",
"yuxz69",
"Adam Taylor",
"Weird_With_A_Beard",
"esthe",
"The Spawn", "The Spawn",
"graysock", "graysock",
"Pozadine1", "Pozadine1",
"Greenmoustache",
"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",
"AELOX",
"Dankin",
"Nicfit23",
"wamekukyouzin",
"drum matthieu",
"Dogmaster",
"Matt Wenzel",
"Frank Nitty",
"Pronredn",
"Christopher Michel",
"Serge Bekenkamp",
"DougPeterson",
"LeoZero",
"Antonio Pontes",
"ApathyJones",
"nahinahi9",
"lh qwe",
"Kevin John Duck",
"conner",
"Dustin Chen",
"dan",
"Blackfish95",
"Mouthlessman",
"Princess Bright Eyes",
"Paul Kroll",
"AbstractAss",
"otaku fra",
"Felipe dos Santos",
"Bas Imagineer",
"Markus",
"MiraiKuriyamaSy",
"Adam Taylor",
"Douglas Gaspar",
"Weird_With_A_Beard",
"AlexDuKaNa",
"George",
"dw",
"Qarob", "Qarob",
"AIGooner", "AIGooner",
"Luc", "Luc",
"ProtonPrince", "ProtonPrince",
"DiffDuck", "DiffDuck",
"fancypants",
"IamAyam",
"Joboshy",
"Digital",
"takyamtom",
"Bohemian Corporal",
"Dan",
"confiscated Zyra",
"Bro Xie",
"yer fey",
"batblue",
"carey6409",
"太郎 ゲーム",
"Roslynd",
"Tee Gee",
"jinxedx",
"tarek helmi",
"Neco28",
"Max Marklund",
"AELOX",
"Dankin",
"Nicfit23",
"Cristian Vazquez",
"drum matthieu",
"Dogmaster",
"Frank Nitty",
"Magic Noob",
"Pronredn",
"Christopher Michel",
"DougPeterson",
"LeoZero",
"Antonio Pontes",
"Bruce",
"nahinahi9",
"lh qwe",
"Kevin John Duck",
"conner",
"Dustin Chen",
"Blackfish95",
"Princess Bright Eyes",
"Paul Kroll",
"Felipe dos Santos",
"Bas Imagineer",
"Markus",
"John Statham",
"Douglas Gaspar",
"AlexDuKaNa",
"George",
"dw",
"decoy",
"elu3199", "elu3199",
"Hasturkun", "Hasturkun",
"Jon Sandman", "Jon Sandman",
@@ -233,56 +240,59 @@
"wundershark", "wundershark",
"mr_dinosaur", "mr_dinosaur",
"Tyrswood", "Tyrswood",
"Ray Wing",
"Ranzitho",
"Gus",
"MJG",
"David LaVallee",
"linnfrey", "linnfrey",
"Pkrsky",
"奚明 刘", "奚明 刘",
"Josef Lanzl", "Josef Lanzl",
"Nerezza", "Nerezza",
"sanborondon",
"Griffin Dahlberg", "Griffin Dahlberg",
"준희 김", "준희 김",
"Error_Rule34_Not_found", "Error_Rule34_Not_found",
"Taylor Funk",
"aezin",
"jcay015",
"Gerald Welly", "Gerald Welly",
"Roslynd", "Erik Lopez",
"Mateo Curić",
"Geolog", "Geolog",
"Neco28", "Eris3D",
"Tomohiro Baba", "Tomohiro Baba",
"David Ortega", "David Ortega",
"Noora", "Noora",
"Cristian Vazquez",
"Mattssn", "Mattssn",
"Magic Noob", "a _",
"Jeff", "Jeff",
"Bruce", "James Coleman",
"Kevin Christopher", "Kevin Christopher",
"Emil Andersson",
"Ouro Boros", "Ouro Boros",
"Chad Idk", "Chad Idk",
"Yaboi",
"dd", "dd",
"Steam Steam", "Steam Steam",
"CryptoTraderJK", "CryptoTraderJK",
"Davaitamin", "Davaitamin",
"Dušan Ryban", "Dušan Ryban",
"tedcor", "tedcor",
"Sam",
"Fotek Design", "Fotek Design",
"sjon kreutz", "sjon kreutz",
"John Statham",
"MadSpin", "MadSpin",
"Metryman55", "Metryman55",
"inbijiburu", "inbijiburu",
"decoy",
"Nick “Loadstone” D", "Nick “Loadstone” D",
"Ray Wing",
"Ranzitho",
"Gus",
"地獄の禄", "地獄の禄",
"MJG",
"David LaVallee",
"ae", "ae",
"Tr4shP4nda", "Tr4shP4nda",
"Gamalonia", "Gamalonia",
"WRL_SPR", "WRL_SPR",
"capn", "capn",
"Joseph", "Joseph",
"momokai",
"Mirko Katzula", "Mirko Katzula",
"dan", "dan",
"Piccio08", "Piccio08",
@@ -296,54 +306,57 @@
"kudari", "kudari",
"Naomi Hale Danchi", "Naomi Hale Danchi",
"dc7431", "dc7431",
"epicgamer0020690",
"Joshua Porrata",
"SuBu",
"RedPIXel",
"Vir", "Vir",
"Richard",
"Andrew",
"Brian M", "Brian M",
"sanborondon", "Robert Wegemund",
"Seth Christensen", "Littlehuggy",
"Draven T", "Draven T",
"Taylor Funk", "mrjuan",
"aezin", "Brian Buie",
"Thought2Form", "Thought2Form",
"jcay015",
"Kevin Picco", "Kevin Picco",
"Erik Lopez", "Sadlip",
"Mateo Curić",
"Aquatic Coffee", "Aquatic Coffee",
"Eris3D",
"m", "m",
"ethanfel", "ethanfel",
"Pierce McBride", "Pierce McBride",
"Joshua Gray", "Joshua Gray",
"Focuschannel", "Focuschannel",
"Mikko Hemilä", "Mikko Hemilä",
"Jacob McDaniel",
"Jamie Ogletree", "Jamie Ogletree",
"a _", "Temikus",
"James Coleman", "Artokun",
"Michael Taylor",
"Derek Baker",
"Martial", "Martial",
"Anthony Faxlandez", "Anthony Faxlandez",
"battu", "battu",
"Emil Andersson", "Michael Anthony Scott",
"Atilla Berke Pekduyar",
"Decx _",
"Yuji Kaneko", "Yuji Kaneko",
"Pat Hen", "Pat Hen",
"semicolon drainpipe",
"Jordan Shaw", "Jordan Shaw",
"Rops Alot", "Rops Alot",
"Thesharingbrother", "Thesharingbrother",
"Sam",
"Ace Ventura", "Ace Ventura",
"ResidentDeviant", "ResidentDeviant",
"四糸凜音",
"Nihongasuki", "Nihongasuki",
"JC", "JC",
"Prompt Pirate", "Prompt Pirate",
"uwutismxd", "uwutismxd",
"momokai",
"zenobeus", "zenobeus",
"ken", "ken",
"epicgamer0020690", "Crocket",
"Joshua Porrata",
"keemun", "keemun",
"SuBu",
"RedPIXel",
"Wind", "Wind",
"Jackthemind", "Jackthemind",
"Nexus", "Nexus",
@@ -362,21 +375,26 @@
"socrasteeze", "socrasteeze",
"OrganicArtifact", "OrganicArtifact",
"Stryker", "Stryker",
"ResidentDeviant",
"MudkipMedkitz", "MudkipMedkitz",
"deanbrian",
"Alex Wortman",
"Cody",
"smart.edge5178",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Menard",
"Skyfire83",
"Adam Rinehart",
"gzmzmvp", "gzmzmvp",
"raf8osz", "raf8osz",
"ElitaSSJ4", "ElitaSSJ4",
"Richard",
"blikkies", "blikkies",
"Andrew",
"Chris", "Chris",
"Robert Wegemund",
"Littlehuggy",
"Gregory Kozhemiak", "Gregory Kozhemiak",
"mrjuan",
"Brian Buie",
"Shock Shockor", "Shock Shockor",
"Sadlip",
"Goldwaters", "Goldwaters",
"Eric Whitney", "Eric Whitney",
"Joey Callahan", "Joey Callahan",
@@ -390,30 +408,20 @@
"Theerat Jiramate", "Theerat Jiramate",
"aRtFuL_DodGeR", "aRtFuL_DodGeR",
"Noah", "Noah",
"Jacob McDaniel",
"X", "X",
"Sloan Steddy", "Sloan Steddy",
"Temikus", "hexxish",
"Artokun",
"Michael Taylor",
"Derek Baker",
"CrimsonDX",
"Michael Anthony Scott",
"DarkSunset", "DarkSunset",
"Atilla Berke Pekduyar",
"Nathan", "Nathan",
"Billy Gladky", "Billy Gladky",
"NICHOLAS BAXLEY", "NICHOLAS BAXLEY",
"Decx _",
"Probis", "Probis",
"Ed Wang", "Ed Wang",
"ItsGeneralButtNaked", "ItsGeneralButtNaked",
"Nimess",
"SRDB", "SRDB",
"g unit", "g unit",
"Distortik", "Distortik",
"Youguang", "Youguang",
"四糸凜音",
"Saya", "Saya",
"andrewzpong", "andrewzpong",
"FrxzenSnxw", "FrxzenSnxw",
@@ -421,40 +429,38 @@
"lrdchs", "lrdchs",
"Tree Tagger", "Tree Tagger",
"Inversity", "Inversity",
"Crocket",
"AIVORY3D", "AIVORY3D",
"Kevinj", "Kevinj",
"Mitchell Robson", "Mitchell Robson",
"Whitepinetrader", "Whitepinetrader",
"ResidentDeviant",
"deanbrian",
"POPPIN", "POPPIN",
"Alex Wortman", "Ginnie",
"Cody",
"Raku", "Raku",
"smart.edge5178", "emadsultan",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Menard",
"Skyfire83",
"Adam Rinehart",
"Pitpe11", "Pitpe11",
"TheD1rtyD03", "TheD1rtyD03",
"moonpetal", "moonpetal",
"SomeDude", "SomeDude",
"g9p0o", "g9p0o",
"Pkrsky",
"TheHolySheep", "TheHolySheep",
"Monte Won", "Monte Won",
"SpringBootisTrash", "SpringBootisTrash",
"carsten", "carsten",
"ikok", "ikok",
"quantenmecha",
"Jason+Nash",
"BillyBoy84",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"lrdchs2",
"Duk3+Rand0m",
"Nathen+Choi", "Nathen+Choi",
"T", "T",
"LarsesFPC", "LarsesFPC",
"cocona", "cocona",
"sfasdfasfdsa",
"Buecyb99", "Buecyb99",
"Welkor", "Welkor",
"David Schenck", "David Schenck",
@@ -463,15 +469,15 @@
"Ink Temptation", "Ink Temptation",
"moranqianlong", "moranqianlong",
"Kalli Core", "Kalli Core",
"Time Valentine",
"elleshar666", "elleshar666",
"ACTUALLY_the_Real_Willem_Dafoe", "ACTUALLY_the_Real_Willem_Dafoe",
"Haru Yotu", "Михал Михалыч",
"Matt",
"Kauffy", "Kauffy",
"EpicElric",
"Kyron Mahan", "Kyron Mahan",
"Edward Kennedy", "Edward Kennedy",
"Justin Blaylock", "Justin Blaylock",
"Matura Arbeit",
"Nick Kage", "Nick Kage",
"TBitz33", "TBitz33",
"Anonym dkjglfleeoeldldldlkf", "Anonym dkjglfleeoeldldldlkf",
@@ -480,12 +486,14 @@
"Cyrus Fett", "Cyrus Fett",
"Ezokewn", "Ezokewn",
"SendingRavens", "SendingRavens",
"hexxish", "Xenon Xue",
"notedfakes", "notedfakes",
"Michael Docherty", "Michael Docherty",
"Michael Scott", "Michael Scott",
"Paul Hartsuyker", "Paul Hartsuyker",
"Henrique Faiolli",
"elitassj", "elitassj",
"Solixer",
"Jacob Winter", "Jacob Winter",
"Ryan Presley Ng", "Ryan Presley Ng",
"Wes Sims", "Wes Sims",
@@ -494,7 +502,6 @@
"David", "David",
"Meilo", "Meilo",
"Filippo Ferrari", "Filippo Ferrari",
"Pen Bouryoung",
"shinonomeiro", "shinonomeiro",
"Snille", "Snille",
"MaartenAlbers", "MaartenAlbers",
@@ -511,12 +518,21 @@
"Kalnei", "Kalnei",
"Scott", "Scott",
"Muratoraccio", "Muratoraccio",
"Ginnie",
"emadsultan",
"D", "D",
"nanana", "nanana",
"Dark_Pest",
"Alex",
"Jacky+Ho",
"Karru",
"ghoulars",
"ChaChanoKo",
"null",
"Beau",
"redcarrot",
"powerbot99",
"Fthehappy", "Fthehappy",
"rsamerica", "rsamerica",
"sfasdfasfdsa",
"Alan+Cano", "Alan+Cano",
"FeralOpticsAI", "FeralOpticsAI",
"Pavlaki", "Pavlaki",
@@ -524,60 +540,50 @@
"Doug+Rintoul", "Doug+Rintoul",
"Noor", "Noor",
"Yorunai", "Yorunai",
"quantenmecha",
"abattoirblues", "abattoirblues",
"Jason+Nash",
"BillyBoy84",
"zounik", "zounik",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"lrdchs2",
"Duk3+Rand0m",
"4IXplr0r3r", "4IXplr0r3r",
"hayden", "hayden",
"ahoystan", "ahoystan",
"Leland Saunders",
"Bob Barker", "Bob Barker",
"edk", "edk",
"JBsuede", "JBsuede",
"Time Valentine", "Christian Schäfer",
"Aeternyx",
"YOU SINWOO",
"りん あめ", "りん あめ",
"ja s", "ja s",
"Михал Михалыч",
"Matt",
"Doug Mason", "Doug Mason",
"Jeremy Townsend", "Jeremy Townsend",
"Locrospiel",
"Frogmilk", "Frogmilk",
"Sean voets", "Sean voets",
"Owen Gwosdz", "Owen Gwosdz",
"SPJ", "SPJ",
"Thomas Wanner", "Kor",
"Joseph Hanson",
"Bryan Rutkowski", "Bryan Rutkowski",
"Devil Lude", "Devil Lude",
"David Murcko", "David Murcko",
"kevin stoddard",
"Jack Dole", "Jack Dole",
"max blo", "max blo",
"Xenon Xue", "Steven",
"CptNeo", "CptNeo",
"JackJohnnyJim", "JackJohnnyJim",
"TenaciousD",
"Dmitry Ryzhov", "Dmitry Ryzhov",
"Khánh Đặng",
"Maso", "Maso",
"Edward Ten Eyck", "Edward Ten Eyck",
"Eric Ketchum", "Eric Ketchum",
"Kevin Wallace", "Kevin Wallace",
"Matheus Couto", "Jimmy Borup",
"ChicRic", "ChicRic",
"Henrique Faiolli",
"mercur", "mercur",
"Solixer", "Pete Pain",
"J C", "RHopkirk",
"jinksta187", "jinksta187",
"Andrew Wilkinson", "Andrew Wilkinson",
"Yavizu3d",
"Maxim",
"Manu Thetug", "Manu Thetug",
"Karlanx", "Karlanx",
"Yves Poezevara", "Yves Poezevara",
@@ -629,6 +635,20 @@
"SelfishMedic", "SelfishMedic",
"adderleighn", "adderleighn",
"EnragedAntelope", "EnragedAntelope",
"Drizzly",
"Sildoren",
"Darvidous",
"Seon+Song",
"2turbo",
"balut+omelette",
"Nebuleux",
"Dmitry+Viznesenskiy",
"Tanjin90",
"Somebody",
"sternenkrieger",
"eriick",
"Join+Chun",
"Pascalou",
"lighthawke", "lighthawke",
"Terraformer", "Terraformer",
"GDS+DEV", "GDS+DEV",
@@ -651,77 +671,66 @@
"D", "D",
"datasl4ve", "datasl4ve",
"Somebody", "Somebody",
"Dark_Pest",
"Aza",
"Jacky+Ho",
"koopa990", "koopa990",
"Karru",
"ChaChanoKo",
"null",
"bo",
"The+Forgetful+Dev", "The+Forgetful+Dev",
"redcarrot",
"powerbot99",
"Mateusz+Kosela", "Mateusz+Kosela",
"Bula", "Bula",
"KUJYAKU", "KUJYAKU",
"Coeur+de+cochon", "Coeur+de+cochon",
"han b", "han b",
"Nico", "Nico",
"Maximilian Krischan",
"Banana Joe", "Banana Joe",
"_ G3n", "_ G3n",
"Donovan Jenkins", "Donovan Jenkins",
"Tú Nguyễn Lý Hoàng", "Tú Nguyễn Lý Hoàng",
"shira1011",
"Michael Eid", "Michael Eid",
"beersandbacon", "beersandbacon",
"Maximilian Pyko",
"Invis",
"Bob barker", "Bob barker",
"Ben D", "Ben D",
"Garrett Wood", "G",
"Ronan Delevacq", "Ronan Delevacq",
"james", "james",
"Christian Schäfer",
"OrochiNights",
"Michael Zhu", "Michael Zhu",
"gonzalo", "Nemisu",
"Seraphy", "Seraphy",
"雨の心 落", "雨の心 落",
"AllTimeNoobie", "AllTimeNoobie",
"Leslie Andrew Ridings",
"jumpd", "jumpd",
"John C", "John C",
"Rim", "Rim",
"Dave Abraham", "Dave Abraham",
"Joaquin Hierrezuelo", "Joaquin Hierrezuelo",
"Dismem",
"Locrospiel",
"Jairus Knudsen", "Jairus Knudsen",
"Jarrid Lee", "Jarrid Lee",
"Poophead27 Blyat",
"Xan Dionysus", "Xan Dionysus",
"Nathan lee", "Nathan lee",
"Kor",
"Joseph Hanson",
"Mewtora",
"Middo", "Middo",
"Forbidden Atelier", "Forbidden Atelier",
"John Rednoulf", "John Rednoulf",
"Spire", "Spire",
"DrB",
"AZ Party Oasis",
"Adictedtohumping", "Adictedtohumping",
"Boba Smith", "Boba Smith",
"Towelie", "Towelie",
"MR.Bear", "MR.Bear",
"matt",
"dsffsdfsdfsdfsdfsdf", "dsffsdfsdfsdfsdfsdf",
"somethingtosay8",
"Jean-françois SEMA", "Jean-françois SEMA",
"Kurt", "Kurt",
"ivistorm", "ivistorm",
"Sauv", "Sauv",
"Steven", "jimyjomson",
"TenaciousD", "Borte",
"Khánh Đặng",
"Chase Kwon", "Chase Kwon",
"Ted Cart", "Ted Cart",
"Sage Himeros",
"Inyoshu", "Inyoshu",
"Goober719",
"Chad Barnes", "Chad Barnes",
"Person Y", "Person Y",
"David Spearing", "David Spearing",
@@ -740,7 +749,8 @@
"dxjaymz", "dxjaymz",
"L C", "L C",
"Dude", "Dude",
"Somebody",
"CK" "CK"
], ],
"totalCount": 739 "totalCount": 749
} }

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "Kein Credit erforderlich", "noCreditRequired": "Kein Credit erforderlich",
"allowSellingGeneratedContent": "Verkauf erlaubt", "allowSellingGeneratedContent": "Verkauf erlaubt",
"noTags": "Keine Tags", "noTags": "Keine Tags",
"autoTags": "Auto-Tags",
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.", "noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
"clearAll": "Alle Filter löschen", "clearAll": "Alle Filter löschen",
"any": "Beliebig", "any": "Beliebig",
@@ -640,8 +641,6 @@
}, },
"refresh": { "refresh": {
"title": "Modelliste aktualisieren", "title": "Modelliste aktualisieren",
"quick": "Änderungen synchronisieren",
"quickTooltip": "Nach neuen oder fehlenden Modelldateien suchen, damit die Liste aktuell bleibt.",
"full": "Cache neu aufbauen", "full": "Cache neu aufbauen",
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen." "fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
}, },
@@ -692,9 +691,18 @@
"unfavorite": "Aus Favoriten entfernen", "unfavorite": "Aus Favoriten entfernen",
"deleteAll": "Ausgewählte löschen", "deleteAll": "Ausgewählte löschen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen", "downloadMissingLoras": "Fehlende LoRAs herunterladen",
"downloadExamples": "Beispielbilder herunterladen",
"clear": "Auswahl löschen", "clear": "Auswahl löschen",
"skipMetadataRefreshCount": "Überspringen{count} Modelle", "skipMetadataRefreshCount": "Überspringen{count} Modelle",
"resumeMetadataRefreshCount": "Fortsetzen{count} Modelle", "resumeMetadataRefreshCount": "Fortsetzen{count} Modelle",
"sendToWorkflow": "An Workflow senden",
"sections": {
"workflow": "Workflow",
"metadata": "Metadaten",
"attributes": "Attribute",
"organize": "Organisieren",
"download": "Download"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "Automatische Organisation wird initialisiert...", "initializing": "Automatische Organisation wird initialisiert...",
"starting": "Automatische Organisation für {type} wird gestartet...", "starting": "Automatische Organisation für {type} wird gestartet...",
@@ -807,8 +815,6 @@
}, },
"refresh": { "refresh": {
"title": "Rezeptliste aktualisieren", "title": "Rezeptliste aktualisieren",
"quick": "Änderungen synchronisieren",
"quickTooltip": "Änderungen synchronisieren - schnelle Aktualisierung ohne Cache-Neubau",
"full": "Cache neu aufbauen", "full": "Cache neu aufbauen",
"fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien" "fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien"
}, },
@@ -1080,6 +1086,12 @@
"countMessage": "Modelle werden dauerhaft gelöscht.", "countMessage": "Modelle werden dauerhaft gelöscht.",
"action": "Alle löschen" "action": "Alle löschen"
}, },
"bulkDeleteRecipes": {
"title": "Mehrere Rezepte löschen",
"message": "Sind Sie sicher, dass Sie alle ausgewählten Rezepte und ihre zugehörigen Dateien löschen möchten?",
"countMessage": "Rezepte werden dauerhaft gelöscht.",
"action": "Alle löschen"
},
"checkUpdates": { "checkUpdates": {
"title": "Alle {typePlural} auf Updates prüfen?", "title": "Alle {typePlural} auf Updates prüfen?",
"message": "Damit werden alle {typePlural} in deiner Bibliothek auf Updates geprüft. Bei großen Sammlungen kann das etwas länger dauern.", "message": "Damit werden alle {typePlural} in deiner Bibliothek auf Updates geprüft. Bei großen Sammlungen kann das etwas länger dauern.",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "No Credit Required", "noCreditRequired": "No Credit Required",
"allowSellingGeneratedContent": "Allow Selling", "allowSellingGeneratedContent": "Allow Selling",
"noTags": "No tags", "noTags": "No tags",
"autoTags": "Auto Tags",
"noBaseModelMatches": "No base models match the current search.", "noBaseModelMatches": "No base models match the current search.",
"clearAll": "Clear All Filters", "clearAll": "Clear All Filters",
"any": "Any", "any": "Any",
@@ -640,8 +641,6 @@
}, },
"refresh": { "refresh": {
"title": "Refresh model list", "title": "Refresh model list",
"quick": "Sync Changes",
"quickTooltip": "Scan for new or missing model files so the list stays current.",
"full": "Rebuild Cache", "full": "Rebuild Cache",
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits." "fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
}, },
@@ -692,9 +691,18 @@
"unfavorite": "Remove from Favorites", "unfavorite": "Remove from Favorites",
"deleteAll": "Delete Selected", "deleteAll": "Delete Selected",
"downloadMissingLoras": "Download Missing LoRAs", "downloadMissingLoras": "Download Missing LoRAs",
"downloadExamples": "Download Example Images",
"clear": "Clear Selection", "clear": "Clear Selection",
"skipMetadataRefreshCount": "Skip ({count} models)", "skipMetadataRefreshCount": "Skip ({count} models)",
"resumeMetadataRefreshCount": "Resume ({count} models)", "resumeMetadataRefreshCount": "Resume ({count} models)",
"sendToWorkflow": "Send to Workflow",
"sections": {
"workflow": "Workflow",
"metadata": "Metadata",
"attributes": "Attributes",
"organize": "Organize",
"download": "Download"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "Initializing auto-organize...", "initializing": "Initializing auto-organize...",
"starting": "Starting auto-organize for {type}...", "starting": "Starting auto-organize for {type}...",
@@ -807,8 +815,6 @@
}, },
"refresh": { "refresh": {
"title": "Refresh recipe list", "title": "Refresh recipe list",
"quick": "Sync Changes",
"quickTooltip": "Sync changes - quick refresh without rebuilding cache",
"full": "Rebuild Cache", "full": "Rebuild Cache",
"fullTooltip": "Rebuild cache - full rescan of all recipe files" "fullTooltip": "Rebuild cache - full rescan of all recipe files"
}, },
@@ -1080,6 +1086,12 @@
"countMessage": "models will be permanently deleted.", "countMessage": "models will be permanently deleted.",
"action": "Delete All" "action": "Delete All"
}, },
"bulkDeleteRecipes": {
"title": "Delete Multiple Recipes",
"message": "Are you sure you want to delete all selected recipes and their associated files?",
"countMessage": "recipes will be permanently deleted.",
"action": "Delete All"
},
"checkUpdates": { "checkUpdates": {
"title": "Check updates for all {typePlural}?", "title": "Check updates for all {typePlural}?",
"message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.", "message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "Sin crédito requerido", "noCreditRequired": "Sin crédito requerido",
"allowSellingGeneratedContent": "Venta permitida", "allowSellingGeneratedContent": "Venta permitida",
"noTags": "Sin etiquetas", "noTags": "Sin etiquetas",
"autoTags": "Etiquetas automáticas",
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.", "noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
"clearAll": "Limpiar todos los filtros", "clearAll": "Limpiar todos los filtros",
"any": "Cualquiera", "any": "Cualquiera",
@@ -640,8 +641,6 @@
}, },
"refresh": { "refresh": {
"title": "Actualizar lista de modelos", "title": "Actualizar lista de modelos",
"quick": "Sincronizar cambios",
"quickTooltip": "Busca archivos de modelo nuevos o faltantes para mantener la lista al día.",
"full": "Reconstruir caché", "full": "Reconstruir caché",
"fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales." "fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales."
}, },
@@ -692,9 +691,18 @@
"unfavorite": "Quitar de favoritos", "unfavorite": "Quitar de favoritos",
"deleteAll": "Eliminar seleccionados", "deleteAll": "Eliminar seleccionados",
"downloadMissingLoras": "Descargar LoRAs faltantes", "downloadMissingLoras": "Descargar LoRAs faltantes",
"downloadExamples": "Descargar imágenes de ejemplo",
"clear": "Limpiar selección", "clear": "Limpiar selección",
"skipMetadataRefreshCount": "Omitir{count} modelos", "skipMetadataRefreshCount": "Omitir{count} modelos",
"resumeMetadataRefreshCount": "Reanudar{count} modelos", "resumeMetadataRefreshCount": "Reanudar{count} modelos",
"sendToWorkflow": "Enviar al workflow",
"sections": {
"workflow": "Workflow",
"metadata": "Metadatos",
"attributes": "Atributos",
"organize": "Organizar",
"download": "Descargar"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "Inicializando auto-organización...", "initializing": "Inicializando auto-organización...",
"starting": "Iniciando auto-organización para {type}...", "starting": "Iniciando auto-organización para {type}...",
@@ -807,8 +815,6 @@
}, },
"refresh": { "refresh": {
"title": "Actualizar lista de recetas", "title": "Actualizar lista de recetas",
"quick": "Sincronizar cambios",
"quickTooltip": "Sincronizar cambios - actualización rápida sin reconstruir caché",
"full": "Reconstruir caché", "full": "Reconstruir caché",
"fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas" "fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas"
}, },
@@ -1080,6 +1086,12 @@
"countMessage": "modelos serán eliminados permanentemente.", "countMessage": "modelos serán eliminados permanentemente.",
"action": "Eliminar todo" "action": "Eliminar todo"
}, },
"bulkDeleteRecipes": {
"title": "Eliminar múltiples recetas",
"message": "¿Estás seguro de que quieres eliminar todas las recetas seleccionadas y sus archivos asociados?",
"countMessage": "recetas serán eliminadas permanentemente.",
"action": "Eliminar todo"
},
"checkUpdates": { "checkUpdates": {
"title": "¿Comprobar actualizaciones para todos los {typePlural}?", "title": "¿Comprobar actualizaciones para todos los {typePlural}?",
"message": "Esto comprobará las actualizaciones de todos los {typePlural} de tu biblioteca. En colecciones grandes puede tardar un poco más.", "message": "Esto comprobará las actualizaciones de todos los {typePlural} de tu biblioteca. En colecciones grandes puede tardar un poco más.",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "Crédit non requis", "noCreditRequired": "Crédit non requis",
"allowSellingGeneratedContent": "Vente autorisée", "allowSellingGeneratedContent": "Vente autorisée",
"noTags": "Aucun tag", "noTags": "Aucun tag",
"autoTags": "Auto-Tags",
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.", "noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
"clearAll": "Effacer tous les filtres", "clearAll": "Effacer tous les filtres",
"any": "N'importe quel", "any": "N'importe quel",
@@ -640,8 +641,6 @@
}, },
"refresh": { "refresh": {
"title": "Actualiser la liste des modèles", "title": "Actualiser la liste des modèles",
"quick": "Synchroniser les changements",
"quickTooltip": "Analyse les nouveaux fichiers de modèle ou les fichiers manquants pour garder la liste à jour.",
"full": "Reconstruire le cache", "full": "Reconstruire le cache",
"fullTooltip": "Recharge tous les détails des modèles depuis les fichiers metadata — à utiliser si la bibliothèque paraît obsolète ou après des modifications manuelles." "fullTooltip": "Recharge tous les détails des modèles depuis les fichiers metadata — à utiliser si la bibliothèque paraît obsolète ou après des modifications manuelles."
}, },
@@ -692,9 +691,18 @@
"unfavorite": "Retirer des favoris", "unfavorite": "Retirer des favoris",
"deleteAll": "Supprimer la sélection", "deleteAll": "Supprimer la sélection",
"downloadMissingLoras": "Télécharger les LoRAs manquants", "downloadMissingLoras": "Télécharger les LoRAs manquants",
"downloadExamples": "Télécharger les images d'exemple",
"clear": "Effacer la sélection", "clear": "Effacer la sélection",
"skipMetadataRefreshCount": "Ignorer{count} modèles", "skipMetadataRefreshCount": "Ignorer{count} modèles",
"resumeMetadataRefreshCount": "Reprendre{count} modèles", "resumeMetadataRefreshCount": "Reprendre{count} modèles",
"sendToWorkflow": "Envoyer au workflow",
"sections": {
"workflow": "Workflow",
"metadata": "Métadonnées",
"attributes": "Attributs",
"organize": "Organiser",
"download": "Télécharger"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "Initialisation de l'auto-organisation...", "initializing": "Initialisation de l'auto-organisation...",
"starting": "Démarrage de l'auto-organisation pour {type}...", "starting": "Démarrage de l'auto-organisation pour {type}...",
@@ -807,8 +815,6 @@
}, },
"refresh": { "refresh": {
"title": "Actualiser la liste des recipes", "title": "Actualiser la liste des recipes",
"quick": "Synchroniser les changements",
"quickTooltip": "Synchroniser les changements - actualisation rapide sans reconstruire le cache",
"full": "Reconstruire le cache", "full": "Reconstruire le cache",
"fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes" "fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes"
}, },
@@ -1080,6 +1086,12 @@
"countMessage": "modèles seront définitivement supprimés.", "countMessage": "modèles seront définitivement supprimés.",
"action": "Tout supprimer" "action": "Tout supprimer"
}, },
"bulkDeleteRecipes": {
"title": "Supprimer plusieurs recipes",
"message": "Êtes-vous sûr de vouloir supprimer toutes les recipes sélectionnées et leurs fichiers associés ?",
"countMessage": "recipes seront définitivement supprimées.",
"action": "Tout supprimer"
},
"checkUpdates": { "checkUpdates": {
"title": "Vérifier les mises à jour pour tous les {typePlural} ?", "title": "Vérifier les mises à jour pour tous les {typePlural} ?",
"message": "Cette action vérifie les mises à jour pour tous les {typePlural} de votre bibliothèque. Les grandes collections peuvent prendre un peu plus de temps.", "message": "Cette action vérifie les mises à jour pour tous les {typePlural} de votre bibliothèque. Les grandes collections peuvent prendre un peu plus de temps.",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "ללא קרדיט נדרש", "noCreditRequired": "ללא קרדיט נדרש",
"allowSellingGeneratedContent": "אפשר מכירה", "allowSellingGeneratedContent": "אפשר מכירה",
"noTags": "ללא תגיות", "noTags": "ללא תגיות",
"autoTags": "תגיות אוטומטיות",
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.", "noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
"clearAll": "נקה את כל המסננים", "clearAll": "נקה את כל המסננים",
"any": "כלשהו", "any": "כלשהו",
@@ -640,8 +641,6 @@
}, },
"refresh": { "refresh": {
"title": "רענן רשימת מודלים", "title": "רענן רשימת מודלים",
"quick": "סנכרון שינויים",
"quickTooltip": "סריקה לאיתור קבצי מודל חדשים או חסרים כדי לשמור את הרשימה מעודכנת.",
"full": "בניית מטמון מחדש", "full": "בניית מטמון מחדש",
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות." "fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
}, },
@@ -692,9 +691,18 @@
"unfavorite": "הסר ממועדפים", "unfavorite": "הסר ממועדפים",
"deleteAll": "מחק נבחרים", "deleteAll": "מחק נבחרים",
"downloadMissingLoras": "הורדת LoRAs חסרים", "downloadMissingLoras": "הורדת LoRAs חסרים",
"downloadExamples": "הורד תמונות דוגמה",
"clear": "נקה בחירה", "clear": "נקה בחירה",
"skipMetadataRefreshCount": "דילוג({count} מודלים)", "skipMetadataRefreshCount": "דילוג({count} מודלים)",
"resumeMetadataRefreshCount": "המשך({count} מודלים)", "resumeMetadataRefreshCount": "המשך({count} מודלים)",
"sendToWorkflow": "שלח ל-Workflow",
"sections": {
"workflow": "Workflow",
"metadata": "מטא-נתונים",
"attributes": "מאפיינים",
"organize": "ארגן",
"download": "הורדה"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "מאתחל ארגון אוטומטי...", "initializing": "מאתחל ארגון אוטומטי...",
"starting": "מתחיל ארגון אוטומטי עבור {type}...", "starting": "מתחיל ארגון אוטומטי עבור {type}...",
@@ -807,8 +815,6 @@
}, },
"refresh": { "refresh": {
"title": "רענן רשימת מתכונים", "title": "רענן רשימת מתכונים",
"quick": "סנכרן שינויים",
"quickTooltip": "סנכרן שינויים - רענון מהיר ללא בניית מטמון מחדש",
"full": "בנה מטמון מחדש", "full": "בנה מטמון מחדש",
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים" "fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
}, },
@@ -1080,6 +1086,12 @@
"countMessage": "מודלים יימחקו לצמיתות.", "countMessage": "מודלים יימחקו לצמיתות.",
"action": "מחק הכל" "action": "מחק הכל"
}, },
"bulkDeleteRecipes": {
"title": "מחק מספר מתכונים",
"message": "האם אתה בטוח שברצונך למחוק את כל המתכונים שנבחרו ואת הקבצים הנלווים אליהם?",
"countMessage": "מתכונים יימחקו לצמיתות.",
"action": "מחק הכל"
},
"checkUpdates": { "checkUpdates": {
"title": "לבדוק עדכונים לכל ה-{typePlural}?", "title": "לבדוק עדכונים לכל ה-{typePlural}?",
"message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.", "message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "クレジット不要", "noCreditRequired": "クレジット不要",
"allowSellingGeneratedContent": "販売許可", "allowSellingGeneratedContent": "販売許可",
"noTags": "タグなし", "noTags": "タグなし",
"autoTags": "自動タグ",
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。", "noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
"clearAll": "すべてのフィルタをクリア", "clearAll": "すべてのフィルタをクリア",
"any": "いずれか", "any": "いずれか",
@@ -640,8 +641,6 @@
}, },
"refresh": { "refresh": {
"title": "モデルリストを更新", "title": "モデルリストを更新",
"quick": "変更を同期",
"quickTooltip": "新しいモデルファイルや欠けているファイルをスキャンして一覧を最新に保ちます。",
"full": "キャッシュを再構築", "full": "キャッシュを再構築",
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。" "fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
}, },
@@ -692,9 +691,18 @@
"unfavorite": "お気に入りから削除", "unfavorite": "お気に入りから削除",
"deleteAll": "選択したものを削除", "deleteAll": "選択したものを削除",
"downloadMissingLoras": "不足している LoRA をダウンロード", "downloadMissingLoras": "不足している LoRA をダウンロード",
"downloadExamples": "例画像をダウンロード",
"clear": "選択をクリア", "clear": "選択をクリア",
"skipMetadataRefreshCount": "スキップ({count}モデル)", "skipMetadataRefreshCount": "スキップ({count}モデル)",
"resumeMetadataRefreshCount": "再開({count}モデル)", "resumeMetadataRefreshCount": "再開({count}モデル)",
"sendToWorkflow": "ワークフローに送信",
"sections": {
"workflow": "ワークフロー",
"metadata": "メタデータ",
"attributes": "属性",
"organize": "整理",
"download": "ダウンロード"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "自動整理を初期化中...", "initializing": "自動整理を初期化中...",
"starting": "{type}の自動整理を開始中...", "starting": "{type}の自動整理を開始中...",
@@ -807,8 +815,6 @@
}, },
"refresh": { "refresh": {
"title": "レシピリストを更新", "title": "レシピリストを更新",
"quick": "変更を同期",
"quickTooltip": "変更を同期 - キャッシュを再構築せずにクイック更新",
"full": "キャッシュを再構築", "full": "キャッシュを再構築",
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン" "fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
}, },
@@ -1080,6 +1086,12 @@
"countMessage": "モデルが完全に削除されます。", "countMessage": "モデルが完全に削除されます。",
"action": "すべて削除" "action": "すべて削除"
}, },
"bulkDeleteRecipes": {
"title": "複数のレシピを削除",
"message": "選択したすべてのレシピと関連ファイルを削除してもよろしいですか?",
"countMessage": "レシピが完全に削除されます。",
"action": "すべて削除"
},
"checkUpdates": { "checkUpdates": {
"title": "すべての{type}の更新を確認しますか?", "title": "すべての{type}の更新を確認しますか?",
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。", "message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "크레딧 표기 없음", "noCreditRequired": "크레딧 표기 없음",
"allowSellingGeneratedContent": "판매 허용", "allowSellingGeneratedContent": "판매 허용",
"noTags": "태그 없음", "noTags": "태그 없음",
"autoTags": "자동 태그",
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.", "noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
"clearAll": "모든 필터 지우기", "clearAll": "모든 필터 지우기",
"any": "아무", "any": "아무",
@@ -640,8 +641,6 @@
}, },
"refresh": { "refresh": {
"title": "모델 목록 새로고침", "title": "모델 목록 새로고침",
"quick": "변경 사항 동기화",
"quickTooltip": "새로운 모델 파일이나 누락된 파일을 찾아 목록을 최신 상태로 유지합니다.",
"full": "캐시 재구성", "full": "캐시 재구성",
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요." "fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
}, },
@@ -692,9 +691,18 @@
"unfavorite": "즐겨찾기 해제", "unfavorite": "즐겨찾기 해제",
"deleteAll": "선택된 항목 삭제", "deleteAll": "선택된 항목 삭제",
"downloadMissingLoras": "누락된 LoRA 다운로드", "downloadMissingLoras": "누락된 LoRA 다운로드",
"downloadExamples": "예시 이미지 다운로드",
"clear": "선택 지우기", "clear": "선택 지우기",
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)", "skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
"resumeMetadataRefreshCount": "재개({count}개 모델)", "resumeMetadataRefreshCount": "재개({count}개 모델)",
"sendToWorkflow": "워크플로우로 보내기",
"sections": {
"workflow": "워크플로우",
"metadata": "메타데이터",
"attributes": "속성",
"organize": "정리",
"download": "다운로드"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "자동 정리 초기화 중...", "initializing": "자동 정리 초기화 중...",
"starting": "{type}에 대한 자동 정리 시작...", "starting": "{type}에 대한 자동 정리 시작...",
@@ -807,8 +815,6 @@
}, },
"refresh": { "refresh": {
"title": "레시피 목록 새로고침", "title": "레시피 목록 새로고침",
"quick": "변경 사항 동기화",
"quickTooltip": "변경 사항 동기화 - 캐시를 재구성하지 않고 빠른 새로고침",
"full": "캐시 재구성", "full": "캐시 재구성",
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔" "fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
}, },
@@ -1080,6 +1086,12 @@
"countMessage": "개의 모델이 영구적으로 삭제됩니다.", "countMessage": "개의 모델이 영구적으로 삭제됩니다.",
"action": "모두 삭제" "action": "모두 삭제"
}, },
"bulkDeleteRecipes": {
"title": "여러 레시피 삭제",
"message": "선택된 모든 레시피와 관련 파일을 삭제하시겠습니까?",
"countMessage": "개의 레시피가 영구적으로 삭제됩니다.",
"action": "모두 삭제"
},
"checkUpdates": { "checkUpdates": {
"title": "{type} 전체 업데이트를 확인할까요?", "title": "{type} 전체 업데이트를 확인할까요?",
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.", "message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "Без указания авторства", "noCreditRequired": "Без указания авторства",
"allowSellingGeneratedContent": "Продажа разрешена", "allowSellingGeneratedContent": "Продажа разрешена",
"noTags": "Без тегов", "noTags": "Без тегов",
"autoTags": "Авто-теги",
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.", "noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
"clearAll": "Очистить все фильтры", "clearAll": "Очистить все фильтры",
"any": "Любой", "any": "Любой",
@@ -640,8 +641,6 @@
}, },
"refresh": { "refresh": {
"title": "Обновить список моделей", "title": "Обновить список моделей",
"quick": "Синхронизировать изменения",
"quickTooltip": "Находит новые или отсутствующие файлы моделей, чтобы список оставался актуальным.",
"full": "Перестроить кэш", "full": "Перестроить кэш",
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок." "fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
}, },
@@ -692,9 +691,18 @@
"unfavorite": "Удалить из избранного", "unfavorite": "Удалить из избранного",
"deleteAll": "Удалить выбранные", "deleteAll": "Удалить выбранные",
"downloadMissingLoras": "Скачать отсутствующие LoRAs", "downloadMissingLoras": "Скачать отсутствующие LoRAs",
"downloadExamples": "Загрузить примеры изображений",
"clear": "Очистить выбор", "clear": "Очистить выбор",
"skipMetadataRefreshCount": "Пропустить({count} моделей)", "skipMetadataRefreshCount": "Пропустить({count} моделей)",
"resumeMetadataRefreshCount": "Возобновить({count} моделей)", "resumeMetadataRefreshCount": "Возобновить({count} моделей)",
"sendToWorkflow": "Отправить в Workflow",
"sections": {
"workflow": "Workflow",
"metadata": "Метаданные",
"attributes": "Атрибуты",
"organize": "Организовать",
"download": "Скачать"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "Инициализация автоматической организации...", "initializing": "Инициализация автоматической организации...",
"starting": "Запуск автоматической организации для {type}...", "starting": "Запуск автоматической организации для {type}...",
@@ -807,8 +815,6 @@
}, },
"refresh": { "refresh": {
"title": "Обновить список рецептов", "title": "Обновить список рецептов",
"quick": "Синхронизировать изменения",
"quickTooltip": "Синхронизировать изменения - быстрое обновление без перестроения кэша",
"full": "Перестроить кэш", "full": "Перестроить кэш",
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов" "fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
}, },
@@ -1080,6 +1086,12 @@
"countMessage": "моделей будут удалены навсегда.", "countMessage": "моделей будут удалены навсегда.",
"action": "Удалить все" "action": "Удалить все"
}, },
"bulkDeleteRecipes": {
"title": "Удалить несколько рецептов",
"message": "Вы уверены, что хотите удалить все выбранные рецепты и связанные с ними файлы?",
"countMessage": "рецептов будут удалены навсегда.",
"action": "Удалить все"
},
"checkUpdates": { "checkUpdates": {
"title": "Проверить обновления для всех {typePlural}?", "title": "Проверить обновления для всех {typePlural}?",
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.", "message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "无需署名", "noCreditRequired": "无需署名",
"allowSellingGeneratedContent": "允许销售", "allowSellingGeneratedContent": "允许销售",
"noTags": "无标签", "noTags": "无标签",
"autoTags": "自动标签",
"noBaseModelMatches": "没有基础模型符合当前搜索。", "noBaseModelMatches": "没有基础模型符合当前搜索。",
"clearAll": "清除所有筛选", "clearAll": "清除所有筛选",
"any": "任一", "any": "任一",
@@ -640,8 +641,6 @@
}, },
"refresh": { "refresh": {
"title": "刷新模型列表", "title": "刷新模型列表",
"quick": "同步变更",
"quickTooltip": "扫描新的或缺失的模型文件,保持列表最新。",
"full": "重建缓存", "full": "重建缓存",
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。" "fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
}, },
@@ -692,9 +691,18 @@
"unfavorite": "取消收藏", "unfavorite": "取消收藏",
"deleteAll": "删除已选", "deleteAll": "删除已选",
"downloadMissingLoras": "下载缺失的 LoRAs", "downloadMissingLoras": "下载缺失的 LoRAs",
"downloadExamples": "下载示例图片",
"clear": "清除选择", "clear": "清除选择",
"skipMetadataRefreshCount": "跳过({count} 个模型)", "skipMetadataRefreshCount": "跳过({count} 个模型)",
"resumeMetadataRefreshCount": "恢复({count} 个模型)", "resumeMetadataRefreshCount": "恢复({count} 个模型)",
"sendToWorkflow": "发送到工作流",
"sections": {
"workflow": "工作流",
"metadata": "元数据",
"attributes": "属性",
"organize": "整理",
"download": "下载"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "正在初始化自动整理...", "initializing": "正在初始化自动整理...",
"starting": "正在为 {type} 启动自动整理...", "starting": "正在为 {type} 启动自动整理...",
@@ -807,8 +815,6 @@
}, },
"refresh": { "refresh": {
"title": "刷新配方列表", "title": "刷新配方列表",
"quick": "同步变更",
"quickTooltip": "同步变更 - 快速刷新而不重建缓存",
"full": "重建缓存", "full": "重建缓存",
"fullTooltip": "重建缓存 - 重新扫描所有配方文件" "fullTooltip": "重建缓存 - 重新扫描所有配方文件"
}, },
@@ -1080,6 +1086,12 @@
"countMessage": "模型将被永久删除。", "countMessage": "模型将被永久删除。",
"action": "全部删除" "action": "全部删除"
}, },
"bulkDeleteRecipes": {
"title": "删除多个配方",
"message": "你确定要删除所有选中的配方及其相关文件吗?",
"countMessage": "配方将被永久删除。",
"action": "全部删除"
},
"checkUpdates": { "checkUpdates": {
"title": "检查所有 {type} 的更新?", "title": "检查所有 {type} 的更新?",
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。", "message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",

View File

@@ -233,6 +233,7 @@
"noCreditRequired": "無需署名", "noCreditRequired": "無需署名",
"allowSellingGeneratedContent": "允許銷售", "allowSellingGeneratedContent": "允許銷售",
"noTags": "無標籤", "noTags": "無標籤",
"autoTags": "自動標籤",
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。", "noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
"clearAll": "清除所有篩選", "clearAll": "清除所有篩選",
"any": "任一", "any": "任一",
@@ -640,8 +641,6 @@
}, },
"refresh": { "refresh": {
"title": "重新整理模型列表", "title": "重新整理模型列表",
"quick": "同步變更",
"quickTooltip": "掃描新的或缺少的模型檔案,讓清單保持最新。",
"full": "重建快取", "full": "重建快取",
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。" "fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
}, },
@@ -692,9 +691,18 @@
"unfavorite": "取消收藏", "unfavorite": "取消收藏",
"deleteAll": "刪除所選", "deleteAll": "刪除所選",
"downloadMissingLoras": "下載缺失的 LoRAs", "downloadMissingLoras": "下載缺失的 LoRAs",
"downloadExamples": "下載範例圖片",
"clear": "清除選取", "clear": "清除選取",
"skipMetadataRefreshCount": "跳過({count} 個模型)", "skipMetadataRefreshCount": "跳過({count} 個模型)",
"resumeMetadataRefreshCount": "恢復({count} 個模型)", "resumeMetadataRefreshCount": "恢復({count} 個模型)",
"sendToWorkflow": "發送到工作流",
"sections": {
"workflow": "工作流",
"metadata": "元數據",
"attributes": "屬性",
"organize": "整理",
"download": "下載"
},
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "正在初始化自動整理...", "initializing": "正在初始化自動整理...",
"starting": "正在開始自動整理 {type}...", "starting": "正在開始自動整理 {type}...",
@@ -807,8 +815,6 @@
}, },
"refresh": { "refresh": {
"title": "重新整理配方列表", "title": "重新整理配方列表",
"quick": "同步變更",
"quickTooltip": "同步變更 - 快速重新整理而不重建快取",
"full": "重建快取", "full": "重建快取",
"fullTooltip": "重建快取 - 重新掃描所有配方檔案" "fullTooltip": "重建快取 - 重新掃描所有配方檔案"
}, },
@@ -1080,6 +1086,12 @@
"countMessage": "模型將被永久刪除。", "countMessage": "模型將被永久刪除。",
"action": "全部刪除" "action": "全部刪除"
}, },
"bulkDeleteRecipes": {
"title": "刪除多個配方",
"message": "您確定要刪除所有選取的配方及其相關檔案嗎?",
"countMessage": "配方將被永久刪除。",
"action": "全部刪除"
},
"checkUpdates": { "checkUpdates": {
"title": "要檢查所有 {type} 的更新嗎?", "title": "要檢查所有 {type} 的更新嗎?",
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。", "message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",

View File

@@ -172,6 +172,12 @@ class Config:
self.extra_unet_roots: List[str] = [] self.extra_unet_roots: List[str] = []
self.extra_embeddings_roots: List[str] = [] self.extra_embeddings_roots: List[str] = []
self.recipes_path: str = "" self.recipes_path: str = ""
# Load extra folder paths from active library settings before symlink scan
# so both primary and extra paths are discovered in a single pass.
if not standalone_mode:
self._load_extra_paths_from_settings()
# Scan symbolic links during initialization # Scan symbolic links during initialization
self._initialize_symlink_mappings() self._initialize_symlink_mappings()
@@ -179,6 +185,96 @@ class Config:
# Save the paths to settings.json when running in ComfyUI mode # Save the paths to settings.json when running in ComfyUI mode
self.save_folder_paths_to_settings() self.save_folder_paths_to_settings()
def _load_extra_paths_from_settings(self) -> None:
"""Read extra folder paths from the active library and apply them.
Called during ``Config.__init__`` before the symlink scan so both primary and
extra paths are discovered in a single pass. Mirrors the extra-path
portion of ``_apply_library_paths`` without replacing the primary roots
that were already resolved from ComfyUI's ``folder_paths``.
"""
try:
from .services.settings_manager import get_settings_manager
settings_manager = get_settings_manager()
library_name = settings_manager.get_active_library_name()
libraries = settings_manager.get_libraries()
if not library_name or library_name not in libraries:
return
library_config = libraries[library_name]
if not isinstance(library_config, dict):
return
extra_folder_paths = library_config.get("extra_folder_paths")
if not isinstance(extra_folder_paths, dict):
return
extra_lora = extra_folder_paths.get("loras", []) or []
extra_checkpoint = extra_folder_paths.get("checkpoints", []) or []
extra_unet = extra_folder_paths.get("unet", []) or []
extra_embedding = extra_folder_paths.get("embeddings", []) or []
if not any([extra_lora, extra_checkpoint, extra_unet, extra_embedding]):
return
filtered_extra_lora = self._filter_overlapping_extra_lora_paths(
self.loras_roots, extra_lora
)
self.extra_loras_roots = self._prepare_lora_paths(filtered_extra_lora)
(
_,
self.extra_checkpoints_roots,
self.extra_unet_roots,
) = self._prepare_checkpoint_paths(extra_checkpoint, extra_unet)
self.extra_embeddings_roots = self._prepare_embedding_paths(
extra_embedding
)
recipes_path = library_config.get("recipes_path", "")
if isinstance(recipes_path, str) and recipes_path:
self.recipes_path = recipes_path
if self.extra_loras_roots:
logger.info(
"Found extra LoRA roots:"
+ "\n - "
+ "\n - ".join(self.extra_loras_roots)
)
if self.extra_checkpoints_roots:
logger.info(
"Found extra checkpoint roots:"
+ "\n - "
+ "\n - ".join(self.extra_checkpoints_roots)
)
if self.extra_unet_roots:
logger.info(
"Found extra diffusion model roots:"
+ "\n - "
+ "\n - ".join(self.extra_unet_roots)
)
if self.extra_embeddings_roots:
logger.info(
"Found extra embedding roots:"
+ "\n - "
+ "\n - ".join(self.extra_embeddings_roots)
)
logger.info(
"Applied library settings for '%s' with extra paths: loras=%s, "
"checkpoints=%s, embeddings=%s",
library_name,
extra_lora,
extra_checkpoint,
extra_embedding,
)
except Exception as exc:
logger.debug(
"Could not load extra paths from library settings: %s", exc
)
def save_folder_paths_to_settings(self): def save_folder_paths_to_settings(self):
"""Persist ComfyUI-derived folder paths to the multi-library settings.""" """Persist ComfyUI-derived folder paths to the multi-library settings."""
try: try:

View File

@@ -184,39 +184,6 @@ class LoraManager:
async def _initialize_services(cls): async def _initialize_services(cls):
"""Initialize all services using the ServiceRegistry""" """Initialize all services using the ServiceRegistry"""
try: try:
# Apply library settings to load extra folder paths before scanning
# Only apply if extra paths haven't been loaded yet (preserves test mocks)
try:
from .services.settings_manager import get_settings_manager
settings_manager = get_settings_manager()
library_name = settings_manager.get_active_library_name()
libraries = settings_manager.get_libraries()
if library_name and library_name in libraries:
library_config = libraries[library_name]
# Only apply settings if extra paths are not already configured
# This preserves values set by tests via monkeypatch
extra_paths = library_config.get("extra_folder_paths", {})
has_extra_paths = (
config.extra_loras_roots
or config.extra_checkpoints_roots
or config.extra_unet_roots
or config.extra_embeddings_roots
)
if not has_extra_paths and any(extra_paths.values()):
config.apply_library_settings(library_config)
logger.info(
"Applied library settings for '%s' with extra paths: loras=%s, checkpoints=%s, embeddings=%s",
library_name,
extra_paths.get("loras", []),
extra_paths.get("checkpoints", []),
extra_paths.get("embeddings", []),
)
except Exception as exc:
logger.warning(
"Failed to apply library settings during initialization: %s", exc
)
# Initialize CivitaiClient first to ensure it's ready for other services # Initialize CivitaiClient first to ensure it's ready for other services
await ServiceRegistry.get_civitai_client() await ServiceRegistry.get_civitai_client()

View File

@@ -16,7 +16,9 @@ class RecipeEnricher:
async def enrich_recipe( async def enrich_recipe(
recipe: Dict[str, Any], recipe: Dict[str, Any],
civitai_client: Any, civitai_client: Any,
request_params: Optional[Dict[str, Any]] = None request_params: Optional[Dict[str, Any]] = None,
prefetched_civitai_meta_raw: Optional[Dict[str, Any]] = None,
prefetched_model_version_id: Optional[int] = None,
) -> bool: ) -> bool:
""" """
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params. Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
@@ -25,6 +27,9 @@ class RecipeEnricher:
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized. recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
civitai_client: Authenticated Civitai client instance. civitai_client: Authenticated Civitai client instance.
request_params: (Optional) Parameters from a user request (e.g. import). request_params: (Optional) Parameters from a user request (e.g. import).
prefetched_civitai_meta_raw: (Optional) Pre-fetched raw meta from Civitai
get_image_info, avoiding a duplicate API call.
prefetched_model_version_id: (Optional) Pre-fetched model version ID.
Returns: Returns:
bool: True if the recipe was modified, False otherwise. bool: True if the recipe was modified, False otherwise.
@@ -32,21 +37,27 @@ class RecipeEnricher:
updated = False updated = False
gen_params = recipe.get("gen_params", {}) gen_params = recipe.get("gen_params", {})
# 1. Fetch Civitai Info if available # 1. Obtain Civitai metadata
civitai_meta = None civitai_meta = None
model_version_id = None model_version_id = prefetched_model_version_id
source_url = recipe.get("source_url") or recipe.get("source_path", "") source_path = recipe.get("source_path", "")
# Check if it's a Civitai image URL if prefetched_civitai_meta_raw is not None:
image_id = extract_civitai_image_id(str(source_url)) raw_meta = prefetched_civitai_meta_raw
if isinstance(raw_meta, dict):
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
civitai_meta = raw_meta["meta"]
else:
civitai_meta = raw_meta
else:
image_id = extract_civitai_image_id(str(source_path))
if image_id: if image_id:
try: try:
image_info = await civitai_client.get_image_info( image_info = await civitai_client.get_image_info(
image_id, source_url=str(source_url) image_id, source_url=str(source_path)
) )
if image_info: if image_info:
# Handle nested meta often found in Civitai API responses
raw_meta = image_info.get("meta") raw_meta = image_info.get("meta")
if isinstance(raw_meta, dict): if isinstance(raw_meta, dict):
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict): if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
@@ -55,16 +66,15 @@ class RecipeEnricher:
civitai_meta = raw_meta civitai_meta = raw_meta
model_version_id = image_info.get("modelVersionId") model_version_id = image_info.get("modelVersionId")
except Exception as e:
logger.warning(f"Failed to fetch Civitai image info: {e}")
# If not at top level, check resources in meta
if not model_version_id and civitai_meta: if not model_version_id and civitai_meta:
resources = civitai_meta.get("civitaiResources", []) resources = civitai_meta.get("civitaiResources", [])
for res in resources: for res in resources:
if res.get("type") == "checkpoint": if res.get("type") == "checkpoint":
model_version_id = res.get("modelVersionId") model_version_id = res.get("modelVersionId")
break break
except Exception as e:
logger.warning(f"Failed to fetch Civitai image info: {e}")
# 2. Merge Parameters # 2. Merge Parameters
# Priority: request_params > civitai_meta > embedded (existing gen_params) # Priority: request_params > civitai_meta > embedded (existing gen_params)

View File

@@ -2065,7 +2065,7 @@ class ModelLibraryHandler:
file_path=file_path if isinstance(file_path, str) else None, file_path=file_path if isinstance(file_path, str) else None,
) )
else: else:
await history_service.mark_not_downloaded(model_type, model_version_id) await history_service.mark_as_deleted(model_type, model_version_id)
return web.json_response( return web.json_response(
{ {
@@ -2139,8 +2139,19 @@ class ModelLibraryHandler:
] ]
await found_cache.resort() await found_cache.resort()
scanner_map = {
"lora": lora_scanner,
"checkpoint": checkpoint_scanner,
"embedding": embedding_scanner,
}
scanner = scanner_map.get(found_type)
if scanner:
persist = getattr(scanner, "_persist_current_cache", None)
if callable(persist):
await persist()
history_service = await self._get_download_history_service() history_service = await self._get_download_history_service()
await history_service.mark_not_downloaded(found_type, model_version_id) await history_service.mark_as_deleted(found_type, model_version_id)
return web.json_response( return web.json_response(
{ {

View File

@@ -301,6 +301,15 @@ class ModelListingHandler:
for tag in exclude_tags: for tag in exclude_tags:
if tag: if tag:
tag_filters[tag] = "exclude" tag_filters[tag] = "exclude"
auto_tag_filters: Dict[str, str] = {}
for tag in request.query.getall("auto_tag_include", []):
if tag:
auto_tag_filters[tag] = "include"
for tag in request.query.getall("auto_tag_exclude", []):
if tag:
auto_tag_filters[tag] = "exclude"
favorites_only = request.query.get("favorites_only", "false").lower() == "true" favorites_only = request.query.get("favorites_only", "false").lower() == "true"
search_options = { search_options = {
@@ -367,6 +376,7 @@ class ModelListingHandler:
"fuzzy_search": fuzzy_search, "fuzzy_search": fuzzy_search,
"base_models": base_models, "base_models": base_models,
"tags": tag_filters, "tags": tag_filters,
"auto_tags": auto_tag_filters,
"tag_logic": tag_logic, "tag_logic": tag_logic,
"search_options": search_options, "search_options": search_options,
"hash_filters": hash_filters, "hash_filters": hash_filters,

View File

@@ -93,6 +93,8 @@ class RecipeHandlerSet:
"cancel_batch_import": self.batch_import.cancel_batch_import, "cancel_batch_import": self.batch_import.cancel_batch_import,
"start_directory_import": self.batch_import.start_directory_import, "start_directory_import": self.batch_import.start_directory_import,
"browse_directory": self.batch_import.browse_directory, "browse_directory": self.batch_import.browse_directory,
"check_image_exists": self.management.check_image_exists,
"import_from_url": self.management.import_from_url,
} }
@@ -541,7 +543,7 @@ class RecipeQueryHandler:
) )
response_data.append( response_data.append(
{ {
"type": "source_url", "type": "source_path",
"fingerprint": url, "fingerprint": url,
"count": len(recipes), "count": len(recipes),
"recipes": recipes, "recipes": recipes,
@@ -607,6 +609,7 @@ class RecipeManagementHandler:
self._downloader_factory = downloader_factory self._downloader_factory = downloader_factory
self._civitai_client_getter = civitai_client_getter self._civitai_client_getter = civitai_client_getter
self._ws_manager = ws_manager self._ws_manager = ws_manager
self._import_semaphore = asyncio.Semaphore(2)
async def save_recipe(self, request: web.Request) -> web.Response: async def save_recipe(self, request: web.Request) -> web.Response:
try: try:
@@ -760,32 +763,63 @@ class RecipeManagementHandler:
gen_params_request = self._parse_gen_params(params.get("gen_params")) gen_params_request = self._parse_gen_params(params.get("gen_params"))
self._logger.info( self._logger.info(
"Remote recipe import received: url=%s, request_gen_params_keys=%s, lora_count=%d, checkpoint_keys=%s", "Remote recipe import received: url=%s, lora_count=%d",
image_url, image_url,
sorted(gen_params_request.keys()) if gen_params_request else [],
len(lora_entries), len(lora_entries),
)
self._logger.debug(
" gen_params_keys=%s, checkpoint_keys=%s",
sorted(gen_params_request.keys()) if gen_params_request else [],
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [], sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
) )
# 2. Initial Metadata Construction # Throttle concurrent imports to avoid starving ComfyUI's event loop
async with self._import_semaphore:
return await self._do_import_remote_recipe(
image_url=image_url,
name=name,
lora_entries=lora_entries,
checkpoint_entry=checkpoint_entry,
gen_params_request=gen_params_request,
tags=self._parse_tags(params.get("tags")),
base_model=params.get("base_model", "") or "",
source_path=params.get("source_path") or image_url,
)
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except RecipeDownloadError as exc:
return web.json_response({"error": str(exc)}, status=400)
except Exception as exc:
self._logger.error(
"Error importing recipe from remote source: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def _do_import_remote_recipe(
self,
*,
image_url: str,
name: str,
lora_entries: list,
checkpoint_entry: dict,
gen_params_request: dict,
tags: list,
base_model: str,
source_path: str,
) -> web.Response:
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
metadata: Dict[str, Any] = { metadata: Dict[str, Any] = {
"base_model": params.get("base_model", "") or "", "base_model": base_model,
"loras": lora_entries, "loras": lora_entries,
"gen_params": gen_params_request or {}, "gen_params": gen_params_request or {},
"source_url": image_url, "source_path": source_path,
} }
source_path = params.get("source_path")
if source_path:
metadata["source_path"] = source_path
# Checkpoint handling
if checkpoint_entry: if checkpoint_entry:
metadata["checkpoint"] = checkpoint_entry metadata["checkpoint"] = checkpoint_entry
# Ensure checkpoint is also in gen_params for consistency if needed by enricher?
# Actually enricher looks at metadata['checkpoint'], so this is fine.
# Try to resolve base model from checkpoint if not explicitly provided
if not metadata["base_model"]: if not metadata["base_model"]:
base_model_from_metadata = ( base_model_from_metadata = (
await self._resolve_base_model_from_checkpoint(checkpoint_entry) await self._resolve_base_model_from_checkpoint(checkpoint_entry)
@@ -793,30 +827,17 @@ class RecipeManagementHandler:
if base_model_from_metadata: if base_model_from_metadata:
metadata["base_model"] = base_model_from_metadata metadata["base_model"] = base_model_from_metadata
tags = self._parse_tags(params.get("tags")) # Download image
# 3. Download Image
( (
image_bytes, image_bytes,
extension, extension,
civitai_meta_from_download, civitai_meta_raw,
model_version_id,
) = await self._download_remote_media(image_url) ) = await self._download_remote_media(image_url)
# 4. Extract Embedded Metadata # Extract embedded EXIF metadata (offloaded to thread pool in this call)
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
# with embedded data if we want it to merge it.
# However, logic in Enricher merges: request > civitai > embedded.
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
# OR pass them to enricher to handle?
# The interface of Enricher.enrich_recipe takes `recipe` (with gen_params) and `request_params`.
# So let's extract embedded and put it into recipe['gen_params'] but careful not to overwrite request params.
# Actually, `GenParamsMerger` which `Enricher` uses handles 3 layers.
# But `Enricher` interface is: recipe['gen_params'] (as embedded) + request_params + civitai (fetched internally).
# Wait, `Enricher` fetches Civitai info internally based on URL.
# `civitai_meta_from_download` is returned by `_download_remote_media` which might be useful if URL didn't have ID.
# Let's extract embedded metadata first
embedded_gen_params = {} embedded_gen_params = {}
parsed_embedded = None
try: try:
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
suffix=extension, delete=False suffix=extension, delete=False
@@ -825,7 +846,9 @@ class RecipeManagementHandler:
temp_img_path = temp_img.name temp_img_path = temp_img.name
try: try:
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path) raw_embedded = await asyncio.to_thread(
ExifUtils.extract_image_metadata, temp_img_path
)
if raw_embedded: if raw_embedded:
parser = ( parser = (
self._analysis_service._recipe_parser_factory.create_parser( self._analysis_service._recipe_parser_factory.create_parser(
@@ -848,27 +871,63 @@ class RecipeManagementHandler:
"Failed to extract embedded metadata during import: %s", exc "Failed to extract embedded metadata during import: %s", exc
) )
# Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer # Parse CivitAI API meta to discover all resources from modelVersionIds
# (modelVersionIds is injected at root level by _download_remote_media).
# Run unconditionally — EXIF parsing may succeed for gen_params but miss
# LoRAs since modelVersionIds is NOT embedded in the image EXIF.
civitai_parsed = None
if civitai_meta_raw:
civitai_inner_meta = civitai_meta_raw
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
civitai_inner_meta = civitai_meta_raw["meta"]
# modelVersionIds lives at outer meta level; propagate after unwrap
_mvids = civitai_meta_raw.get("modelVersionIds")
if _mvids and isinstance(civitai_inner_meta, dict):
civitai_inner_meta["modelVersionIds"] = _mvids
if isinstance(civitai_inner_meta, dict):
parser = self._analysis_service._recipe_parser_factory.create_parser(
civitai_inner_meta
)
if parser:
civitai_parsed = await parser.parse_metadata(
civitai_inner_meta, recipe_scanner=recipe_scanner
)
if civitai_parsed and "gen_params" in civitai_parsed:
# Merge: API gen_params override EXIF at field level,
# EXIF fills in fields the API doesn't have.
embedded_gen_params = {
**(embedded_gen_params or {}),
**civitai_parsed["gen_params"],
}
if embedded_gen_params: if embedded_gen_params:
# Merge embedded into existing gen_params (which currently only has request params if any)
# But wait, we want request params to override everything.
# So we should set recipe['gen_params'] = embedded, and pass request params to enricher.
metadata["gen_params"] = embedded_gen_params metadata["gen_params"] = embedded_gen_params
# 5. Enrich with unified logic # Merge LoRAs: prefer frontend resources, supplement with CivitAI modelVersionIds
# This will fetch Civitai info (if URL matches) and merge: request > civitai > embedded if civitai_parsed:
civitai_loras = civitai_parsed.get("loras", [])
if civitai_loras and not metadata.get("loras"):
metadata["loras"] = civitai_loras
civitai_model = civitai_parsed.get("model")
if civitai_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = civitai_model
elif parsed_embedded:
parsed_loras = parsed_embedded.get("loras")
if parsed_loras and not metadata.get("loras"):
metadata["loras"] = parsed_loras
parsed_model = parsed_embedded.get("model")
if parsed_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = parsed_model
civitai_client = self._civitai_client_getter() civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe( await RecipeEnricher.enrich_recipe(
recipe=metadata, recipe=metadata,
civitai_client=civitai_client, civitai_client=civitai_client,
request_params=gen_params_request, # Pass explicit request params here to override request_params=gen_params_request,
prefetched_civitai_meta_raw=civitai_meta_raw,
prefetched_model_version_id=model_version_id,
) )
# If we got civitai_meta from download but Enricher didn't fetch it (e.g. not a civitai URL or failed),
# we might want to manually merge it?
# But usually `import_remote_recipe` is used with Civitai URLs.
# For now, relying on Enricher's internal fetch is consistent with repair.
result = await self._persistence_service.save_recipe( result = await self._persistence_service.save_recipe(
recipe_scanner=recipe_scanner, recipe_scanner=recipe_scanner,
image_bytes=image_bytes, image_bytes=image_bytes,
@@ -879,15 +938,6 @@ class RecipeManagementHandler:
extension=extension, extension=extension,
) )
return web.json_response(result.payload, status=result.status) return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except RecipeDownloadError as exc:
return web.json_response({"error": str(exc)}, status=400)
except Exception as exc:
self._logger.error(
"Error importing recipe from remote source: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def delete_recipe(self, request: web.Request) -> web.Response: async def delete_recipe(self, request: web.Request) -> web.Response:
try: try:
@@ -1190,7 +1240,7 @@ class RecipeManagementHandler:
"exclude": False, "exclude": False,
} }
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any]: async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any, Any]:
civitai_client = self._civitai_client_getter() civitai_client = self._civitai_client_getter()
downloader = await self._downloader_factory() downloader = await self._downloader_factory()
temp_path = None temp_path = None
@@ -1238,10 +1288,31 @@ class RecipeManagementHandler:
extension = ".webp" # Default to webp if unknown extension = ".webp" # Default to webp if unknown
with open(temp_path, "rb") as file_obj: with open(temp_path, "rb") as file_obj:
model_ver_id = None
civitai_meta_raw = (
image_info.get("meta") if civitai_image_id and image_info else None
)
if civitai_image_id and image_info:
model_ver_id = image_info.get("modelVersionId")
if not model_ver_id:
ids = image_info.get("modelVersionIds")
if isinstance(ids, list) and ids:
model_ver_id = ids[0]
# Inject root-level modelVersionIds into meta so downstream
# parsers (CivitaiApiMetadataParser) can discover ALL resources
# (checkpoint + LoRAs), not just the first model version ID.
# CivitAI API returns modelVersionIds at the root level of
# the image response, NOT inside the meta object.
mvids = image_info.get("modelVersionIds")
if mvids and isinstance(civitai_meta_raw, dict):
civitai_meta_raw["modelVersionIds"] = mvids
return ( return (
file_obj.read(), file_obj.read(),
extension, extension,
image_info.get("meta") if civitai_image_id and image_info else None, civitai_meta_raw,
model_ver_id,
) )
except RecipeDownloadError: except RecipeDownloadError:
raise raise
@@ -1289,6 +1360,226 @@ class RecipeManagementHandler:
return "" return ""
async def check_image_exists(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
image_ids_raw = request.query.get("image_ids", "")
if not image_ids_raw:
return web.json_response({"success": True, "results": {}})
requested_ids = set()
for raw in image_ids_raw.split(","):
stripped = raw.strip()
if stripped and stripped.isdigit():
requested_ids.add(stripped)
if not requested_ids:
return web.json_response({"success": True, "results": {}})
cache = await recipe_scanner.get_cached_data()
# Build lookup: image_id -> recipe_id from stored source_path
image_to_recipe = {}
for recipe in getattr(cache, "raw_data", []):
source = recipe.get("source_path")
if not source:
continue
image_id = extract_civitai_image_id(source)
if image_id and image_id not in image_to_recipe:
image_to_recipe[image_id] = recipe.get("id")
results = {}
for img_id in requested_ids:
recipe_id = image_to_recipe.get(img_id)
results[img_id] = {
"in_library": recipe_id is not None,
"recipe_id": recipe_id,
}
return web.json_response({"success": True, "results": results})
except Exception as exc:
self._logger.error(
"Error checking image existence: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def import_from_url(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
image_url = request.query.get("image_url")
if not image_url:
raise RecipeValidationError("Missing required field: image_url")
image_id = extract_civitai_image_id(image_url)
if not image_id:
raise RecipeValidationError(
"Could not extract Civitai image ID from URL"
)
# Check for duplicate (fast, before acquiring semaphore)
cache = await recipe_scanner.get_cached_data()
for recipe in getattr(cache, "raw_data", []):
source = recipe.get("source_path")
if source:
existing_id = extract_civitai_image_id(source)
if existing_id == image_id:
return web.json_response({
"success": True,
"recipe_id": recipe.get("id"),
"name": recipe.get("title", ""),
"already_exists": True,
})
async with self._import_semaphore:
return await self._do_import_from_url(image_url, recipe_scanner)
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except RecipeDownloadError as exc:
return web.json_response({"error": str(exc)}, status=400)
except Exception as exc:
self._logger.error(
"Error importing recipe from URL: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def _do_import_from_url(
self,
image_url: str,
recipe_scanner: Any,
) -> web.Response:
image_id = extract_civitai_image_id(image_url)
if not image_id:
raise RecipeValidationError(
"Could not extract Civitai image ID from URL"
)
image_bytes, extension, civitai_meta_raw, model_version_id = (
await self._download_remote_media(image_url)
)
# Extract embedded EXIF metadata
embedded_gen_params = {}
parsed_embedded = None
try:
with tempfile.NamedTemporaryFile(
suffix=extension, delete=False
) as temp_img:
temp_img.write(image_bytes)
temp_img_path = temp_img.name
try:
raw_embedded = await asyncio.to_thread(
ExifUtils.extract_image_metadata, temp_img_path
)
if raw_embedded:
parser = (
self._analysis_service._recipe_parser_factory.create_parser(
raw_embedded
)
)
if parser:
parsed_embedded = await parser.parse_metadata(
raw_embedded, recipe_scanner=recipe_scanner
)
if parsed_embedded and "gen_params" in parsed_embedded:
embedded_gen_params = parsed_embedded["gen_params"]
finally:
if os.path.exists(temp_img_path):
os.unlink(temp_img_path)
except Exception as exc:
self._logger.warning(
"Failed to extract embedded metadata: %s", exc
)
# Parse CivitAI API meta to discover all resources from modelVersionIds.
# Run unconditionally — EXIF parsing succeeds for gen_params but misses
# LoRAs (modelVersionIds is NOT in the image EXIF).
civitai_parsed = None
if civitai_meta_raw:
civitai_inner_meta = civitai_meta_raw
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
civitai_inner_meta = civitai_meta_raw["meta"]
# Propagate modelVersionIds into unwrapped meta — it lives
# at the outer meta level in the CivitAI API response.
_mvids = civitai_meta_raw.get("modelVersionIds")
if _mvids and isinstance(civitai_inner_meta, dict):
civitai_inner_meta["modelVersionIds"] = _mvids
if isinstance(civitai_inner_meta, dict):
parser = self._analysis_service._recipe_parser_factory.create_parser(
civitai_inner_meta
)
if parser:
civitai_parsed = await parser.parse_metadata(
civitai_inner_meta, recipe_scanner=recipe_scanner
)
if civitai_parsed and "gen_params" in civitai_parsed:
# Merge: API gen_params override EXIF at field level,
# EXIF fills in fields the API doesn't have.
embedded_gen_params = {
**(embedded_gen_params or {}),
**civitai_parsed["gen_params"],
}
metadata: Dict[str, Any] = {
"base_model": "",
"loras": [],
"gen_params": embedded_gen_params or {},
"source_path": image_url,
}
if civitai_parsed:
civitai_loras = civitai_parsed.get("loras", [])
if civitai_loras and not metadata.get("loras"):
metadata["loras"] = civitai_loras
civitai_model = civitai_parsed.get("model")
if civitai_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = civitai_model
elif parsed_embedded:
parsed_loras = parsed_embedded.get("loras")
if parsed_loras and not metadata.get("loras"):
metadata["loras"] = parsed_loras
parsed_model = parsed_embedded.get("model")
if parsed_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = parsed_model
civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe(
recipe=metadata,
civitai_client=civitai_client,
request_params={},
prefetched_civitai_meta_raw=civitai_meta_raw,
prefetched_model_version_id=model_version_id,
)
prompt = (
metadata.get("gen_params", {}).get("prompt")
or metadata.get("gen_params", {}).get("positivePrompt")
or ""
)
if prompt:
name = " ".join(str(prompt).split()[:10])
else:
name = f"Civitai Image {image_id}"
result = await self._persistence_service.save_recipe(
recipe_scanner=recipe_scanner,
image_bytes=image_bytes,
image_base64=None,
name=name,
tags=[],
metadata=metadata,
extension=extension,
)
return web.json_response(result.payload, status=result.status)
class RecipeAnalysisHandler: class RecipeAnalysisHandler:
"""Analyze images to extract recipe metadata.""" """Analyze images to extract recipe metadata."""

View File

@@ -70,6 +70,10 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"POST", "/api/lm/recipes/batch-import/directory", "start_directory_import" "POST", "/api/lm/recipes/batch-import/directory", "start_directory_import"
), ),
RouteDefinition("POST", "/api/lm/recipes/browse-directory", "browse_directory"), RouteDefinition("POST", "/api/lm/recipes/browse-directory", "browse_directory"),
RouteDefinition(
"GET", "/api/lm/recipes/check-image-exists", "check_image_exists"
),
RouteDefinition("GET", "/api/lm/recipes/import-from-url", "import_from_url"),
) )

View File

@@ -0,0 +1,121 @@
"""
Auto-tag extraction service for model cards.
Extracts implicit model attributes (HIGH/LOW, I2V/T2V/TI2V, Lightning, Turbo)
from filename, base_model, and CivitAI version name — no manual tagging required.
"""
from __future__ import annotations
import re
from typing import Dict, List, Set
# ── Tag category definitions ──────────────────────────────────────────
# Each category maps a display label to a regex pattern.
# Patterns are case-insensitive and matched against filename, base_model,
# and civitai version name.
# Use (?<![a-zA-Z0-9]) and (?![a-zA-Z0-9]) instead of \b because
# Python's \b treats underscore as a word character, so \bHIGH\b
# won't match '_HIGH_' in filenames.
_B = r"(?<![a-zA-Z0-9])" # left boundary
_E = r"(?![a-zA-Z0-9])" # right boundary
AUTO_TAG_CATEGORIES: Dict[str, str] = {
"HIGH": _B + r"HIGH" + _E,
"LOW": _B + r"(?<!F)LOW" + _E,
"I2V": _B + r"I2V" + _E,
"T2V": _B + r"T2V" + _E,
"TI2V": _B + r"TI2V" + _E,
"Lightning": _B + r"Lightning" + _E,
"Turbo": _B + r"Turbo" + _E,
}
# Tags that belong to the "mode" group (HIGH/LOW)
MODE_TAGS = {"HIGH", "LOW"}
# Tags that belong to the "video mode" group (I2V/T2V/TI2V)
VIDEO_MODE_TAGS = {"I2V", "T2V", "TI2V"}
# Tags that belong to the "speed/optimization" group
SPEED_TAGS = {"Lightning", "Turbo"}
# ── Display category groups (for settings UI) ─────────────────────────
AUTO_TAG_GROUPS = {
"mode": {"HIGH", "LOW"},
"video": {"I2V", "T2V", "TI2V"},
"speed": {"Lightning", "Turbo"},
}
# Default enabled categories
DEFAULT_ENABLED_GROUPS = {"mode", "video"}
def _collect_sources(model_data: Dict) -> List[str]:
"""Collect all text sources from model data for tag matching."""
sources: List[str] = []
file_name = model_data.get("file_name", "")
if file_name:
sources.append(file_name)
base_model = model_data.get("base_model", "")
if base_model:
sources.append(base_model)
civitai = model_data.get("civitai", {})
if isinstance(civitai, dict):
version_name = civitai.get("name", "")
if version_name:
sources.append(version_name)
return sources
def extract_auto_tags(model_data: Dict) -> List[str]:
"""Extract auto-detected tags from model metadata.
Matches predefined patterns against filename, base_model, and
CivitAI version name. Returns a sorted, deduplicated list of tag labels.
HIGH/LOW tags are only returned when the base_model indicates a Wan
family model — no other model architecture uses this distinction.
Args:
model_data: Model metadata dict with keys:
file_name, base_model, civitai (with optional 'name' field).
Returns:
Sorted list of unique auto-tag strings (e.g. ["I2V"]).
"""
sources = _collect_sources(model_data)
if not sources:
return []
base_model = model_data.get("base_model", "")
is_wan = "wan" in base_model.lower()
found: Set[str] = set()
for label, pattern in AUTO_TAG_CATEGORIES.items():
# HIGH/LOW are Wan-specific — skip for non-Wan to avoid noise
if label in ("HIGH", "LOW"):
if not is_wan:
continue
# Use case-insensitive character class + case-sensitive boundary,
# so "HighNoise" (camelCase) matches but "highlight" doesn't.
# Boundary: not followed by lowercase letter (= word has ended).
ci = "".join(f"[{c.lower()}{c.upper()}]" for c in label)
if label == "LOW":
regex = re.compile(r"(?<![Ff])" + ci + r"(?![a-z])")
else:
regex = re.compile(ci + r"(?![a-z])")
else:
regex = re.compile(pattern, re.IGNORECASE)
for source in sources:
if regex.search(source):
found.add(label)
break
return sorted(found)

View File

@@ -77,6 +77,7 @@ class BaseModelService(ABC):
base_models: list = None, base_models: list = None,
model_types: list = None, model_types: list = None,
tags: Optional[Dict[str, str]] = None, tags: Optional[Dict[str, str]] = None,
auto_tags: Optional[Dict[str, str]] = None,
search_options: dict = None, search_options: dict = None,
hash_filters: dict = None, hash_filters: dict = None,
favorites_only: bool = False, favorites_only: bool = False,
@@ -95,6 +96,11 @@ class BaseModelService(ABC):
sorted_data = await self._fetch_with_usage_sort(sort_params) sorted_data = await self._fetch_with_usage_sort(sort_params)
else: else:
sorted_data = await self.cache_repository.fetch_sorted(sort_params) sorted_data = await self.cache_repository.fetch_sorted(sort_params)
# Pre-compute auto_tags for every item — needed for both filtering
# and display. Computation is cheap (string regex on 2-3 fields).
from .auto_tag_service import extract_auto_tags
for item in sorted_data:
item["auto_tags"] = extract_auto_tags(item)
fetch_duration = time.perf_counter() - t0 fetch_duration = time.perf_counter() - t0
initial_count = len(sorted_data) initial_count = len(sorted_data)
@@ -110,6 +116,7 @@ class BaseModelService(ABC):
base_models=base_models, base_models=base_models,
model_types=model_types, model_types=model_types,
tags=tags, tags=tags,
auto_tags=auto_tags,
favorites_only=favorites_only, favorites_only=favorites_only,
search_options=search_options, search_options=search_options,
tag_logic=tag_logic, tag_logic=tag_logic,
@@ -354,6 +361,7 @@ class BaseModelService(ABC):
base_models: list = None, base_models: list = None,
model_types: list = None, model_types: list = None,
tags: Optional[Dict[str, str]] = None, tags: Optional[Dict[str, str]] = None,
auto_tags: Optional[Dict[str, str]] = None,
favorites_only: bool = False, favorites_only: bool = False,
search_options: dict = None, search_options: dict = None,
tag_logic: str = "any", tag_logic: str = "any",
@@ -367,6 +375,7 @@ class BaseModelService(ABC):
base_models=base_models, base_models=base_models,
model_types=model_types, model_types=model_types,
tags=tags, tags=tags,
auto_tags=auto_tags,
favorites_only=favorites_only, favorites_only=favorites_only,
search_options=normalized_options, search_options=normalized_options,
tag_logic=tag_logic, tag_logic=tag_logic,
@@ -908,6 +917,17 @@ class BaseModelService(ABC):
) )
if should_skip or metadata is None: if should_skip or metadata is None:
return None return None
# Prune stale example-image metadata entries whose files no longer
# exist on disk (e.g. a user deleted the files manually).
from ..utils.example_images_metadata import MetadataUpdater
was_modified = await MetadataUpdater.prune_stale_example_images(metadata)
if was_modified:
asyncio.create_task(
MetadataManager.save_metadata(file_path, metadata)
)
return self.filter_civitai_data(metadata.to_dict().get("civitai", {})) return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
async def get_model_description(self, file_path: str) -> Optional[str]: async def get_model_description(self, file_path: str) -> Optional[str]:

View File

@@ -224,7 +224,7 @@ class BatchImportService:
return False return False
for recipe in getattr(cache, "raw_data", []): for recipe in getattr(cache, "raw_data", []):
source_path = recipe.get("source_path") or recipe.get("source_url") source_path = recipe.get("source_path")
if source_path and source_path == source: if source_path and source_path == source:
return True return True
return False return False

View File

@@ -3,6 +3,7 @@ import logging
from typing import Dict from typing import Dict
from .base_model_service import BaseModelService from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags
from ..utils.models import CheckpointMetadata from ..utils.models import CheckpointMetadata
from ..config import config from ..config import config
@@ -45,7 +46,8 @@ class CheckpointService(BaseModelService):
"exclude": bool(checkpoint_data.get("exclude", False)), "exclude": bool(checkpoint_data.get("exclude", False)),
"update_available": bool(checkpoint_data.get("update_available", False)), "update_available": bool(checkpoint_data.get("update_available", False)),
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)), "skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True) "civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
} }
def find_duplicate_hashes(self) -> Dict: def find_duplicate_hashes(self) -> Dict:

View File

@@ -257,7 +257,7 @@ class CivitaiClient:
"GET", "GET",
f"{self.base_url}/models", f"{self.base_url}/models",
use_auth=True, use_auth=True,
params={"ids": query}, params={"ids": query, "nsfw": "true"},
) )
if not success: if not success:
return None return None
@@ -640,7 +640,7 @@ class CivitaiClient:
"GET", "GET",
f"{self.base_url}/models", f"{self.base_url}/models",
use_auth=True, use_auth=True,
params={"username": username}, params={"username": username, "nsfw": "true"},
) )
if not success: if not success:

View File

@@ -206,7 +206,7 @@ class DownloadedVersionHistoryService:
) )
conn.commit() conn.commit()
async def mark_not_downloaded(self, model_type: str, version_id: int) -> None: async def mark_as_deleted(self, model_type: str, version_id: int) -> None:
normalized_type = _normalize_model_type(model_type) normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id) normalized_version_id = _normalize_int(version_id)
if normalized_type is None or normalized_version_id is None: if normalized_type is None or normalized_version_id is None:

View File

@@ -3,6 +3,7 @@ import logging
from typing import Dict from typing import Dict
from .base_model_service import BaseModelService from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags
from ..utils.models import EmbeddingMetadata from ..utils.models import EmbeddingMetadata
from ..config import config from ..config import config
@@ -45,7 +46,8 @@ class EmbeddingService(BaseModelService):
"exclude": bool(embedding_data.get("exclude", False)), "exclude": bool(embedding_data.get("exclude", False)),
"update_available": bool(embedding_data.get("update_available", False)), "update_available": bool(embedding_data.get("update_available", False)),
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)), "skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True) "civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
} }
def find_duplicate_hashes(self) -> Dict: def find_duplicate_hashes(self) -> Dict:

View File

@@ -5,6 +5,7 @@ from typing import Dict, List, Optional
from .base_model_service import BaseModelService from .base_model_service import BaseModelService
from .model_query import resolve_sub_type from .model_query import resolve_sub_type
from .auto_tag_service import extract_auto_tags
from ..utils.models import LoraMetadata from ..utils.models import LoraMetadata
from ..config import config from ..config import config
@@ -57,6 +58,7 @@ class LoraService(BaseModelService):
"civitai": self.filter_civitai_data( "civitai": self.filter_civitai_data(
lora_data.get("civitai", {}), minimal=True lora_data.get("civitai", {}), minimal=True
), ),
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
} }
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]: async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:

View File

@@ -111,6 +111,11 @@ class ModelLifecycleService:
self._scanner._hash_index.remove_by_path(file_path) self._scanner._hash_index.remove_by_path(file_path)
await self._sync_update_for_model(model_id) await self._sync_update_for_model(model_id)
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
if callable(persist_current_cache):
await persist_current_cache()
return {"success": True, "deleted_files": deleted_files} return {"success": True, "deleted_files": deleted_files}
@staticmethod @staticmethod

View File

@@ -96,6 +96,7 @@ class FilterCriteria:
folder_exclude: Optional[Sequence[str]] = None folder_exclude: Optional[Sequence[str]] = None
base_models: Optional[Sequence[str]] = None base_models: Optional[Sequence[str]] = None
tags: Optional[Dict[str, str]] = None tags: Optional[Dict[str, str]] = None
auto_tags: Optional[Dict[str, str]] = None
favorites_only: bool = False favorites_only: bool = False
search_options: Optional[Dict[str, Any]] = None search_options: Optional[Dict[str, Any]] = None
model_types: Optional[Sequence[str]] = None model_types: Optional[Sequence[str]] = None
@@ -359,10 +360,37 @@ class ModelFilterSet:
] ]
model_types_duration = time.perf_counter() - t0 model_types_duration = time.perf_counter() - t0
auto_tags_duration = 0
auto_tag_filters = criteria.auto_tags or {}
if auto_tag_filters:
t0 = time.perf_counter()
include_at = set()
exclude_at = set()
for tag, state in auto_tag_filters.items():
if not tag:
continue
if state == "exclude":
exclude_at.add(tag)
else:
include_at.add(tag)
if include_at:
items = [
item for item in items
if any(tag in include_at for tag in (item.get("auto_tags") or []))
]
if exclude_at:
items = [
item for item in items
if not any(tag in exclude_at for tag in (item.get("auto_tags") or []))
]
auto_tags_duration = time.perf_counter() - t0
duration = time.perf_counter() - overall_start duration = time.perf_counter() - overall_start
if duration > 0.1: # Only log if it's potentially slow if duration > 0.1: # Only log if it's potentially slow
logger.debug( logger.debug(
"ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs). " "ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs, auto_tags: %.3fs). "
"Count: %d -> %d", "Count: %d -> %d",
duration, duration,
sfw_duration, sfw_duration,
@@ -371,6 +399,7 @@ class ModelFilterSet:
base_models_duration, base_models_duration,
tags_duration, tags_duration,
model_types_duration, model_types_duration,
auto_tags_duration,
initial_count, initial_count,
len(items), len(items),
) )

View File

@@ -38,6 +38,7 @@ class PersistentRecipeCache:
"json_path", "json_path",
"title", "title",
"folder", "folder",
"source_path",
"base_model", "base_model",
"fingerprint", "fingerprint",
"created_date", "created_date",
@@ -334,6 +335,7 @@ class PersistentRecipeCache:
json_path TEXT, json_path TEXT,
title TEXT, title TEXT,
folder TEXT, folder TEXT,
source_path TEXT,
base_model TEXT, base_model TEXT,
fingerprint TEXT, fingerprint TEXT,
created_date REAL, created_date REAL,
@@ -358,6 +360,13 @@ class PersistentRecipeCache:
); );
""" """
) )
# Migration: add source_path column to existing databases
try:
conn.execute(
"ALTER TABLE recipes ADD COLUMN source_path TEXT"
)
except Exception:
pass # column already exists
conn.commit() conn.commit()
self._schema_initialized = True self._schema_initialized = True
except Exception as exc: except Exception as exc:
@@ -406,6 +415,7 @@ class PersistentRecipeCache:
json_path, json_path,
recipe.get("title"), recipe.get("title"),
recipe.get("folder"), recipe.get("folder"),
recipe.get("source_path"),
recipe.get("base_model"), recipe.get("base_model"),
recipe.get("fingerprint"), recipe.get("fingerprint"),
float(recipe.get("created_date") or 0.0), float(recipe.get("created_date") or 0.0),
@@ -456,6 +466,7 @@ class PersistentRecipeCache:
"file_path": row["file_path"] or "", "file_path": row["file_path"] or "",
"title": row["title"] or "", "title": row["title"] or "",
"folder": row["folder"] or "", "folder": row["folder"] or "",
"source_path": row["source_path"] or "",
"base_model": row["base_model"] or "", "base_model": row["base_model"] or "",
"fingerprint": row["fingerprint"] or "", "fingerprint": row["fingerprint"] or "",
"created_date": row["created_date"] or 0.0, "created_date": row["created_date"] or 0.0,

View File

@@ -504,6 +504,9 @@ class RecipeScanner:
self._cache.raw_data = recipes self._cache.raw_data = recipes
self._update_folder_metadata(self._cache) self._update_folder_metadata(self._cache)
self._sort_cache_sync() self._sort_cache_sync()
# Backfill source_path from JSON files if missing (schema migration)
if self._backfill_source_path_if_needed(recipes, json_paths):
self._persistent_cache.save_cache(recipes, json_paths)
return self._cache return self._cache
else: else:
# Partial update: some files changed # Partial update: some files changed
@@ -514,6 +517,8 @@ class RecipeScanner:
self._cache.raw_data = recipes self._cache.raw_data = recipes
self._update_folder_metadata(self._cache) self._update_folder_metadata(self._cache)
self._sort_cache_sync() self._sort_cache_sync()
# Backfill source_path from JSON files if missing (schema migration)
self._backfill_source_path_if_needed(recipes, json_paths)
# Persist updated cache # Persist updated cache
self._persistent_cache.save_cache(recipes, json_paths) self._persistent_cache.save_cache(recipes, json_paths)
return self._cache return self._cache
@@ -642,6 +647,34 @@ class RecipeScanner:
return recipes, changed, json_paths return recipes, changed, json_paths
def _backfill_source_path_if_needed(
self,
recipes: List[Dict],
json_paths: Dict[str, str],
) -> bool:
"""Backfill source_path from recipe JSON files if missing from cache.
Returns True if any recipes were updated (caller should persist cache).
"""
updated = False
for recipe in recipes:
if recipe.get("source_path"):
continue
recipe_id = str(recipe.get("id", ""))
json_path = json_paths.get(recipe_id)
if not json_path or not os.path.exists(json_path):
continue
try:
with open(json_path, "r", encoding="utf-8") as f:
json_data = json.load(f)
file_source_path = json_data.get("source_path")
if file_source_path:
recipe["source_path"] = file_source_path
updated = True
except Exception:
pass
return updated
def _full_directory_scan_sync( def _full_directory_scan_sync(
self, recipes_dir: str self, recipes_dir: str
) -> Tuple[List[Dict], Dict[str, str]]: ) -> Tuple[List[Dict], Dict[str, str]]:

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import base64 import base64
import io import io
import os import os
@@ -14,6 +15,7 @@ from PIL import Image
from ...utils.utils import calculate_recipe_fingerprint from ...utils.utils import calculate_recipe_fingerprint
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
from ...recipes.enrichment import RecipeEnricher
from .errors import ( from .errors import (
RecipeDownloadError, RecipeDownloadError,
RecipeNotFoundError, RecipeNotFoundError,
@@ -170,9 +172,11 @@ class RecipeAnalysisService:
await self._download_image(url, temp_path) await self._download_image(url, temp_path)
if metadata is None and not is_video: if metadata is None and not is_video:
metadata = self._exif_utils.extract_image_metadata(temp_path) metadata = await asyncio.to_thread(
self._exif_utils.extract_image_metadata, temp_path
)
return await self._parse_metadata( result = await self._parse_metadata(
metadata or {}, metadata or {},
recipe_scanner=recipe_scanner, recipe_scanner=recipe_scanner,
image_path=temp_path, image_path=temp_path,
@@ -180,6 +184,37 @@ class RecipeAnalysisService:
is_video=is_video, is_video=is_video,
extension=extension, extension=extension,
) )
if civitai_image_id and image_info and not result.payload.get("error"):
mvid = image_info.get("modelVersionId")
if not mvid:
mvids = image_info.get("modelVersionIds")
if isinstance(mvids, list) and mvids:
mvid = mvids[0]
recipe_for_enrich = {
"gen_params": result.payload.get("gen_params", {}),
"loras": result.payload.get("loras", []),
"base_model": result.payload.get("base_model", "") or "",
"checkpoint": result.payload.get("checkpoint") or result.payload.get("model"),
"source_path": url,
}
await RecipeEnricher.enrich_recipe(
recipe=recipe_for_enrich,
civitai_client=civitai_client,
request_params=None,
prefetched_civitai_meta_raw=image_info.get("meta"),
prefetched_model_version_id=mvid,
)
result.payload["gen_params"] = recipe_for_enrich["gen_params"]
if recipe_for_enrich.get("checkpoint"):
result.payload["checkpoint"] = recipe_for_enrich["checkpoint"]
if recipe_for_enrich.get("base_model"):
result.payload["base_model"] = recipe_for_enrich["base_model"]
return result
finally: finally:
if temp_path: if temp_path:
self._safe_cleanup(temp_path) self._safe_cleanup(temp_path)
@@ -199,7 +234,9 @@ class RecipeAnalysisService:
if not os.path.isfile(normalized_path): if not os.path.isfile(normalized_path):
raise RecipeNotFoundError("File not found") raise RecipeNotFoundError("File not found")
metadata = self._exif_utils.extract_image_metadata(normalized_path) metadata = await asyncio.to_thread(
self._exif_utils.extract_image_metadata, normalized_path
)
if not metadata: if not metadata:
return self._metadata_not_found_response(normalized_path) return self._metadata_not_found_response(normalized_path)

View File

@@ -7,7 +7,7 @@ from typing import Any, Dict, Iterable, Mapping, Sequence
from urllib.parse import parse_qs, urlparse, urlunparse from urllib.parse import parse_qs, urlparse, urlunparse
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red"}) _SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red", "civitai.green"})
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com" DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",) _DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
_LICENSE_DEFAULTS: Dict[str, Any] = { _LICENSE_DEFAULTS: Dict[str, Any] = {

View File

@@ -452,3 +452,111 @@ class MetadataUpdater:
except Exception as e: except Exception as e:
logger.error(f"Error parsing image metadata: {e}", exc_info=True) logger.error(f"Error parsing image metadata: {e}", exc_info=True)
return None return None
@staticmethod
async def prune_stale_example_images(metadata) -> bool:
"""Remove example-image metadata entries whose files no longer exist on disk.
Checks ``civitai.customImages`` (by ``id``) and ``civitai.images`` entries
that have an empty ``url`` (no remote fallback) against actual files in
the model's example-image folder. Stale entries are removed in-place so
the caller can persist the cleaned metadata afterwards.
Args:
metadata: A ``BaseModelMetadata`` instance (modified in place).
Returns:
True if at least one entry was removed.
"""
from ..utils.example_images_paths import get_model_folder
model_hash = getattr(metadata, "sha256", None)
if not model_hash:
return False
model_folder = get_model_folder(model_hash)
if not model_folder:
return False
civitai = getattr(metadata, "civitai", None)
if not isinstance(civitai, dict):
return False
has_changes = False
custom_images = civitai.get("customImages")
if isinstance(custom_images, list) and custom_images:
stale: list[int] = []
for idx, img in enumerate(custom_images):
img_id = img.get("id", "")
if not img_id:
continue
if not os.path.isdir(model_folder):
stale.append(idx)
else:
found = False
try:
prefix = f"custom_{img_id}"
for fname in os.listdir(model_folder):
if fname.startswith(prefix) and os.path.isfile(
os.path.join(model_folder, fname)
):
found = True
break
except OSError:
stale.append(idx)
continue
if not found:
stale.append(idx)
if stale:
for idx in reversed(stale):
custom_images.pop(idx)
has_changes = True
logger.info(
"Pruned %d stale custom image(s) for %s",
len(stale),
getattr(metadata, "model_name", model_hash),
)
images = civitai.get("images")
if isinstance(images, list) and images:
stale: list[int] = []
for idx, img in enumerate(images):
if img.get("url", ""):
# Has a remote fallback keep it even if the local copy
# is gone.
continue
if not os.path.isdir(model_folder):
stale.append(idx)
else:
found = False
try:
prefix = f"image_{idx}."
for fname in os.listdir(model_folder):
if fname.startswith(prefix):
found = True
break
except OSError:
stale.append(idx)
continue
if not found:
stale.append(idx)
if stale:
for idx in reversed(stale):
images.pop(idx)
has_changes = True
logger.info(
"Pruned %d stale image entry(ies) for %s",
len(stale),
getattr(metadata, "model_name", model_hash),
)
return has_changes

View File

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

View File

@@ -507,21 +507,96 @@
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */ background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
} }
/* Version row — flex container for badges + version names */
.version-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 3px;
margin-top: 2px;
}
/* Badge + version-name binding: they wrap as a single unit */
.badge-version-unit {
display: inline-flex;
align-items: center;
gap: 3px;
min-width: 0;
flex-shrink: 0;
}
/* Medium density adjustments for version name */ /* Medium density adjustments for version name */
.medium-density .version-name { .medium-density .version-name {
font-size: 0.8em; font-size: 0.8em;
} }
.medium-density .badge-version-unit .version-name {
max-width: 90px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Compact density adjustments for version name */ /* Compact density adjustments for version name */
.compact-density .version-name { .compact-density .version-name {
font-size: 0.75em; font-size: 0.75em;
} }
/* Hide civitai version name when setting is disabled */ .compact-density .badge-version-unit .version-name {
body.hide-card-version .civitai-version { max-width: 70px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.medium-density .version-row {
gap: 2px;
}
/* HIGH / LOW badges — shown inline before version name in card footer */
.hl-badge {
display: inline-block;
font-size: 0.7em;
font-weight: 600;
line-height: 1.1;
padding: 1px 5px;
border-radius: var(--border-radius-xs);
border: 1px solid rgba(255, 255, 255, 0.2);
white-space: nowrap;
}
.hl-badge--high {
color: oklch(75% 0.12 230);
background: oklch(55% 0.15 240 / 0.25);
border-color: oklch(60% 0.18 250 / 0.3);
}
.hl-badge--low {
color: oklch(78% 0.10 185);
background: oklch(50% 0.10 190 / 0.25);
border-color: oklch(55% 0.12 195 / 0.3);
}
.medium-density .hl-badge {
font-size: 0.65em;
}
.compact-density .hl-badge {
font-size: 0.62em;
padding: 0px 4px;
}
/* Hide version-related elements when setting is disabled */
body.hide-card-version .civitai-version,
body.hide-card-version .hl-badge {
display: none; display: none;
} }
/* Compact density adjustments for version name */
.compact-density .version-name {
font-size: 0.75em;
}
/* Prevent text selection on cards and interactive elements */ /* Prevent text selection on cards and interactive elements */
.model-card, .model-card,
.model-card *, .model-card *,

View File

@@ -0,0 +1,124 @@
.media-viewer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.3s ease;
}
.media-viewer-overlay.active {
background: rgba(0, 0, 0, 0.92);
}
.media-viewer-close {
position: fixed;
top: 16px;
right: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
border: none;
color: #fff;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10001;
transition: background 0.2s ease;
opacity: 0;
}
.media-viewer-overlay.active .media-viewer-close {
opacity: 1;
}
.media-viewer-close:hover {
background: rgba(255, 255, 255, 0.25);
}
.media-viewer-content-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
max-width: 90vw;
max-height: 95vh;
cursor: default;
}
.media-viewer-media {
display: block;
max-width: 90vw;
max-height: 85vh;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
.media-viewer-video {
max-height: 80vh;
}
.media-viewer-counter {
margin-top: 8px;
color: rgba(255, 255, 255, 0.5);
font-size: 0.85em;
text-align: center;
min-height: 1.2em;
}
.media-viewer-title {
margin-top: 4px;
color: rgba(255, 255, 255, 0.7);
font-size: 0.9em;
text-align: center;
max-width: 90vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-viewer-nav {
position: fixed;
top: 50%;
transform: translateY(-50%);
width: 48px;
height: 80px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
border: none;
color: #fff;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10001;
opacity: 0;
transition: opacity 0.2s ease, background 0.2s ease;
}
.media-viewer-overlay.active .media-viewer-nav {
opacity: 1;
}
.media-viewer-nav:hover {
background: rgba(255, 255, 255, 0.18);
}
.media-viewer-prev {
left: 16px;
}
.media-viewer-next {
right: 16px;
}

View File

@@ -41,6 +41,63 @@
text-align: center; text-align: center;
} }
/* Section Headers */
.context-menu-section-header {
padding: 6px 12px 2px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
cursor: default;
user-select: none;
}
/* Submenu */
.context-menu-item.has-submenu {
position: relative;
justify-content: space-between;
}
.submenu-arrow {
margin-left: auto;
font-size: 10px;
width: auto !important;
}
.context-submenu {
position: absolute;
left: calc(100% - 4px);
top: -1px;
display: none;
background: var(--lora-surface);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: 0;
min-width: 200px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
z-index: 1001;
backdrop-filter: blur(10px);
}
.context-submenu .context-menu-item {
white-space: nowrap;
margin: 0;
}
.context-submenu .context-menu-item:first-child {
padding-top: 9px;
}
.context-submenu .context-menu-item:last-child {
padding-bottom: 9px;
}
.context-submenu.flip-left {
left: auto;
right: 100%;
}
/* NSFW Level Selector */ /* NSFW Level Selector */
.nsfw-level-selector { .nsfw-level-selector {
position: fixed; position: fixed;

View File

@@ -4,15 +4,20 @@
justify-content: flex-start; justify-content: flex-start;
align-items: flex-start; align-items: flex-start;
border-bottom: 1px solid var(--lora-border); border-bottom: 1px solid var(--lora-border);
padding-bottom: 10px; padding-bottom: var(--space-2);
margin-bottom: 10px; margin-bottom: var(--space-3);
position: relative;
} }
.recipe-modal-header h2 { .recipe-modal-header h2 {
font-size: 1.4em; /* Reduced from default h2 size */ margin: 0 0 var(--space-1);
line-height: 1.3; padding: var(--space-1);
margin: 0; border-radius: var(--border-radius-xs);
max-height: 2.6em; /* Limit to 2 lines */ font-size: 1.5em;
font-weight: 600;
line-height: 1.2;
color: var(--text-color);
max-height: 2.8em;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
@@ -127,7 +132,7 @@
/* Recipe Tags styles */ /* Recipe Tags styles */
.recipe-tags-container { .recipe-tags-container {
position: relative; position: relative;
margin-top: 6px; margin-top: 0;
margin-bottom: 10px; margin-bottom: 10px;
} }
@@ -225,6 +230,62 @@
overflow: hidden; overflow: hidden;
} }
/* Recipe Header Actions */
.recipe-header-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
width: 100%;
margin-bottom: var(--space-1);
flex-shrink: 0;
min-height: 0;
}
.recipe-header-actions:empty {
display: none;
}
.recipe-source-url-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
font-weight: 500;
font-size: 0.9em;
transition: all 0.2s;
white-space: nowrap;
}
[data-theme="dark"] .recipe-source-url-btn {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
.recipe-source-url-btn:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent);
transform: translateY(-1px);
}
.recipe-source-url-btn i {
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
@media (max-height: 860px) {
.recipe-header-actions {
padding-bottom: 4px;
}
}
/* Top Section: Preview and Gen Params */ /* Top Section: Preview and Gen Params */
.recipe-top-section { .recipe-top-section {
display: grid; display: grid;
@@ -396,14 +457,54 @@
flex-direction: column; flex-direction: column;
} }
.recipe-gen-params h3 { .gen-params-header-row {
margin-top: 0; display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
font-size: 1.2em;
color: var(--text-color);
padding-bottom: var(--space-1); padding-bottom: var(--space-1);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
flex-shrink: 0; flex-shrink: 0;
gap: 8px;
}
.gen-params-header-row h3 {
margin: 0;
font-size: 1.2em;
color: var(--text-color);
}
/* Inline toggle for lora strip setting */
.lora-strip-toggle {
flex-shrink: 0;
gap: 6px;
}
.lora-strip-toggle .inline-toggle-label {
font-size: 0.78em;
white-space: nowrap;
opacity: 0.7;
transition: opacity 0.2s;
}
.lora-strip-toggle:hover .inline-toggle-label {
opacity: 1;
}
.lora-strip-toggle .toggle-switch {
width: 32px;
height: 16px;
}
.lora-strip-toggle .toggle-slider:before {
height: 10px;
width: 10px;
left: 3px;
bottom: 3px;
}
.lora-strip-toggle .toggle-switch input:checked + .toggle-slider:before {
transform: translateX(16px);
} }
.gen-params-container { .gen-params-container {
@@ -1043,13 +1144,13 @@
} }
.recipe-modal-header { .recipe-modal-header {
padding-bottom: 6px; padding-bottom: var(--space-1);
margin-bottom: 8px; margin-bottom: var(--space-2);
} }
.recipe-modal-header h2 { .recipe-modal-header h2 {
font-size: 1.25em; font-size: 1.3em;
max-height: 2.5em; max-height: 2.4em;
} }
.recipe-tags-container { .recipe-tags-container {

View File

@@ -39,6 +39,7 @@
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */ @import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
@import 'components/statistics.css'; /* Add statistics component */ @import 'components/statistics.css'; /* Add statistics component */
@import 'components/sidebar.css'; /* Add sidebar component */ @import 'components/sidebar.css'; /* Add sidebar component */
@import 'components/media-viewer.css';
.initialization-notice { .initialization-notice {
display: flex; display: flex;

View File

@@ -978,6 +978,16 @@ export class BaseModelApiClient {
}); });
} }
if (pageState.filters.autoTags && Object.keys(pageState.filters.autoTags).length > 0) {
Object.entries(pageState.filters.autoTags).forEach(([tag, state]) => {
if (state === 'include') {
params.append('auto_tag_include', tag);
} else if (state === 'exclude') {
params.append('auto_tag_exclude', tag);
}
});
}
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) { if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
// Check for empty wildcard marker - if present, no models should match // Check for empty wildcard marker - if present, no models should match
const EMPTY_WILDCARD_MARKER = '__EMPTY_WILDCARD_RESULT__'; const EMPTY_WILDCARD_MARKER = '__EMPTY_WILDCARD_RESULT__';

View File

@@ -3,6 +3,8 @@ export class BaseContextMenu {
this.menu = document.getElementById(menuId); this.menu = document.getElementById(menuId);
this.cardSelector = cardSelector; this.cardSelector = cardSelector;
this.currentCard = null; this.currentCard = null;
this.submenuTimeout = null;
this.openSubmenu = null;
if (!this.menu) { if (!this.menu) {
console.error(`Context menu element with ID ${menuId} not found`); console.error(`Context menu element with ID ${menuId} not found`);
@@ -13,20 +15,99 @@ export class BaseContextMenu {
} }
init() { init() {
// Hide menu on regular clicks // Hide menu when clicking outside
document.addEventListener('click', () => this.hideMenu()); document.addEventListener('click', (e) => {
if (!this.menu.contains(e.target)) {
this.hideMenu();
}
});
// Handle menu item clicks // Handle menu item clicks (including submenu items)
this.menu.addEventListener('click', (e) => { this.menu.addEventListener('click', (e) => {
const menuItem = e.target.closest('.context-menu-item'); const menuItem = e.target.closest('.context-menu-item');
if (!menuItem || !this.currentCard) return; if (!menuItem || !this.currentCard) return;
// Ignore clicks on submenu trigger (has-submenu parent)
if (menuItem.classList.contains('has-submenu')) return;
const action = menuItem.dataset.action; const action = menuItem.dataset.action;
if (!action) return; if (!action) return;
this.handleMenuAction(action, menuItem); this.handleMenuAction(action, menuItem);
this.hideMenu(); this.hideMenu();
}); });
// Submenu hover handling
// Use mouseover/mouseout (which bubble) with relatedTarget checks
// to reliably detect crossing the .has-submenu boundary
this.menu.addEventListener('mouseover', (e) => {
const trigger = e.target.closest('.has-submenu');
if (!trigger) return;
// Only act when entering from outside this trigger's tree
if (e.relatedTarget && trigger.contains(e.relatedTarget)) return;
this._openSubmenu(trigger);
});
this.menu.addEventListener('mouseout', (e) => {
const trigger = e.target.closest('.has-submenu');
if (!trigger) return;
// Only close when leaving the trigger's tree entirely
if (e.relatedTarget && trigger.contains(e.relatedTarget)) return;
this._scheduleSubmenuClose(trigger);
});
}
_openSubmenu(trigger) {
// Clear any pending close
if (this.submenuTimeout) {
clearTimeout(this.submenuTimeout);
this.submenuTimeout = null;
}
// Hide any previously open submenu
if (this.openSubmenu && this.openSubmenu !== trigger) {
this._hideSubmenu(this.openSubmenu);
}
const submenu = trigger.querySelector('.context-submenu');
if (!submenu) return;
submenu.style.display = 'block';
this.openSubmenu = trigger;
this._positionSubmenu(submenu);
}
_scheduleSubmenuClose(trigger) {
this.submenuTimeout = setTimeout(() => {
this._hideSubmenu(trigger);
this.submenuTimeout = null;
}, 250);
}
_hideSubmenu(trigger) {
const submenu = trigger.querySelector('.context-submenu');
if (submenu) {
submenu.style.display = 'none';
submenu.classList.remove('flip-left');
}
if (this.openSubmenu === trigger) {
this.openSubmenu = null;
}
}
_positionSubmenu(submenu) {
const submenuRect = submenu.getBoundingClientRect();
const viewportWidth = document.documentElement.clientWidth;
if (submenuRect.right > viewportWidth) {
submenu.classList.add('flip-left');
} else {
submenu.classList.remove('flip-left');
}
} }
handleMenuAction(action, menuItem) { handleMenuAction(action, menuItem) {
@@ -65,6 +146,13 @@ export class BaseContextMenu {
} }
hideMenu() { hideMenu() {
if (this.submenuTimeout) {
clearTimeout(this.submenuTimeout);
this.submenuTimeout = null;
}
if (this.openSubmenu) {
this._hideSubmenu(this.openSubmenu);
}
if (this.menu) { if (this.menu) {
this.menu.style.display = 'none'; this.menu.style.display = 'none';
} }

View File

@@ -4,6 +4,7 @@ import { bulkManager } from '../../managers/BulkManager.js';
import { updateElementText, translate } from '../../utils/i18nHelpers.js'; import { updateElementText, translate } from '../../utils/i18nHelpers.js';
import { bulkMissingLoraDownloadManager } from '../../managers/BulkMissingLoraDownloadManager.js'; import { bulkMissingLoraDownloadManager } from '../../managers/BulkMissingLoraDownloadManager.js';
import { showToast } from '../../utils/uiHelpers.js'; import { showToast } from '../../utils/uiHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
export class BulkContextMenu extends BaseContextMenu { export class BulkContextMenu extends BaseContextMenu {
constructor() { constructor() {
@@ -50,6 +51,14 @@ export class BulkContextMenu extends BaseContextMenu {
if (copyAllItem) { if (copyAllItem) {
copyAllItem.style.display = config.copyAll ? 'flex' : 'none'; copyAllItem.style.display = config.copyAll ? 'flex' : 'none';
} }
// Submenu parent visibility
const sendToWorkflowSubmenu = this.menu.querySelector('[data-has-submenu="send-to-workflow"]');
if (sendToWorkflowSubmenu) {
const hasWorkflowActions = config.sendToWorkflow || config.copyAll;
sendToWorkflowSubmenu.style.display = hasWorkflowActions ? 'flex' : 'none';
}
if (refreshAllItem) { if (refreshAllItem) {
refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none'; refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none';
} }
@@ -107,6 +116,13 @@ export class BulkContextMenu extends BaseContextMenu {
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none'; downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
} }
const downloadExampleImagesItem = this.menu.querySelector('[data-action="download-example-images"]');
if (downloadExampleImagesItem) {
// Show on model pages (loras, checkpoints, embeddings), hide on recipes
const modelPages = ['loras', 'checkpoints', 'embeddings'];
downloadExampleImagesItem.style.display = modelPages.includes(currentModelType) ? 'flex' : 'none';
}
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]'); const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]'); const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
@@ -140,6 +156,14 @@ export class BulkContextMenu extends BaseContextMenu {
); );
} }
} }
// Hide empty sections
this.menu.querySelectorAll('.context-menu-section').forEach(section => {
const items = Array.from(section.querySelectorAll('.context-menu-item'))
.filter(item => !item.closest('.context-submenu'));
const allHidden = items.length > 0 && items.every(item => item.style.display === 'none');
section.style.display = allHidden ? 'none' : '';
});
} }
updateSelectedCountHeader() { updateSelectedCountHeader() {
@@ -235,6 +259,9 @@ export class BulkContextMenu extends BaseContextMenu {
case 'download-missing-loras': case 'download-missing-loras':
this.handleDownloadMissingLoras(); this.handleDownloadMissingLoras();
break; break;
case 'download-example-images':
this.handleDownloadExampleImages();
break;
case 'clear': case 'clear':
bulkManager.clearSelection(); bulkManager.clearSelection();
break; break;
@@ -277,4 +304,31 @@ export class BulkContextMenu extends BaseContextMenu {
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes); await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
} }
async handleDownloadExampleImages() {
if (state.selectedModels.size === 0) {
return;
}
const hashes = new Set();
for (const filePath of state.selectedModels) {
const escapedPath = CSS.escape(filePath);
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
if (card?.dataset?.sha256) {
hashes.add(card.dataset.sha256);
}
}
if (hashes.size === 0) {
showToast('No valid model hashes found in selection', {}, 'warning');
return;
}
try {
const apiClient = getModelApiClient();
await apiClient.downloadExampleImages([...hashes]);
} catch (error) {
console.error('Bulk download example images failed:', error);
}
}
} }

View File

@@ -2,10 +2,11 @@
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js'; import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js'; import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js'; import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
import { downloadManager } from '../managers/DownloadManager.js'; import { downloadManager } from '../managers/DownloadManager.js';
import { MODEL_TYPES } from '../api/apiConfig.js'; import { MODEL_TYPES } from '../api/apiConfig.js';
import { openMediaViewer } from './shared/MediaViewer.js';
const ALLOWED_GEN_PARAM_KEYS = new Set([ const ALLOWED_GEN_PARAM_KEYS = new Set([
'prompt', 'prompt',
@@ -104,6 +105,7 @@ class RecipeModal {
init() { init() {
this.setupCopyButtons(); this.setupCopyButtons();
this.setupStripLoraToggle();
this.setupPromptEditors(); this.setupPromptEditors();
// Set up tooltip positioning handlers after DOM is ready // Set up tooltip positioning handlers after DOM is ready
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@@ -112,6 +114,23 @@ class RecipeModal {
// Set up document click handler to close edit fields // Set up document click handler to close edit fields
document.addEventListener('click', (event) => { document.addEventListener('click', (event) => {
const recipeModal = document.getElementById('recipeModal');
if (recipeModal && recipeModal.style.display !== 'none') {
const mediaEl = event.target.closest('.recipe-preview-media');
if (mediaEl && mediaEl.tagName) {
event.stopPropagation();
const isVideo = mediaEl.tagName === 'VIDEO';
const url = mediaEl.src || mediaEl.currentSrc;
if (url) {
openMediaViewer(url, {
type: isVideo ? 'video' : 'image',
title: document.getElementById('recipeModalTitle')?.textContent || ''
});
}
return;
}
}
// Handle title edit // Handle title edit
const titleEditor = document.getElementById('recipeTitleEditor'); const titleEditor = document.getElementById('recipeTitleEditor');
if (titleEditor && titleEditor.classList.contains('active') && if (titleEditor && titleEditor.classList.contains('active') &&
@@ -364,6 +383,7 @@ class RecipeModal {
this.syncGenerationParams(hydratedRecipe.gen_params); this.syncGenerationParams(hydratedRecipe.gen_params);
this.syncResourcesSection(hydratedRecipe); this.syncResourcesSection(hydratedRecipe);
this.syncSourceUrlAction();
// Show the modal // Show the modal
modalManager.showModal('recipeModal'); modalManager.showModal('recipeModal');
@@ -496,6 +516,7 @@ class RecipeModal {
} else { } else {
this.updateSourceUrlDisplay(this.currentRecipe.source_path || ''); this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
} }
this.syncSourceUrlAction();
} }
getPreviewMediaUrl(recipe = {}) { getPreviewMediaUrl(recipe = {}) {
@@ -563,6 +584,30 @@ class RecipeModal {
} }
} }
syncSourceUrlAction() {
const actionsContainer = document.getElementById('recipeHeaderActions');
if (!actionsContainer) {
return;
}
actionsContainer.innerHTML = '';
const sourcePath = this.currentRecipe?.source_path || '';
const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://');
if (!isValidUrl) {
return;
}
const btn = document.createElement('button');
btn.className = 'recipe-source-url-btn';
btn.title = sourcePath;
btn.innerHTML = '<i class="fas fa-globe"></i> Open Source URL';
btn.addEventListener('click', () => {
window.open(sourcePath, '_blank');
});
actionsContainer.appendChild(btn);
}
syncTagsDisplay(tags) { syncTagsDisplay(tags) {
const tagsContainer = document.getElementById('recipeTagsCompact'); const tagsContainer = document.getElementById('recipeTagsCompact');
if (!tagsContainer) { if (!tagsContainer) {
@@ -1297,6 +1342,7 @@ class RecipeModal {
// Update source URL in the UI // Update source URL in the UI
this.commitField('source_path'); this.commitField('source_path');
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true }); this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
this.syncSourceUrlAction();
// Update the current recipe object // Update the current recipe object
this.currentRecipe.source_path = newSourceUrl; this.currentRecipe.source_path = newSourceUrl;
@@ -1332,14 +1378,20 @@ class RecipeModal {
if (copyPromptBtn) { if (copyPromptBtn) {
copyPromptBtn.addEventListener('click', () => { copyPromptBtn.addEventListener('click', () => {
const promptText = this.currentRecipe?.gen_params?.prompt || ''; let promptText = this.currentRecipe?.gen_params?.prompt || '';
if (this.shouldStripLoraOnCopy()) {
promptText = RecipeModal.stripLoraTags(promptText);
}
this.copyToClipboard(promptText, 'Prompt copied to clipboard'); this.copyToClipboard(promptText, 'Prompt copied to clipboard');
}); });
} }
if (copyNegativePromptBtn) { if (copyNegativePromptBtn) {
copyNegativePromptBtn.addEventListener('click', () => { copyNegativePromptBtn.addEventListener('click', () => {
const negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || ''; let negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
if (this.shouldStripLoraOnCopy()) {
negativePromptText = RecipeModal.stripLoraTags(negativePromptText);
}
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard'); this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
}); });
} }
@@ -1359,6 +1411,43 @@ class RecipeModal {
} }
} }
/**
* Strip <lora:...> tags from prompt text and clean up residual punctuation/whitespace.
* Handles both unescaped (<lora:...>) and HTML-escaped (&lt;lora:...&gt;) variants.
* Cleans up artifacts like leading ", ", double commas, and extra whitespace.
*/
static stripLoraTags(text) {
return text
.replace(/<lora:[^>]*>/gi, '')
.replace(/&lt;lora:[^&]*&gt;/gi, '')
.replace(/,(\s*,)+/g, ',')
.replace(/^,\s*/, '')
.replace(/,\s*$/, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
shouldStripLoraOnCopy() {
const toggle = document.getElementById('stripLoraOnCopyToggle');
return toggle ? toggle.checked : false;
}
setupStripLoraToggle() {
const toggle = document.getElementById('stripLoraOnCopyToggle');
if (!toggle) return;
const stored = getStorageItem('strip_lora_on_copy');
if (stored !== null) {
toggle.checked = stored === true;
}
toggle.addEventListener('change', () => {
const checked = toggle.checked;
setStorageItem('strip_lora_on_copy', checked);
state.global.settings.strip_lora_on_copy = checked;
});
}
// Fetch recipe syntax from backend and copy to clipboard // Fetch recipe syntax from backend and copy to clipboard
async fetchAndCopyRecipeSyntax() { async fetchAndCopyRecipeSyntax() {
if (!this.recipeId) { if (!this.recipeId) {

View File

@@ -166,17 +166,6 @@ export class PageControls {
}); });
}); });
// Handle quick refresh option
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
if (quickRefreshOption) {
quickRefreshOption.addEventListener('click', (e) => {
e.stopPropagation();
this.refreshModels(false);
// Close the dropdown
document.querySelector('.dropdown-group.active')?.classList.remove('active');
});
}
// Handle full rebuild option // Handle full rebuild option
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]'); const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
if (fullRebuildOption) { if (fullRebuildOption) {

View File

@@ -0,0 +1,204 @@
let activeViewer = null;
function createMediaElement(item) {
const { url, type = 'image' } = item;
if (type === 'video') {
const el = document.createElement('video');
el.controls = true;
el.autoplay = true;
el.loop = true;
el.muted = true;
el.className = 'media-viewer-media media-viewer-video';
el.src = url;
return el;
}
const el = document.createElement('img');
el.className = 'media-viewer-media media-viewer-image';
el.src = url;
el.alt = 'Full size preview';
el.draggable = false;
return el;
}
function preloadAdjacent(items, index) {
[index - 1, index + 1].forEach(i => {
if (i >= 0 && i < items.length && items[i].type !== 'video') {
const preload = new Image();
preload.src = items[i].url;
}
});
}
export function openMediaViewer(arg1, arg2, arg3) {
closeMediaViewer();
let items, currentIndex, title = '';
if (Array.isArray(arg1)) {
items = arg1;
currentIndex = typeof arg2 === 'number' ? arg2 : 0;
title = (arg3 && arg3.title) || '';
} else {
items = [{ url: arg1, type: (arg2 && arg2.type) || 'image' }];
currentIndex = 0;
title = (arg2 && arg2.title) || '';
}
if (currentIndex < 0 || currentIndex >= items.length) currentIndex = 0;
const overlay = document.createElement('div');
overlay.className = 'media-viewer-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-label', title || 'Media viewer');
const closeBtn = document.createElement('button');
closeBtn.className = 'media-viewer-close';
closeBtn.innerHTML = '<i class="fas fa-times"></i>';
closeBtn.title = 'Close (Esc)';
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
closeMediaViewer();
});
const contentContainer = document.createElement('div');
contentContainer.className = 'media-viewer-content-container';
let mediaElement = createMediaElement(items[currentIndex]);
contentContainer.appendChild(mediaElement);
const hasNavigation = items.length > 1;
const counter = document.createElement('div');
counter.className = 'media-viewer-counter';
counter.textContent = hasNavigation ? `${currentIndex + 1} / ${items.length}` : '';
contentContainer.appendChild(counter);
if (title) {
const titleBar = document.createElement('div');
titleBar.className = 'media-viewer-title';
titleBar.textContent = title;
contentContainer.appendChild(titleBar);
}
let prevBtn, nextBtn;
if (hasNavigation) {
prevBtn = document.createElement('button');
prevBtn.className = 'media-viewer-nav media-viewer-prev';
prevBtn.innerHTML = '<i class="fas fa-chevron-left"></i>';
prevBtn.title = 'Previous (←)';
nextBtn = document.createElement('button');
nextBtn.className = 'media-viewer-nav media-viewer-next';
nextBtn.innerHTML = '<i class="fas fa-chevron-right"></i>';
nextBtn.title = 'Next (→)';
const navigate = (delta) => {
const newIndex = (currentIndex + delta + items.length) % items.length;
currentIndex = newIndex;
const oldMedia = contentContainer.querySelector('.media-viewer-media');
const newMedia = createMediaElement(items[currentIndex]);
if (oldMedia) {
if (oldMedia.tagName === 'VIDEO') {
oldMedia.pause();
oldMedia.src = '';
}
oldMedia.replaceWith(newMedia);
}
mediaElement = newMedia;
counter.textContent = `${currentIndex + 1} / ${items.length}`;
preloadAdjacent(items, currentIndex);
};
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(1); });
overlay.appendChild(prevBtn);
overlay.appendChild(nextBtn);
}
overlay.appendChild(closeBtn);
overlay.appendChild(contentContainer);
document.body.appendChild(overlay);
requestAnimationFrame(() => {
overlay.classList.add('active');
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
closeMediaViewer();
}
});
const keyHandler = (e) => {
if (e.key === 'Escape') {
closeMediaViewer();
return;
}
if (hasNavigation) {
if (e.key === 'ArrowLeft') {
e.stopPropagation();
e.preventDefault();
prevBtn.click();
return;
}
if (e.key === 'ArrowRight') {
e.stopPropagation();
e.preventDefault();
nextBtn.click();
return;
}
}
};
document.addEventListener('keydown', keyHandler, true);
activeViewer = { overlay, keyHandler };
preloadAdjacent(items, currentIndex);
if (items[currentIndex].type === 'video') {
const recipeVideo = document.getElementById('recipeModalVideo');
if (recipeVideo && !recipeVideo.paused) {
recipeVideo.pause();
}
}
}
export function closeMediaViewer() {
if (!activeViewer) return;
const { overlay, keyHandler } = activeViewer;
const video = overlay.querySelector('video');
if (video) {
video.pause();
video.src = '';
}
const img = overlay.querySelector('img');
if (img) {
img.src = '';
}
document.removeEventListener('keydown', keyHandler, true);
overlay.classList.remove('active');
overlay.addEventListener('transitionend', () => {
if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}, { once: true });
setTimeout(() => {
if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}, 500);
activeViewer = null;
}
export function isMediaViewerOpen() {
return activeViewer !== null;
}

View File

@@ -644,8 +644,23 @@ export function createModelCard(model, modelType) {
<div class="card-footer"> <div class="card-footer">
<div class="model-info"> <div class="model-info">
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '&quot;')}">${getDisplayName(model)}</span> <span class="model-name" title="${getDisplayName(model).replace(/"/g, '&quot;')}">${getDisplayName(model)}</span>
<div> <div class="version-row">
${model.civitai?.name ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : ''} ${(() => {
const autoTags = model.auto_tags || [];
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
const hasVersionName = model.civitai?.name;
if (!hlTags.length && !hasVersionName) return '';
const density = state.global.settings.display_density || 'default';
const shortLabels = density === 'medium' || density === 'compact';
const badges = hlTags.map(t => {
const cls = t === 'HIGH' ? 'hl-badge hl-badge--high' : 'hl-badge hl-badge--low';
const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t;
const titleAttr = shortLabels ? ` title="${t}"` : '';
return `<span class="${cls}"${titleAttr}>${label}</span>`;
}).join('');
const versionHtml = hasVersionName ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : '';
return `<span class="badge-version-unit">${badges}${versionHtml}</span>`;
})()}
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''} ${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
</div> </div>
</div> </div>

View File

@@ -17,6 +17,7 @@ import {
import { generateMetadataPanel } from './MetadataPanel.js'; import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js'; import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js'; import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
import { openMediaViewer } from '../MediaViewer.js';
export const showcaseListenerMetrics = { export const showcaseListenerMetrics = {
wheelListeners: 0, wheelListeners: 0,
@@ -640,6 +641,27 @@ export function initShowcaseContent(carousel) {
initMediaControlHandlers(carousel); initMediaControlHandlers(carousel);
positionAllMediaControls(carousel); positionAllMediaControls(carousel);
// Click-to-view: open full-size media viewer when clicking showcase images/videos
const viewerElements = carousel.querySelectorAll('.media-wrapper img, .media-wrapper video');
const allItems = [];
const elementIndexMap = new Map();
viewerElements.forEach((el) => {
const isVideo = el.tagName === 'VIDEO';
const url = el.src || el.dataset.localSrc || el.dataset.remoteSrc;
if (url) {
elementIndexMap.set(el, allItems.length);
allItems.push({ url, type: isVideo ? 'video' : 'image' });
}
});
viewerElements.forEach((mediaEl) => {
const idx = elementIndexMap.get(mediaEl);
if (idx === undefined) return;
mediaEl.addEventListener('click', (e) => {
e.stopPropagation();
openMediaViewer(allItems, idx);
});
});
// Bind scroll-indicator click events // Bind scroll-indicator click events
bindScrollIndicatorEvents(carousel); bindScrollIndicatorEvents(carousel);

View File

@@ -546,9 +546,23 @@ export class BulkManager {
return; return;
} }
const countElement = document.getElementById('bulkDeleteCount'); const count = state.selectedModels.size;
if (countElement) { const isRecipes = state.currentPageType === 'recipes';
countElement.textContent = state.selectedModels.size; const keyPrefix = isRecipes ? 'modals.bulkDeleteRecipes' : 'modals.bulkDelete';
const titleEl = document.querySelector('#bulkDeleteModal h2');
if (titleEl) {
titleEl.textContent = translate(`${keyPrefix}.title`);
}
const messageEl = document.querySelector('#bulkDeleteModal .delete-message');
if (messageEl) {
messageEl.textContent = translate(`${keyPrefix}.message`);
}
const countInfoEl = document.querySelector('#bulkDeleteModal .delete-model-info p');
if (countInfoEl) {
countInfoEl.innerHTML = `<span id="bulkDeleteCount">${count}</span> ${translate(`${keyPrefix}.countMessage`)}`;
} }
modalManager.showModal('bulkDeleteModal'); modalManager.showModal('bulkDeleteModal');

View File

@@ -70,6 +70,9 @@ export class FilterManager {
// Initialize tag logic toggle // Initialize tag logic toggle
this.initializeTagLogicToggle(); this.initializeTagLogicToggle();
// Create auto-tag filter section (I2V, T2V, TI2V, Lightning, Turbo)
this.createAutoTagFilters();
// Add click handler for filter button // Add click handler for filter button
if (this.filterButton) { if (this.filterButton) {
this.filterButton.addEventListener('click', () => { this.filterButton.addEventListener('click', () => {
@@ -480,6 +483,58 @@ export class FilterManager {
} }
} }
AUTO_TAG_FILTER_TAGS = ['I2V', 'T2V', 'TI2V', 'Lightning', 'Turbo'];
createAutoTagFilters() {
const container = document.getElementById('autoTagFilterTags');
if (container) return;
const modelTypeSection = document.getElementById('modelTypeTags')?.closest('.filter-section');
if (!modelTypeSection) return;
const section = document.createElement('div');
section.className = 'filter-section';
section.innerHTML = `
<h4>${translate('header.filter.autoTags', {}, 'Auto Tags')}</h4>
<div class="filter-tags" id="autoTagFilterTags"></div>
`;
modelTypeSection.parentNode.insertBefore(section, modelTypeSection.nextSibling);
const tagsContainer = document.getElementById('autoTagFilterTags');
this.AUTO_TAG_FILTER_TAGS.forEach(tag => {
const el = document.createElement('div');
el.className = 'filter-tag auto-tag-filter';
el.dataset.autoTag = tag;
el.textContent = tag;
// Restore previous state
const state = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
this._applyTriState(el, state);
el.addEventListener('click', async () => {
const current = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
const next = current === 'none' ? 'include' : current === 'include' ? 'exclude' : 'none';
if (!this.filters.autoTags) this.filters.autoTags = {};
if (next === 'none') {
delete this.filters.autoTags[tag];
} else {
this.filters.autoTags[tag] = next;
}
this._applyTriState(el, next);
this.updateActiveFiltersCount();
await this.applyFilters(false);
});
tagsContainer.appendChild(el);
});
}
_applyTriState(el, state) {
el.classList.remove('active', 'exclude');
if (state === 'include') el.classList.add('active');
else if (state === 'exclude') el.classList.add('exclude');
}
toggleFilterPanel() { toggleFilterPanel() {
if (this.filterPanel) { if (this.filterPanel) {
const isHidden = this.filterPanel.classList.contains('hidden'); const isHidden = this.filterPanel.classList.contains('hidden');
@@ -540,6 +595,13 @@ export class FilterManager {
this.updateLicenseSelections(); this.updateLicenseSelections();
} }
this.updateModelTypeSelections(); this.updateModelTypeSelections();
const autoTagEls = document.querySelectorAll('.auto-tag-filter');
autoTagEls.forEach(el => {
const tag = el.dataset.autoTag;
const state = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
this._applyTriState(el, state);
});
} }
updateModelTypeSelections() { updateModelTypeSelections() {
@@ -556,11 +618,12 @@ export class FilterManager {
updateActiveFiltersCount() { updateActiveFiltersCount() {
const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0; const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
const autoTagFilterCount = this.filters.autoTags ? Object.keys(this.filters.autoTags).length : 0;
const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0; const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
const modelTypeFilterCount = this.filters.modelTypes.length; const modelTypeFilterCount = this.filters.modelTypes.length;
// Exclude EMPTY_WILDCARD_MARKER from base model count // Exclude EMPTY_WILDCARD_MARKER from base model count
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length; const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
const totalActiveFilters = baseModelCount + tagFilterCount + licenseFilterCount + modelTypeFilterCount; const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount;
if (this.activeFiltersCount) { if (this.activeFiltersCount) {
if (totalActiveFilters > 0) { if (totalActiveFilters > 0) {
@@ -652,6 +715,7 @@ export class FilterManager {
...this.filters, ...this.filters,
baseModel: [], baseModel: [],
tags: {}, tags: {},
autoTags: {},
license: {}, license: {},
modelTypes: [], modelTypes: [],
tagLogic: 'any' tagLogic: 'any'
@@ -721,6 +785,7 @@ export class FilterManager {
hasActiveFilters() { hasActiveFilters() {
const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0; const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
const autoTagCount = this.filters.autoTags ? Object.keys(this.filters.autoTags).length : 0;
const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0; const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
const modelTypeCount = this.filters.modelTypes.length; const modelTypeCount = this.filters.modelTypes.length;
// Exclude EMPTY_WILDCARD_MARKER from base model count // Exclude EMPTY_WILDCARD_MARKER from base model count
@@ -728,6 +793,7 @@ export class FilterManager {
return ( return (
baseModelCount > 0 || baseModelCount > 0 ||
tagCount > 0 || tagCount > 0 ||
autoTagCount > 0 ||
licenseCount > 0 || licenseCount > 0 ||
modelTypeCount > 0 modelTypeCount > 0
); );
@@ -739,6 +805,7 @@ export class FilterManager {
...source, ...source,
baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [], baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [],
tags: this.normalizeTagFilters(source.tags), tags: this.normalizeTagFilters(source.tags),
autoTags: this.normalizeTagFilters(source.autoTags),
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {}, license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
modelTypes: this.normalizeModelTypeFilters(source.modelTypes), modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
tagLogic: source.tagLogic || 'any' tagLogic: source.tagLogic || 'any'
@@ -822,6 +889,7 @@ export class FilterManager {
...this.filters, ...this.filters,
baseModel: [...(this.filters.baseModel || [])], baseModel: [...(this.filters.baseModel || [])],
tags: { ...(this.filters.tags || {}) }, tags: { ...(this.filters.tags || {}) },
autoTags: { ...(this.filters.autoTags || {}) },
license: { ...(this.filters.license || {}) }, license: { ...(this.filters.license || {}) },
modelTypes: [...(this.filters.modelTypes || [])], modelTypes: [...(this.filters.modelTypes || [])],
tagLogic: this.filters.tagLogic || 'any' tagLogic: this.filters.tagLogic || 'any'

View File

@@ -286,16 +286,6 @@ class RecipeManager {
}); });
}); });
// Handle quick refresh option (Sync Changes)
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
if (quickRefreshOption) {
quickRefreshOption.addEventListener('click', (e) => {
e.stopPropagation();
this.pageControls.refreshModels(false);
this.closeDropdowns();
});
}
// Handle full rebuild option (Rebuild Cache) // Handle full rebuild option (Rebuild Cache)
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]'); const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
if (fullRebuildOption) { if (fullRebuildOption) {

View File

@@ -50,6 +50,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
download_skip_base_models: [], download_skip_base_models: [],
backup_auto_enabled: true, backup_auto_enabled: true,
backup_retention_count: 5, backup_retention_count: 5,
strip_lora_on_copy: false,
}); });
export function createDefaultSettings() { export function createDefaultSettings() {

View File

@@ -500,6 +500,18 @@ export function clearDynamicBaseModels() {
dynamicBaseModelsTimestamp = null; dynamicBaseModelsTimestamp = null;
} }
export const AUTO_TAG_GROUPS = {
mode: new Set(['HIGH', 'LOW']),
video: new Set(['I2V', 'T2V', 'TI2V']),
speed: new Set(['Lightning', 'Turbo']),
};
export const AUTO_TAG_GROUP_LABELS = {
mode: 'High / Low',
video: 'I2V / T2V / TI2V',
speed: 'Lightning / Turbo',
};
/** /**
* Check if dynamic base models cache is valid * Check if dynamic base models cache is valid
* @returns {boolean} * @returns {boolean}

View File

@@ -53,24 +53,42 @@
<span>{{ t('loras.bulkOperations.selected', {'count': 0}) }}</span> <span>{{ t('loras.bulkOperations.selected', {'count': 0}) }}</span>
</div> </div>
<div class="context-menu-separator"></div> <div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="refresh-all"> <div class="context-menu-section" data-section="workflow">
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span> <div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.workflow') }}</div>
</div> <div class="context-menu-item has-submenu" data-has-submenu="send-to-workflow">
<div class="context-menu-item" data-action="check-updates"> <i class="fas fa-paper-plane"></i>
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span> <span>{{ t('loras.bulkOperations.sendToWorkflow') }}</span>
</div> <i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-menu-item" data-action="copy-all"> <div class="context-submenu">
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
</div>
<div class="context-menu-item" data-action="send-to-workflow-append"> <div class="context-menu-item" data-action="send-to-workflow-append">
<i class="fas fa-paper-plane"></i> <span>{{ t('loras.contextMenu.sendToWorkflowAppend') }}</span> <i class="fas fa-paper-plane"></i> <span>{{ t('loras.contextMenu.sendToWorkflowAppend') }}</span>
</div> </div>
<div class="context-menu-item" data-action="send-to-workflow-replace"> <div class="context-menu-item" data-action="send-to-workflow-replace">
<i class="fas fa-exchange-alt"></i> <span>{{ t('loras.contextMenu.sendToWorkflowReplace') }}</span> <i class="fas fa-exchange-alt"></i> <span>{{ t('loras.contextMenu.sendToWorkflowReplace') }}</span>
</div> </div>
<div class="context-menu-item" data-action="auto-organize"> <div class="context-menu-item" data-action="copy-all">
<i class="fas fa-magic"></i> <span>{{ t('loras.bulkOperations.autoOrganize') }}</span> <i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
</div> </div>
</div>
</div>
</div>
<div class="context-menu-section" data-section="metadata">
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.metadata') }}</div>
<div class="context-menu-item" data-action="refresh-all">
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span>
</div>
<div class="context-menu-item" data-action="check-updates">
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
</div>
<div class="context-menu-item" data-action="skip-metadata-refresh">
<i class="fas fa-ban"></i> <span>{{ t('loras.bulkOperations.skipMetadataRefresh') }}</span>
</div>
<div class="context-menu-item" data-action="resume-metadata-refresh">
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
</div>
</div>
<div class="context-menu-section" data-section="attributes">
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.attributes') }}</div>
<div class="context-menu-item" data-action="add-tags"> <div class="context-menu-item" data-action="add-tags">
<i class="fas fa-tags"></i> <span>{{ t('loras.bulkOperations.addTags') }}</span> <i class="fas fa-tags"></i> <span>{{ t('loras.bulkOperations.addTags') }}</span>
</div> </div>
@@ -83,19 +101,26 @@
<div class="context-menu-item" data-action="set-content-rating"> <div class="context-menu-item" data-action="set-content-rating">
<i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span> <i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span>
</div> </div>
<div class="context-menu-item" data-action="skip-metadata-refresh">
<i class="fas fa-ban"></i> <span>{{ t('loras.bulkOperations.skipMetadataRefresh') }}</span>
</div> </div>
<div class="context-menu-item" data-action="resume-metadata-refresh"> <div class="context-menu-section" data-section="organize">
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span> <div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.organize') }}</div>
</div> <div class="context-menu-item" data-action="auto-organize">
<div class="context-menu-separator"></div> <i class="fas fa-magic"></i> <span>{{ t('loras.bulkOperations.autoOrganize') }}</span>
<div class="context-menu-item" data-action="download-missing-loras">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
</div> </div>
<div class="context-menu-item" data-action="move-all"> <div class="context-menu-item" data-action="move-all">
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span> <i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
</div> </div>
</div>
<div class="context-menu-section" data-section="download">
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.download') }}</div>
<div class="context-menu-item" data-action="download-example-images">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadExamples') }}</span>
</div>
<div class="context-menu-item" data-action="download-missing-loras">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
</div>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item delete-item" data-action="delete-all"> <div class="context-menu-item delete-item" data-action="delete-all">
<i class="fas fa-trash"></i> <span>{{ t('loras.bulkOperations.deleteAll') }}</span> <i class="fas fa-trash"></i> <span>{{ t('loras.bulkOperations.deleteAll') }}</span>
</div> </div>

View File

@@ -41,9 +41,6 @@
<i class="fas fa-caret-down"></i> <i class="fas fa-caret-down"></i>
</button> </button>
<div class="dropdown-menu"> <div class="dropdown-menu">
<div class="dropdown-item" data-action="quick-refresh" title="{{ t('loras.controls.refresh.quickTooltip') }}">
<i class="fas fa-bolt"></i> <span>{{ t('loras.controls.refresh.quick') }}</span>
</div>
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('loras.controls.refresh.fullTooltip') }}"> <div class="dropdown-item" data-action="full-rebuild" title="{{ t('loras.controls.refresh.fullTooltip') }}">
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full') }}</span> <i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full') }}</span>
</div> </div>

View File

@@ -4,6 +4,8 @@
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <h2 id="recipeModalTitle">Recipe Details</h2>
<!-- Header Actions: populated dynamically in RecipeModal.js -->
<div class="recipe-header-actions" id="recipeHeaderActions"></div>
<!-- Recipe Tags Container --> <!-- Recipe Tags Container -->
<div class="recipe-tags-container"> <div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div> <div class="recipe-tags-compact" id="recipeTagsCompact"></div>
@@ -22,7 +24,16 @@
</div> </div>
<div class="info-section recipe-gen-params"> <div class="info-section recipe-gen-params">
<div class="gen-params-header-row">
<h3>Generation Parameters</h3> <h3>Generation Parameters</h3>
<label class="inline-toggle-container lora-strip-toggle" title="When enabled, &lt;lora:...&gt; tags are removed from prompt text when copying">
<span class="inline-toggle-label">Strip &lt;lora:&gt;</span>
<div class="toggle-switch">
<input type="checkbox" id="stripLoraOnCopyToggle">
<span class="toggle-slider"></span>
</div>
</label>
</div>
<div class="gen-params-container"> <div class="gen-params-container">
<!-- Prompt --> <!-- Prompt -->

View File

@@ -75,9 +75,6 @@
<i class="fas fa-caret-down"></i> <i class="fas fa-caret-down"></i>
</button> </button>
<div class="dropdown-menu"> <div class="dropdown-menu">
<div class="dropdown-item" data-action="quick-refresh" title="{{ t('recipes.controls.refresh.quickTooltip', default='Sync changes - quick refresh without rebuilding cache') }}">
<i class="fas fa-bolt"></i> <span>{{ t('loras.controls.refresh.quick', default='Sync Changes') }}</span>
</div>
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}"> <div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}">
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span> <i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span>
</div> </div>

View File

@@ -135,7 +135,6 @@ function renderControlsDom(pageKey) {
<button data-action="refresh" class="dropdown-main"></button> <button data-action="refresh" class="dropdown-main"></button>
<button class="dropdown-toggle"></button> <button class="dropdown-toggle"></button>
<div class="dropdown-menu"> <div class="dropdown-menu">
<div class="dropdown-item" data-action="quick-refresh"></div>
<div class="dropdown-item" data-action="full-rebuild"></div> <div class="dropdown-item" data-action="full-rebuild"></div>
</div> </div>
</div> </div>

View File

@@ -79,7 +79,7 @@ class FakeDownloadHistoryService:
async def mark_downloaded(self, *_args, **_kwargs): async def mark_downloaded(self, *_args, **_kwargs):
return None return None
async def mark_not_downloaded(self, *_args, **_kwargs): async def mark_as_deleted(self, *_args, **_kwargs):
return None return None

View File

@@ -903,7 +903,7 @@ class FakeDownloadHistoryService:
(model_type, version_id, model_id, source, file_path) (model_type, version_id, model_id, source, file_path)
) )
async def mark_not_downloaded(self, model_type, version_id): async def mark_as_deleted(self, model_type, version_id):
self.marked_not_downloaded.append((model_type, version_id)) self.marked_not_downloaded.append((model_type, version_id))

View File

@@ -785,10 +785,16 @@ async def test_import_remote_recipe_merges_metadata(
async def parse_metadata(self, raw, recipe_scanner=None): async def parse_metadata(self, raw, recipe_scanner=None):
return json.loads(raw[len("Recipe metadata: ") :]) return json.loads(raw[len("Recipe metadata: ") :])
class MockApiParser:
async def parse_metadata(self, raw, recipe_scanner=None):
return {"gen_params": raw, "loras": []}
class MockFactory: class MockFactory:
def create_parser(self, raw): def create_parser(self, raw):
if raw.startswith("Recipe metadata: "): if isinstance(raw, str) and raw.startswith("Recipe metadata: "):
return MockParser() return MockParser()
if isinstance(raw, dict):
return MockApiParser()
return None return None
# 4. Setup Harness and run test # 4. Setup Harness and run test

View File

@@ -222,7 +222,7 @@ async def test_get_model_versions_raises_on_other_errors(monkeypatch, downloader
async def test_get_model_versions_bulk_success(monkeypatch, downloader): async def test_get_model_versions_bulk_success(monkeypatch, downloader):
async def fake_make_request(method, url, use_auth=True, **kwargs): async def fake_make_request(method, url, use_auth=True, **kwargs):
assert url.endswith("/models") assert url.endswith("/models")
assert kwargs.get("params") == {"ids": "1,2"} assert kwargs.get("params") == {"ids": "1,2", "nsfw": "true"}
return True, { return True, {
"items": [ "items": [
{ {

View File

@@ -30,7 +30,7 @@ async def test_download_history_roundtrip_and_manual_override(tmp_path: Path) ->
assert await service.has_been_downloaded("lora", 101) is True assert await service.has_been_downloaded("lora", 101) is True
assert await service.get_downloaded_version_ids("lora", 11) == [101] assert await service.get_downloaded_version_ids("lora", 11) == [101]
await service.mark_not_downloaded("lora", 101) await service.mark_as_deleted("lora", 101)
assert await service.has_been_downloaded("lora", 101) is False assert await service.has_been_downloaded("lora", 101) is False
assert await service.get_downloaded_version_ids("lora", 11) == [] assert await service.get_downloaded_version_ids("lora", 11) == []

View File

@@ -77,7 +77,7 @@ async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
recipe = { recipe = {
"id": "r1", "id": "r1",
"title": "Old Recipe", "title": "Old Recipe",
"source_url": "https://civitai.com/images/12345", "source_path": "https://civitai.com/images/12345",
"checkpoint": None, "checkpoint": None,
"gen_params": {"prompt": ""} "gen_params": {"prompt": ""}
} }
@@ -127,7 +127,7 @@ async def test_repair_all_recipes_supports_civitai_red_source_url(setup_scanner)
recipe = { recipe = {
"id": "r1", "id": "r1",
"title": "Red Recipe", "title": "Red Recipe",
"source_url": "https://civitai.red/images/12345", "source_path": "https://civitai.red/images/12345",
"checkpoint": None, "checkpoint": None,
"gen_params": {"prompt": ""}, "gen_params": {"prompt": ""},
} }

View File

@@ -0,0 +1,151 @@
import pytest
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "py"))
from services.auto_tag_service import extract_auto_tags, AUTO_TAG_CATEGORIES
class TestExtractAutoTags:
def test_file_name_high_i2v(self):
result = extract_auto_tags({
"file_name": "Shirt_lift_Wan2.2_14B_I2V_HIGH_v1.0",
"base_model": "Wan Video 2.2 I2V-A14B",
"civitai": {},
})
assert set(result) == {"HIGH", "I2V"}
def test_file_name_t2v_low(self):
result = extract_auto_tags({
"file_name": "my_wan_t2v_low_v2",
"base_model": "Wan 2.1",
"civitai": {},
})
assert set(result) == {"LOW", "T2V"}
def test_file_name_ti2v_high(self):
result = extract_auto_tags({
"file_name": "wan_ti2v_high_quality",
"base_model": "Wan 2.2",
"civitai": {},
})
assert set(result) == {"HIGH", "TI2V"}
def test_file_name_lightning_turbo(self):
result = extract_auto_tags({
"file_name": "sdxl_lightning_turbo_v3",
"base_model": "SDXL",
"civitai": {},
})
assert set(result) == {"Lightning", "Turbo"}
def test_base_model_source(self):
result = extract_auto_tags({
"file_name": "my_lora_v1",
"base_model": "Wan Video 2.2 I2V-A14B",
"civitai": {},
})
assert "I2V" in result
def test_civitai_name_source(self):
result = extract_auto_tags({
"file_name": "model_v1",
"base_model": "Wan",
"civitai": {"name": "HIGH Quality"},
})
assert "HIGH" in result
def test_no_false_match_flow(self):
result = extract_auto_tags({
"file_name": "flux_dev_model",
"base_model": "Flux.1 D",
"civitai": {},
})
assert "LOW" not in result
def test_no_false_match_glow(self):
result = extract_auto_tags({
"file_name": "glow_style_lora",
"base_model": "SDXL",
"civitai": {},
})
assert "LOW" not in result
def test_high_low_only_for_wan(self):
"""HIGH/LOW should not appear for non-Wan models even in filename."""
result = extract_auto_tags({
"file_name": "my_model_high_quality_v2",
"base_model": "Flux.1 D",
"civitai": {"name": "HIGH"},
})
assert "HIGH" not in result
assert "LOW" not in result
def test_no_distilled(self):
result = extract_auto_tags({
"file_name": "ltx-2.3-22b-distilled-lora-384",
"base_model": "LTXV 2.3",
"civitai": {},
})
assert result == []
def test_empty(self):
result = extract_auto_tags({
"file_name": "generic_lora_v1",
"base_model": "SDXL",
"civitai": {},
})
assert result == []
def test_missing_fields(self):
result = extract_auto_tags({})
assert result == []
def test_dash_separated(self):
result = extract_auto_tags({
"file_name": "wan-i2v-high-v2",
"base_model": "Wan 2.2",
"civitai": {},
})
assert set(result) == {"HIGH", "I2V"}
def test_dot_separated(self):
result = extract_auto_tags({
"file_name": "wan.i2v.high.v2",
"base_model": "Wan 2.2",
"civitai": {},
})
assert set(result) == {"HIGH", "I2V"}
def test_case_insensitive(self):
result = extract_auto_tags({
"file_name": "WAN_i2v_High",
"base_model": "Wan 2.2",
"civitai": {},
})
assert set(result) == {"HIGH", "I2V"}
class TestAutoTagCategories:
def test_all_patterns_compile(self):
import re
for label, pattern in AUTO_TAG_CATEGORIES.items():
re.compile(pattern, re.IGNORECASE)
def test_mode_group_tags(self):
from services.auto_tag_service import MODE_TAGS
assert "HIGH" in MODE_TAGS
assert "LOW" in MODE_TAGS
def test_video_group_tags(self):
from services.auto_tag_service import VIDEO_MODE_TAGS
assert "I2V" in VIDEO_MODE_TAGS
assert "T2V" in VIDEO_MODE_TAGS
assert "TI2V" in VIDEO_MODE_TAGS
def test_default_enabled_groups(self):
from services.auto_tag_service import DEFAULT_ENABLED_GROUPS
assert "mode" in DEFAULT_ENABLED_GROUPS
assert "video" in DEFAULT_ENABLED_GROUPS
assert "speed" not in DEFAULT_ENABLED_GROUPS

View File

@@ -658,6 +658,7 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
textEl.style.maxWidth = "140px"; textEl.style.maxWidth = "140px";
} }
if (tagData.items.length > 1) {
const countBadge = document.createElement("span"); const countBadge = document.createElement("span");
countBadge.className = "lm-trigger-count-badge"; countBadge.className = "lm-trigger-count-badge";
countBadge.textContent = `${groupState.activeChildren}/${groupState.totalChildren}`; countBadge.textContent = `${groupState.activeChildren}/${groupState.totalChildren}`;
@@ -684,6 +685,7 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
}); });
} }
groupChip.appendChild(countBadge); groupChip.appendChild(countBadge);
}
if (showStrengthInfo) { if (showStrengthInfo) {
const strengthBadge = createStrengthBadge(); const strengthBadge = createStrengthBadge();
@@ -697,7 +699,10 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
groupChip.title = activePreview ? `${tagData.text}\nActive: ${activePreview}` : tagData.text; groupChip.title = activePreview ? `${tagData.text}\nActive: ${activePreview}` : tagData.text;
} }
const editButton = document.createElement("button"); let editButton = null;
if (tagData.items.length > 1) {
editButton = document.createElement("button");
editButton.type = "button"; editButton.type = "button";
editButton.className = "lm-trigger-group-edit-button"; editButton.className = "lm-trigger-group-edit-button";
editButton.textContent = "⋯"; editButton.textContent = "⋯";
@@ -726,10 +731,11 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
groupChip.addEventListener("contextmenu", openEditor); groupChip.addEventListener("contextmenu", openEditor);
groupChip.appendChild(editButton); groupChip.appendChild(editButton);
}
groupChip.addEventListener("click", (e) => { groupChip.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
if (e.target === editButton) { if (editButton && e.target === editButton) {
return; return;
} }
updateWidgetValue(widget, (updatedTags) => { updateWidgetValue(widget, (updatedTags) => {
@@ -740,7 +746,7 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
if (showStrengthInfo) { if (showStrengthInfo) {
groupChip.addEventListener("wheel", (e) => { groupChip.addEventListener("wheel", (e) => {
if (e.target === editButton) { if (editButton && e.target === editButton) {
return; return;
} }
e.preventDefault(); e.preventDefault();

View File

@@ -303,6 +303,8 @@ app.registerExtension({
return; return;
} }
const groupMode = groupModeWidget?.value ?? false;
const updatedTags = node.tagWidget.value.map((tag) => { const updatedTags = node.tagWidget.value.map((tag) => {
if (!Array.isArray(tag.items)) { if (!Array.isArray(tag.items)) {
return { return {
@@ -311,6 +313,15 @@ app.registerExtension({
}; };
} }
// In group mode, default_active only controls the group-level switch.
// Children's individual active states are managed exclusively via the group editor.
if (groupMode) {
return {
...tag,
active: value,
};
}
return { return {
...tag, ...tag,
active: value, active: value,
@@ -320,7 +331,6 @@ app.registerExtension({
})), })),
}; };
}); });
node.tagWidget.value = updatedTags; node.tagWidget.value = updatedTags;
node.applyTriggerHighlightState?.(); node.applyTriggerHighlightState?.();
}; };