Compare commits

...

10 Commits

Author SHA1 Message Date
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
Will Miao
07f49559be fix(virtual-scroll): avoid full reload on move-to-folder, scroll to top on filter/page reset
- MoveManager/SidebarManager: replace resetAndReload with in-place
  VirtualScroller update after move operations (remove non-visible,
  update visible items' file_path). Preserves scroll position and
  avoids empty grid.
- VirtualScroller: add removeMultipleItemsByFilePath for efficient
  batch removal with Array.isArray guard.
- baseModelApi: scroll to top on loadMoreWithVirtualScroll(true),
  covering filter/sort/search/folder/views changes.
- SidebarManager selectFolder: scroll now handled centrally.
2026-06-19 09:18:49 +08:00
Will Miao
b24b1a7e57 feat(settings): hide API key from frontend, use status+edit instead of password field
Backend changes:
- Add civitai_api_key to _NO_SYNC_KEYS, return only boolean civitai_api_key_set
- Clean up known template placeholder on load to prevent false positive

Frontend changes:
- Replace type=password with type=text + CSS masking (-webkit-text-security)
- Replace pre-filled input with status display (Configured/Not configured)
- Add inline edit view with Save/Cancel buttons
- Re-add eye toggle via CSS class toggle (not type switching)
- Use CSS transitions for smooth status/edit view switching

This prevents Chromium/Vivaldi password manager from triggering
'save password' prompts when opening the settings modal.
2026-06-19 08:05:04 +08:00
43 changed files with 1924 additions and 750 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,7 +49,10 @@ from ...utils.constants import (
VALID_LORA_TYPES, VALID_LORA_TYPES,
) )
from ...utils.civitai_utils import rewrite_preview_url from ...utils.civitai_utils import rewrite_preview_url
from ...utils.example_images_paths import is_valid_example_images_root from ...utils.example_images_paths import (
find_non_compliant_items_in_example_images_root,
is_valid_example_images_root,
)
from ...utils.lora_metadata import extract_trained_words from ...utils.lora_metadata import extract_trained_words
from ...utils.session_logging import get_standalone_session_log_snapshot from ...utils.session_logging import get_standalone_session_log_snapshot
from ...utils.usage_stats import UsageStats from ...utils.usage_stats import UsageStats
@@ -1328,6 +1331,9 @@ class SettingsHandler:
"folder_paths", "folder_paths",
"libraries", "libraries",
"active_library", "active_library",
# Sensitive — never expose the actual value to the frontend;
# frontend receives a boolean instead (civitai_api_key_set).
"civitai_api_key",
} }
) )
@@ -1382,6 +1388,9 @@ class SettingsHandler:
value = self._settings.get(key) value = self._settings.get(key)
if value is not None: if value is not None:
response_data[key] = value response_data[key] = value
# Sensitive fields: only expose a boolean indicating whether set
raw_key = self._settings.get("civitai_api_key")
response_data["civitai_api_key_set"] = bool(raw_key)
settings_file = getattr(self._settings, "settings_file", None) settings_file = getattr(self._settings, "settings_file", None)
if settings_file: if settings_file:
response_data["settings_file"] = settings_file response_data["settings_file"] = settings_file
@@ -1492,6 +1501,16 @@ class SettingsHandler:
if not os.path.isdir(folder_path): if not os.path.isdir(folder_path):
return "Please set a dedicated folder for example images." return "Please set a dedicated folder for example images."
if not self._is_dedicated_example_images_folder(folder_path): if not self._is_dedicated_example_images_folder(folder_path):
offending = find_non_compliant_items_in_example_images_root(folder_path)
if offending:
items_str = ", ".join(repr(item) for item in offending[:5])
if len(offending) > 5:
items_str += f" … and {len(offending) - 5} more"
return (
f"The folder contains items that are not valid example image "
f"folders: {items_str}. Please use a dedicated, empty folder "
f"for example images to prevent accidental data loss."
)
return "Please set a dedicated folder for example images." return "Please set a dedicated folder for example images."
return None return None

View File

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

View File

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

View File

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

View File

