Compare commits

...

9 Commits

Author SHA1 Message Date
willmiao
b58abbad7c docs: auto-update supporters list in README 2026-06-19 10:31:18 +00:00
Will Miao
999814ca87 chore(release): bump version to v1.1.4 2026-06-19 18:31:03 +08:00
Will Miao
3c2760a803 fix(stats): sort Base Model Distribution X-axis labels alphabetically (#796) 2026-06-19 17:29:33 +08:00
Will Miao
0edbd7bcca fix(metadata): add LoraTextLoaderLM extractor so SaveImageLM records its loras (#801) 2026-06-19 17:13:48 +08:00
Will Miao
21e89fa7de fix(tags): normalize tag case on save and make filtering case-insensitive (#727)
- save_metadata_updates now trims/lowercases/dedupes tags on write
- ModelFilterSet tag matching is now case-insensitive (both include/exclude)
- Removed redundant .lower() calls in tag_update_service.py
2026-06-19 16:42:09 +08:00
Will Miao
968d6d1d1f feat(tags): unify recipe modal tag UI with model modal
- Replace recipe modal's custom tag display/edit with shared
  renderCompactTags/setupTagEditMode from ModelTags and utils
- Remove 300+ lines of duplicated tag display and editing code
- Parameterize setupTagEditMode with saveHandler/onSaved/showSuggestions
  options for recipe-specific save flow (updateRecipeMetadata + dirty state)
- Scope all DOM queries in ModelTags.js via options.container / this.closest
  to prevent cross-modal element conflicts
- Fix edit button alignment (justify-content: flex-start)
- Fix tag tooltip selector scoping in setupTagTooltip
- Add width: 100% to #recipeTagsContainer for edit container full width
2026-06-19 16:31:27 +08:00
Will Miao
cf0fd0e0ad feat(i18n): internationalize dynamic insights content with key/params architecture (#489) 2026-06-19 13:49:03 +08:00
Will Miao
16e5dcf7b2 feat(i18n): internationalize statistics page strings across all locales 2026-06-19 13:37:01 +08:00
Will Miao
ab6bb25d46 fix(example-images): skip hidden files in path validation, show offending items on failure (#807) 2026-06-19 11:54:55 +08:00
32 changed files with 1420 additions and 694 deletions

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1016,6 +1016,18 @@
"storage": "Speicher", "storage": "Speicher",
"insights": "Erkenntnisse" "insights": "Erkenntnisse"
}, },
"metrics": {
"totalModels": "Modelle gesamt",
"totalStorage": "Speicher gesamt",
"totalGenerations": "Generationen gesamt",
"usageRate": "Nutzungsrate",
"loras": "LoRAs",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"uniqueTags": "Einzigartige Tags",
"unusedModels": "Ungenutzte Modelle",
"avgUsesPerModel": "Ø Nutzungen/Modell"
},
"usage": { "usage": {
"mostUsedLoras": "Meistgenutzte LoRAs", "mostUsedLoras": "Meistgenutzte LoRAs",
"mostUsedCheckpoints": "Meistgenutzte Checkpoints", "mostUsedCheckpoints": "Meistgenutzte Checkpoints",
@@ -1033,13 +1045,77 @@
}, },
"insights": { "insights": {
"smartInsights": "Intelligente Erkenntnisse", "smartInsights": "Intelligente Erkenntnisse",
"recommendations": "Empfehlungen" "recommendations": "Empfehlungen",
"noInsights": "Keine Erkenntnisse verfügbar",
"unusedLoras": {
"high": {
"title": "Hohe Anzahl ungenutzter LoRAs",
"description": "{percent}% Ihrer LoRAs ({count}/{total}) wurden noch nie verwendet.",
"suggestion": "Erwägen Sie, ungenutzte Modelle zu organisieren oder zu archivieren, um Speicherplatz freizugeben."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Ungenutzte Checkpoints erkannt",
"description": "{percent}% Ihrer Checkpoints ({count}/{total}) wurden noch nie verwendet.",
"suggestion": "Überprüfen Sie nicht mehr benötigte Checkpoints und erwägen Sie deren Entfernung."
}
},
"unusedEmbeddings": {
"high": {
"title": "Hohe Anzahl ungenutzter Embeddings",
"description": "{percent}% Ihrer Embeddings ({count}/{total}) wurden noch nie verwendet.",
"suggestion": "Organisieren oder archivieren Sie ungenutzte Embeddings, um Ihre Sammlung zu optimieren."
}
},
"collection": {
"large": {
"title": "Große Sammlung erkannt",
"description": "Ihre Modellsammlung verwendet {size} Speicher.",
"suggestion": "Erwägen Sie externe Speicher- oder Cloud-Lösungen für eine bessere Organisation."
}
},
"activity": {
"active": {
"title": "Aktiver Benutzer",
"description": "Sie haben {count} Generationen abgeschlossen!",
"suggestion": "Entdecken und erstellen Sie weiterhin großartige Inhalte mit Ihren Modellen."
}
}
}, },
"charts": { "charts": {
"collectionOverview": "Sammlungsübersicht", "collectionOverview": "Sammlungsübersicht",
"baseModelDistribution": "Basis-Modell-Verteilung", "baseModelDistribution": "Basis-Modell-Verteilung",
"usageTrends": "Nutzungstrends (Letzte 30 Tage)", "usageTrends": "Nutzungstrends (Letzte 30 Tage)",
"usageDistribution": "Nutzungsverteilung" "usageDistribution": "Nutzungsverteilung",
"date": "Datum",
"usageCount": "Nutzungsanzahl",
"fileSizeBytes": "Dateigröße (Bytes)",
"models": "Modelle",
"loraUsage": "LoRA-Nutzung",
"checkpointUsage": "Checkpoint-Nutzung",
"embeddingUsage": "Embedding-Nutzung"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusionsmodell",
"embedding": "Embeddings"
},
"placeholders": {
"loading": "Lädt...",
"noModels": "Keine Modelle gefunden",
"errorLoading": "Fehler beim Laden der Daten",
"noStorageData": "Keine Speicherdaten verfügbar",
"rootFolder": "Root",
"chartLibraryMissing": "Diagramm benötigt Chart.js-Bibliothek"
},
"tooltips": {
"tagCount": "{tag}: {count} Modelle",
"chartUsage": "{name}: {size}, {count} Nutzungen",
"chartPercentage": "{label}: {value} ({pct}%)"
} }
}, },
"modals": { "modals": {

View File

@@ -1016,6 +1016,18 @@
"storage": "Storage", "storage": "Storage",
"insights": "Insights" "insights": "Insights"
}, },
"metrics": {
"totalModels": "Total Models",
"totalStorage": "Total Storage",
"totalGenerations": "Total Generations",
"usageRate": "Usage Rate",
"loras": "LoRAs",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"uniqueTags": "Unique Tags",
"unusedModels": "Unused Models",
"avgUsesPerModel": "Avg. Uses/Model"
},
"usage": { "usage": {
"mostUsedLoras": "Most Used LoRAs", "mostUsedLoras": "Most Used LoRAs",
"mostUsedCheckpoints": "Most Used Checkpoints", "mostUsedCheckpoints": "Most Used Checkpoints",
@@ -1033,13 +1045,77 @@
}, },
"insights": { "insights": {
"smartInsights": "Smart Insights", "smartInsights": "Smart Insights",
"recommendations": "Recommendations" "recommendations": "Recommendations",
"noInsights": "No insights available",
"unusedLoras": {
"high": {
"title": "High Number of Unused LoRAs",
"description": "{percent}% of your LoRAs ({count}/{total}) have never been used.",
"suggestion": "Consider organizing or archiving unused models to free up storage space."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Unused Checkpoints Detected",
"description": "{percent}% of your checkpoints ({count}/{total}) have never been used.",
"suggestion": "Review and consider removing checkpoints you no longer need."
}
},
"unusedEmbeddings": {
"high": {
"title": "High Number of Unused Embeddings",
"description": "{percent}% of your embeddings ({count}/{total}) have never been used.",
"suggestion": "Consider organizing or archiving unused embeddings to optimize your collection."
}
},
"collection": {
"large": {
"title": "Large Collection Detected",
"description": "Your model collection is using {size} of storage.",
"suggestion": "Consider using external storage or cloud solutions for better organization."
}
},
"activity": {
"active": {
"title": "Active User",
"description": "You've completed {count} generations so far!",
"suggestion": "Keep exploring and creating amazing content with your models."
}
}
}, },
"charts": { "charts": {
"collectionOverview": "Collection Overview", "collectionOverview": "Collection Overview",
"baseModelDistribution": "Base Model Distribution", "baseModelDistribution": "Base Model Distribution",
"usageTrends": "Usage Trends (Last 30 Days)", "usageTrends": "Usage Trends (Last 30 Days)",
"usageDistribution": "Usage Distribution" "usageDistribution": "Usage Distribution",
"date": "Date",
"usageCount": "Usage Count",
"fileSizeBytes": "File Size (bytes)",
"models": "Models",
"loraUsage": "LoRA Usage",
"checkpointUsage": "Checkpoint Usage",
"embeddingUsage": "Embedding Usage"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model",
"embedding": "Embeddings"
},
"placeholders": {
"loading": "Loading...",
"noModels": "No models found",
"errorLoading": "Error loading data",
"noStorageData": "No storage data available",
"rootFolder": "Root",
"chartLibraryMissing": "Chart requires Chart.js library"
},
"tooltips": {
"tagCount": "{tag}: {count} models",
"chartUsage": "{name}: {size}, {count} uses",
"chartPercentage": "{label}: {value} ({pct}%)"
} }
}, },
"modals": { "modals": {

View File

@@ -1016,6 +1016,18 @@
"storage": "Almacenamiento", "storage": "Almacenamiento",
"insights": "Perspectivas" "insights": "Perspectivas"
}, },
"metrics": {
"totalModels": "Total de modelos",
"totalStorage": "Almacenamiento total",
"totalGenerations": "Generaciones totales",
"usageRate": "Tasa de uso",
"loras": "LoRAs",
"checkpoints": "Puntos de control",
"embeddings": "Embeddings",
"uniqueTags": "Etiquetas únicas",
"unusedModels": "Modelos no usados",
"avgUsesPerModel": "Prom. usos/modelo"
},
"usage": { "usage": {
"mostUsedLoras": "LoRAs más utilizados", "mostUsedLoras": "LoRAs más utilizados",
"mostUsedCheckpoints": "Checkpoints más utilizados", "mostUsedCheckpoints": "Checkpoints más utilizados",
@@ -1033,13 +1045,77 @@
}, },
"insights": { "insights": {
"smartInsights": "Perspectivas inteligentes", "smartInsights": "Perspectivas inteligentes",
"recommendations": "Recomendaciones" "recommendations": "Recomendaciones",
"noInsights": "No hay información disponible",
"unusedLoras": {
"high": {
"title": "Alta cantidad de LoRAs no utilizadas",
"description": "El {percent}% de tus LoRAs ({count}/{total}) nunca se han utilizado.",
"suggestion": "Considera organizar o archivar modelos no utilizados para liberar espacio."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Puntos de control no utilizados detectados",
"description": "El {percent}% de tus puntos de control ({count}/{total}) nunca se han utilizado.",
"suggestion": "Revisa y considera eliminar los puntos de control que ya no necesites."
}
},
"unusedEmbeddings": {
"high": {
"title": "Alta cantidad de Embeddings no utilizados",
"description": "El {percent}% de tus embeddings ({count}/{total}) nunca se han utilizado.",
"suggestion": "Considera organizar o archivar embeddings no utilizados para optimizar tu colección."
}
},
"collection": {
"large": {
"title": "Colección grande detectada",
"description": "Tu colección de modelos está usando {size} de almacenamiento.",
"suggestion": "Considera usar almacenamiento externo o soluciones en la nube para una mejor organización."
}
},
"activity": {
"active": {
"title": "Usuario activo",
"description": "¡Has completado {count} generaciones hasta ahora!",
"suggestion": "Sigue explorando y creando contenido increíble con tus modelos."
}
}
}, },
"charts": { "charts": {
"collectionOverview": "Resumen de colección", "collectionOverview": "Resumen de colección",
"baseModelDistribution": "Distribución de modelo base", "baseModelDistribution": "Distribución de modelo base",
"usageTrends": "Tendencias de uso (Últimos 30 días)", "usageTrends": "Tendencias de uso (Últimos 30 días)",
"usageDistribution": "Distribución de uso" "usageDistribution": "Distribución de uso",
"date": "Fecha",
"usageCount": "Conteo de uso",
"fileSizeBytes": "Tamaño del archivo (bytes)",
"models": "Modelos",
"loraUsage": "Uso de LoRA",
"checkpointUsage": "Uso de Checkpoint",
"embeddingUsage": "Uso de Embedding"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Punto de control",
"diffusion_model": "Modelo de difusión",
"embedding": "Embeddings"
},
"placeholders": {
"loading": "Cargando...",
"noModels": "No se encontraron modelos",
"errorLoading": "Error al cargar datos",
"noStorageData": "No hay datos de almacenamiento disponibles",
"rootFolder": "Raíz",
"chartLibraryMissing": "El gráfico requiere la librería Chart.js"
},
"tooltips": {
"tagCount": "{tag}: {count} modelos",
"chartUsage": "{name}: {size}, {count} usos",
"chartPercentage": "{label}: {value} ({pct}%)"
} }
}, },
"modals": { "modals": {

View File

@@ -1016,6 +1016,18 @@
"storage": "Stockage", "storage": "Stockage",
"insights": "Aperçus" "insights": "Aperçus"
}, },
"metrics": {
"totalModels": "Total des modèles",
"totalStorage": "Stockage total",
"totalGenerations": "Générations totales",
"usageRate": "Taux d'utilisation",
"loras": "LoRAs",
"checkpoints": "Points de contrôle",
"embeddings": "Embeddings",
"uniqueTags": "Tags uniques",
"unusedModels": "Modèles inutilisés",
"avgUsesPerModel": "Moy. utilisations/modèle"
},
"usage": { "usage": {
"mostUsedLoras": "LoRAs les plus utilisés", "mostUsedLoras": "LoRAs les plus utilisés",
"mostUsedCheckpoints": "Checkpoints les plus utilisés", "mostUsedCheckpoints": "Checkpoints les plus utilisés",
@@ -1033,13 +1045,77 @@
}, },
"insights": { "insights": {
"smartInsights": "Aperçus intelligents", "smartInsights": "Aperçus intelligents",
"recommendations": "Recommandations" "recommendations": "Recommandations",
"noInsights": "Aucun aperçu disponible",
"unusedLoras": {
"high": {
"title": "Nombre élevé de LoRAs inutilisées",
"description": "{percent}% de vos LoRAs ({count}/{total}) n'ont jamais été utilisées.",
"suggestion": "Envisagez d'organiser ou d'archiver les modèles inutilisés pour libérer de l'espace."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Points de contrôle inutilisés détectés",
"description": "{percent}% de vos points de contrôle ({count}/{total}) n'ont jamais été utilisés.",
"suggestion": "Examinez et envisagez de supprimer les points de contrôle dont vous n'avez plus besoin."
}
},
"unusedEmbeddings": {
"high": {
"title": "Nombre élevé d'Embeddings inutilisées",
"description": "{percent}% de vos embeddings ({count}/{total}) n'ont jamais été utilisées.",
"suggestion": "Envisagez d'organiser ou d'archiver les embeddings inutilisées pour optimiser votre collection."
}
},
"collection": {
"large": {
"title": "Grande collection détectée",
"description": "Votre collection de modèles utilise {size} de stockage.",
"suggestion": "Envisagez d'utiliser un stockage externe ou des solutions cloud pour une meilleure organisation."
}
},
"activity": {
"active": {
"title": "Utilisateur actif",
"description": "Vous avez effectué {count} générations jusqu'à présent !",
"suggestion": "Continuez à explorer et à créer du contenu formidable avec vos modèles."
}
}
}, },
"charts": { "charts": {
"collectionOverview": "Aperçu de la collection", "collectionOverview": "Aperçu de la collection",
"baseModelDistribution": "Distribution des modèles de base", "baseModelDistribution": "Distribution des modèles de base",
"usageTrends": "Tendances d'utilisation (30 derniers jours)", "usageTrends": "Tendances d'utilisation (30 derniers jours)",
"usageDistribution": "Distribution de l'utilisation" "usageDistribution": "Distribution de l'utilisation",
"date": "Date",
"usageCount": "Nombre d'utilisations",
"fileSizeBytes": "Taille du fichier (octets)",
"models": "Modèles",
"loraUsage": "Utilisation LoRA",
"checkpointUsage": "Utilisation Checkpoint",
"embeddingUsage": "Utilisation Embedding"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Point de contrôle",
"diffusion_model": "Modèle de diffusion",
"embedding": "Embeddings"
},
"placeholders": {
"loading": "Chargement...",
"noModels": "Aucun modèle trouvé",
"errorLoading": "Erreur de chargement des données",
"noStorageData": "Aucune donnée de stockage disponible",
"rootFolder": "Racine",
"chartLibraryMissing": "Le graphique nécessite la bibliothèque Chart.js"
},
"tooltips": {
"tagCount": "{tag}: {count} modèles",
"chartUsage": "{name}: {size}, {count} utilisations",
"chartPercentage": "{label}: {value} ({pct}%)"
} }
}, },
"modals": { "modals": {

View File

@@ -1016,6 +1016,18 @@
"storage": "אחסון", "storage": "אחסון",
"insights": "תובנות" "insights": "תובנות"
}, },
"metrics": {
"totalModels": "סה\"כ דגמים",
"totalStorage": "סה\"כ אחסון",
"totalGenerations": "סה\"כ יצירות",
"usageRate": "שיעור שימוש",
"loras": "LoRA",
"checkpoints": "נקודות ביקורת",
"embeddings": "הטמעות",
"uniqueTags": "תגיות ייחודיות",
"unusedModels": "דגמים שאינם בשימוש",
"avgUsesPerModel": "ממוצע שימושים/דגם"
},
"usage": { "usage": {
"mostUsedLoras": "LoRAs הנפוצים ביותר", "mostUsedLoras": "LoRAs הנפוצים ביותר",
"mostUsedCheckpoints": "Checkpoints הנפוצים ביותר", "mostUsedCheckpoints": "Checkpoints הנפוצים ביותר",
@@ -1033,13 +1045,77 @@
}, },
"insights": { "insights": {
"smartInsights": "תובנות חכמות", "smartInsights": "תובנות חכמות",
"recommendations": "המלצות" "recommendations": "המלצות",
"noInsights": "אין תובנות זמינות",
"unusedLoras": {
"high": {
"title": "כמות גבוהה של LoRAs שאינן בשימוש",
"description": "{percent}% מה-LoRAs שלך ({count}/{total}) מעולם לא נעשה בהם שימוש.",
"suggestion": "שקול לארגן או לאחסן בארכיון מודלים שאינם בשימוש כדי לפנות שטח אחסון."
}
},
"unusedCheckpoints": {
"detected": {
"title": "התגלו נקודות ביקורת שאינן בשימוש",
"description": "{percent}% מנקודות הביקורת שלך ({count}/{total}) מעולם לא נעשה בהן שימוש.",
"suggestion": "בדוק ושקול להסיר נקודות ביקורת שאינך צריך עוד."
}
},
"unusedEmbeddings": {
"high": {
"title": "כמות גבוהה של Embeddings שאינם בשימוש",
"description": "{percent}% מה-Embeddings שלך ({count}/{total}) מעולם לא נעשה בהם שימוש.",
"suggestion": "שקול לארגן או לאחסן בארכיון Embeddings שאינם בשימוש כדי לייעל את האוסף."
}
},
"collection": {
"large": {
"title": "התגלה אוסף גדול",
"description": "אוסף המודלים שלך משתמש ב-{size} של אחסון.",
"suggestion": "שקול להשתמש באחסון חיצוני או בפתרונות ענן לארגון טוב יותר."
}
},
"activity": {
"active": {
"title": "משתמש פעיל",
"description": "השלמת {count} יצירות עד כה!",
"suggestion": "המשך לחקור וליצור תוכן מדהים עם המודלים שלך."
}
}
}, },
"charts": { "charts": {
"collectionOverview": "סקירת אוסף", "collectionOverview": "סקירת אוסף",
"baseModelDistribution": "התפלגות מודלי בסיס", "baseModelDistribution": "התפלגות מודלי בסיס",
"usageTrends": "מגמות שימוש (30 יום אחרונים)", "usageTrends": "מגמות שימוש (30 יום אחרונים)",
"usageDistribution": "התפלגות שימוש" "usageDistribution": "התפלגות שימוש",
"date": "תאריך",
"usageCount": "מספר שימושים",
"fileSizeBytes": "גודל קובץ (בתים)",
"models": "דגמים",
"loraUsage": "שימוש ב-LoRA",
"checkpointUsage": "שימוש ב-Checkpoint",
"embeddingUsage": "שימוש ב-Embedding"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "נקודת ביקורת",
"diffusion_model": "מודל דיפוזיה",
"embedding": "הטמעות"
},
"placeholders": {
"loading": "טוען...",
"noModels": "לא נמצאו דגמים",
"errorLoading": "שגיאה בטעינת נתונים",
"noStorageData": "אין נתוני אחסון זמינים",
"rootFolder": "שורש",
"chartLibraryMissing": "הגרף דורש את ספריית Chart.js"
},
"tooltips": {
"tagCount": "{tag}: {count} דגמים",
"chartUsage": "{name}: {size}, {count} שימושים",
"chartPercentage": "{label}: {value} ({pct}%)"
} }
}, },
"modals": { "modals": {

View File

@@ -1016,6 +1016,18 @@
"storage": "ストレージ", "storage": "ストレージ",
"insights": "インサイト" "insights": "インサイト"
}, },
"metrics": {
"totalModels": "モデル総数",
"totalStorage": "ストレージ合計",
"totalGenerations": "生成回数合計",
"usageRate": "使用率",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "ユニークタグ",
"unusedModels": "未使用モデル",
"avgUsesPerModel": "平均使用回数/モデル"
},
"usage": { "usage": {
"mostUsedLoras": "最も使用されているLoRA", "mostUsedLoras": "最も使用されているLoRA",
"mostUsedCheckpoints": "最も使用されているCheckpoint", "mostUsedCheckpoints": "最も使用されているCheckpoint",
@@ -1033,13 +1045,77 @@
}, },
"insights": { "insights": {
"smartInsights": "スマートインサイト", "smartInsights": "スマートインサイト",
"recommendations": "推奨事項" "recommendations": "推奨事項",
"noInsights": "インサイトはありません",
"unusedLoras": {
"high": {
"title": "未使用のLoRAが多数あります",
"description": "LoRAの{percent}%{count}/{total})が一度も使用されていません。",
"suggestion": "未使用のモデルを整理またはアーカイブしてストレージを解放してください。"
}
},
"unusedCheckpoints": {
"detected": {
"title": "未使用のCheckpointを検出",
"description": "Checkpointの{percent}%{count}/{total})が一度も使用されていません。",
"suggestion": "不要なCheckpointを確認して削除を検討してください。"
}
},
"unusedEmbeddings": {
"high": {
"title": "未使用のEmbeddingが多数あります",
"description": "Embeddingの{percent}%{count}/{total})が一度も使用されていません。",
"suggestion": "未使用のEmbeddingを整理またはアーカイブしてコレクションを最適化してください。"
}
},
"collection": {
"large": {
"title": "大規模コレクションを検出",
"description": "モデルコレクションが{size}のストレージを使用しています。",
"suggestion": "外部ストレージやクラウドソリューションの使用を検討してください。"
}
},
"activity": {
"active": {
"title": "アクティブユーザー",
"description": "これまでに{count}回の生成を完了しました!",
"suggestion": "モデルを使って素晴らしいコンテンツを作り続けてください。"
}
}
}, },
"charts": { "charts": {
"collectionOverview": "コレクション概要", "collectionOverview": "コレクション概要",
"baseModelDistribution": "ベースモデル分布", "baseModelDistribution": "ベースモデル分布",
"usageTrends": "使用傾向過去30日", "usageTrends": "使用傾向過去30日",
"usageDistribution": "使用分布" "usageDistribution": "使用分布",
"date": "日付",
"usageCount": "使用回数",
"fileSizeBytes": "ファイルサイズ(バイト)",
"models": "モデル",
"loraUsage": "LoRA 使用量",
"checkpointUsage": "Checkpoint 使用量",
"embeddingUsage": "Embedding 使用量"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "拡散モデル",
"embedding": "Embedding"
},
"placeholders": {
"loading": "読み込み中...",
"noModels": "モデルが見つかりません",
"errorLoading": "データ読み込みエラー",
"noStorageData": "ストレージデータがありません",
"rootFolder": "ルート",
"chartLibraryMissing": "Chart.js ライブラリが必要です"
},
"tooltips": {
"tagCount": "{tag}: {count} モデル",
"chartUsage": "{name}: {size}, {count} 回使用",
"chartPercentage": "{label}: {value} ({pct}%)"
} }
}, },
"modals": { "modals": {

View File

@@ -1016,6 +1016,18 @@
"storage": "저장소", "storage": "저장소",
"insights": "인사이트" "insights": "인사이트"
}, },
"metrics": {
"totalModels": "모델 총계",
"totalStorage": "총 저장 공간",
"totalGenerations": "총 생성 횟수",
"usageRate": "사용률",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "고유 태그",
"unusedModels": "미사용 모델",
"avgUsesPerModel": "모델당 평균 사용"
},
"usage": { "usage": {
"mostUsedLoras": "가장 많이 사용된 LoRA", "mostUsedLoras": "가장 많이 사용된 LoRA",
"mostUsedCheckpoints": "가장 많이 사용된 Checkpoint", "mostUsedCheckpoints": "가장 많이 사용된 Checkpoint",
@@ -1033,13 +1045,77 @@
}, },
"insights": { "insights": {
"smartInsights": "스마트 인사이트", "smartInsights": "스마트 인사이트",
"recommendations": "추천" "recommendations": "추천",
"noInsights": "인사이트 없음",
"unusedLoras": {
"high": {
"title": "사용하지 않은 LoRA가 많음",
"description": "LoRA의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
"suggestion": "사용하지 않는 모델을 정리하거나 보관하여 저장 공간을 확보하세요."
}
},
"unusedCheckpoints": {
"detected": {
"title": "사용하지 않은 Checkpoint 감지",
"description": "Checkpoint의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
"suggestion": "더 이상 필요하지 않은 Checkpoint를 검토하고 제거하세요."
}
},
"unusedEmbeddings": {
"high": {
"title": "사용하지 않은 Embedding이 많음",
"description": "Embedding의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
"suggestion": "사용하지 않는 Embedding을 정리하여 컬렉션을 최적화하세요."
}
},
"collection": {
"large": {
"title": "대규모 컬렉션 감지",
"description": "모델 컬렉션이 {size}의 저장 공간을 사용 중입니다.",
"suggestion": "더 나은 관리를 위해 외부 저장소나 클라우드 솔루션을 고려하세요."
}
},
"activity": {
"active": {
"title": "활성 사용자",
"description": "지금까지 {count}번의 생성을 완료했습니다!",
"suggestion": "모델로 계속해서 멋진 콘텐츠를 탐색하고 만들어보세요."
}
}
}, },
"charts": { "charts": {
"collectionOverview": "컬렉션 개요", "collectionOverview": "컬렉션 개요",
"baseModelDistribution": "베이스 모델 분포", "baseModelDistribution": "베이스 모델 분포",
"usageTrends": "사용량 트렌드 (최근 30일)", "usageTrends": "사용량 트렌드 (최근 30일)",
"usageDistribution": "사용량 분포" "usageDistribution": "사용량 분포",
"date": "날짜",
"usageCount": "사용 횟수",
"fileSizeBytes": "파일 크기(바이트)",
"models": "모델",
"loraUsage": "LoRA 사용량",
"checkpointUsage": "Checkpoint 사용량",
"embeddingUsage": "Embedding 사용량"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "확산 모델",
"embedding": "Embedding"
},
"placeholders": {
"loading": "로딩 중...",
"noModels": "모델을 찾을 수 없음",
"errorLoading": "데이터 로딩 오류",
"noStorageData": "저장 데이터 없음",
"rootFolder": "루트",
"chartLibraryMissing": "Chart.js 라이브러리가 필요합니다"
},
"tooltips": {
"tagCount": "{tag}: {count}개 모델",
"chartUsage": "{name}: {size}, {count}회 사용",
"chartPercentage": "{label}: {value}({pct}%)"
} }
}, },
"modals": { "modals": {

View File

@@ -1016,6 +1016,18 @@
"storage": "Хранение", "storage": "Хранение",
"insights": "Аналитика" "insights": "Аналитика"
}, },
"metrics": {
"totalModels": "Всего моделей",
"totalStorage": "Всего хранилища",
"totalGenerations": "Всего генераций",
"usageRate": "Коэффициент использования",
"loras": "LoRA",
"checkpoints": "Контрольные точки",
"embeddings": "Эмбеддинги",
"uniqueTags": "Уникальные теги",
"unusedModels": "Неиспользуемые модели",
"avgUsesPerModel": "Сред. использований/модель"
},
"usage": { "usage": {
"mostUsedLoras": "Наиболее используемые LoRAs", "mostUsedLoras": "Наиболее используемые LoRAs",
"mostUsedCheckpoints": "Наиболее используемые Checkpoints", "mostUsedCheckpoints": "Наиболее используемые Checkpoints",
@@ -1033,13 +1045,77 @@
}, },
"insights": { "insights": {
"smartInsights": "Умная аналитика", "smartInsights": "Умная аналитика",
"recommendations": "Рекомендации" "recommendations": "Рекомендации",
"noInsights": "Нет доступных данных",
"unusedLoras": {
"high": {
"title": "Большое количество неиспользуемых LoRA",
"description": "{percent}% ваших LoRA ({count}/{total}) никогда не использовались.",
"suggestion": "Рассмотрите возможность организации или архивирования неиспользуемых моделей для освобождения места."
}
},
"unusedCheckpoints": {
"detected": {
"title": "Обнаружены неиспользуемые контрольные точки",
"description": "{percent}% ваших контрольных точек ({count}/{total}) никогда не использовались.",
"suggestion": "Проверьте и удалите ненужные контрольные точки."
}
},
"unusedEmbeddings": {
"high": {
"title": "Большое количество неиспользуемых эмбеддингов",
"description": "{percent}% ваших эмбеддингов ({count}/{total}) никогда не использовались.",
"suggestion": "Организуйте или архивируйте неиспользуемые эмбеддинги для оптимизации коллекции."
}
},
"collection": {
"large": {
"title": "Обнаружена большая коллекция",
"description": "Ваша коллекция моделей использует {size} хранилища.",
"suggestion": "Рассмотрите внешнее хранилище или облачные решения для лучшей организации."
}
},
"activity": {
"active": {
"title": "Активный пользователь",
"description": "Вы завершили {count} генераций!",
"suggestion": "Продолжайте исследовать и создавать удивительный контент с вашими моделями."
}
}
}, },
"charts": { "charts": {
"collectionOverview": "Обзор коллекции", "collectionOverview": "Обзор коллекции",
"baseModelDistribution": "Распределение базовых моделей", "baseModelDistribution": "Распределение базовых моделей",
"usageTrends": "Тенденции использования (за последние 30 дней)", "usageTrends": "Тенденции использования (за последние 30 дней)",
"usageDistribution": "Распределение использования" "usageDistribution": "Распределение использования",
"date": "Дата",
"usageCount": "Количество использований",
"fileSizeBytes": "Размер файла (байты)",
"models": "Модели",
"loraUsage": "Использование LoRA",
"checkpointUsage": "Использование Checkpoint",
"embeddingUsage": "Использование Embedding"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Контрольная точка",
"diffusion_model": "Диффузионная модель",
"embedding": "Эмбеддинги"
},
"placeholders": {
"loading": "Загрузка...",
"noModels": "Модели не найдены",
"errorLoading": "Ошибка загрузки данных",
"noStorageData": "Нет данных о хранилище",
"rootFolder": "Корень",
"chartLibraryMissing": "Для графика требуется библиотека Chart.js"
},
"tooltips": {
"tagCount": "{tag}: {count} моделей",
"chartUsage": "{name}: {size}, {count} использований",
"chartPercentage": "{label}: {value} ({pct}%)"
} }
}, },
"modals": { "modals": {

View File

@@ -1016,6 +1016,18 @@
"storage": "存储", "storage": "存储",
"insights": "洞察" "insights": "洞察"
}, },
"metrics": {
"totalModels": "模型总数",
"totalStorage": "总存储空间",
"totalGenerations": "总生成次数",
"usageRate": "使用率",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "唯一标签",
"unusedModels": "未使用模型",
"avgUsesPerModel": "平均使用次数/模型"
},
"usage": { "usage": {
"mostUsedLoras": "最常用 LoRA", "mostUsedLoras": "最常用 LoRA",
"mostUsedCheckpoints": "最常用 Checkpoint", "mostUsedCheckpoints": "最常用 Checkpoint",
@@ -1033,13 +1045,77 @@
}, },
"insights": { "insights": {
"smartInsights": "智能洞察", "smartInsights": "智能洞察",
"recommendations": "推荐" "recommendations": "推荐",
"noInsights": "暂无可用洞察",
"unusedLoras": {
"high": {
"title": "大量未使用的 LoRA",
"description": "你的 LoRA 中有 {percent}%{count}/{total})从未被使用过。",
"suggestion": "考虑整理或归档未使用的模型以释放存储空间。"
}
},
"unusedCheckpoints": {
"detected": {
"title": "检测到未使用的 Checkpoint",
"description": "你的 Checkpoint 中有 {percent}%{count}/{total})从未被使用过。",
"suggestion": "审查并考虑删除不再需要的 Checkpoint。"
}
},
"unusedEmbeddings": {
"high": {
"title": "大量未使用的 Embedding",
"description": "你的 Embedding 中有 {percent}%{count}/{total})从未被使用过。",
"suggestion": "考虑整理或归档未使用的 Embedding 以优化你的收藏。"
}
},
"collection": {
"large": {
"title": "检测到大型收藏",
"description": "你的模型收藏正在使用 {size} 的存储空间。",
"suggestion": "考虑使用外部存储或云解决方案以获得更好的组织。"
}
},
"activity": {
"active": {
"title": "活跃用户",
"description": "你已经完成了 {count} 次生成!",
"suggestion": "继续探索并用你的模型创作精彩内容。"
}
}
}, },
"charts": { "charts": {
"collectionOverview": "收藏概览", "collectionOverview": "收藏概览",
"baseModelDistribution": "基础模型分布", "baseModelDistribution": "基础模型分布",
"usageTrends": "使用趋势最近30天", "usageTrends": "使用趋势最近30天",
"usageDistribution": "使用分布" "usageDistribution": "使用分布",
"date": "日期",
"usageCount": "使用次数",
"fileSizeBytes": "文件大小(字节)",
"models": "模型",
"loraUsage": "LoRA 使用量",
"checkpointUsage": "Checkpoint 使用量",
"embeddingUsage": "Embedding 使用量"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "扩散模型",
"embedding": "Embedding"
},
"placeholders": {
"loading": "加载中...",
"noModels": "未找到模型",
"errorLoading": "数据加载失败",
"noStorageData": "暂无存储数据",
"rootFolder": "根目录",
"chartLibraryMissing": "需要 Chart.js 库来显示图表"
},
"tooltips": {
"tagCount": "{tag}{count} 个模型",
"chartUsage": "{name}{size}{count} 次使用",
"chartPercentage": "{label}{value}{pct}%"
} }
}, },
"modals": { "modals": {

View File

@@ -1016,6 +1016,18 @@
"storage": "儲存空間", "storage": "儲存空間",
"insights": "洞察" "insights": "洞察"
}, },
"metrics": {
"totalModels": "模型總數",
"totalStorage": "總儲存空間",
"totalGenerations": "總生成次數",
"usageRate": "使用率",
"loras": "LoRA",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"uniqueTags": "唯一標籤",
"unusedModels": "未使用模型",
"avgUsesPerModel": "平均使用次數/模型"
},
"usage": { "usage": {
"mostUsedLoras": "最常用的 LoRA", "mostUsedLoras": "最常用的 LoRA",
"mostUsedCheckpoints": "最常用的 Checkpoint", "mostUsedCheckpoints": "最常用的 Checkpoint",
@@ -1033,13 +1045,77 @@
}, },
"insights": { "insights": {
"smartInsights": "智慧洞察", "smartInsights": "智慧洞察",
"recommendations": "推薦" "recommendations": "推薦",
"noInsights": "暫無可用洞察",
"unusedLoras": {
"high": {
"title": "大量未使用的 LoRA",
"description": "你的 LoRA 中有 {percent}%{count}/{total})從未被使用過。",
"suggestion": "考慮整理或封存未使用的模型以釋放儲存空間。"
}
},
"unusedCheckpoints": {
"detected": {
"title": "檢測到未使用的 Checkpoint",
"description": "你的 Checkpoint 中有 {percent}%{count}/{total})從未被使用過。",
"suggestion": "審查並考慮刪除不再需要的 Checkpoint。"
}
},
"unusedEmbeddings": {
"high": {
"title": "大量未使用的 Embedding",
"description": "你的 Embedding 中有 {percent}%{count}/{total})從未被使用過。",
"suggestion": "考慮整理或封存未使用的 Embedding 以優化你的收藏。"
}
},
"collection": {
"large": {
"title": "檢測到大型收藏",
"description": "你的模型收藏正在使用 {size} 的儲存空間。",
"suggestion": "考慮使用外部儲存或雲端解決方案以獲得更好的組織。"
}
},
"activity": {
"active": {
"title": "活躍用戶",
"description": "你已經完成了 {count} 次生成!",
"suggestion": "繼續探索並用你的模型創作精彩內容。"
}
}
}, },
"charts": { "charts": {
"collectionOverview": "收藏總覽", "collectionOverview": "收藏總覽",
"baseModelDistribution": "基礎模型分布", "baseModelDistribution": "基礎模型分布",
"usageTrends": "使用趨勢(最近 30 天)", "usageTrends": "使用趨勢(最近 30 天)",
"usageDistribution": "使用分布" "usageDistribution": "使用分布",
"date": "日期",
"usageCount": "使用次數",
"fileSizeBytes": "檔案大小(位元組)",
"models": "模型",
"loraUsage": "LoRA 使用量",
"checkpointUsage": "Checkpoint 使用量",
"embeddingUsage": "Embedding 使用量"
},
"modelTypes": {
"lora": "LoRA",
"locon": "LyCORIS",
"dora": "DoRA",
"checkpoint": "Checkpoint",
"diffusion_model": "擴散模型",
"embedding": "Embedding"
},
"placeholders": {
"loading": "載入中...",
"noModels": "找不到模型",
"errorLoading": "資料載入失敗",
"noStorageData": "暫無儲存資料",
"rootFolder": "根目錄",
"chartLibraryMissing": "需要 Chart.js 函式庫來顯示圖表"
},
"tooltips": {
"tagCount": "{tag}{count} 個模型",
"chartUsage": "{name}{size}{count} 次使用",
"chartPercentage": "{label}{value}{pct}%"
} }
}, },
"modals": { "modals": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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.1.3" version = "1.1.4"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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