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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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