@@ -134,6 +134,9 @@ class SettingsManager:
self._template_path = ( self._template_path = (
Path(__file__).resolve().parents[2] / "settings.json.example" Path(__file__).resolve().parents[2] / "settings.json.example"
) )
# Known placeholder value in settings.json.example; any file containing
# this value should be treated as "not configured".
self._TEMPLATE_PLACEHOLDER_API_KEY = "your_civitai_api_key_here"
self.settings = self._load_settings() self.settings = self._load_settings()
self._migrate_setting_keys() self._migrate_setting_keys()
self._ensure_default_settings() self._ensure_default_settings()
@@ -165,6 +168,12 @@ class SettingsManager:
self._original_disk_payload = copy.deepcopy(data) self._original_disk_payload = copy.deepcopy(data)
if self._matches_template_payload(data): if self._matches_template_payload(data):
self._preserve_disk_template = True self._preserve_disk_template = True
# Clean up the template placeholder so it is not treated
# as a real key (affects both the frontend boolean and
# the downloader's Authorization header).
placeholder = self._TEMPLATE_PLACEHOLDER_API_KEY
if data.get("civitai_api_key") == placeholder:
data["civitai_api_key"] = ""
return data return data
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
logger.error("Failed to parse settings.json: %s", exc) logger.error("Failed to parse settings.json: %s", exc)

View File

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

View File

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

View File

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

View File

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

View File

@@ -335,7 +335,12 @@
} }
} }
/* API key input specific styles */ /* API key input — CSS masking (prevents Chrome password manager triggers) */
.api-key-masked {
-webkit-text-security: disc;
}
/* API key input specific styles (shared with proxy password) */
.api-key-input { .api-key-input {
width: 100%; /* Take full width of parent */ width: 100%; /* Take full width of parent */
position: relative; position: relative;
@@ -345,7 +350,7 @@
.api-key-input input { .api-key-input input {
width: 100%; width: 100%;
padding: 6px 40px 6px 10px; /* Add left padding */ padding: 6px 40px 6px 10px; /* Right padding for eye button */
height: 32px; height: 32px;
box-sizing: border-box; box-sizing: border-box;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
@@ -353,6 +358,13 @@
background-color: var(--lora-surface); background-color: var(--lora-surface);
color: var(--text-color); color: var(--text-color);
font-size: 0.95em; font-size: 0.95em;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.api-key-input input:focus {
border-color: var(--lora-accent);
outline: none;
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
} }
.api-key-input .toggle-visibility { .api-key-input .toggle-visibility {
@@ -364,12 +376,98 @@
opacity: 0.6; opacity: 0.6;
cursor: pointer; cursor: pointer;
padding: 4px 8px; padding: 4px 8px;
transition: opacity 0.2s ease;
} }
.api-key-input .toggle-visibility:hover { .api-key-input .toggle-visibility:hover {
opacity: 1; opacity: 1;
} }
/* API key item — stack status/edit views vertically for smooth cross-fade */
.api-key-item .setting-control {
flex-direction: column;
align-items: flex-end;
}
/* API key status display (shown when not editing) */
.api-key-status {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
justify-content: flex-end;
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
max-height: 80px;
overflow: hidden;
}
.api-key-status.is-hidden {
opacity: 0;
max-height: 0;
transform: translateY(-4px);
pointer-events: none;
margin: 0;
}
.api-key-status-text {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.95em;
white-space: nowrap;
transition: color 0.2s ease;
}
/* Status color modifiers — replace inline styles */
.api-key-status--configured .fa-check-circle {
color: var(--lora-success);
}
.api-key-status--unconfigured .fa-times-circle {
color: var(--lora-error);
}
/* Utility classes for status icon colors (used by JS) */
.text-success {
color: var(--lora-success);
}
.text-error {
color: var(--lora-error);
}
/* API key inline edit container — flex row with input + buttons */
.api-key-edit {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
justify-content: flex-end;
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
max-height: 80px;
overflow: hidden;
}
.api-key-edit.is-hidden {
opacity: 0;
max-height: 0;
transform: translateY(-4px);
pointer-events: none;
margin: 0;
}
.api-key-edit .api-key-input {
flex: 1;
min-width: 0;
}
.api-key-edit .primary-btn,
.api-key-edit .secondary-btn {
height: 32px;
flex-shrink: 0;
white-space: nowrap;
}
/* Text input wrapper styles for consistent input styling */ /* Text input wrapper styles for consistent input styling */
.text-input-wrapper { .text-input-wrapper {
width: 100%; width: 100%;

View File

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

View File

@@ -133,6 +133,16 @@ export class BaseModelApiClient {
pageState.hasMore = result.hasMore; pageState.hasMore = result.hasMore;
pageState.currentPage = pageState.currentPage + 1; pageState.currentPage = pageState.currentPage + 1;
// When resetting to page 1, scroll back to the top
// This covers: folder selection, filter/sort/search changes,
// favorites/update/excluded view toggles, alphabet filter, etc.
if (resetPage) {
const scrollContainer = document.querySelector('.page-content');
if (scrollContainer) {
scrollContainer.scrollTop = 0;
}
}
if (updateFolders) { if (updateFolders) {
sidebarManager.refresh(); sidebarManager.refresh();
} }

View File

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

View File

@@ -4,7 +4,7 @@
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js'; import { getModelApiClient } from '../api/modelApiFactory.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js'; import { bulkManager } from '../managers/BulkManager.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js'; import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
@@ -457,21 +457,69 @@ export class SidebarManager {
try { try {
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove); console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
let movedFiles = []; // Array of { original_file_path, new_file_path }
if (useBulkMove) { if (useBulkMove) {
await this.apiClient.moveBulkModels(this.draggedFilePaths, destination); const results = await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
movedFiles = (results || [])
.filter(r => r.success)
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
} else { } else {
await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination); const result = await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
if (result) {
movedFiles.push({
original_file_path: result.original_file_path || this.draggedFilePaths[0],
new_file_path: result.new_file_path
});
}
} }
console.log('[SidebarManager] apiClient.move successful'); console.log('[SidebarManager] apiClient.move successful');
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') { // Update VirtualScroller in-place instead of full reload
console.log('[SidebarManager] calling resetAndReload'); if (movedFiles.length > 0 && state.virtualScroller) {
await this.pageControls.resetAndReload(true); const pageState = getCurrentPageState();
} else { const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
console.log('[SidebarManager] calling refresh'); const isRecursive = pageState.searchOptions?.recursive ?? true;
await this.refresh(); const isFolderFiltered = pageState.activeFolder !== null;
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
// Determine if items in the target folder are visible in the current view
let itemsRemainVisible = true;
if (isFolderFiltered) {
if (isRecursive) {
itemsRemainVisible = normalizedActive === '' ||
normalizedTarget === normalizedActive ||
normalizedTarget.startsWith(normalizedActive + '/');
} else {
itemsRemainVisible = normalizedTarget === normalizedActive;
}
}
if (itemsRemainVisible) {
// Items stay visible — update each item's file_path to reflect new location
for (const moved of movedFiles) {
if (moved.original_file_path && moved.new_file_path) {
state.virtualScroller.updateSingleItem(moved.original_file_path, {
file_path: moved.new_file_path,
folder: normalizedTarget
});
}
}
} else {
// Items no longer visible in current folder — remove from VirtualScroller
const pathsToRemove = movedFiles
.map(m => m.original_file_path)
.filter(Boolean);
if (pathsToRemove.length > 0) {
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
}
}
} }
// Refresh sidebar folder tree only (no model data reload)
await this.refresh();
if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') { if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
bulkManager.toggleBulkMode(); bulkManager.toggleBulkMode();
} }
@@ -530,21 +578,69 @@ export class SidebarManager {
try { try {
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove); console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
let movedFiles = []; // Array of { original_file_path, new_file_path }
if (useBulkMove) { if (useBulkMove) {
await this.apiClient.moveBulkModels(draggedFilePaths, destination); const results = await this.apiClient.moveBulkModels(draggedFilePaths, destination);
movedFiles = (results || [])
.filter(r => r.success)
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
} else { } else {
await this.apiClient.moveSingleModel(draggedFilePaths[0], destination); const result = await this.apiClient.moveSingleModel(draggedFilePaths[0], destination);
if (result) {
movedFiles.push({
original_file_path: result.original_file_path || draggedFilePaths[0],
new_file_path: result.new_file_path
});
}
} }
console.log('[SidebarManager] apiClient.move successful'); console.log('[SidebarManager] apiClient.move successful');
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') { // Update VirtualScroller in-place instead of full reload
console.log('[SidebarManager] calling resetAndReload'); if (movedFiles.length > 0 && state.virtualScroller) {
await this.pageControls.resetAndReload(true); const pageState = getCurrentPageState();
} else { const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
console.log('[SidebarManager] calling refresh'); const isRecursive = pageState.searchOptions?.recursive ?? true;
await this.refresh(); const isFolderFiltered = pageState.activeFolder !== null;
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
// Determine if items in the target folder are visible in the current view
let itemsRemainVisible = true;
if (isFolderFiltered) {
if (isRecursive) {
itemsRemainVisible = normalizedActive === '' ||
normalizedTarget === normalizedActive ||
normalizedTarget.startsWith(normalizedActive + '/');
} else {
itemsRemainVisible = normalizedTarget === normalizedActive;
}
}
if (itemsRemainVisible) {
// Items stay visible — update each item's file_path to reflect new location
for (const moved of movedFiles) {
if (moved.original_file_path && moved.new_file_path) {
state.virtualScroller.updateSingleItem(moved.original_file_path, {
file_path: moved.new_file_path,
folder: normalizedTarget
});
}
}
} else {
// Items no longer visible in current folder — remove from VirtualScroller
const pathsToRemove = movedFiles
.map(m => m.original_file_path)
.filter(Boolean);
if (pathsToRemove.length > 0) {
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
}
}
} }
// Refresh sidebar folder tree only (no model data reload)
await this.refresh();
if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') { if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
bulkManager.toggleBulkMode(); bulkManager.toggleBulkMode();
} }
@@ -1346,7 +1442,7 @@ export class SidebarManager {
this.pageControls.pageState.activeFolder = normalizedPath; this.pageControls.pageState.activeFolder = normalizedPath;
setStorageItem(`${this.pageType}_activeFolder`, normalizedPath); setStorageItem(`${this.pageType}_activeFolder`, normalizedPath);
// Reload models with new filter // Reload models with new filter (loadMoreWithVirtualScroll will scroll to top)
await this.pageControls.resetAndReload(); await this.pageControls.resetAndReload();
} }

View File

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

View File

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

View File

@@ -327,10 +327,15 @@ export class DoctorManager {
case 'open-settings': case 'open-settings':
modalManager.showModal('settingsModal'); modalManager.showModal('settingsModal');
window.setTimeout(() => { window.setTimeout(() => {
const input = document.getElementById('civitaiApiKey'); // Open the API key editor directly
if (input) { if (typeof settingsManager.editApiKey === 'function') {
input.focus(); settingsManager.editApiKey();
input.scrollIntoView({ behavior: 'smooth', block: 'center' }); } else {
const input = document.getElementById('civitaiApiKey');
if (input) {
input.focus();
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} }
}, 100); }, 100);
break; break;

View File

@@ -321,29 +321,94 @@ class MoveManager {
} }
try { try {
let movedFiles = []; // Array of { original_file_path, new_file_path }
if (this.bulkFilePaths) { if (this.bulkFilePaths) {
// Bulk move mode // Bulk move mode
await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath); const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
movedFiles = (results || [])
.filter(r => r.success)
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
// Deselect moving items // Deselect moving items
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path)); this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
} else { } else {
// Single move mode // Single move mode
await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath); const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
if (result) {
movedFiles.push({
original_file_path: result.original_file_path || this.currentFilePath,
new_file_path: result.new_file_path
});
}
// Deselect moving item // Deselect moving item
bulkManager.deselectItem(this.currentFilePath); bulkManager.deselectItem(this.currentFilePath);
} }
// Refresh UI by reloading the current page, same as drag-and-drop behavior // Update VirtualScroller in-place instead of full reload
// This ensures all metadata (like preview URLs) are correctly formatted by the backend if (movedFiles.length > 0 && state.virtualScroller) {
if (sidebarManager.pageControls && typeof sidebarManager.pageControls.resetAndReload === 'function') { // Get current page state for folder filter check
await sidebarManager.pageControls.resetAndReload(true); const pageState = getCurrentPageState();
} else if (sidebarManager.lastPageControls && typeof sidebarManager.lastPageControls.resetAndReload === 'function') { const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
await sidebarManager.lastPageControls.resetAndReload(true); const isRecursive = pageState.searchOptions?.recursive ?? true;
const isFolderFiltered = pageState.activeFolder !== null;
// Determine which items are still visible after the move
const pathsToRemove = [];
const pathsToUpdate = []; // { originalPath, newData }
for (const moved of movedFiles) {
if (!moved.original_file_path) continue;
if (isFolderFiltered) {
// Compute relative folder of the new path
const newRelativeFolder = this._getRelativeFolder(moved.new_file_path);
const normalizedNewFolder = newRelativeFolder.replace(/\\/g, '/').replace(/\/$/, '');
// Check if the new location is still within the active folder
let stillVisible;
if (isRecursive) {
stillVisible = normalizedActive === '' ||
normalizedNewFolder === normalizedActive ||
normalizedNewFolder.startsWith(normalizedActive + '/');
} else {
stillVisible = normalizedNewFolder === normalizedActive;
}
if (stillVisible) {
pathsToUpdate.push({
originalPath: moved.original_file_path,
newData: {
file_path: moved.new_file_path,
folder: newRelativeFolder
}
});
} else {
pathsToRemove.push(moved.original_file_path);
}
} else {
// No folder filter active — items remain visible, just update path
pathsToUpdate.push({
originalPath: moved.original_file_path,
newData: {
file_path: moved.new_file_path,
folder: this._getRelativeFolder(moved.new_file_path)
}
});
}
}
// Apply updates to the VirtualScroller
if (pathsToRemove.length > 0) {
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
}
for (const update of pathsToUpdate) {
state.virtualScroller.updateSingleItem(update.originalPath, update.newData);
}
} }
// Refresh folder tree in sidebar // Refresh folder tree in sidebar (no model data reload)
await sidebarManager.refresh(); await sidebarManager.refresh();
modalManager.closeModal('moveModal'); modalManager.closeModal('moveModal');

View File

@@ -347,9 +347,9 @@ export class SettingsManager {
if (this.isOpen) { if (this.isOpen) {
this.loadSettingsToUI(); this.loadSettingsToUI();
} else { } else {
// Clear sensitive fields on close to prevent browser save-password prompts // Reset API key edit mode on close
const apiKeyInput = document.getElementById('civitaiApiKey'); this.cancelEditApiKey(true);
if (apiKeyInput) apiKeyInput.value = ''; // Clear proxy password on close
const proxyPasswordInput = document.getElementById('proxyPassword'); const proxyPasswordInput = document.getElementById('proxyPassword');
if (proxyPasswordInput) proxyPasswordInput.value = ''; if (proxyPasswordInput) proxyPasswordInput.value = '';
} }
@@ -825,10 +825,8 @@ export class SettingsManager {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings; usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
} }
const civitaiApiKeyInput = document.getElementById('civitaiApiKey'); // Update API key status display (do NOT pre-fill the input)
if (civitaiApiKeyInput) { this.updateApiKeyStatus();
civitaiApiKeyInput.value = state.global.settings.civitai_api_key || '';
}
const civitaiHostSelect = document.getElementById('civitaiHost'); const civitaiHostSelect = document.getElementById('civitaiHost');
if (civitaiHostSelect) { if (civitaiHostSelect) {
@@ -2898,16 +2896,97 @@ export class SettingsManager {
} }
} }
// ── CivitAI API Key management ──────────────────────────────
updateApiKeyStatus() {
const hasKey = !!(state.global.settings.civitai_api_key_set ||
state.global.settings.civitai_api_key);
const statusEl = document.getElementById('civitaiApiKeyStatus');
const statusText = document.getElementById('civitaiApiKeyStatusText');
const actionBtn = document.getElementById('civitaiApiKeyActionBtn');
if (!statusText || !actionBtn) return;
if (hasKey) {
statusText.classList.remove('api-key-status--unconfigured');
statusText.classList.add('api-key-status--configured');
statusText.innerHTML = '<i class="fas fa-check-circle text-success"></i> '
+ translate('settings.civitaiApiKeyConfigured', {}, 'Configured');
actionBtn.textContent = translate('common.actions.change', {}, 'Change');
} else {
statusText.classList.remove('api-key-status--configured');
statusText.classList.add('api-key-status--unconfigured');
statusText.innerHTML = '<i class="fas fa-times-circle text-error"></i> '
+ translate('settings.civitaiApiKeyNotConfigured', {}, 'Not configured');
actionBtn.textContent = translate('settings.civitaiApiKeySet', {}, 'Set up');
}
}
editApiKey() {
const statusEl = document.getElementById('civitaiApiKeyStatus');
if (statusEl) statusEl.classList.add('is-hidden');
const editContainer = document.getElementById('civitaiApiKeyEdit');
if (editContainer) editContainer.classList.remove('is-hidden');
// Focus the input
const input = document.getElementById('civitaiApiKey');
if (input) {
input.value = ''; // Never pre-fill the secret
setTimeout(() => input.focus(), 50);
}
}
cancelEditApiKey(silent) {
const editContainer = document.getElementById('civitaiApiKeyEdit');
if (editContainer) editContainer.classList.add('is-hidden');
const statusContainer = document.getElementById('civitaiApiKeyStatus');
if (statusContainer) statusContainer.classList.remove('is-hidden');
// Clear any typed value
const input = document.getElementById('civitaiApiKey');
if (input) input.value = '';
if (!silent) {
this.updateApiKeyStatus();
}
}
async saveApiKey() {
const input = document.getElementById('civitaiApiKey');
if (!input) return;
const value = input.value.trim();
try {
await this.saveSetting('civitai_api_key', value);
showToast('toast.settings.settingsUpdated',
{ setting: 'CivitAI API Key' }, 'success');
} catch (error) {
showToast('toast.settings.settingSaveFailed',
{ message: error.message }, 'error');
return;
}
// Update the in-memory flag so the UI reflects the change
state.global.settings.civitai_api_key_set = !!value;
this.cancelEditApiKey(true);
this.updateApiKeyStatus();
}
toggleInputVisibility(button) { toggleInputVisibility(button) {
const input = button.parentElement.querySelector('input'); const input = button.parentElement.querySelector('input');
if (!input) return;
const icon = button.querySelector('i'); const icon = button.querySelector('i');
if (input.dataset.mask === 'css') {
if (input.type === 'password') { // CSS-masked input (CivitAI API key) — toggle class, not type
input.classList.toggle('api-key-masked');
if (icon) {
icon.className = input.classList.contains('api-key-masked')
? 'fas fa-eye'
: 'fas fa-eye-slash';
}
} else if (input.type === 'password') {
input.type = 'text'; input.type = 'text';
icon.className = 'fas fa-eye-slash'; if (icon) icon.className = 'fas fa-eye-slash';
} else { } else {
input.type = 'password'; input.type = 'password';
icon.className = 'fas fa-eye'; if (icon) icon.className = 'fas fa-eye';
} }
} }

View File

@@ -5,6 +5,7 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/co
const DEFAULT_SETTINGS_BASE = Object.freeze({ const DEFAULT_SETTINGS_BASE = Object.freeze({
civitai_api_key: '', civitai_api_key: '',
civitai_api_key_set: false,
civitai_host: 'civitai.com', civitai_host: 'civitai.com',
download_backend: 'python', download_backend: 'python',
aria2c_path: '', aria2c_path: '',

View File

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

View File

@@ -931,6 +931,38 @@ export class VirtualScroller {
return true; return true;
} }
/**
* Remove multiple items by their file paths.
* More efficient than calling removeItemByFilePath individually.
* @param {string[]} filePaths - Array of file paths to remove
* @returns {boolean} - True if any items were removed
*/
removeMultipleItemsByFilePath(filePaths) {
if (!Array.isArray(filePaths) || filePaths.length === 0 || this.disabled || this.items.length === 0) return false;
// Build a set for fast lookup
const pathsToRemove = new Set(filePaths);
const originalLength = this.items.length;
// Filter out removed items; keep those not in the set
this.items = this.items.filter(item => !pathsToRemove.has(item.file_path));
const removedCount = originalLength - this.items.length;
if (removedCount === 0) return false;
this.totalItems = Math.max(0, this.totalItems - removedCount);
// Update the spacer height
this.updateSpacerHeight();
// Re-render to fill gaps left by removed items
this.clearRenderedItems();
this.scheduleRender();
console.log(`Removed ${removedCount} items from virtual scroller data`);
return true;
}
// Add keyboard navigation methods // Add keyboard navigation methods
handlePageUpDown(direction) { handlePageUpDown(direction) {
// Prevent duplicate animations by checking last trigger time // Prevent duplicate animations by checking last trigger time

View File

@@ -95,21 +95,36 @@
<div class="setting-item api-key-item"> <div class="setting-item api-key-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<label for="civitaiApiKey">{{ t('settings.civitaiApiKey') }}</label> <label>{{ t('settings.civitaiApiKey') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.civitaiApiKeyHelp') }}"></i> <i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.civitaiApiKeyHelp') }}"></i>
</div> </div>
<div class="setting-control"> <div class="setting-control">
<div class="api-key-input"> <!-- Status display (shown when not editing) -->
<input type="password" <div id="civitaiApiKeyStatus" class="api-key-status">
id="civitaiApiKey" <span id="civitaiApiKeyStatusText" class="api-key-status-text api-key-status--unconfigured">
placeholder="{{ t('settings.civitaiApiKeyPlaceholder') }}" <i class="fas fa-times-circle text-error"></i>
autocomplete="new-password" {{ t('settings.civitaiApiKeyNotConfigured') }}
onblur="settingsManager.saveInputSetting('civitaiApiKey', 'civitai_api_key')" </span>
onkeydown="if(event.key === 'Enter') { this.blur(); }" /> <button type="button" class="secondary-btn" id="civitaiApiKeyActionBtn" onclick="settingsManager.editApiKey()">
<button class="toggle-visibility"> {{ t('settings.civitaiApiKeySet') }}
<i class="fas fa-eye"></i>
</button> </button>
</div> </div>
<!-- Inline edit view (shown when editing) -->
<div id="civitaiApiKeyEdit" class="api-key-edit is-hidden">
<div class="api-key-input">
<input type="text"
id="civitaiApiKey"
class="api-key-masked"
placeholder="{{ t('settings.civitaiApiKeyPlaceholder') }}"
autocomplete="off"
data-mask="css" />
<button type="button" class="toggle-visibility">
<i class="fas fa-eye"></i>
</button>
</div>
<button type="button" class="primary-btn" onclick="settingsManager.saveApiKey()">{{ t('common.actions.save') }}</button>
<button type="button" class="secondary-btn" onclick="settingsManager.cancelEditApiKey()">{{ t('common.actions.cancel') }}</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,7 @@
'messages': list([ 'messages': list([
]), ]),
'settings': dict({ 'settings': dict({
'civitai_api_key': 'test-key', 'civitai_api_key_set': True,
'language': 'en', 'language': 'en',
'theme': 'dark', 'theme': 'dark',
}), }),

View File

@@ -134,8 +134,10 @@ async def test_get_settings_excludes_no_sync_keys():
assert payload["success"] is True assert payload["success"] is True
# Regular settings should be synced # Regular settings should be synced
assert payload["settings"]["civitai_api_key"] == "abc"
assert payload["settings"]["regular_setting"] == "value" assert payload["settings"]["regular_setting"] == "value"
# civitai_api_key is in _NO_SYNC_KEYS; only the boolean flag is returned
assert payload["settings"].get("civitai_api_key") is None
assert payload["settings"]["civitai_api_key_set"] is True
# _NO_SYNC_KEYS should not be synced # _NO_SYNC_KEYS should not be synced
assert "hash_chunk_size_mb" not in payload["settings"] assert "hash_chunk_size_mb" not in payload["settings"]
assert "folder_paths" not in payload["settings"] assert "folder_paths" not in payload["settings"]

View File

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

View File

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