mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-09 20:39:25 -03:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82b77bf593 | ||
|
|
1beef5dea9 | ||
|
|
c8beaa64e1 | ||
|
|
fb443ed6ae | ||
|
|
151a467598 | ||
|
|
98e1d168b0 | ||
|
|
716f18e0ed | ||
|
|
b060dc99fc | ||
|
|
54bcdfab38 | ||
|
|
2e7532eecc | ||
|
|
7e5e3b1ec7 | ||
|
|
df67bd396a | ||
|
|
dd5d9cfcb2 | ||
|
|
d9fd60bec1 | ||
|
|
b633b22779 | ||
|
|
1ffa543160 | ||
|
|
cdc940586e | ||
|
|
ccf1c6f2ae | ||
|
|
bfe7b5e1c7 | ||
|
|
85c020cd12 | ||
|
|
1b202f8ec7 | ||
|
|
d02a0611d3 | ||
|
|
92166a161a |
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -13,8 +13,5 @@ A clear and concise description of what the problem is. Ex. I'm always frustrate
|
|||||||
**Describe the solution you'd like**
|
**Describe the solution you'd like**
|
||||||
A clear and concise description of what you want to happen.
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context or screenshots about the feature request here.
|
Add any other context or screenshots about the feature request here.
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Phil, Carl
|
|||||||
|
|
||||||
### Option 2: **Portable Standalone Edition** (No ComfyUI required)
|
### Option 2: **Portable Standalone Edition** (No ComfyUI required)
|
||||||
|
|
||||||
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v1.0.0/lora_manager_portable.7z)
|
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v1.0.10/lora_manager_portable.7z)
|
||||||
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder.
|
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder.
|
||||||
3. Edit the new `settings.json` to include your correct model folder paths and CivitAI API key
|
3. Edit the new `settings.json` to include your correct model folder paths and CivitAI API key
|
||||||
- Set `"use_portable_settings": true` if you want the configuration to remain inside the repository folder instead of your user settings directory.
|
- Set `"use_portable_settings": true` if you want the configuration to remain inside the repository folder instead of your user settings directory.
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
],
|
],
|
||||||
"allSupporters": [
|
"allSupporters": [
|
||||||
"Insomnia Art Designs",
|
"Insomnia Art Designs",
|
||||||
|
"2018cfh",
|
||||||
"megakirbs",
|
"megakirbs",
|
||||||
"Brennok",
|
"Brennok",
|
||||||
"2018cfh",
|
|
||||||
"W+K+White",
|
"W+K+White",
|
||||||
"wackop",
|
"wackop",
|
||||||
"Phil",
|
"Phil",
|
||||||
@@ -17,56 +17,67 @@
|
|||||||
"Arlecchino Shion",
|
"Arlecchino Shion",
|
||||||
"Charles Blakemore",
|
"Charles Blakemore",
|
||||||
"Rob Williams",
|
"Rob Williams",
|
||||||
"$MetaSamsara",
|
|
||||||
"stone9k",
|
"stone9k",
|
||||||
|
"itismyelement",
|
||||||
|
"$MetaSamsara",
|
||||||
|
"onesecondinosaur",
|
||||||
"Rosenthal",
|
"Rosenthal",
|
||||||
"Francisco Tatis",
|
"Francisco Tatis",
|
||||||
|
"Tobi_Swagg",
|
||||||
|
"Andrew Wilson",
|
||||||
|
"Greybush",
|
||||||
|
"Ricky Carter",
|
||||||
"JongWon Han",
|
"JongWon Han",
|
||||||
|
"VantAI",
|
||||||
"runte3221",
|
"runte3221",
|
||||||
"FreelancerZ",
|
"FreelancerZ",
|
||||||
|
"Edgar Tejeda",
|
||||||
"Fraser Cross",
|
"Fraser Cross",
|
||||||
|
"Liam MacDougal",
|
||||||
"Polymorphic Indeterminate",
|
"Polymorphic Indeterminate",
|
||||||
"Marc Whiffen",
|
"Marc Whiffen",
|
||||||
"Skalabananen",
|
"Skalabananen",
|
||||||
"Birdy",
|
"Birdy",
|
||||||
"Kiba",
|
"Kiba",
|
||||||
"Mozzel",
|
"Mozzel",
|
||||||
"itismyelement",
|
|
||||||
"Gingko Biloba",
|
"Gingko Biloba",
|
||||||
"Reno Lam",
|
"Reno Lam",
|
||||||
"onesecondinosaur",
|
|
||||||
"sig",
|
"sig",
|
||||||
"Christian Byrne",
|
"Christian Byrne",
|
||||||
"DM",
|
"DM",
|
||||||
"Sen314",
|
"Sen314",
|
||||||
"Estragon",
|
"Estragon",
|
||||||
"J\\B/ 8r0wns0n",
|
"J\\B/ 8r0wns0n",
|
||||||
|
"Snaggwort",
|
||||||
"Takkan",
|
"Takkan",
|
||||||
|
"Matt+J",
|
||||||
"ClockDaemon",
|
"ClockDaemon",
|
||||||
"KD",
|
"KD",
|
||||||
"Omnidex",
|
"Omnidex",
|
||||||
"Tyler Trebuchon",
|
"Tyler Trebuchon",
|
||||||
"Release Cabrakan",
|
"Release Cabrakan",
|
||||||
"Tobi_Swagg",
|
|
||||||
"SG",
|
"SG",
|
||||||
|
"carozzz",
|
||||||
"James Dooley",
|
"James Dooley",
|
||||||
"zenbound",
|
"zenbound",
|
||||||
"Buzzard",
|
"Buzzard",
|
||||||
"jmack",
|
"jmack",
|
||||||
"Andrew Wilson",
|
"Adam Shaw",
|
||||||
"Greybush",
|
|
||||||
"Mark Corneglio",
|
"Mark Corneglio",
|
||||||
"SarcasticHashtag",
|
"SarcasticHashtag",
|
||||||
|
"Anthony Rizzo",
|
||||||
"iamresist",
|
"iamresist",
|
||||||
|
"RedrockVP",
|
||||||
"Wolffen",
|
"Wolffen",
|
||||||
"Ricky Carter",
|
|
||||||
"James Todd",
|
"James Todd",
|
||||||
"Steven Pfeiffer",
|
"Steven Pfeiffer",
|
||||||
"VantAI",
|
|
||||||
"Tim",
|
"Tim",
|
||||||
|
"Timmy",
|
||||||
|
"Johnny",
|
||||||
"Lisster",
|
"Lisster",
|
||||||
"Michael Wong",
|
"Michael Wong",
|
||||||
"Illrigger",
|
"Illrigger",
|
||||||
|
"whudunit",
|
||||||
"Tom Corrigan",
|
"Tom Corrigan",
|
||||||
"JackieWang",
|
"JackieWang",
|
||||||
"fnkylove",
|
"fnkylove",
|
||||||
@@ -77,16 +88,16 @@
|
|||||||
"Robert Stacey",
|
"Robert Stacey",
|
||||||
"PM",
|
"PM",
|
||||||
"Todd Keck",
|
"Todd Keck",
|
||||||
"Edgar Tejeda",
|
"Briton Heilbrun",
|
||||||
"Jorge Hussni",
|
"Jorge Hussni",
|
||||||
"Liam MacDougal",
|
|
||||||
"Sterilized",
|
"Sterilized",
|
||||||
"BadassArabianMofo",
|
"BadassArabianMofo",
|
||||||
|
"Pascal Dahle",
|
||||||
"quarz",
|
"quarz",
|
||||||
"Greg",
|
"Greg",
|
||||||
"JSST",
|
"JSST",
|
||||||
"Snaggwort",
|
|
||||||
"lmsupporter",
|
"lmsupporter",
|
||||||
|
"zounic",
|
||||||
"wfpearl",
|
"wfpearl",
|
||||||
"Baekdoosixt",
|
"Baekdoosixt",
|
||||||
"Jonathan Ross",
|
"Jonathan Ross",
|
||||||
@@ -99,29 +110,25 @@
|
|||||||
"contrite831",
|
"contrite831",
|
||||||
"Alex",
|
"Alex",
|
||||||
"bh",
|
"bh",
|
||||||
"carozzz",
|
|
||||||
"Marlon Daniels",
|
"Marlon Daniels",
|
||||||
"Starkselle",
|
"Starkselle",
|
||||||
"Aaron Bleuer",
|
"Aaron Bleuer",
|
||||||
"LacesOut!",
|
"LacesOut!",
|
||||||
"greebles",
|
"greebles",
|
||||||
"Adam Shaw",
|
|
||||||
"Anthony Rizzo",
|
|
||||||
"M Postkasse",
|
"M Postkasse",
|
||||||
"Gooohokrbe",
|
"Gooohokrbe",
|
||||||
"RedrockVP",
|
|
||||||
"Wicked Choices by ASLPro3D",
|
"Wicked Choices by ASLPro3D",
|
||||||
"OldBones",
|
"OldBones",
|
||||||
"Jacob Hoehler",
|
"Jacob Hoehler",
|
||||||
"FinalyFree",
|
"FinalyFree",
|
||||||
"Weasyl",
|
"Weasyl",
|
||||||
"Timmy",
|
"Lex Song",
|
||||||
"Johnny",
|
|
||||||
"Cory Paza",
|
"Cory Paza",
|
||||||
"Tak",
|
"Tak",
|
||||||
|
"Gonzalo Andre Allendes Lopez",
|
||||||
"Zach Gonser",
|
"Zach Gonser",
|
||||||
"Big Red",
|
"Big Red",
|
||||||
"whudunit",
|
"Jimmy Ledbetter",
|
||||||
"Luc Job",
|
"Luc Job",
|
||||||
"dl0901dm",
|
"dl0901dm",
|
||||||
"Philip Hempel",
|
"Philip Hempel",
|
||||||
@@ -129,13 +136,13 @@
|
|||||||
"Nick Walker",
|
"Nick Walker",
|
||||||
"Bishoujoker",
|
"Bishoujoker",
|
||||||
"aai",
|
"aai",
|
||||||
"Briton Heilbrun",
|
|
||||||
"Tori",
|
"Tori",
|
||||||
"wildnut",
|
"wildnut",
|
||||||
"jean jahren",
|
"jean jahren",
|
||||||
"Aleksander Wujczyk",
|
"Aleksander Wujczyk",
|
||||||
"AM Kuro",
|
"AM Kuro",
|
||||||
"Pascal Dahle",
|
"Ran C",
|
||||||
|
"ViperC",
|
||||||
"Penfore",
|
"Penfore",
|
||||||
"Sangheili460",
|
"Sangheili460",
|
||||||
"MagnaInsomnia",
|
"MagnaInsomnia",
|
||||||
@@ -148,32 +155,35 @@
|
|||||||
"The Spawn",
|
"The Spawn",
|
||||||
"graysock",
|
"graysock",
|
||||||
"Greenmoustache",
|
"Greenmoustache",
|
||||||
"zounic",
|
|
||||||
"fancypants",
|
"fancypants",
|
||||||
"Eldithor",
|
"Eldithor",
|
||||||
|
"Joboshy",
|
||||||
"Digital",
|
"Digital",
|
||||||
"JaxMax",
|
"JaxMax",
|
||||||
"takyamtom",
|
"takyamtom",
|
||||||
|
"Bohemian Corporal",
|
||||||
|
"Dan",
|
||||||
"Jwk0205",
|
"Jwk0205",
|
||||||
"Bro Xie",
|
"Bro Xie",
|
||||||
|
"yer fey",
|
||||||
"batblue",
|
"batblue",
|
||||||
"carey6409",
|
"carey6409",
|
||||||
"Olive",
|
"Olive",
|
||||||
"太郎 ゲーム",
|
"太郎 ゲーム",
|
||||||
"Some Guy Named Barry",
|
"Some Guy Named Barry",
|
||||||
|
"jinxedx",
|
||||||
"Cosmosis",
|
"Cosmosis",
|
||||||
"AELOX",
|
"AELOX",
|
||||||
|
"Dankin",
|
||||||
"Nicfit23",
|
"Nicfit23",
|
||||||
"FloPro4Sho",
|
"FloPro4Sho",
|
||||||
"wamekukyouzin",
|
"wamekukyouzin",
|
||||||
"drum matthieu",
|
"drum matthieu",
|
||||||
"Dogmaster",
|
"Dogmaster",
|
||||||
"Matt Wenzel",
|
"Matt Wenzel",
|
||||||
"Lex Song",
|
"Frank Nitty",
|
||||||
"Christopher Michel",
|
"Christopher Michel",
|
||||||
"Gonzalo Andre Allendes Lopez",
|
|
||||||
"Serge Bekenkamp",
|
"Serge Bekenkamp",
|
||||||
"Jimmy Ledbetter",
|
|
||||||
"LeoZero",
|
"LeoZero",
|
||||||
"Antonio Pontes",
|
"Antonio Pontes",
|
||||||
"ApathyJones",
|
"ApathyJones",
|
||||||
@@ -182,11 +192,12 @@
|
|||||||
"nahinahi9",
|
"nahinahi9",
|
||||||
"Dustin Chen",
|
"Dustin Chen",
|
||||||
"dan",
|
"dan",
|
||||||
|
"Blackfish95",
|
||||||
"Mouthlessman",
|
"Mouthlessman",
|
||||||
|
"Paul Kroll",
|
||||||
"otaku fra",
|
"otaku fra",
|
||||||
"ViperC",
|
|
||||||
"Ran C",
|
|
||||||
"MiraiKuriyamaSy",
|
"MiraiKuriyamaSy",
|
||||||
|
"Bas Imagineer",
|
||||||
"yuxz69",
|
"yuxz69",
|
||||||
"Adam Taylor",
|
"Adam Taylor",
|
||||||
"Weird_With_A_Beard",
|
"Weird_With_A_Beard",
|
||||||
@@ -202,25 +213,25 @@
|
|||||||
"Jon Sandman",
|
"Jon Sandman",
|
||||||
"Ubivis",
|
"Ubivis",
|
||||||
"CloudValley",
|
"CloudValley",
|
||||||
|
"thesoftwaredruid",
|
||||||
|
"wundershark",
|
||||||
|
"mr_dinosaur",
|
||||||
|
"Tyrswood",
|
||||||
"linnfrey",
|
"linnfrey",
|
||||||
"IamAyam",
|
"IamAyam",
|
||||||
"skaterb949",
|
"skaterb949",
|
||||||
"Joboshy",
|
"Josef Lanzl",
|
||||||
"Bohemian Corporal",
|
|
||||||
"Dan",
|
|
||||||
"confiscated Zyra",
|
"confiscated Zyra",
|
||||||
"yer fey",
|
|
||||||
"Error_Rule34_Not_found",
|
"Error_Rule34_Not_found",
|
||||||
|
"Gerald Welly",
|
||||||
"Roslynd",
|
"Roslynd",
|
||||||
"Tee Gee",
|
"Tee Gee",
|
||||||
"jinxedx",
|
"Geolog",
|
||||||
"tarek helmi",
|
"tarek helmi",
|
||||||
"Neco28",
|
"Neco28",
|
||||||
"Max Marklund",
|
"Max Marklund",
|
||||||
"David Ortega",
|
"David Ortega",
|
||||||
"Dankin",
|
|
||||||
"Cristian Vazquez",
|
"Cristian Vazquez",
|
||||||
"Frank Nitty",
|
|
||||||
"Magic Noob",
|
"Magic Noob",
|
||||||
"Pronredn",
|
"Pronredn",
|
||||||
"DougPeterson",
|
"DougPeterson",
|
||||||
@@ -230,22 +241,17 @@
|
|||||||
"Kevin John Duck",
|
"Kevin John Duck",
|
||||||
"conner",
|
"conner",
|
||||||
"Kevin Christopher",
|
"Kevin Christopher",
|
||||||
"Blackfish95",
|
|
||||||
"dd",
|
"dd",
|
||||||
"Princess Bright Eyes",
|
"Princess Bright Eyes",
|
||||||
"Paul Kroll",
|
"Dušan Ryban",
|
||||||
"Felipe dos Santos",
|
"Felipe dos Santos",
|
||||||
"Bas Imagineer",
|
|
||||||
"John Statham",
|
"John Statham",
|
||||||
"Douglas Gaspar",
|
"Douglas Gaspar",
|
||||||
|
"Metryman55",
|
||||||
"AlexDuKaNa",
|
"AlexDuKaNa",
|
||||||
"George",
|
"George",
|
||||||
"dw",
|
"dw",
|
||||||
"decoy",
|
"decoy",
|
||||||
"thesoftwaredruid",
|
|
||||||
"wundershark",
|
|
||||||
"mr_dinosaur",
|
|
||||||
"Tyrswood",
|
|
||||||
"Ray Wing",
|
"Ray Wing",
|
||||||
"Ranzitho",
|
"Ranzitho",
|
||||||
"Gus",
|
"Gus",
|
||||||
@@ -254,6 +260,7 @@
|
|||||||
"David LaVallee",
|
"David LaVallee",
|
||||||
"ae",
|
"ae",
|
||||||
"Tr4shP4nda",
|
"Tr4shP4nda",
|
||||||
|
"Gamalonia",
|
||||||
"WRL_SPR",
|
"WRL_SPR",
|
||||||
"capn",
|
"capn",
|
||||||
"Joseph",
|
"Joseph",
|
||||||
@@ -262,9 +269,12 @@
|
|||||||
"Piccio08",
|
"Piccio08",
|
||||||
"kumakichi",
|
"kumakichi",
|
||||||
"cppbel",
|
"cppbel",
|
||||||
|
"Moon Knight",
|
||||||
|
"몽타주",
|
||||||
|
"Kland",
|
||||||
|
"Hailshem",
|
||||||
"奚明 刘",
|
"奚明 刘",
|
||||||
"Brian M",
|
"Brian M",
|
||||||
"Josef Lanzl",
|
|
||||||
"Nerezza",
|
"Nerezza",
|
||||||
"sanborondon",
|
"sanborondon",
|
||||||
"준희 김",
|
"준희 김",
|
||||||
@@ -272,16 +282,15 @@
|
|||||||
"aezin",
|
"aezin",
|
||||||
"Thought2Form",
|
"Thought2Form",
|
||||||
"jcay015",
|
"jcay015",
|
||||||
"Gerald Welly",
|
|
||||||
"Kevin Picco",
|
"Kevin Picco",
|
||||||
"Erik Lopez",
|
"Erik Lopez",
|
||||||
"Mateo Curić",
|
"Mateo Curić",
|
||||||
"Geolog",
|
|
||||||
"Eris3D",
|
"Eris3D",
|
||||||
"Tomohiro Baba",
|
"Tomohiro Baba",
|
||||||
"m",
|
"m",
|
||||||
"Noora",
|
"Noora",
|
||||||
"Pierce McBride",
|
"Pierce McBride",
|
||||||
|
"Joshua Gray",
|
||||||
"Mattssn",
|
"Mattssn",
|
||||||
"Mikko Hemilä",
|
"Mikko Hemilä",
|
||||||
"Jamie Ogletree",
|
"Jamie Ogletree",
|
||||||
@@ -295,7 +304,6 @@
|
|||||||
"CryptoTraderJK",
|
"CryptoTraderJK",
|
||||||
"Yuji Kaneko",
|
"Yuji Kaneko",
|
||||||
"Davaitamin",
|
"Davaitamin",
|
||||||
"Dušan Ryban",
|
|
||||||
"Rops Alot",
|
"Rops Alot",
|
||||||
"tedcor",
|
"tedcor",
|
||||||
"Sam",
|
"Sam",
|
||||||
@@ -303,16 +311,10 @@
|
|||||||
"sjon kreutz",
|
"sjon kreutz",
|
||||||
"Ace Ventura",
|
"Ace Ventura",
|
||||||
"MadSpin",
|
"MadSpin",
|
||||||
"Metryman55",
|
|
||||||
"inbijiburu",
|
"inbijiburu",
|
||||||
"Nick “Loadstone” D",
|
"Nick “Loadstone” D",
|
||||||
"Gamalonia",
|
|
||||||
"momokai",
|
"momokai",
|
||||||
"starbugx",
|
"starbugx",
|
||||||
"Moon Knight",
|
|
||||||
"몽타주",
|
|
||||||
"Kland",
|
|
||||||
"Hailshem",
|
|
||||||
"kudari",
|
"kudari",
|
||||||
"Naomi Hale Danchi",
|
"Naomi Hale Danchi",
|
||||||
"dc7431",
|
"dc7431",
|
||||||
@@ -333,6 +335,10 @@
|
|||||||
"JohnDoe42054",
|
"JohnDoe42054",
|
||||||
"BillyHill",
|
"BillyHill",
|
||||||
"emyth",
|
"emyth",
|
||||||
|
"chriphost",
|
||||||
|
"KitKatM",
|
||||||
|
"socrasteeze",
|
||||||
|
"OrganicArtifact",
|
||||||
"Vir",
|
"Vir",
|
||||||
"gzmzmvp",
|
"gzmzmvp",
|
||||||
"Richard",
|
"Richard",
|
||||||
@@ -350,8 +356,9 @@
|
|||||||
"Ivan Tadic",
|
"Ivan Tadic",
|
||||||
"Mike Simone",
|
"Mike Simone",
|
||||||
"ethanfel",
|
"ethanfel",
|
||||||
"Joshua Gray",
|
"Elliot E",
|
||||||
"Morgandel",
|
"Morgandel",
|
||||||
|
"Theerat Jiramate",
|
||||||
"Focuschannel",
|
"Focuschannel",
|
||||||
"Noah",
|
"Noah",
|
||||||
"Jacob McDaniel",
|
"Jacob McDaniel",
|
||||||
@@ -365,11 +372,14 @@
|
|||||||
"battu",
|
"battu",
|
||||||
"Michael Anthony Scott",
|
"Michael Anthony Scott",
|
||||||
"Atilla Berke Pekduyar",
|
"Atilla Berke Pekduyar",
|
||||||
|
"Nathan",
|
||||||
"Decx _",
|
"Decx _",
|
||||||
"Pat Hen",
|
"Pat Hen",
|
||||||
"Jordan Shaw",
|
"Jordan Shaw",
|
||||||
|
"Srdb",
|
||||||
"四糸凜音",
|
"四糸凜音",
|
||||||
"Nihongasuki",
|
"Nihongasuki",
|
||||||
|
"LarsesFPC",
|
||||||
"JC",
|
"JC",
|
||||||
"Prompt Pirate",
|
"Prompt Pirate",
|
||||||
"uwutismxd",
|
"uwutismxd",
|
||||||
@@ -377,17 +387,14 @@
|
|||||||
"zenobeus",
|
"zenobeus",
|
||||||
"Crocket",
|
"Crocket",
|
||||||
"Jackthemind",
|
"Jackthemind",
|
||||||
"chriphost",
|
|
||||||
"KitKatM",
|
|
||||||
"ryoma",
|
"ryoma",
|
||||||
"socrasteeze",
|
|
||||||
"OrganicArtifact",
|
|
||||||
"Stryker",
|
"Stryker",
|
||||||
"ResidentDeviant",
|
"ResidentDeviant",
|
||||||
"MudkipMedkitz",
|
"MudkipMedkitz",
|
||||||
"deanbrian",
|
"deanbrian",
|
||||||
"Alex Wortman",
|
"Alex Wortman",
|
||||||
"Cody",
|
"Cody",
|
||||||
|
"Raku",
|
||||||
"smart.edge5178",
|
"smart.edge5178",
|
||||||
"InformedViewz",
|
"InformedViewz",
|
||||||
"CHKeeho80",
|
"CHKeeho80",
|
||||||
@@ -401,6 +408,7 @@
|
|||||||
"moonpetal",
|
"moonpetal",
|
||||||
"SomeDude",
|
"SomeDude",
|
||||||
"g9p0o",
|
"g9p0o",
|
||||||
|
"Pkrsky",
|
||||||
"TheHolySheep",
|
"TheHolySheep",
|
||||||
"raf8osz",
|
"raf8osz",
|
||||||
"Monte Won",
|
"Monte Won",
|
||||||
@@ -408,6 +416,7 @@
|
|||||||
"carsten",
|
"carsten",
|
||||||
"ikok",
|
"ikok",
|
||||||
"ElitaSSJ4",
|
"ElitaSSJ4",
|
||||||
|
"David Schenck",
|
||||||
"Wolfe7D1",
|
"Wolfe7D1",
|
||||||
"blikkies",
|
"blikkies",
|
||||||
"Chris",
|
"Chris",
|
||||||
@@ -419,16 +428,15 @@
|
|||||||
"Zude",
|
"Zude",
|
||||||
"John J Linehan",
|
"John J Linehan",
|
||||||
"Kyler",
|
"Kyler",
|
||||||
"Elliot E",
|
|
||||||
"Theerat Jiramate",
|
|
||||||
"Edward Kennedy",
|
"Edward Kennedy",
|
||||||
"Justin Blaylock",
|
"Justin Blaylock",
|
||||||
"aRtFuL_DodGeR",
|
"aRtFuL_DodGeR",
|
||||||
|
"Nick Kage",
|
||||||
"Vane Holzer",
|
"Vane Holzer",
|
||||||
"psytrax",
|
"psytrax",
|
||||||
|
"Cyrus Fett",
|
||||||
"hexxish",
|
"hexxish",
|
||||||
"notedfakes",
|
"notedfakes",
|
||||||
"Nathan",
|
|
||||||
"Billy Gladky",
|
"Billy Gladky",
|
||||||
"NICHOLAS BAXLEY",
|
"NICHOLAS BAXLEY",
|
||||||
"Michael Scott",
|
"Michael Scott",
|
||||||
@@ -436,7 +444,7 @@
|
|||||||
"Ed Wang",
|
"Ed Wang",
|
||||||
"Wes Sims",
|
"Wes Sims",
|
||||||
"ItsGeneralButtNaked",
|
"ItsGeneralButtNaked",
|
||||||
"SRDB",
|
"Donor4115",
|
||||||
"g unit",
|
"g unit",
|
||||||
"Distortik",
|
"Distortik",
|
||||||
"Filippo Ferrari",
|
"Filippo Ferrari",
|
||||||
@@ -453,10 +461,11 @@
|
|||||||
"Whitepinetrader",
|
"Whitepinetrader",
|
||||||
"POPPIN",
|
"POPPIN",
|
||||||
"Ginnie",
|
"Ginnie",
|
||||||
"Raku",
|
|
||||||
"emadsultan",
|
"emadsultan",
|
||||||
"Pkrsky",
|
|
||||||
"nanana",
|
"nanana",
|
||||||
|
"g",
|
||||||
|
"J",
|
||||||
|
"Alan+Cano",
|
||||||
"FeralOpticsAI",
|
"FeralOpticsAI",
|
||||||
"Pavlaki",
|
"Pavlaki",
|
||||||
"Doug+Rintoul",
|
"Doug+Rintoul",
|
||||||
@@ -473,13 +482,12 @@
|
|||||||
"Duk3+Rand0m",
|
"Duk3+Rand0m",
|
||||||
"Nathen+Choi",
|
"Nathen+Choi",
|
||||||
"T",
|
"T",
|
||||||
"LarsesFPC",
|
|
||||||
"cocona",
|
"cocona",
|
||||||
"Buecyb99",
|
"Buecyb99",
|
||||||
"Welkor",
|
"Welkor",
|
||||||
"David Schenck",
|
|
||||||
"John Martin",
|
"John Martin",
|
||||||
"Ink Temptation",
|
"Ink Temptation",
|
||||||
|
"JBsuede",
|
||||||
"moranqianlong",
|
"moranqianlong",
|
||||||
"Kalli Core",
|
"Kalli Core",
|
||||||
"Time Valentine",
|
"Time Valentine",
|
||||||
@@ -489,10 +497,8 @@
|
|||||||
"SPJ",
|
"SPJ",
|
||||||
"Kyron Mahan",
|
"Kyron Mahan",
|
||||||
"Bryan Rutkowski",
|
"Bryan Rutkowski",
|
||||||
"Nick Kage",
|
|
||||||
"TBitz33",
|
"TBitz33",
|
||||||
"Anonym dkjglfleeoeldldldlkf",
|
"Anonym dkjglfleeoeldldldlkf",
|
||||||
"Cyrus Fett",
|
|
||||||
"Ezokewn",
|
"Ezokewn",
|
||||||
"SendingRavens",
|
"SendingRavens",
|
||||||
"Xenon Xue",
|
"Xenon Xue",
|
||||||
@@ -506,7 +512,7 @@
|
|||||||
"Jacob Winter",
|
"Jacob Winter",
|
||||||
"Ryan Presley Ng",
|
"Ryan Presley Ng",
|
||||||
"jinksta187",
|
"jinksta187",
|
||||||
"Donor4115",
|
"Andrew Wilkinson",
|
||||||
"Manu Thetug",
|
"Manu Thetug",
|
||||||
"Karlanx",
|
"Karlanx",
|
||||||
"Lyavph",
|
"Lyavph",
|
||||||
@@ -531,6 +537,8 @@
|
|||||||
"Scott",
|
"Scott",
|
||||||
"Muratoraccio",
|
"Muratoraccio",
|
||||||
"D",
|
"D",
|
||||||
|
"low9",
|
||||||
|
"Winged",
|
||||||
"YassineKhaled",
|
"YassineKhaled",
|
||||||
"Y",
|
"Y",
|
||||||
"MatteKey",
|
"MatteKey",
|
||||||
@@ -551,9 +559,6 @@
|
|||||||
"redcarrot",
|
"redcarrot",
|
||||||
"powerbot99",
|
"powerbot99",
|
||||||
"Fthehappy",
|
"Fthehappy",
|
||||||
"rsamerica",
|
|
||||||
"sfasdfasfdsa",
|
|
||||||
"Alan+Cano",
|
|
||||||
"generic404",
|
"generic404",
|
||||||
"abattoirblues",
|
"abattoirblues",
|
||||||
"zounik",
|
"zounik",
|
||||||
@@ -562,7 +567,8 @@
|
|||||||
"ahoystan",
|
"ahoystan",
|
||||||
"Bob Barker",
|
"Bob Barker",
|
||||||
"edk",
|
"edk",
|
||||||
"JBsuede",
|
"Tú Nguyễn Lý Hoàng",
|
||||||
|
"Ronan Delevacq",
|
||||||
"Christian Schäfer",
|
"Christian Schäfer",
|
||||||
"りん あめ",
|
"りん あめ",
|
||||||
"ja s",
|
"ja s",
|
||||||
@@ -580,6 +586,7 @@
|
|||||||
"Boba Smith",
|
"Boba Smith",
|
||||||
"Devil Lude",
|
"Devil Lude",
|
||||||
"David Murcko",
|
"David Murcko",
|
||||||
|
"MR.Bear",
|
||||||
"Jack Dole",
|
"Jack Dole",
|
||||||
"max blo",
|
"max blo",
|
||||||
"Sauv",
|
"Sauv",
|
||||||
@@ -593,10 +600,11 @@
|
|||||||
"Kevin Wallace",
|
"Kevin Wallace",
|
||||||
"Jimmy Borup",
|
"Jimmy Borup",
|
||||||
"ChicRic",
|
"ChicRic",
|
||||||
|
"Tigon",
|
||||||
|
"BastardSama",
|
||||||
"mercur",
|
"mercur",
|
||||||
"Pete Pain",
|
"Pete Pain",
|
||||||
"RHopkirk",
|
"RHopkirk",
|
||||||
"Andrew Wilkinson",
|
|
||||||
"Yavizu3d",
|
"Yavizu3d",
|
||||||
"Maxim",
|
"Maxim",
|
||||||
"Yves Poezevara",
|
"Yves Poezevara",
|
||||||
@@ -647,6 +655,9 @@
|
|||||||
"SelfishMedic",
|
"SelfishMedic",
|
||||||
"adderleighn",
|
"adderleighn",
|
||||||
"EnragedAntelope",
|
"EnragedAntelope",
|
||||||
|
"SRCRCOSS",
|
||||||
|
"imer",
|
||||||
|
"Akkas+Haque",
|
||||||
"Kachac",
|
"Kachac",
|
||||||
"tyrant2811",
|
"tyrant2811",
|
||||||
"Kevin",
|
"Kevin",
|
||||||
@@ -678,8 +689,6 @@
|
|||||||
"Terraformer",
|
"Terraformer",
|
||||||
"GDS+DEV",
|
"GDS+DEV",
|
||||||
"4rt+r3d",
|
"4rt+r3d",
|
||||||
"low9",
|
|
||||||
"Winged",
|
|
||||||
"you+halo9",
|
"you+halo9",
|
||||||
"Somebody",
|
"Somebody",
|
||||||
"Somebody",
|
"Somebody",
|
||||||
@@ -696,21 +705,22 @@
|
|||||||
"Obsidian.Studios",
|
"Obsidian.Studios",
|
||||||
"han b",
|
"han b",
|
||||||
"Zomba Mann",
|
"Zomba Mann",
|
||||||
|
"Aquaneo",
|
||||||
"Nico",
|
"Nico",
|
||||||
"Maximilian Krischan",
|
"Maximilian Krischan",
|
||||||
"Banana Joe",
|
"Banana Joe",
|
||||||
"_ G3n",
|
"_ G3n",
|
||||||
"Donovan Jenkins",
|
"Donovan Jenkins",
|
||||||
"Hans Meier",
|
"Hans Meier",
|
||||||
"Tú Nguyễn Lý Hoàng",
|
|
||||||
"shira1011",
|
"shira1011",
|
||||||
|
"sicarius",
|
||||||
"Michael Eid",
|
"Michael Eid",
|
||||||
"beersandbacon",
|
"beersandbacon",
|
||||||
"Neko Desco",
|
"Neko Desco",
|
||||||
"Bob barker",
|
"Bob barker",
|
||||||
"Ben D",
|
"Ben D",
|
||||||
|
"Ninja Tom",
|
||||||
"G",
|
"G",
|
||||||
"Ronan Delevacq",
|
|
||||||
"karim ben brik",
|
"karim ben brik",
|
||||||
"Vinarus",
|
"Vinarus",
|
||||||
"Michael Zhu",
|
"Michael Zhu",
|
||||||
@@ -735,8 +745,7 @@
|
|||||||
"AZ Party Oasis",
|
"AZ Party Oasis",
|
||||||
"Adictedtohumping",
|
"Adictedtohumping",
|
||||||
"Towelie",
|
"Towelie",
|
||||||
"Ryan Smith",
|
"TheFusion",
|
||||||
"MR.Bear",
|
|
||||||
"matt",
|
"matt",
|
||||||
"dsffsdfsdfsdfsdfsdf",
|
"dsffsdfsdfsdfsdfsdf",
|
||||||
"somethingtosay8",
|
"somethingtosay8",
|
||||||
@@ -745,6 +754,7 @@
|
|||||||
"Terminuz",
|
"Terminuz",
|
||||||
"Kurt",
|
"Kurt",
|
||||||
"ivistorm",
|
"ivistorm",
|
||||||
|
"Matt M.",
|
||||||
"Ivan Imes",
|
"Ivan Imes",
|
||||||
"Faburizu",
|
"Faburizu",
|
||||||
"Jack Lawfield",
|
"Jack Lawfield",
|
||||||
@@ -763,12 +773,13 @@
|
|||||||
"Rizzi",
|
"Rizzi",
|
||||||
"nimin",
|
"nimin",
|
||||||
"OMAR LUCIANO",
|
"OMAR LUCIANO",
|
||||||
|
"Somebody",
|
||||||
|
"CoffeeMage",
|
||||||
"Ken+Suzuki",
|
"Ken+Suzuki",
|
||||||
"hannibal",
|
"hannibal",
|
||||||
"Jo+Example",
|
"Jo+Example",
|
||||||
"BrentBertram",
|
"BrentBertram",
|
||||||
"inusanorthcape",
|
"inusanorthcape",
|
||||||
"Tigon",
|
|
||||||
"eumelzocker",
|
"eumelzocker",
|
||||||
"dxjaymz",
|
"dxjaymz",
|
||||||
"L C",
|
"L C",
|
||||||
@@ -776,5 +787,5 @@
|
|||||||
"Somebody",
|
"Somebody",
|
||||||
"CK"
|
"CK"
|
||||||
],
|
],
|
||||||
"totalCount": 773
|
"totalCount": 784
|
||||||
}
|
}
|
||||||
@@ -963,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "Keine Ordner gefunden",
|
"noFolders": "Keine Ordner gefunden",
|
||||||
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
|
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Auf Updates in diesem Ordner prüfen",
|
||||||
|
"loading": "Prüfe {type}-Updates in diesem Ordner...",
|
||||||
|
"success": "{count} Update(s) für {type}s in diesem Ordner gefunden",
|
||||||
|
"none": "Alle {type}s in diesem Ordner sind aktuell",
|
||||||
|
"error": "Fehler beim Prüfen des Ordners auf {type}-Updates: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1031,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
|
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
|
||||||
"alreadyInLibrary": "Bereits in Bibliothek",
|
"alreadyInLibrary": "Bereits in Bibliothek",
|
||||||
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Dateiformat auswählen",
|
||||||
|
"files": "Dateien",
|
||||||
|
"select": "Datei auswählen"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Ungültiges Civitai URL-Format",
|
"invalidUrl": "Ungültiges Civitai URL-Format",
|
||||||
"noVersions": "Keine Versionen für dieses Modell verfügbar"
|
"noVersions": "Keine Versionen für dieses Modell verfügbar"
|
||||||
@@ -1656,6 +1668,10 @@
|
|||||||
"noRecipeId": "Keine Rezept-ID verfügbar",
|
"noRecipeId": "Keine Rezept-ID verfügbar",
|
||||||
"sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}",
|
"sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}",
|
||||||
"copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}",
|
"copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}",
|
||||||
|
"createError": "Fehler beim Erstellen des Rezepts:{message}",
|
||||||
|
"createFailed": "Fehler beim Erstellen des Rezepts:{error}",
|
||||||
|
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
|
||||||
|
"created": "Rezept erfolgreich erstellt",
|
||||||
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
||||||
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||||
"preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download",
|
"preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download",
|
||||||
|
|||||||
@@ -963,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "No folders found",
|
"noFolders": "No folders found",
|
||||||
"dragHint": "Drag items here to create folders"
|
"dragHint": "Drag items here to create folders"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Check for updates in this folder",
|
||||||
|
"loading": "Checking {type} updates for this folder...",
|
||||||
|
"success": "Found {count} update(s) for {type}s in this folder",
|
||||||
|
"none": "All {type}s in this folder are up to date",
|
||||||
|
"error": "Failed to check folder for {type} updates: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1031,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
||||||
"alreadyInLibrary": "Already in Library",
|
"alreadyInLibrary": "Already in Library",
|
||||||
"autoOrganizedPath": "[Auto-organized by path template]",
|
"autoOrganizedPath": "[Auto-organized by path template]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Select File Format",
|
||||||
|
"files": "files",
|
||||||
|
"select": "Select File"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Invalid Civitai URL format",
|
"invalidUrl": "Invalid Civitai URL format",
|
||||||
"noVersions": "No versions available for this model"
|
"noVersions": "No versions available for this model"
|
||||||
@@ -1656,6 +1668,10 @@
|
|||||||
"noRecipeId": "No recipe ID available",
|
"noRecipeId": "No recipe ID available",
|
||||||
"sendToWorkflowFailed": "Failed to send recipe to workflow: {message}",
|
"sendToWorkflowFailed": "Failed to send recipe to workflow: {message}",
|
||||||
"copyFailed": "Error copying recipe syntax: {message}",
|
"copyFailed": "Error copying recipe syntax: {message}",
|
||||||
|
"createError": "Error creating recipe: {message}",
|
||||||
|
"createFailed": "Failed to create recipe: {error}",
|
||||||
|
"createMissingData": "Missing required data to create recipe",
|
||||||
|
"created": "Recipe created successfully",
|
||||||
"noMissingLoras": "No missing LoRAs to download",
|
"noMissingLoras": "No missing LoRAs to download",
|
||||||
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
|
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
|
||||||
"preparingForDownloadFailed": "Error preparing LoRAs for download",
|
"preparingForDownloadFailed": "Error preparing LoRAs for download",
|
||||||
|
|||||||
@@ -963,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "No se encontraron carpetas",
|
"noFolders": "No se encontraron carpetas",
|
||||||
"dragHint": "Arrastra elementos aquí para crear carpetas"
|
"dragHint": "Arrastra elementos aquí para crear carpetas"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Buscar actualizaciones en esta carpeta",
|
||||||
|
"loading": "Buscando actualizaciones de {type} en esta carpeta...",
|
||||||
|
"success": "Se encontraron {count} actualización(es) para {type}s en esta carpeta",
|
||||||
|
"none": "Todos los {type}s en esta carpeta están actualizados",
|
||||||
|
"error": "Error al buscar actualizaciones de {type} en la carpeta: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1031,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
|
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
|
||||||
"alreadyInLibrary": "Ya en la biblioteca",
|
"alreadyInLibrary": "Ya en la biblioteca",
|
||||||
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Seleccionar formato de archivo",
|
||||||
|
"files": "archivos",
|
||||||
|
"select": "Seleccionar archivo"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Formato de URL de Civitai inválido",
|
"invalidUrl": "Formato de URL de Civitai inválido",
|
||||||
"noVersions": "No hay versiones disponibles para este modelo"
|
"noVersions": "No hay versiones disponibles para este modelo"
|
||||||
@@ -1656,6 +1668,10 @@
|
|||||||
"noRecipeId": "No hay ID de receta disponible",
|
"noRecipeId": "No hay ID de receta disponible",
|
||||||
"sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}",
|
"sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}",
|
||||||
"copyFailed": "Error copiando sintaxis de receta: {message}",
|
"copyFailed": "Error copiando sintaxis de receta: {message}",
|
||||||
|
"createError": "Error al crear la receta:{message}",
|
||||||
|
"createFailed": "Error al crear la receta:{error}",
|
||||||
|
"createMissingData": "Faltan datos necesarios para crear la receta",
|
||||||
|
"created": "Receta creada exitosamente",
|
||||||
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
||||||
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
|
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||||
"preparingForDownloadFailed": "Error preparando LoRAs para descarga",
|
"preparingForDownloadFailed": "Error preparando LoRAs para descarga",
|
||||||
|
|||||||
@@ -963,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "Aucun dossier trouvé",
|
"noFolders": "Aucun dossier trouvé",
|
||||||
"dragHint": "Faites glisser des éléments ici pour créer des dossiers"
|
"dragHint": "Faites glisser des éléments ici pour créer des dossiers"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Vérifier les mises à jour dans ce dossier",
|
||||||
|
"loading": "Vérification des mises à jour {type} dans ce dossier...",
|
||||||
|
"success": "{count} mise(s) à jour trouvée(s) pour les {type}s dans ce dossier",
|
||||||
|
"none": "Tous les {type}s dans ce dossier sont à jour",
|
||||||
|
"error": "Échec de la vérification des mises à jour {type} dans ce dossier : {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1031,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
|
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
|
||||||
"alreadyInLibrary": "Déjà dans la bibliothèque",
|
"alreadyInLibrary": "Déjà dans la bibliothèque",
|
||||||
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Choisir le format de fichier",
|
||||||
|
"files": "fichiers",
|
||||||
|
"select": "Choisir le fichier"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Format d'URL Civitai invalide",
|
"invalidUrl": "Format d'URL Civitai invalide",
|
||||||
"noVersions": "Aucune version disponible pour ce modèle"
|
"noVersions": "Aucune version disponible pour ce modèle"
|
||||||
@@ -1656,6 +1668,10 @@
|
|||||||
"noRecipeId": "Aucun ID de recipe disponible",
|
"noRecipeId": "Aucun ID de recipe disponible",
|
||||||
"sendToWorkflowFailed": "Échec de l'envoi de la recette vers le workflow : {message}",
|
"sendToWorkflowFailed": "Échec de l'envoi de la recette vers le workflow : {message}",
|
||||||
"copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}",
|
"copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}",
|
||||||
|
"createError": "Erreur lors de la création du Recipe :{message}",
|
||||||
|
"createFailed": "Échec de la création du Recipe :{error}",
|
||||||
|
"createMissingData": "Données requises manquantes pour créer le Recipe",
|
||||||
|
"created": "Recipe créé avec succès",
|
||||||
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
||||||
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
||||||
"preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement",
|
"preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement",
|
||||||
|
|||||||
@@ -963,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "לא נמצאו תיקיות",
|
"noFolders": "לא נמצאו תיקיות",
|
||||||
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
|
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "בדוק עדכונים בתיקייה זו",
|
||||||
|
"loading": "בודק עדכוני {type} בתיקייה זו...",
|
||||||
|
"success": "נמצאו {count} עדכון/ים עבור {type}s בתיקייה זו",
|
||||||
|
"none": "כל ה-{type}s בתיקייה זו מעודכנים",
|
||||||
|
"error": "נכשל בבדיקת עדכוני {type} בתיקייה: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1031,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
|
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
|
||||||
"alreadyInLibrary": "כבר בספרייה",
|
"alreadyInLibrary": "כבר בספרייה",
|
||||||
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "בחר פורמט קובץ",
|
||||||
|
"files": "קבצים",
|
||||||
|
"select": "בחר קובץ"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
||||||
"noVersions": "אין גרסאות זמינות למודל זה"
|
"noVersions": "אין גרסאות זמינות למודל זה"
|
||||||
@@ -1656,6 +1668,10 @@
|
|||||||
"noRecipeId": "אין מזהה מתכון זמין",
|
"noRecipeId": "אין מזהה מתכון זמין",
|
||||||
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
|
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
|
||||||
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
|
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
|
||||||
|
"createError": "שגיאה ביצירת המתכון:{message}",
|
||||||
|
"createFailed": "יצירת המתכון נכשלה:{error}",
|
||||||
|
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
|
||||||
|
"created": "המתכון נוצר בהצלחה",
|
||||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||||
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||||
"preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה",
|
"preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה",
|
||||||
|
|||||||
@@ -963,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "フォルダが見つかりません",
|
"noFolders": "フォルダが見つかりません",
|
||||||
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
|
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "このフォルダのアップデートを確認",
|
||||||
|
"loading": "このフォルダの{type}アップデートを確認中...",
|
||||||
|
"success": "このフォルダの{type}sに{count}件のアップデートが見つかりました",
|
||||||
|
"none": "このフォルダのすべての{type}sは最新です",
|
||||||
|
"error": "フォルダの{type}アップデート確認に失敗しました: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1031,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
||||||
"alreadyInLibrary": "既にライブラリ内",
|
"alreadyInLibrary": "既にライブラリ内",
|
||||||
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "ファイル形式を選択",
|
||||||
|
"files": "ファイル",
|
||||||
|
"select": "ファイルを選択"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "無効なCivitai URL形式",
|
"invalidUrl": "無効なCivitai URL形式",
|
||||||
"noVersions": "このモデルの利用可能なバージョンがありません"
|
"noVersions": "このモデルの利用可能なバージョンがありません"
|
||||||
@@ -1656,6 +1668,10 @@
|
|||||||
"noRecipeId": "レシピIDが利用できません",
|
"noRecipeId": "レシピIDが利用できません",
|
||||||
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
|
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
|
||||||
"copyFailed": "レシピ構文のコピーエラー:{message}",
|
"copyFailed": "レシピ構文のコピーエラー:{message}",
|
||||||
|
"createError": "レシピ作成中にエラーが発生しました:{message}",
|
||||||
|
"createFailed": "レシピの作成に失敗しました:{error}",
|
||||||
|
"createMissingData": "レシピ作成に必要なデータが不足しています",
|
||||||
|
"created": "レシピを作成しました",
|
||||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||||
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||||
"preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました",
|
"preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました",
|
||||||
|
|||||||
@@ -963,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "폴더를 찾을 수 없습니다",
|
"noFolders": "폴더를 찾을 수 없습니다",
|
||||||
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
|
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "이 폴더의 업데이트 확인",
|
||||||
|
"loading": "이 폴더의 {type} 업데이트를 확인하는 중...",
|
||||||
|
"success": "이 폴더에서 {type}s에 대한 {count}개 업데이트를 찾았습니다",
|
||||||
|
"none": "이 폴더의 모든 {type}s가 최신 상태입니다",
|
||||||
|
"error": "폴더의 {type} 업데이트 확인 실패: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1031,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
||||||
"alreadyInLibrary": "이미 라이브러리에 있음",
|
"alreadyInLibrary": "이미 라이브러리에 있음",
|
||||||
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "파일 형식 선택",
|
||||||
|
"files": "개 파일",
|
||||||
|
"select": "파일 선택"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "잘못된 Civitai URL 형식",
|
"invalidUrl": "잘못된 Civitai URL 형식",
|
||||||
"noVersions": "이 모델에 사용 가능한 버전이 없습니다"
|
"noVersions": "이 모델에 사용 가능한 버전이 없습니다"
|
||||||
@@ -1656,6 +1668,10 @@
|
|||||||
"noRecipeId": "사용 가능한 레시피 ID가 없습니다",
|
"noRecipeId": "사용 가능한 레시피 ID가 없습니다",
|
||||||
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
|
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
|
||||||
"copyFailed": "레시피 문법 복사 오류: {message}",
|
"copyFailed": "레시피 문법 복사 오류: {message}",
|
||||||
|
"createError": "레시피 생성 중 오류 발생:{message}",
|
||||||
|
"createFailed": "레시피 생성 실패:{error}",
|
||||||
|
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
|
||||||
|
"created": "레시피가 생성되었습니다",
|
||||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||||
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||||
"preparingForDownloadFailed": "LoRA 다운로드 준비 오류",
|
"preparingForDownloadFailed": "LoRA 다운로드 준비 오류",
|
||||||
|
|||||||
@@ -963,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "Папки не найдены",
|
"noFolders": "Папки не найдены",
|
||||||
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
|
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Проверить обновления в этой папке",
|
||||||
|
"loading": "Проверка обновлений {type} в этой папке...",
|
||||||
|
"success": "Найдено {count} обновление(й) для {type}s в этой папке",
|
||||||
|
"none": "Все {type}s в этой папке актуальны",
|
||||||
|
"error": "Не удалось проверить папку на наличие обновлений {type}: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1031,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
|
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
|
||||||
"alreadyInLibrary": "Уже в библиотеке",
|
"alreadyInLibrary": "Уже в библиотеке",
|
||||||
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Выбрать формат файла",
|
||||||
|
"files": "файлов",
|
||||||
|
"select": "Выбрать файл"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Неверный формат URL Civitai",
|
"invalidUrl": "Неверный формат URL Civitai",
|
||||||
"noVersions": "Нет доступных версий для этой модели"
|
"noVersions": "Нет доступных версий для этой модели"
|
||||||
@@ -1656,6 +1668,10 @@
|
|||||||
"noRecipeId": "ID рецепта недоступен",
|
"noRecipeId": "ID рецепта недоступен",
|
||||||
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
|
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
|
||||||
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
|
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
|
||||||
|
"createError": "Ошибка при создании рецепта:{message}",
|
||||||
|
"createFailed": "Не удалось создать рецепт:{error}",
|
||||||
|
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
|
||||||
|
"created": "Рецепт успешно создан",
|
||||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||||
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||||
"preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки",
|
"preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки",
|
||||||
|
|||||||
@@ -963,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "未找到文件夹",
|
"noFolders": "未找到文件夹",
|
||||||
"dragHint": "拖拽项目到此处以创建文件夹"
|
"dragHint": "拖拽项目到此处以创建文件夹"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "检查此文件夹的更新",
|
||||||
|
"loading": "正在检查此文件夹中的{type}更新...",
|
||||||
|
"success": "在此文件夹中找到 {count} 个{type}更新",
|
||||||
|
"none": "此文件夹中的所有{type}都是最新版本",
|
||||||
|
"error": "检查文件夹{type}更新失败: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1031,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
||||||
"alreadyInLibrary": "已存在于库中",
|
"alreadyInLibrary": "已存在于库中",
|
||||||
"autoOrganizedPath": "【已按路径模板自动整理】",
|
"autoOrganizedPath": "【已按路径模板自动整理】",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "选择文件格式",
|
||||||
|
"files": "个文件",
|
||||||
|
"select": "选择文件"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "无效的 Civitai URL 格式",
|
"invalidUrl": "无效的 Civitai URL 格式",
|
||||||
"noVersions": "此模型没有可用版本"
|
"noVersions": "此模型没有可用版本"
|
||||||
@@ -1656,6 +1668,10 @@
|
|||||||
"noRecipeId": "无配方 ID",
|
"noRecipeId": "无配方 ID",
|
||||||
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
|
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
|
||||||
"copyFailed": "复制配方语法出错:{message}",
|
"copyFailed": "复制配方语法出错:{message}",
|
||||||
|
"createError": "创建配方时出错:{message}",
|
||||||
|
"createFailed": "创建配方失败:{error}",
|
||||||
|
"createMissingData": "缺少创建配方所需的数据",
|
||||||
|
"created": "配方创建成功",
|
||||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||||
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
||||||
"preparingForDownloadFailed": "准备下载 LoRA 时出错",
|
"preparingForDownloadFailed": "准备下载 LoRA 时出错",
|
||||||
|
|||||||
@@ -963,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "未找到資料夾",
|
"noFolders": "未找到資料夾",
|
||||||
"dragHint": "將項目拖到此處以建立資料夾"
|
"dragHint": "將項目拖到此處以建立資料夾"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "檢查此資料夾的更新",
|
||||||
|
"loading": "正在檢查此資料夾中的{type}更新...",
|
||||||
|
"success": "在此資料夾中找到 {count} 個{type}更新",
|
||||||
|
"none": "此資料夾中的所有{type}都是最新版本",
|
||||||
|
"error": "檢查資料夾{type}更新失敗: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1031,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
||||||
"alreadyInLibrary": "已在庫存",
|
"alreadyInLibrary": "已在庫存",
|
||||||
"autoOrganizedPath": "[依路徑範本自動整理]",
|
"autoOrganizedPath": "[依路徑範本自動整理]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "選擇檔案格式",
|
||||||
|
"files": "個檔案",
|
||||||
|
"select": "選擇檔案"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Civitai 網址格式無效",
|
"invalidUrl": "Civitai 網址格式無效",
|
||||||
"noVersions": "此模型無可用版本"
|
"noVersions": "此模型無可用版本"
|
||||||
@@ -1656,6 +1668,10 @@
|
|||||||
"noRecipeId": "無配方 ID",
|
"noRecipeId": "無配方 ID",
|
||||||
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
|
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
|
||||||
"copyFailed": "複製配方語法錯誤:{message}",
|
"copyFailed": "複製配方語法錯誤:{message}",
|
||||||
|
"createError": "建立配方時發生錯誤:{message}",
|
||||||
|
"createFailed": "建立配方失敗:{error}",
|
||||||
|
"createMissingData": "缺少建立配方所需的資料",
|
||||||
|
"created": "配方建立成功",
|
||||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||||
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||||
"preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤",
|
"preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤",
|
||||||
|
|||||||
@@ -58,9 +58,52 @@ class RecipeMetadataParser(ABC):
|
|||||||
civitai_info, error_msg = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
civitai_info, error_msg = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
||||||
|
|
||||||
if not civitai_info or error_msg == "Model not found":
|
if not civitai_info or error_msg == "Model not found":
|
||||||
# Model not found or deleted
|
# CivitAI may fail to resolve a hash that is still being
|
||||||
lora_entry['isDeleted'] = True
|
# computed (known CivitAI issue). Before marking as deleted,
|
||||||
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
# try to reconcile with a local model that has the same
|
||||||
|
# filename and matching AutoV3 hash.
|
||||||
|
reconciled = False
|
||||||
|
file_name = lora_entry.get("file_name")
|
||||||
|
if file_name and recipe_scanner and hash_value:
|
||||||
|
lora_scanner = getattr(recipe_scanner, "_lora_scanner", None)
|
||||||
|
if lora_scanner:
|
||||||
|
try:
|
||||||
|
# Local import to avoid circular dependency:
|
||||||
|
# base.py → file_utils → settings_manager → ...
|
||||||
|
# → recipe_scanner → enrichment → base.py
|
||||||
|
from ..utils.file_utils import calculate_autov3 # fmt: skip
|
||||||
|
cache = await lora_scanner.get_cached_data()
|
||||||
|
for item in getattr(cache, "raw_data", []):
|
||||||
|
if item.get("file_name") == file_name:
|
||||||
|
local_path = item.get("file_path")
|
||||||
|
if local_path and os.path.exists(local_path):
|
||||||
|
local_autov3 = calculate_autov3(local_path)
|
||||||
|
if local_autov3 and local_autov3 == hash_value:
|
||||||
|
lora_entry["existsLocally"] = True
|
||||||
|
lora_entry["localPath"] = local_path
|
||||||
|
lora_entry["hash"] = item.get("sha256", hash_value)
|
||||||
|
if "preview_url" in item:
|
||||||
|
lora_entry["thumbnailUrl"] = config.get_preview_static_url(item["preview_url"])
|
||||||
|
civ = item.get("civitai") or {}
|
||||||
|
if isinstance(civ, dict):
|
||||||
|
if civ.get("id") is not None:
|
||||||
|
lora_entry["id"] = civ["id"]
|
||||||
|
if civ.get("modelId") is not None:
|
||||||
|
lora_entry["modelId"] = civ["modelId"]
|
||||||
|
if civ.get("name"):
|
||||||
|
lora_entry["version"] = civ["name"]
|
||||||
|
# model_name is the CivitAI model display
|
||||||
|
# name stored directly in the cache column.
|
||||||
|
cached_model_name = item.get("model_name")
|
||||||
|
if cached_model_name:
|
||||||
|
lora_entry["name"] = cached_model_name
|
||||||
|
reconciled = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not reconciled:
|
||||||
|
lora_entry['isDeleted'] = True
|
||||||
|
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
||||||
return lora_entry
|
return lora_entry
|
||||||
|
|
||||||
# Get model type and validate
|
# Get model type and validate
|
||||||
|
|||||||
@@ -190,26 +190,41 @@ class RecipeEnricher:
|
|||||||
existing_cp = recipe.get("checkpoint")
|
existing_cp = recipe.get("checkpoint")
|
||||||
if existing_cp is None:
|
if existing_cp is None:
|
||||||
existing_cp = {}
|
existing_cp = {}
|
||||||
|
|
||||||
|
# Extract baseModel from raw civitai_info before populate_checkpoint_from_civitai
|
||||||
|
# (populate may reject non-checkpoint types and lose this data)
|
||||||
|
base_model_from_civitai: str = ""
|
||||||
|
if isinstance(civitai_info, dict):
|
||||||
|
base_model_from_civitai = civitai_info.get("baseModel", "") or ""
|
||||||
|
elif isinstance(civitai_info, tuple) and len(civitai_info) > 0 and isinstance(civitai_info[0], dict):
|
||||||
|
base_model_from_civitai = civitai_info[0].get("baseModel", "") or ""
|
||||||
|
|
||||||
checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info)
|
checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info)
|
||||||
# 1. First, resolve base_model using full data before we format it away
|
|
||||||
|
# 1. Resolve base_model from checkpoint_data first, then fall back to raw civitai_info
|
||||||
current_base_model = recipe.get("base_model")
|
current_base_model = recipe.get("base_model")
|
||||||
resolved_base_model = checkpoint_data.get("baseModel")
|
resolved_base_model = checkpoint_data.get("baseModel") or base_model_from_civitai
|
||||||
if resolved_base_model:
|
if resolved_base_model:
|
||||||
# Update if empty OR if it matches our generic prefix but is less specific
|
|
||||||
is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"]
|
is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"]
|
||||||
if is_generic and resolved_base_model != current_base_model:
|
if is_generic and resolved_base_model != current_base_model:
|
||||||
recipe["base_model"] = resolved_base_model
|
recipe["base_model"] = resolved_base_model
|
||||||
|
|
||||||
# 2. Format according to requirements: type, modelId, modelVersionId, modelName, modelVersionName
|
# 2. Only format and save checkpoint if it has real data (not just type after type rejection)
|
||||||
formatted_checkpoint = {
|
has_checkpoint_data = any([
|
||||||
"type": "checkpoint",
|
checkpoint_data.get("modelId"),
|
||||||
"modelId": checkpoint_data.get("modelId"),
|
checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
||||||
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
checkpoint_data.get("name"),
|
||||||
"modelName": checkpoint_data.get("name"), # In base.py, 'name' is populated from civitai_data['model']['name']
|
checkpoint_data.get("version"),
|
||||||
"modelVersionName": checkpoint_data.get("version") # In base.py, 'version' is populated from civitai_data['name']
|
])
|
||||||
}
|
if has_checkpoint_data:
|
||||||
# Remove None values
|
formatted_checkpoint = {
|
||||||
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
|
"type": "checkpoint",
|
||||||
|
"modelId": checkpoint_data.get("modelId"),
|
||||||
|
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
||||||
|
"modelName": checkpoint_data.get("name"),
|
||||||
|
"modelVersionName": checkpoint_data.get("version"),
|
||||||
|
}
|
||||||
|
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
|
||||||
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Dict, Any, Union
|
|||||||
from ..base import RecipeMetadataParser
|
from ..base import RecipeMetadataParser
|
||||||
from ..constants import GEN_PARAM_KEYS
|
from ..constants import GEN_PARAM_KEYS
|
||||||
from ...services.metadata_service import get_default_metadata_provider
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
|
from ...config import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -73,7 +74,8 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def parse_metadata( # type: ignore[override]
|
async def parse_metadata( # type: ignore[override]
|
||||||
self, user_comment, recipe_scanner=None, civitai_client=None
|
self, user_comment, recipe_scanner=None, civitai_client=None,
|
||||||
|
local_cache: dict[str, Any] | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Parse metadata from Civitai image format
|
"""Parse metadata from Civitai image format
|
||||||
|
|
||||||
@@ -81,6 +83,8 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
user_comment: The metadata from the image (dict)
|
user_comment: The metadata from the image (dict)
|
||||||
recipe_scanner: Optional recipe scanner service
|
recipe_scanner: Optional recipe scanner service
|
||||||
civitai_client: Optional Civitai API client (deprecated, use metadata_provider instead)
|
civitai_client: Optional Civitai API client (deprecated, use metadata_provider instead)
|
||||||
|
local_cache: Optional dict mapping sha256/autov3 hash → scanner cache item.
|
||||||
|
When provided, matching models skip CivitAI API calls.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing parsed recipe data
|
Dict containing parsed recipe data
|
||||||
@@ -210,35 +214,45 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Try to look up base model from the checkpoint hash
|
# Try to look up base model from the checkpoint hash
|
||||||
if checkpoint_entry["hash"] and metadata_provider:
|
cp_hash = checkpoint_entry.get("hash")
|
||||||
try:
|
if cp_hash and metadata_provider:
|
||||||
civitai_info = (
|
local_cached = local_cache.get(cp_hash) if local_cache else None
|
||||||
await metadata_provider.get_model_by_hash(
|
if local_cached:
|
||||||
checkpoint_entry["hash"]
|
self._populate_entry_from_cache(
|
||||||
|
checkpoint_entry, local_cached
|
||||||
|
)
|
||||||
|
bm = checkpoint_entry.get("baseModel", "")
|
||||||
|
if bm and not result["base_model"]:
|
||||||
|
result["base_model"] = bm
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
civitai_info = (
|
||||||
|
await metadata_provider.get_model_by_hash(
|
||||||
|
cp_hash
|
||||||
|
)
|
||||||
|
)
|
||||||
|
civitai_data, error_msg = (
|
||||||
|
(civitai_info, None)
|
||||||
|
if not isinstance(civitai_info, tuple)
|
||||||
|
else civitai_info
|
||||||
|
)
|
||||||
|
if civitai_data and error_msg != "Model not found":
|
||||||
|
if 'model' in civitai_data and 'name' in civitai_data['model']:
|
||||||
|
checkpoint_entry['name'] = civitai_data['model']['name']
|
||||||
|
checkpoint_entry['id'] = civitai_data.get('id', 0)
|
||||||
|
checkpoint_entry['modelId'] = civitai_data.get('modelId', 0)
|
||||||
|
if 'name' in civitai_data:
|
||||||
|
checkpoint_entry['version'] = civitai_data['name']
|
||||||
|
base_model = civitai_data.get('baseModel', '')
|
||||||
|
if base_model:
|
||||||
|
checkpoint_entry['baseModel'] = base_model
|
||||||
|
if not result['base_model']:
|
||||||
|
result['base_model'] = base_model
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error fetching checkpoint info for hash "
|
||||||
|
f"{cp_hash}: {e}"
|
||||||
)
|
)
|
||||||
)
|
|
||||||
civitai_data, error_msg = (
|
|
||||||
(civitai_info, None)
|
|
||||||
if not isinstance(civitai_info, tuple)
|
|
||||||
else civitai_info
|
|
||||||
)
|
|
||||||
if civitai_data and error_msg != "Model not found":
|
|
||||||
if 'model' in civitai_data and 'name' in civitai_data['model']:
|
|
||||||
checkpoint_entry['name'] = civitai_data['model']['name']
|
|
||||||
checkpoint_entry['id'] = civitai_data.get('id', 0)
|
|
||||||
checkpoint_entry['modelId'] = civitai_data.get('modelId', 0)
|
|
||||||
if 'name' in civitai_data:
|
|
||||||
checkpoint_entry['version'] = civitai_data['name']
|
|
||||||
base_model = civitai_data.get('baseModel', '')
|
|
||||||
if base_model:
|
|
||||||
checkpoint_entry['baseModel'] = base_model
|
|
||||||
if not result['base_model']:
|
|
||||||
result['base_model'] = base_model
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Error fetching checkpoint info for hash "
|
|
||||||
f"{checkpoint_entry['hash']}: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if result["model"] is None:
|
if result["model"] is None:
|
||||||
result["model"] = checkpoint_entry
|
result["model"] = checkpoint_entry
|
||||||
@@ -279,34 +293,45 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Try to get info from Civitai if hash is available
|
# Try to get info from Civitai if hash is available
|
||||||
if lora_entry["hash"] and metadata_provider:
|
if lora_hash and metadata_provider:
|
||||||
try:
|
local_cached = local_cache.get(lora_hash) if local_cache else None
|
||||||
civitai_info = (
|
if local_cached:
|
||||||
await metadata_provider.get_model_by_hash(lora_hash)
|
self._populate_entry_from_cache(
|
||||||
|
lora_entry, local_cached
|
||||||
)
|
)
|
||||||
|
# Track by version ID for deduplication
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
if lora_entry.get("id"):
|
||||||
lora_entry,
|
|
||||||
civitai_info,
|
|
||||||
recipe_scanner,
|
|
||||||
base_model_counts,
|
|
||||||
lora_hash,
|
|
||||||
)
|
|
||||||
|
|
||||||
if populated_entry is None:
|
|
||||||
continue # Skip invalid LoRA types
|
|
||||||
|
|
||||||
lora_entry = populated_entry
|
|
||||||
|
|
||||||
# If we have a version ID from Civitai, track it for deduplication
|
|
||||||
if "id" in lora_entry and lora_entry["id"]:
|
|
||||||
added_loras[str(lora_entry["id"])] = len(
|
added_loras[str(lora_entry["id"])] = len(
|
||||||
result["loras"]
|
result["loras"]
|
||||||
)
|
)
|
||||||
except Exception as e:
|
else:
|
||||||
logger.error(
|
try:
|
||||||
f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}"
|
civitai_info = (
|
||||||
)
|
await metadata_provider.get_model_by_hash(lora_hash)
|
||||||
|
)
|
||||||
|
|
||||||
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
|
lora_entry,
|
||||||
|
civitai_info,
|
||||||
|
recipe_scanner,
|
||||||
|
base_model_counts,
|
||||||
|
lora_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
if populated_entry is None:
|
||||||
|
continue # Skip invalid LoRA types
|
||||||
|
|
||||||
|
lora_entry = populated_entry
|
||||||
|
|
||||||
|
# If we have a version ID from Civitai, track it for deduplication
|
||||||
|
if "id" in lora_entry and lora_entry["id"]:
|
||||||
|
added_loras[str(lora_entry["id"])] = len(
|
||||||
|
result["loras"]
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# Track by hash if we have it
|
# Track by hash if we have it
|
||||||
if lora_hash:
|
if lora_hash:
|
||||||
@@ -684,3 +709,41 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing Civitai image metadata: {e}", exc_info=True)
|
logger.error(f"Error parsing Civitai image metadata: {e}", exc_info=True)
|
||||||
return {"error": str(e), "loras": []}
|
return {"error": str(e), "loras": []}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _populate_entry_from_cache(
|
||||||
|
entry: dict[str, Any],
|
||||||
|
cache_item: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Fill a lora/checkpoint entry from a scanner cache item.
|
||||||
|
|
||||||
|
Avoids CivitAI API calls for models that exist locally.
|
||||||
|
Mirrors the population logic in
|
||||||
|
``RecipeMetadataParser.populate_lora_from_civitai()`` but operates
|
||||||
|
entirely on cached data.
|
||||||
|
"""
|
||||||
|
civ = cache_item.get("civitai") or {}
|
||||||
|
if isinstance(civ, dict):
|
||||||
|
if civ.get("id") is not None:
|
||||||
|
entry["id"] = civ["id"]
|
||||||
|
if civ.get("modelId") is not None:
|
||||||
|
entry["modelId"] = civ["modelId"]
|
||||||
|
if civ.get("name"):
|
||||||
|
entry["version"] = civ["name"]
|
||||||
|
cached_name = cache_item.get("model_name")
|
||||||
|
if cached_name:
|
||||||
|
entry["name"] = cached_name
|
||||||
|
entry["existsLocally"] = True
|
||||||
|
local_path = cache_item.get("file_path")
|
||||||
|
if local_path:
|
||||||
|
entry["localPath"] = local_path
|
||||||
|
sha256 = cache_item.get("sha256")
|
||||||
|
if sha256:
|
||||||
|
entry["hash"] = sha256
|
||||||
|
if "preview_url" in cache_item:
|
||||||
|
entry["thumbnailUrl"] = config.get_preview_static_url(
|
||||||
|
cache_item["preview_url"]
|
||||||
|
)
|
||||||
|
base_model = cache_item.get("base_model", "")
|
||||||
|
if base_model:
|
||||||
|
entry["baseModel"] = base_model
|
||||||
|
|||||||
@@ -1472,6 +1472,21 @@ class ModelDownloadHandler:
|
|||||||
)
|
)
|
||||||
return web.Response(status=500, text=str(exc))
|
return web.Response(status=500, text=str(exc))
|
||||||
|
|
||||||
|
async def skip_download_get(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
download_id = request.query.get("download_id")
|
||||||
|
if not download_id:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Download ID is required"}, status=400
|
||||||
|
)
|
||||||
|
result = await self._download_coordinator.skip_download(download_id)
|
||||||
|
return web.json_response(result)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error skipping download via GET: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def cancel_download_get(self, request: web.Request) -> web.Response:
|
async def cancel_download_get(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
download_id = request.query.get("download_id")
|
download_id = request.query.get("download_id")
|
||||||
@@ -1960,6 +1975,10 @@ class ModelUpdateHandler:
|
|||||||
if target_model_ids:
|
if target_model_ids:
|
||||||
target_model_ids = sorted(set(target_model_ids))
|
target_model_ids = sorted(set(target_model_ids))
|
||||||
|
|
||||||
|
folder_path: Optional[str] = payload.get("folder_path")
|
||||||
|
if folder_path is not None and not isinstance(folder_path, str):
|
||||||
|
folder_path = None
|
||||||
|
|
||||||
provider = await self._get_civitai_provider()
|
provider = await self._get_civitai_provider()
|
||||||
if provider is None:
|
if provider is None:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -1974,6 +1993,7 @@ class ModelUpdateHandler:
|
|||||||
provider,
|
provider,
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
target_model_ids=target_model_ids or None,
|
target_model_ids=target_model_ids or None,
|
||||||
|
folder_path=folder_path,
|
||||||
)
|
)
|
||||||
if self._service.scanner.is_cancelled():
|
if self._service.scanner.is_cancelled():
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -2561,6 +2581,7 @@ class ModelHandlerSet:
|
|||||||
"download_model": self.download.download_model,
|
"download_model": self.download.download_model,
|
||||||
"download_model_get": self.download.download_model_get,
|
"download_model_get": self.download.download_model_get,
|
||||||
"cancel_download_get": self.download.cancel_download_get,
|
"cancel_download_get": self.download.cancel_download_get,
|
||||||
|
"skip_download_get": self.download.skip_download_get,
|
||||||
"pause_download_get": self.download.pause_download_get,
|
"pause_download_get": self.download.pause_download_get,
|
||||||
"resume_download_get": self.download.resume_download_get,
|
"resume_download_get": self.download.resume_download_get,
|
||||||
"get_download_progress": self.download.get_download_progress,
|
"get_download_progress": self.download.get_download_progress,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from aiohttp import web
|
|||||||
|
|
||||||
from ...config import config
|
from ...config import config
|
||||||
from ...services.server_i18n import server_i18n as default_server_i18n
|
from ...services.server_i18n import server_i18n as default_server_i18n
|
||||||
from ...services.settings_manager import SettingsManager
|
from ...services.settings_manager import SettingsManager, get_settings_manager
|
||||||
from ...services.recipes import (
|
from ...services.recipes import (
|
||||||
RecipeAnalysisService,
|
RecipeAnalysisService,
|
||||||
RecipeDownloadError,
|
RecipeDownloadError,
|
||||||
@@ -26,7 +26,12 @@ from ...services.recipes import (
|
|||||||
RecipeValidationError,
|
RecipeValidationError,
|
||||||
)
|
)
|
||||||
from ...services.metadata_service import get_default_metadata_provider
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
from ...utils.civitai_utils import (
|
||||||
|
build_civitai_image_page_url,
|
||||||
|
extract_civitai_image_id,
|
||||||
|
extract_civitai_image_id_from_cdn_url,
|
||||||
|
rewrite_preview_url,
|
||||||
|
)
|
||||||
from ...utils.exif_utils import ExifUtils
|
from ...utils.exif_utils import ExifUtils
|
||||||
from ...recipes.merger import GenParamsMerger
|
from ...recipes.merger import GenParamsMerger
|
||||||
from ...recipes.enrichment import RecipeEnricher
|
from ...recipes.enrichment import RecipeEnricher
|
||||||
@@ -96,6 +101,7 @@ class RecipeHandlerSet:
|
|||||||
"browse_directory": self.batch_import.browse_directory,
|
"browse_directory": self.batch_import.browse_directory,
|
||||||
"check_image_exists": self.management.check_image_exists,
|
"check_image_exists": self.management.check_image_exists,
|
||||||
"import_from_url": self.management.import_from_url,
|
"import_from_url": self.management.import_from_url,
|
||||||
|
"create_from_example": self.management.create_from_example,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -461,7 +467,11 @@ class RecipeQueryHandler:
|
|||||||
if recipe_scanner is None:
|
if recipe_scanner is None:
|
||||||
raise RuntimeError("Recipe scanner unavailable")
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
self._logger.info("Manually triggering recipe cache rebuild")
|
full_rebuild = request.query.get("full_rebuild", "true").lower() == "true"
|
||||||
|
self._logger.info(
|
||||||
|
"Manually triggering recipe cache %s",
|
||||||
|
"full rebuild" if full_rebuild else "refresh",
|
||||||
|
)
|
||||||
await recipe_scanner.get_cached_data(force_refresh=True)
|
await recipe_scanner.get_cached_data(force_refresh=True)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": True, "message": "Recipe cache refreshed successfully"}
|
{"success": True, "message": "Recipe cache refreshed successfully"}
|
||||||
@@ -975,6 +985,9 @@ class RecipeManagementHandler:
|
|||||||
civitai_model = civitai_parsed.get("model")
|
civitai_model = civitai_parsed.get("model")
|
||||||
if civitai_model and not metadata.get("checkpoint"):
|
if civitai_model and not metadata.get("checkpoint"):
|
||||||
metadata["checkpoint"] = civitai_model
|
metadata["checkpoint"] = civitai_model
|
||||||
|
civitai_base_model = civitai_parsed.get("base_model")
|
||||||
|
if civitai_base_model and not metadata.get("base_model"):
|
||||||
|
metadata["base_model"] = civitai_base_model
|
||||||
elif parsed_embedded:
|
elif parsed_embedded:
|
||||||
parsed_loras = parsed_embedded.get("loras")
|
parsed_loras = parsed_embedded.get("loras")
|
||||||
if parsed_loras and not metadata.get("loras"):
|
if parsed_loras and not metadata.get("loras"):
|
||||||
@@ -982,6 +995,8 @@ class RecipeManagementHandler:
|
|||||||
parsed_model = parsed_embedded.get("model")
|
parsed_model = parsed_embedded.get("model")
|
||||||
if parsed_model and not metadata.get("checkpoint"):
|
if parsed_model and not metadata.get("checkpoint"):
|
||||||
metadata["checkpoint"] = parsed_model
|
metadata["checkpoint"] = parsed_model
|
||||||
|
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
|
||||||
|
metadata["base_model"] = parsed_embedded["base_model"]
|
||||||
|
|
||||||
civitai_client = self._civitai_client_getter()
|
civitai_client = self._civitai_client_getter()
|
||||||
await RecipeEnricher.enrich_recipe(
|
await RecipeEnricher.enrich_recipe(
|
||||||
@@ -1489,25 +1504,28 @@ class RecipeManagementHandler:
|
|||||||
if not image_url:
|
if not image_url:
|
||||||
raise RecipeValidationError("Missing required field: image_url")
|
raise RecipeValidationError("Missing required field: image_url")
|
||||||
|
|
||||||
|
force = request.query.get("force", "false").lower() == "true"
|
||||||
|
|
||||||
image_id = extract_civitai_image_id(image_url)
|
image_id = extract_civitai_image_id(image_url)
|
||||||
if not image_id:
|
if not image_id:
|
||||||
raise RecipeValidationError(
|
raise RecipeValidationError(
|
||||||
"Could not extract Civitai image ID from URL"
|
"Could not extract Civitai image ID from URL"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for duplicate (fast, before acquiring semaphore)
|
# Check for duplicate (fast, before acquiring semaphore), unless force
|
||||||
cache = await recipe_scanner.get_cached_data()
|
if not force:
|
||||||
for recipe in getattr(cache, "raw_data", []):
|
cache = await recipe_scanner.get_cached_data()
|
||||||
source = recipe.get("source_path")
|
for recipe in getattr(cache, "raw_data", []):
|
||||||
if source:
|
source = recipe.get("source_path")
|
||||||
existing_id = extract_civitai_image_id(source)
|
if source:
|
||||||
if existing_id == image_id:
|
existing_id = extract_civitai_image_id(source)
|
||||||
return web.json_response({
|
if existing_id == image_id:
|
||||||
"success": True,
|
return web.json_response({
|
||||||
"recipe_id": recipe.get("id"),
|
"success": True,
|
||||||
"name": recipe.get("title", ""),
|
"recipe_id": recipe.get("id"),
|
||||||
"already_exists": True,
|
"name": recipe.get("title", ""),
|
||||||
})
|
"already_exists": True,
|
||||||
|
})
|
||||||
|
|
||||||
async with self._import_semaphore:
|
async with self._import_semaphore:
|
||||||
return await self._do_import_from_url(image_url, recipe_scanner)
|
return await self._do_import_from_url(image_url, recipe_scanner)
|
||||||
@@ -1613,6 +1631,9 @@ class RecipeManagementHandler:
|
|||||||
civitai_model = civitai_parsed.get("model")
|
civitai_model = civitai_parsed.get("model")
|
||||||
if civitai_model and not metadata.get("checkpoint"):
|
if civitai_model and not metadata.get("checkpoint"):
|
||||||
metadata["checkpoint"] = civitai_model
|
metadata["checkpoint"] = civitai_model
|
||||||
|
civitai_base_model = civitai_parsed.get("base_model")
|
||||||
|
if civitai_base_model and not metadata.get("base_model"):
|
||||||
|
metadata["base_model"] = civitai_base_model
|
||||||
elif parsed_embedded:
|
elif parsed_embedded:
|
||||||
parsed_loras = parsed_embedded.get("loras")
|
parsed_loras = parsed_embedded.get("loras")
|
||||||
if parsed_loras and not metadata.get("loras"):
|
if parsed_loras and not metadata.get("loras"):
|
||||||
@@ -1620,6 +1641,8 @@ class RecipeManagementHandler:
|
|||||||
parsed_model = parsed_embedded.get("model")
|
parsed_model = parsed_embedded.get("model")
|
||||||
if parsed_model and not metadata.get("checkpoint"):
|
if parsed_model and not metadata.get("checkpoint"):
|
||||||
metadata["checkpoint"] = parsed_model
|
metadata["checkpoint"] = parsed_model
|
||||||
|
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
|
||||||
|
metadata["base_model"] = parsed_embedded["base_model"]
|
||||||
|
|
||||||
civitai_client = self._civitai_client_getter()
|
civitai_client = self._civitai_client_getter()
|
||||||
await RecipeEnricher.enrich_recipe(
|
await RecipeEnricher.enrich_recipe(
|
||||||
@@ -1651,6 +1674,272 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response(result.payload, status=result.status)
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
|
||||||
|
async def create_from_example(self, request: web.Request) -> web.Response:
|
||||||
|
"""Create a recipe from a model's example image using cached metadata.
|
||||||
|
|
||||||
|
Uses the image's meta data (already cached in .metadata.json from the
|
||||||
|
CivitAI model-versions API) to create a recipe without additional
|
||||||
|
CivitAI API calls.
|
||||||
|
|
||||||
|
If the image metadata doesn't contain any resources of the parent
|
||||||
|
model's type (LoRA-type or Checkpoint), the parent model is
|
||||||
|
auto-populated as a fallback.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
image_data (dict): The full image object from model-versions API
|
||||||
|
(includes meta, additionalResources, url, etc.)
|
||||||
|
model_hash (str): SHA256 hash of the parent model
|
||||||
|
model_name (str): Filename of the parent model
|
||||||
|
model_type (str): Page type (``"loras"``, ``"checkpoints"``, etc.)
|
||||||
|
local_image_path (str, optional): Local filesystem path to read
|
||||||
|
the image bytes for the recipe preview
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
image_data = data.get("image_data")
|
||||||
|
model_hash = data.get("model_hash")
|
||||||
|
model_name = data.get("model_name")
|
||||||
|
model_type = data.get("model_type", "")
|
||||||
|
|
||||||
|
if not image_data or not model_hash or not model_name:
|
||||||
|
raise RecipeValidationError(
|
||||||
|
"Missing required fields: image_data, model_hash, model_name"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge nested meta into top level so the parser finds everything.
|
||||||
|
# CivitaiApiMetadataParser expects prompt, seed, resources, etc.
|
||||||
|
# at the top level or wrapped under a "meta" key.
|
||||||
|
inner_meta = image_data.get("meta") or {}
|
||||||
|
parsed_input = {**image_data, **inner_meta}
|
||||||
|
parsed_input.pop("meta", None)
|
||||||
|
|
||||||
|
# Build a local cache of {hash → cache_item} so the parser can
|
||||||
|
# skip CivitAI API calls for models that exist on disk.
|
||||||
|
local_cache: Dict[str, Dict[str, Any]] = {}
|
||||||
|
lora_scanner = getattr(recipe_scanner, "_lora_scanner", None)
|
||||||
|
if lora_scanner and model_hash:
|
||||||
|
try:
|
||||||
|
parent_cache_data = await lora_scanner.get_cached_data()
|
||||||
|
for item in getattr(parent_cache_data, "raw_data", []):
|
||||||
|
if item.get("sha256", "").lower() == model_hash.lower():
|
||||||
|
local_cache[model_hash.lower()] = item
|
||||||
|
# Compute AutoV3 so the parser can also match on
|
||||||
|
# that hash type (CivitAI metadata resources use
|
||||||
|
# AutoV3).
|
||||||
|
file_path = item.get("file_path")
|
||||||
|
if file_path and os.path.exists(file_path):
|
||||||
|
try:
|
||||||
|
from ...utils.file_utils import (
|
||||||
|
calculate_autov3,
|
||||||
|
)
|
||||||
|
autov3 = calculate_autov3(file_path)
|
||||||
|
if autov3:
|
||||||
|
local_cache[autov3.lower()] = item
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
|
parsed_input
|
||||||
|
)
|
||||||
|
if not parser:
|
||||||
|
raise RecipeValidationError("Unable to parse image metadata")
|
||||||
|
|
||||||
|
from ...recipes.parsers.civitai_image import CivitaiApiMetadataParser
|
||||||
|
|
||||||
|
if isinstance(parser, CivitaiApiMetadataParser):
|
||||||
|
parsed = await parser.parse_metadata(
|
||||||
|
parsed_input,
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
local_cache=local_cache,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parsed = await parser.parse_metadata(
|
||||||
|
parsed_input, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
|
|
||||||
|
loras = list(parsed.get("loras") or [])
|
||||||
|
checkpoint = parsed.get("model")
|
||||||
|
is_lora_type = model_type.startswith("lora")
|
||||||
|
is_ckpt_type = model_type.startswith("checkpoint")
|
||||||
|
|
||||||
|
# Extract parent model metadata from local_cache (used below to
|
||||||
|
# reconcile isDeleted entries and enrich auto-populated ones).
|
||||||
|
parent_civitai_id: int | None = None
|
||||||
|
parent_model_id: int | None = None
|
||||||
|
parent_version_name: str | None = None
|
||||||
|
parent_model_name: str | None = None
|
||||||
|
# Prefer sha256 key; fall back to any cached entry.
|
||||||
|
parent_item = local_cache.get(model_hash.lower()) if model_hash else None
|
||||||
|
if parent_item is None and local_cache:
|
||||||
|
parent_item = next(iter(local_cache.values()))
|
||||||
|
if parent_item:
|
||||||
|
civ = parent_item.get("civitai") or {}
|
||||||
|
if isinstance(civ, dict):
|
||||||
|
parent_civitai_id = civ.get("id")
|
||||||
|
parent_model_id = civ.get("modelId")
|
||||||
|
parent_version_name = civ.get("name")
|
||||||
|
parent_model_name = parent_item.get("model_name")
|
||||||
|
|
||||||
|
# Reconcile isDeleted entries against the parent model.
|
||||||
|
# When the CivitAI hash lookup fails (known issue — hashes not
|
||||||
|
# yet computed), the parser marks the entry isDeleted even though
|
||||||
|
# the model exists locally.
|
||||||
|
if is_lora_type:
|
||||||
|
for lora in loras:
|
||||||
|
if lora.get("isDeleted") and lora.get("file_name") == model_name:
|
||||||
|
lora["isDeleted"] = False
|
||||||
|
lora["existsLocally"] = True
|
||||||
|
lora["hash"] = model_hash
|
||||||
|
if parent_civitai_id is not None:
|
||||||
|
lora["id"] = parent_civitai_id
|
||||||
|
if parent_model_id is not None:
|
||||||
|
lora["modelId"] = parent_model_id
|
||||||
|
if parent_version_name is not None:
|
||||||
|
lora["version"] = parent_version_name
|
||||||
|
if parent_model_name is not None:
|
||||||
|
lora["name"] = parent_model_name
|
||||||
|
elif is_ckpt_type and checkpoint and checkpoint.get("isDeleted"):
|
||||||
|
if checkpoint.get("file_name") == model_name:
|
||||||
|
checkpoint["isDeleted"] = False
|
||||||
|
checkpoint["existsLocally"] = True
|
||||||
|
checkpoint["hash"] = model_hash
|
||||||
|
if parent_civitai_id is not None:
|
||||||
|
checkpoint["id"] = parent_civitai_id
|
||||||
|
if parent_model_id is not None:
|
||||||
|
checkpoint["modelId"] = parent_model_id
|
||||||
|
if parent_version_name is not None:
|
||||||
|
checkpoint["version"] = parent_version_name
|
||||||
|
|
||||||
|
# Auto-populate parent model only when the image metadata didn't
|
||||||
|
# contain any resources of that type.
|
||||||
|
if is_lora_type and not loras:
|
||||||
|
lora_entry = {
|
||||||
|
"name": model_name,
|
||||||
|
"type": "lora",
|
||||||
|
"weight": 1.0,
|
||||||
|
"hash": model_hash,
|
||||||
|
"existsLocally": True,
|
||||||
|
"localPath": None,
|
||||||
|
"file_name": model_name,
|
||||||
|
"thumbnailUrl": "/loras_static/images/no-preview.png",
|
||||||
|
"baseModel": parsed.get("base_model", ""),
|
||||||
|
"size": 0,
|
||||||
|
"downloadUrl": "",
|
||||||
|
"isDeleted": False,
|
||||||
|
}
|
||||||
|
if parent_civitai_id is not None:
|
||||||
|
lora_entry["id"] = parent_civitai_id
|
||||||
|
if parent_model_id is not None:
|
||||||
|
lora_entry["modelId"] = parent_model_id
|
||||||
|
if parent_version_name is not None:
|
||||||
|
lora_entry["version"] = parent_version_name
|
||||||
|
if parent_model_name is not None:
|
||||||
|
lora_entry["name"] = parent_model_name
|
||||||
|
loras.insert(0, lora_entry)
|
||||||
|
elif is_ckpt_type and not checkpoint:
|
||||||
|
checkpoint = {
|
||||||
|
"name": model_name,
|
||||||
|
"type": "checkpoint",
|
||||||
|
"hash": model_hash,
|
||||||
|
"file_name": model_name,
|
||||||
|
"existsLocally": True,
|
||||||
|
"baseModel": parsed.get("base_model", ""),
|
||||||
|
"isDeleted": False,
|
||||||
|
}
|
||||||
|
if parent_civitai_id is not None:
|
||||||
|
checkpoint["id"] = parent_civitai_id
|
||||||
|
if parent_model_id is not None:
|
||||||
|
checkpoint["modelId"] = parent_model_id
|
||||||
|
if parent_version_name is not None:
|
||||||
|
checkpoint["version"] = parent_version_name
|
||||||
|
if parent_model_name is not None:
|
||||||
|
checkpoint["name"] = parent_model_name
|
||||||
|
|
||||||
|
image_url = image_data.get("url") or ""
|
||||||
|
image_id = extract_civitai_image_id_from_cdn_url(image_url)
|
||||||
|
settings_mgr = get_settings_manager()
|
||||||
|
civitai_host = settings_mgr.get("civitai_host") if settings_mgr else None
|
||||||
|
page_url = build_civitai_image_page_url(image_id, host=civitai_host) or image_url
|
||||||
|
|
||||||
|
recipe_metadata: dict[str, Any] = {
|
||||||
|
"base_model": parsed.get("base_model") or "",
|
||||||
|
"loras": loras,
|
||||||
|
"gen_params": parsed.get("gen_params") or {},
|
||||||
|
"source_path": page_url,
|
||||||
|
}
|
||||||
|
nsfw_level = image_data.get("nsfwLevel")
|
||||||
|
if isinstance(nsfw_level, int):
|
||||||
|
recipe_metadata["preview_nsfw_level"] = nsfw_level
|
||||||
|
if checkpoint:
|
||||||
|
recipe_metadata["checkpoint"] = checkpoint
|
||||||
|
|
||||||
|
image_bytes: bytes | None = None
|
||||||
|
extension: str | None = None
|
||||||
|
local_image_path = data.get("local_image_path")
|
||||||
|
if local_image_path and os.path.exists(local_image_path):
|
||||||
|
with open(local_image_path, "rb") as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
ext = os.path.splitext(local_image_path)[1].lower()
|
||||||
|
if ext in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
|
||||||
|
extension = ext
|
||||||
|
elif image_data.get("url"):
|
||||||
|
try:
|
||||||
|
downloader = await self._downloader_factory()
|
||||||
|
url = image_data["url"]
|
||||||
|
tmp = tempfile.NamedTemporaryFile(delete=False)
|
||||||
|
tmp.close()
|
||||||
|
success, result = await downloader.download_file(
|
||||||
|
url, tmp.name, use_auth=False
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
with open(tmp.name, "rb") as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
url_path = url.split("?")[0].split("#")[0]
|
||||||
|
ext = os.path.splitext(url_path)[1].lower()
|
||||||
|
if ext:
|
||||||
|
extension = ext
|
||||||
|
if os.path.exists(tmp.name):
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.warning(
|
||||||
|
"Failed to download image for recipe: %s", exc
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
(parsed.get("gen_params") or {}).get("prompt") or ""
|
||||||
|
)
|
||||||
|
if prompt:
|
||||||
|
name = " ".join(str(prompt).split()[:10])
|
||||||
|
else:
|
||||||
|
name = f"Recipe from {model_name}"
|
||||||
|
|
||||||
|
save_result = await self._persistence_service.save_recipe(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
image_base64=None,
|
||||||
|
name=name,
|
||||||
|
tags=[],
|
||||||
|
metadata=recipe_metadata,
|
||||||
|
extension=extension,
|
||||||
|
)
|
||||||
|
return web.json_response(save_result.payload, status=save_result.status)
|
||||||
|
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error creating recipe from example: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class RecipeAnalysisHandler:
|
class RecipeAnalysisHandler:
|
||||||
"""Analyze images to extract recipe metadata."""
|
"""Analyze images to extract recipe metadata."""
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/download-model", "download_model"),
|
RouteDefinition("POST", "/api/lm/download-model", "download_model"),
|
||||||
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
|
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
|
||||||
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
|
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
|
||||||
|
RouteDefinition("GET", "/api/lm/skip-download", "skip_download_get"),
|
||||||
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
|
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
|
||||||
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
|
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
"GET", "/api/lm/recipes/check-image-exists", "check_image_exists"
|
"GET", "/api/lm/recipes/check-image-exists", "check_image_exists"
|
||||||
),
|
),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/import-from-url", "import_from_url"),
|
RouteDefinition("GET", "/api/lm/recipes/import-from-url", "import_from_url"),
|
||||||
|
RouteDefinition(
|
||||||
|
"POST", "/api/lm/recipes/create-from-example", "create_from_example"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import Dict, List
|
|||||||
|
|
||||||
from ..utils.settings_paths import ensure_settings_file
|
from ..utils.settings_paths import ensure_settings_file
|
||||||
from ..services.downloader import get_downloader
|
from ..services.downloader import get_downloader
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -212,8 +213,19 @@ class UpdateRoutes:
|
|||||||
|
|
||||||
zip_path = tmp_zip_path
|
zip_path = tmp_zip_path
|
||||||
|
|
||||||
# Skip both settings.json, civitai and model cache folder
|
# Close the downloaded-versions SQLite connection before cleaning,
|
||||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache'])
|
# so that shutil.rmtree() does not fail on Windows (the process
|
||||||
|
# cannot delete a file with an outstanding open handle).
|
||||||
|
try:
|
||||||
|
history_svc = ServiceRegistry._services.get("downloaded_version_history_service")
|
||||||
|
if history_svc is not None:
|
||||||
|
history_svc.close()
|
||||||
|
logger.info("Closed downloaded-version history database connection")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not close downloaded-version history database", exc_info=True)
|
||||||
|
|
||||||
|
# Skip settings.json, civitai, model cache and runtime cache folders
|
||||||
|
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache', 'cache', 'wildcards', 'backups'])
|
||||||
|
|
||||||
# Extract ZIP to temp dir
|
# Extract ZIP to temp dir
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
@@ -222,16 +234,17 @@ class UpdateRoutes:
|
|||||||
# Find extracted folder (GitHub ZIP contains a root folder)
|
# Find extracted folder (GitHub ZIP contains a root folder)
|
||||||
extracted_root = next(os.scandir(tmp_dir)).path
|
extracted_root = next(os.scandir(tmp_dir)).path
|
||||||
|
|
||||||
# Copy files, skipping settings.json and civitai folder
|
# Copy files, skipping user data that should be preserved
|
||||||
|
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups'}
|
||||||
for item in os.listdir(extracted_root):
|
for item in os.listdir(extracted_root):
|
||||||
if item == 'settings.json' or item == 'civitai':
|
if item in skip_items:
|
||||||
continue
|
continue
|
||||||
src = os.path.join(extracted_root, item)
|
src = os.path.join(extracted_root, item)
|
||||||
dst = os.path.join(plugin_root, item)
|
dst = os.path.join(plugin_root, item)
|
||||||
if os.path.isdir(src):
|
if os.path.isdir(src):
|
||||||
if os.path.exists(dst):
|
if os.path.exists(dst):
|
||||||
shutil.rmtree(dst)
|
shutil.rmtree(dst)
|
||||||
shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json', 'civitai'))
|
shutil.copytree(src, dst, ignore=shutil.ignore_patterns(*skip_items))
|
||||||
else:
|
else:
|
||||||
shutil.copy2(src, dst)
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
@@ -239,15 +252,17 @@ class UpdateRoutes:
|
|||||||
# for ComfyUI Manager to work properly
|
# for ComfyUI Manager to work properly
|
||||||
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
||||||
tracking_files = []
|
tracking_files = []
|
||||||
|
skip_tracked = {'civitai', 'wildcards', 'backups'}
|
||||||
for root, dirs, files in os.walk(extracted_root):
|
for root, dirs, files in os.walk(extracted_root):
|
||||||
# Skip civitai folder and its contents
|
# Skip user data directories and their contents
|
||||||
rel_root = os.path.relpath(root, extracted_root)
|
rel_root = os.path.relpath(root, extracted_root)
|
||||||
if rel_root == 'civitai' or rel_root.startswith('civitai' + os.sep):
|
top_dir = rel_root.split(os.sep)[0] if rel_root != '.' else ''
|
||||||
|
if top_dir in skip_tracked:
|
||||||
continue
|
continue
|
||||||
for file in files:
|
for file in files:
|
||||||
rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
|
rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
|
||||||
# Skip settings.json and any file under civitai
|
# Skip settings.json and any file under user data dirs
|
||||||
if rel_path == 'settings.json' or rel_path.startswith('civitai' + os.sep):
|
if rel_path == 'settings.json' or rel_path.split(os.sep)[0] in skip_tracked:
|
||||||
continue
|
continue
|
||||||
tracking_files.append(rel_path.replace("\\", "/"))
|
tracking_files.append(rel_path.replace("\\", "/"))
|
||||||
with open(tracking_info_file, "w", encoding='utf-8') as file:
|
with open(tracking_info_file, "w", encoding='utf-8') as file:
|
||||||
|
|||||||
@@ -186,6 +186,22 @@ class CivArchiveClient:
|
|||||||
if "metadata" in file_data:
|
if "metadata" in file_data:
|
||||||
transformed["metadata"] = file_data["metadata"]
|
transformed["metadata"] = file_data["metadata"]
|
||||||
|
|
||||||
|
# Infer metadata.format from filename extension
|
||||||
|
name = transformed.get("name")
|
||||||
|
if name and isinstance(name, str):
|
||||||
|
lower_name = name.lower()
|
||||||
|
if lower_name.endswith(".safetensors"):
|
||||||
|
inferred_format = "SafeTensor"
|
||||||
|
elif lower_name.endswith(".ckpt"):
|
||||||
|
inferred_format = "PickleTensor"
|
||||||
|
else:
|
||||||
|
inferred_format = None
|
||||||
|
if inferred_format:
|
||||||
|
if "metadata" not in transformed:
|
||||||
|
transformed["metadata"] = {}
|
||||||
|
if isinstance(transformed["metadata"], dict):
|
||||||
|
transformed["metadata"].setdefault("format", inferred_format)
|
||||||
|
|
||||||
if file_data.get("modelVersionId") is not None:
|
if file_data.get("modelVersionId") is not None:
|
||||||
transformed["modelVersionId"] = file_data.get("modelVersionId")
|
transformed["modelVersionId"] = file_data.get("modelVersionId")
|
||||||
elif file_data.get("model_version_id") is not None:
|
elif file_data.get("model_version_id") is not None:
|
||||||
@@ -213,6 +229,20 @@ class CivArchiveClient:
|
|||||||
for file_data in candidates:
|
for file_data in candidates:
|
||||||
if isinstance(file_data, dict):
|
if isinstance(file_data, dict):
|
||||||
transformed_files.append(self._transform_file_entry(file_data))
|
transformed_files.append(self._transform_file_entry(file_data))
|
||||||
|
|
||||||
|
# Sort: .safetensors first, .ckpt second, others last
|
||||||
|
# so the backend fallback (no file_params) prefers safetensors
|
||||||
|
def _sort_key(f: Dict) -> int:
|
||||||
|
fname = f.get("name") or ""
|
||||||
|
if isinstance(fname, str):
|
||||||
|
lower = fname.lower()
|
||||||
|
if lower.endswith(".safetensors"):
|
||||||
|
return 0
|
||||||
|
elif lower.endswith(".ckpt"):
|
||||||
|
return 1
|
||||||
|
return 2
|
||||||
|
|
||||||
|
transformed_files.sort(key=_sort_key)
|
||||||
return transformed_files
|
return transformed_files
|
||||||
|
|
||||||
def _transform_version(
|
def _transform_version(
|
||||||
|
|||||||
@@ -110,6 +110,23 @@ class DownloadCoordinator:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def skip_download(self, download_id: str) -> Dict[str, Any]:
|
||||||
|
"""Skip a download while preserving all partial files on disk."""
|
||||||
|
download_manager = await self._download_manager_factory()
|
||||||
|
result = await download_manager.skip_download(download_id)
|
||||||
|
|
||||||
|
await self._ws_manager.broadcast_download_progress(
|
||||||
|
download_id,
|
||||||
|
{
|
||||||
|
"status": "skipped",
|
||||||
|
"progress": 0,
|
||||||
|
"download_id": download_id,
|
||||||
|
"message": "Download skipped by user (partial files preserved)",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
async def pause_download(self, download_id: str) -> Dict[str, Any]:
|
async def pause_download(self, download_id: str) -> Dict[str, Any]:
|
||||||
"""Pause an active download and notify listeners."""
|
"""Pause an active download and notify listeners."""
|
||||||
|
|
||||||
|
|||||||
@@ -2404,6 +2404,89 @@ class DownloadManager:
|
|||||||
self._download_tasks.pop(download_id, None)
|
self._download_tasks.pop(download_id, None)
|
||||||
await self._aria2_state_store.remove(download_id)
|
await self._aria2_state_store.remove(download_id)
|
||||||
|
|
||||||
|
async def skip_download(self, download_id: str) -> Dict:
|
||||||
|
"""Skip a download while preserving all partial files on disk.
|
||||||
|
|
||||||
|
Removes all in-memory tracking (asyncio task, semaphore, active/pause
|
||||||
|
state) but keeps partial files (.part / .aria2) on disk so that a
|
||||||
|
subsequent download-model-get request for the same save path can
|
||||||
|
auto-resume from the preserved partial download.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
download_id: The unique identifier of the download task
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: Status of the skip operation
|
||||||
|
"""
|
||||||
|
await self._restore_persisted_downloads()
|
||||||
|
|
||||||
|
if download_id not in self._download_tasks and download_id not in self._active_downloads:
|
||||||
|
return {"success": False, "error": "Download task not found"}
|
||||||
|
|
||||||
|
download_info = self._active_downloads.get(download_id)
|
||||||
|
task = self._download_tasks.get(download_id)
|
||||||
|
active_statuses = {"queued", "waiting", "downloading", "paused", "cancelling"}
|
||||||
|
if task is None and (
|
||||||
|
not isinstance(download_info, dict)
|
||||||
|
or download_info.get("status") not in active_statuses
|
||||||
|
):
|
||||||
|
return {"success": False, "error": "Download task not found"}
|
||||||
|
|
||||||
|
backend = (
|
||||||
|
self._active_downloads.get(download_id, {}).get("transfer_backend")
|
||||||
|
or "python"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# For aria2: pause the transfer rather than force-removing it, so
|
||||||
|
# the .aria2 control file stays on disk for future resume
|
||||||
|
if backend == "aria2":
|
||||||
|
try:
|
||||||
|
aria2_downloader = await get_aria2_downloader()
|
||||||
|
pause_result = await aria2_downloader.pause_download(download_id)
|
||||||
|
if not pause_result.get("success"):
|
||||||
|
logger.warning(
|
||||||
|
"Failed to pause aria2 transfer for %s during skip: %s",
|
||||||
|
download_id,
|
||||||
|
pause_result.get("error"),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to pause aria2 transfer for %s during skip: %s",
|
||||||
|
download_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancel the asyncio task so the semaphore slot is released
|
||||||
|
if task is not None:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
# Resume pause event so the task can exit cleanly
|
||||||
|
pause_control = self._pause_events.get(download_id)
|
||||||
|
if pause_control is not None:
|
||||||
|
pause_control.resume()
|
||||||
|
|
||||||
|
# Wait briefly for task to acknowledge cancellation
|
||||||
|
if task is not None:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(asyncio.shield(task), timeout=2.0)
|
||||||
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info(f"Download skipped for task {download_id} (partial files preserved)")
|
||||||
|
return {"success": True, "message": "Download skipped successfully"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error skipping download: {e}", exc_info=True)
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
finally:
|
||||||
|
# Clean up local in-memory tracking only - NO file deletion
|
||||||
|
self._pause_events.pop(download_id, None)
|
||||||
|
self._download_tasks.pop(download_id, None)
|
||||||
|
if download_id in self._active_downloads:
|
||||||
|
del self._active_downloads[download_id]
|
||||||
|
# Preserve aria2 state store entry so the partial download
|
||||||
|
# info survives restarts and can be resumed later
|
||||||
|
|
||||||
async def pause_download(self, download_id: str) -> Dict:
|
async def pause_download(self, download_id: str) -> Dict:
|
||||||
"""Pause an active download without losing progress."""
|
"""Pause an active download without losing progress."""
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,21 @@ class DownloadedVersionHistoryService:
|
|||||||
def get_database_path(self) -> str:
|
def get_database_path(self) -> str:
|
||||||
return self._db_path
|
return self._db_path
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the persistent SQLite connection, if open.
|
||||||
|
|
||||||
|
This is called before plugin update operations to release the
|
||||||
|
database file lock on Windows, allowing ``shutil.rmtree()`` to
|
||||||
|
succeed when the cache resides inside the plugin directory.
|
||||||
|
"""
|
||||||
|
if self._conn is not None:
|
||||||
|
try:
|
||||||
|
self._conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
def _get_active_library_name(self) -> str | None:
|
def _get_active_library_name(self) -> str | None:
|
||||||
try:
|
try:
|
||||||
value = self._settings.get_active_library_name()
|
value = self._settings.get_active_library_name()
|
||||||
|
|||||||
@@ -689,6 +689,7 @@ class ModelUpdateService:
|
|||||||
*,
|
*,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
target_model_ids: Optional[Sequence[int]] = None,
|
target_model_ids: Optional[Sequence[int]] = None,
|
||||||
|
folder_path: Optional[str] = None,
|
||||||
) -> Dict[int, ModelUpdateRecord]:
|
) -> Dict[int, ModelUpdateRecord]:
|
||||||
"""Refresh update information for every model present in the cache."""
|
"""Refresh update information for every model present in the cache."""
|
||||||
scanner.reset_cancellation()
|
scanner.reset_cancellation()
|
||||||
@@ -703,6 +704,7 @@ class ModelUpdateService:
|
|||||||
local_versions = await self._collect_local_versions(
|
local_versions = await self._collect_local_versions(
|
||||||
scanner,
|
scanner,
|
||||||
target_model_ids=target_filter,
|
target_model_ids=target_filter,
|
||||||
|
folder_path=folder_path,
|
||||||
)
|
)
|
||||||
total_models = len(local_versions)
|
total_models = len(local_versions)
|
||||||
if total_models == 0:
|
if total_models == 0:
|
||||||
@@ -1276,6 +1278,7 @@ class ModelUpdateService:
|
|||||||
scanner,
|
scanner,
|
||||||
*,
|
*,
|
||||||
target_model_ids: Optional[Sequence[int]] = None,
|
target_model_ids: Optional[Sequence[int]] = None,
|
||||||
|
folder_path: Optional[str] = None,
|
||||||
) -> Dict[int, List[int]]:
|
) -> Dict[int, List[int]]:
|
||||||
cache = await scanner.get_cached_data()
|
cache = await scanner.get_cached_data()
|
||||||
mapping: Dict[int, set[int]] = {}
|
mapping: Dict[int, set[int]] = {}
|
||||||
@@ -1288,7 +1291,19 @@ class ModelUpdateService:
|
|||||||
if not target_set:
|
if not target_set:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
normalized_folder = None
|
||||||
|
if folder_path is not None:
|
||||||
|
normalized_folder = folder_path.replace("\\", "/").strip("/")
|
||||||
|
|
||||||
for item in cache.raw_data:
|
for item in cache.raw_data:
|
||||||
|
# Apply folder filter first (cheapest check)
|
||||||
|
if normalized_folder is not None:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
item_folder = (item.get("folder") or "").replace("\\", "/").strip("/")
|
||||||
|
if item_folder != normalized_folder and not item_folder.startswith(normalized_folder + "/"):
|
||||||
|
continue
|
||||||
|
|
||||||
civitai = item.get("civitai") if isinstance(item, dict) else None
|
civitai = item.get("civitai") if isinstance(item, dict) else None
|
||||||
if not isinstance(civitai, dict):
|
if not isinstance(civitai, dict):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ class RecipePersistenceService:
|
|||||||
if metadata.get("source_path"):
|
if metadata.get("source_path"):
|
||||||
recipe_data["source_path"] = metadata.get("source_path")
|
recipe_data["source_path"] = metadata.get("source_path")
|
||||||
|
|
||||||
|
nsfw_level = metadata.get("preview_nsfw_level")
|
||||||
|
if nsfw_level is not None and isinstance(nsfw_level, int):
|
||||||
|
recipe_data["preview_nsfw_level"] = nsfw_level
|
||||||
|
|
||||||
json_filename = f"{recipe_id}.recipe.json"
|
json_filename = f"{recipe_id}.recipe.json"
|
||||||
json_path = os.path.join(recipes_dir, json_filename)
|
json_path = os.path.join(recipes_dir, json_filename)
|
||||||
json_path = os.path.normpath(json_path)
|
json_path = os.path.normpath(json_path)
|
||||||
|
|||||||
@@ -66,6 +66,46 @@ def build_civitai_model_page_url(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_RE_CDN_IMAGE_ID = re.compile(r"/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_civitai_image_id_from_cdn_url(url: str | None) -> str | None:
|
||||||
|
"""Extract the numeric image ID from a Cloudflare CDN image URL.
|
||||||
|
|
||||||
|
CivitAI image CDN URLs follow the pattern::
|
||||||
|
|
||||||
|
https://image.civitai.com/{cf_uuid}/{params}/{image_id}.{ext}
|
||||||
|
|
||||||
|
The image database ID is always the last path segment (minus extension)
|
||||||
|
because ``getEdgeUrl(…, name=id.toString())`` embeds it explicitly
|
||||||
|
in the model-versions REST API response.
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
match = _RE_CDN_IMAGE_ID.search(url)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_civitai_image_page_url(
|
||||||
|
image_id: str | int | None,
|
||||||
|
*,
|
||||||
|
host: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Build a Civitai image page URL.
|
||||||
|
|
||||||
|
Returns something like ``https://civitai.com/images/12345``.
|
||||||
|
The host is resolved through :func:`normalize_civitai_page_host` and
|
||||||
|
therefore respects the user's ``civitai_host`` setting.
|
||||||
|
"""
|
||||||
|
if not image_id:
|
||||||
|
return None
|
||||||
|
normalized_host = normalize_civitai_page_host(host)
|
||||||
|
normalized_id = str(image_id).strip()
|
||||||
|
if not normalized_id:
|
||||||
|
return None
|
||||||
|
return urlunparse(("https", normalized_host, f"/images/{normalized_id}", "", "", ""))
|
||||||
|
|
||||||
|
|
||||||
def _parse_supported_civitai_page_url(url: str | None):
|
def _parse_supported_civitai_page_url(url: str | None):
|
||||||
if not url:
|
if not url:
|
||||||
return None
|
return None
|
||||||
@@ -328,8 +368,10 @@ def rewrite_preview_url(
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"build_civitai_image_page_url",
|
||||||
"build_license_flags",
|
"build_license_flags",
|
||||||
"extract_civitai_image_id",
|
"extract_civitai_image_id",
|
||||||
|
"extract_civitai_image_id_from_cdn_url",
|
||||||
"extract_civitai_page_host",
|
"extract_civitai_page_host",
|
||||||
"extract_civitai_model_url_parts",
|
"extract_civitai_model_url_parts",
|
||||||
"is_supported_civitai_page_host",
|
"is_supported_civitai_page_host",
|
||||||
|
|||||||
@@ -101,8 +101,34 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
|
|||||||
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||||
[
|
[
|
||||||
"Anima",
|
"Anima",
|
||||||
"ZImageTurbo",
|
# Flux series — DiT architecture, loaded via UNETLoader in ComfyUI
|
||||||
"ZImageBase",
|
"Flux.1 D",
|
||||||
|
"Flux.1 S",
|
||||||
|
"Flux.1 Krea",
|
||||||
|
"Flux.1 Kontext",
|
||||||
|
"Flux.2 D",
|
||||||
|
"Flux.2 Klein 9B",
|
||||||
|
"Flux.2 Klein 9B-base",
|
||||||
|
"Flux.2 Klein 4B",
|
||||||
|
"Flux.2 Klein 4B-base",
|
||||||
|
# Non-UNet / DiT image diffusion models
|
||||||
|
"AuraFlow",
|
||||||
|
"Chroma",
|
||||||
|
"HiDream",
|
||||||
|
"Hunyuan 1",
|
||||||
|
"Kolors",
|
||||||
|
"Lumina",
|
||||||
|
"PixArt a",
|
||||||
|
"PixArt E",
|
||||||
|
# Video diffusion models
|
||||||
|
"CogVideoX",
|
||||||
|
"Hunyuan Video",
|
||||||
|
"LTXV",
|
||||||
|
"LTXV2",
|
||||||
|
"LTXV 2.3",
|
||||||
|
"Mochi",
|
||||||
|
"SVD",
|
||||||
|
"Wan Video",
|
||||||
"Wan Video 1.3B t2v",
|
"Wan Video 1.3B t2v",
|
||||||
"Wan Video 14B t2v",
|
"Wan Video 14B t2v",
|
||||||
"Wan Video 14B i2v 480p",
|
"Wan Video 14B i2v 480p",
|
||||||
@@ -112,9 +138,13 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
|||||||
"Wan Video 2.2 T2V-A14B",
|
"Wan Video 2.2 T2V-A14B",
|
||||||
"Wan Video 2.5 T2V",
|
"Wan Video 2.5 T2V",
|
||||||
"Wan Video 2.5 I2V",
|
"Wan Video 2.5 I2V",
|
||||||
"CogVideoX",
|
# Other diffusion models
|
||||||
"Mochi",
|
"Ernie",
|
||||||
|
"Ernie Turbo",
|
||||||
|
"Nucleus",
|
||||||
"Qwen",
|
"Qwen",
|
||||||
|
"ZImageBase",
|
||||||
|
"ZImageTurbo",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import struct
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from .constants import (
|
from .constants import (
|
||||||
CARD_PREVIEW_WIDTH,
|
CARD_PREVIEW_WIDTH,
|
||||||
@@ -31,7 +34,7 @@ def _get_hash_chunk_size_bytes() -> int:
|
|||||||
|
|
||||||
|
|
||||||
async def calculate_sha256(file_path: str) -> str:
|
async def calculate_sha256(file_path: str) -> str:
|
||||||
"""Calculate SHA256 hash of a file"""
|
"""Calculate SHA256 hash of a file (full file content)."""
|
||||||
sha256_hash = hashlib.sha256()
|
sha256_hash = hashlib.sha256()
|
||||||
chunk_size = _get_hash_chunk_size_bytes()
|
chunk_size = _get_hash_chunk_size_bytes()
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
@@ -39,6 +42,79 @@ async def calculate_sha256(file_path: str) -> str:
|
|||||||
sha256_hash.update(byte_block)
|
sha256_hash.update(byte_block)
|
||||||
return sha256_hash.hexdigest()
|
return sha256_hash.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_autov2(file_path: str) -> str:
|
||||||
|
"""Calculate CivitAI AutoV2 hash.
|
||||||
|
|
||||||
|
AutoV2 is the first 10 characters of the full file SHA256.
|
||||||
|
Used by CivitAI as a shortened file identifier.
|
||||||
|
|
||||||
|
Reference: https://developer.civitai.com/site/reference/model-versions
|
||||||
|
"""
|
||||||
|
full_hash = hashlib.sha256()
|
||||||
|
chunk_size = _get_hash_chunk_size_bytes()
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
for byte_block in iter(lambda: f.read(chunk_size), b""):
|
||||||
|
full_hash.update(byte_block)
|
||||||
|
return full_hash.hexdigest()[:10]
|
||||||
|
|
||||||
|
|
||||||
|
def read_safetensors_metadata(file_path: str) -> dict[str, Any]:
|
||||||
|
"""Read the ``__metadata__`` dict from a safetensors file header.
|
||||||
|
|
||||||
|
Safetensors file format:
|
||||||
|
- 8 bytes: header length (little-endian 64-bit)
|
||||||
|
- N bytes: UTF-8 JSON header
|
||||||
|
- The header JSON contains a ``__metadata__`` key holding arbitrary metadata.
|
||||||
|
|
||||||
|
Returns an empty dict if the file is not a valid safetensors file or has no
|
||||||
|
metadata.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
header_len_bytes = f.read(8)
|
||||||
|
if len(header_len_bytes) < 8:
|
||||||
|
return {}
|
||||||
|
header_len = struct.unpack("<Q", header_len_bytes)[0]
|
||||||
|
header_bytes = f.read(header_len)
|
||||||
|
if len(header_bytes) < header_len:
|
||||||
|
return {}
|
||||||
|
header = json.loads(header_bytes.decode("utf-8"))
|
||||||
|
return header.get("__metadata__", {})
|
||||||
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError, struct.error):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_autov3(file_path: str) -> str | None:
|
||||||
|
"""Calculate CivitAI AutoV3 hash from a safetensors file.
|
||||||
|
|
||||||
|
AutoV3 is extracted from the safetensors file's embedded metadata, not
|
||||||
|
computed from the file bytes directly. The orchestrator reads the
|
||||||
|
``sshs_model_hash`` (kohya-ss format) or ``modelspec.hash_sha256`` field
|
||||||
|
from the safetensors header and stores the first 12 characters.
|
||||||
|
|
||||||
|
The embedded hash itself is the SHA256 of the file after skipping the
|
||||||
|
8-byte header length + JSON header (a.k.a. the addnet hash / tensor-only
|
||||||
|
hash).
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
- CivitAI DB trigger: ``SUBSTRING(NEW.hash FROM 1 FOR 12)``
|
||||||
|
- https://developer.civitai.com/site/reference/model-versions
|
||||||
|
|
||||||
|
Returns ``None`` when no AutoV3 hash can be determined (e.g. the file is
|
||||||
|
not safetensors, or the metadata doesn't contain a recognised hash field).
|
||||||
|
"""
|
||||||
|
metadata = read_safetensors_metadata(file_path)
|
||||||
|
if not metadata:
|
||||||
|
return None
|
||||||
|
|
||||||
|
embedded_hash = metadata.get("sshs_model_hash") or metadata.get("modelspec.hash_sha256")
|
||||||
|
if embedded_hash and isinstance(embedded_hash, str) and len(embedded_hash) >= 12:
|
||||||
|
return embedded_hash[:12]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def find_preview_file(base_name: str, dir_path: str) -> str:
|
def find_preview_file(base_name: str, dir_path: str) -> str:
|
||||||
"""Find preview file for given base name in directory.
|
"""Find preview file for given base name in directory.
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,27 @@ def _build_log_file_path(settings_file: str | None, started_at: datetime) -> str
|
|||||||
return os.path.join(log_dir, f"standalone-session-{timestamp}.log")
|
return os.path.join(log_dir, f"standalone-session-{timestamp}.log")
|
||||||
|
|
||||||
|
|
||||||
|
_KEEP_LOG_COUNT = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_old_logs(log_dir: str) -> None:
|
||||||
|
"""Remove older session log files, keeping only the ``_KEEP_LOG_COUNT`` newest."""
|
||||||
|
try:
|
||||||
|
files = [
|
||||||
|
os.path.join(log_dir, name)
|
||||||
|
for name in os.listdir(log_dir)
|
||||||
|
if name.startswith("standalone-session-") and name.endswith(".log")
|
||||||
|
]
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
files.sort(key=os.path.getmtime, reverse=True)
|
||||||
|
for path in files[_KEEP_LOG_COUNT:]:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState:
|
def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState:
|
||||||
global _session_state
|
global _session_state
|
||||||
|
|
||||||
@@ -90,6 +111,7 @@ def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSes
|
|||||||
file_handler.set_name(_FILE_HANDLER_NAME)
|
file_handler.set_name(_FILE_HANDLER_NAME)
|
||||||
file_handler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
root_logger.addHandler(file_handler)
|
root_logger.addHandler(file_handler)
|
||||||
|
_prune_old_logs(os.path.dirname(log_file_path))
|
||||||
|
|
||||||
_session_state = StandaloneSessionLogState(
|
_session_state = StandaloneSessionLogState(
|
||||||
started_at=started_at,
|
started_at=started_at,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.0.10"
|
version = "1.0.11"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
404
scripts/restore_suffixed_filenames.py
Normal file
404
scripts/restore_suffixed_filenames.py
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Restore original filenames by removing leftover 4-char hash suffixes.
|
||||||
|
|
||||||
|
When LoRA Manager's old duplicate filename resolver ran, it appended
|
||||||
|
``-{first4ofSHA256}`` to duplicate filenames, e.g.::
|
||||||
|
|
||||||
|
my_lora.safetensors → my_lora-a3f7.safetensors
|
||||||
|
|
||||||
|
With full-path LoRA syntax now available (``<lora:subfolder/name:1.0>``),
|
||||||
|
these suffixes are unnecessary. This script detects such files and, with
|
||||||
|
your confirmation, restores their original names.
|
||||||
|
|
||||||
|
The same suffix pattern is also used by the download conflict handler
|
||||||
|
(``{name}-{hash}.{ext}``). To avoid false positives, this script skips
|
||||||
|
any file whose original name already exists in the same directory — those
|
||||||
|
were likely added by a download conflict, not the old resolver.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
# Detect only (dry-run, default)
|
||||||
|
python scripts/restore_suffixed_filenames.py
|
||||||
|
|
||||||
|
# Detect + restore (with confirmation prompt)
|
||||||
|
python scripts/restore_suffixed_filenames.py --apply
|
||||||
|
|
||||||
|
After restoring filenames, run **Rebuild Cache** in the LoRA Manager
|
||||||
|
Doctor panel to refresh the model cache.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
APP_NAME = "ComfyUI-LoRA-Manager"
|
||||||
|
MODEL_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin"}
|
||||||
|
PREVIEW_EXTENSIONS = {
|
||||||
|
".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp",
|
||||||
|
".mp4", ".webm", ".mov",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Matches filenames like "my_lora-a3f7.safetensors"
|
||||||
|
# Groups: (base_name, 4-char-hex, extension)
|
||||||
|
_SUFFIX_RE = re.compile(r"^(.+)-([0-9a-f]{4})(\.[^.]+)$")
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers (copied from migrate_legacy_metadata.py for consistency) ──────────
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_settings_path() -> Path:
|
||||||
|
repo_root = Path(__file__).parent.parent.resolve()
|
||||||
|
portable = repo_root / "settings.json"
|
||||||
|
if portable.exists():
|
||||||
|
payload = _load_json(portable)
|
||||||
|
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
|
||||||
|
return portable
|
||||||
|
|
||||||
|
config_home = os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
if config_home:
|
||||||
|
return Path(config_home).expanduser() / APP_NAME / "settings.json"
|
||||||
|
return Path.home() / ".config" / APP_NAME / "settings.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_path(value: str) -> str:
|
||||||
|
return str(Path(value).expanduser().resolve(strict=False))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_path_list(value: Any) -> list[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [_expand_path(value)] if value else []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_expand_path(item) for item in value if isinstance(item, str) and item]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe(values: list[str]) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
if value not in seen:
|
||||||
|
result.append(value)
|
||||||
|
seen.add(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_roots(settings: dict[str, Any]) -> dict[str, list[str]]:
|
||||||
|
"""Extract model folder roots from LoRA Manager settings.
|
||||||
|
|
||||||
|
Returns ``{model_type: [path, ...]}`` where *model_type* is one of
|
||||||
|
``loras``, ``checkpoints``, ``embeddings``, ``unet``, etc.
|
||||||
|
|
||||||
|
Both primary (``folder_paths``) and extra (``extra_folder_paths``)
|
||||||
|
paths are included. Extra paths can be configured via the UI at
|
||||||
|
Settings → Model Libraries → Extra Folder Paths.
|
||||||
|
"""
|
||||||
|
roots: dict[str, list[str]] = {}
|
||||||
|
active_library = settings.get("active_library") or "default"
|
||||||
|
sources = [settings]
|
||||||
|
library = settings.get("libraries", {}).get(active_library)
|
||||||
|
if isinstance(library, dict):
|
||||||
|
sources.insert(0, library)
|
||||||
|
for source in sources:
|
||||||
|
# Primary folder paths.
|
||||||
|
folder_paths = source.get("folder_paths")
|
||||||
|
if isinstance(folder_paths, dict):
|
||||||
|
for key, value in folder_paths.items():
|
||||||
|
roots.setdefault(key, []).extend(_normalize_path_list(value))
|
||||||
|
# Extra folder paths (Settings → Model Libraries → Extra Folder Paths).
|
||||||
|
extra_folder_paths = source.get("extra_folder_paths")
|
||||||
|
if isinstance(extra_folder_paths, dict):
|
||||||
|
for key, value in extra_folder_paths.items():
|
||||||
|
roots.setdefault(key, []).extend(_normalize_path_list(value))
|
||||||
|
for default_key, folder_key in (
|
||||||
|
("default_lora_root", "loras"),
|
||||||
|
("default_checkpoint_root", "checkpoints"),
|
||||||
|
("default_unet_root", "unet"),
|
||||||
|
("default_embedding_root", "embeddings"),
|
||||||
|
):
|
||||||
|
value = settings.get(default_key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
roots.setdefault(folder_key, []).append(_expand_path(value))
|
||||||
|
return {key: _dedupe(values) for key, values in roots.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def find_model_files(directory: Path) -> list[Path]:
|
||||||
|
"""Recursively find all model files in *directory*."""
|
||||||
|
files: list[Path] = []
|
||||||
|
for ext in MODEL_EXTENSIONS:
|
||||||
|
files.extend(directory.rglob(f"*{ext}"))
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
# ── core detection logic ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def check_file(path: Path) -> tuple[str, str, str] | None:
|
||||||
|
"""If *path* matches the suffix pattern, return ``(base_name, hex, ext)``.
|
||||||
|
|
||||||
|
Returns ``None`` when:
|
||||||
|
* The filename does not match the pattern, or
|
||||||
|
* The original name (without the suffix) already exists in the same
|
||||||
|
directory (likely a download-conflict rename, not a doctor rename).
|
||||||
|
"""
|
||||||
|
match = _SUFFIX_RE.match(path.name)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
base_name = match.group(1)
|
||||||
|
hex_part = match.group(2)
|
||||||
|
extension = match.group(3)
|
||||||
|
orig_name = base_name + extension
|
||||||
|
orig_path = path.with_name(orig_name)
|
||||||
|
|
||||||
|
# Safety: skip if the original name already exists.
|
||||||
|
if orig_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
return base_name, hex_part, extension
|
||||||
|
|
||||||
|
|
||||||
|
def scan_roots(
|
||||||
|
roots: dict[str, list[str]],
|
||||||
|
) -> dict[str, list[tuple[Path, str, str, str]]]:
|
||||||
|
"""Scan all model roots and return detected files grouped by model type.
|
||||||
|
|
||||||
|
Returns ``{model_type: [(full_path, base_name, hex, ext), ...]}``.
|
||||||
|
"""
|
||||||
|
results: dict[str, list[tuple[Path, str, str, str]]] = {}
|
||||||
|
|
||||||
|
for model_type, root_list in roots.items():
|
||||||
|
type_results: list[tuple[Path, str, str, str]] = []
|
||||||
|
for root in root_list:
|
||||||
|
root_path = Path(root)
|
||||||
|
if not root_path.is_dir():
|
||||||
|
continue
|
||||||
|
for model_file in find_model_files(root_path):
|
||||||
|
match = check_file(model_file)
|
||||||
|
if match:
|
||||||
|
type_results.append((model_file, *match))
|
||||||
|
if type_results:
|
||||||
|
results[model_type] = type_results
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def rename_file(
|
||||||
|
path: Path, base_name: str, extension: str, dry_run: bool
|
||||||
|
) -> bool:
|
||||||
|
"""Rename *path* to ``{base_name}{extension}``.
|
||||||
|
|
||||||
|
Also renames sidecar files (``.metadata.json``, ``.civitai.info``) and
|
||||||
|
preview images. Returns ``True`` on success.
|
||||||
|
"""
|
||||||
|
new_path = path.with_name(base_name + extension)
|
||||||
|
old_stem = path.with_suffix("") # /dir/base_name-hex (no ext)
|
||||||
|
new_stem = new_path.with_suffix("") # /dir/base_name (no ext)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logger.info(" would rename: %s", path.name)
|
||||||
|
logger.info(" -> %s", new_path.name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.rename(path, new_path)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(" FAILED to rename %s: %s", path.name, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Rename sidecar metadata files.
|
||||||
|
for suffix in (".metadata.json", ".civitai.info"):
|
||||||
|
old_sidecar = old_stem.with_name(old_stem.name + suffix)
|
||||||
|
new_sidecar = new_stem.with_name(new_stem.name + suffix)
|
||||||
|
if old_sidecar.exists():
|
||||||
|
try:
|
||||||
|
os.rename(old_sidecar, new_sidecar)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(" could not rename sidecar %s: %s", old_sidecar.name, exc)
|
||||||
|
|
||||||
|
# Rename preview images.
|
||||||
|
for preview_ext in PREVIEW_EXTENSIONS:
|
||||||
|
old_preview = old_stem.with_name(old_stem.name + preview_ext)
|
||||||
|
new_preview = new_stem.with_name(new_stem.name + preview_ext)
|
||||||
|
if old_preview.exists():
|
||||||
|
try:
|
||||||
|
os.rename(old_preview, new_preview)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(" could not rename preview %s: %s", old_preview.name, exc)
|
||||||
|
|
||||||
|
logger.info(" renamed: %s -> %s", path.name, new_path.name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ── report helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def print_report(results: dict[str, list[tuple[Path, str, str, str]]]) -> int:
|
||||||
|
"""Print a human-readable report of detected files. Returns total count."""
|
||||||
|
if not results:
|
||||||
|
logger.info("No leftover suffixed filenames detected.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for model_type in sorted(results):
|
||||||
|
entries = results[model_type]
|
||||||
|
total += len(entries)
|
||||||
|
label = model_type.capitalize()
|
||||||
|
logger.info("")
|
||||||
|
logger.info("─" * 50)
|
||||||
|
logger.info(" %s (%d file(s))", label, len(entries))
|
||||||
|
logger.info("─" * 50)
|
||||||
|
for path, base_name, hex_part, ext in sorted(entries):
|
||||||
|
logger.info(" %s → %s%s", path.name, base_name, ext)
|
||||||
|
|
||||||
|
logger.info("")
|
||||||
|
logger.info("=" * 50)
|
||||||
|
logger.info(" Total: %d file(s) with leftover suffixes.", total)
|
||||||
|
logger.info("=" * 50)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_user(count: int) -> bool:
|
||||||
|
"""Ask the user whether to proceed with the rename."""
|
||||||
|
try:
|
||||||
|
answer = input(
|
||||||
|
f"\nRestore {count} file(s) to their original names? [y/N] "
|
||||||
|
).strip().lower()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print()
|
||||||
|
return False
|
||||||
|
return answer in ("y", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
# ── main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Detect and restore model filenames that have leftover "
|
||||||
|
"4-character hash suffixes from the old conflict resolver."
|
||||||
|
),
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=(
|
||||||
|
"Examples:\n"
|
||||||
|
" python scripts/restore_suffixed_filenames.py\n"
|
||||||
|
" python scripts/restore_suffixed_filenames.py --apply\n"
|
||||||
|
" python scripts/restore_suffixed_filenames.py --apply --yes\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--apply",
|
||||||
|
action="store_true",
|
||||||
|
help="Actually rename files (with confirmation prompt unless --yes is given)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--yes", "-y",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip confirmation prompt (implies --apply)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Detect only — show what would be renamed without making changes",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v", "--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable debug-level logging",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.verbose:
|
||||||
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# Resolve settings.
|
||||||
|
settings_path = resolve_settings_path()
|
||||||
|
logger.info("Settings: %s", settings_path)
|
||||||
|
settings = _load_json(settings_path)
|
||||||
|
if not settings:
|
||||||
|
logger.error("Could not load settings.json. Is LoRA Manager configured?")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
roots = get_model_roots(settings)
|
||||||
|
if not roots:
|
||||||
|
logger.error("No model folders found in settings.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Log which roots are being scanned.
|
||||||
|
for model_type, root_list in roots.items():
|
||||||
|
for root in root_list:
|
||||||
|
logger.info("Scanning %s: %s", model_type, root)
|
||||||
|
|
||||||
|
# Detect.
|
||||||
|
results = scan_roots(roots)
|
||||||
|
total = print_report(results)
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Determine mode.
|
||||||
|
dry_run = not args.apply and not args.yes
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logger.info("\n[Dry-run mode — no files modified]")
|
||||||
|
logger.info("Run with --apply to restore filenames.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Confirm unless --yes.
|
||||||
|
if not args.yes:
|
||||||
|
if not prompt_user(total):
|
||||||
|
logger.info("Aborted.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Rename.
|
||||||
|
logger.info("")
|
||||||
|
success = 0
|
||||||
|
fail = 0
|
||||||
|
for model_type in sorted(results):
|
||||||
|
entries = results[model_type]
|
||||||
|
logger.info("")
|
||||||
|
logger.info("─" * 50)
|
||||||
|
logger.info(" Restoring %s (%d file(s))", model_type, len(entries))
|
||||||
|
logger.info("─" * 50)
|
||||||
|
for path, base_name, hex_part, ext in sorted(entries):
|
||||||
|
ok = rename_file(path, base_name, ext, dry_run=False)
|
||||||
|
if ok:
|
||||||
|
success += 1
|
||||||
|
else:
|
||||||
|
fail += 1
|
||||||
|
|
||||||
|
logger.info("")
|
||||||
|
logger.info("=" * 50)
|
||||||
|
logger.info(" Done: %d restored, %d failed.", success, fail)
|
||||||
|
logger.info("=" * 50)
|
||||||
|
logger.info("")
|
||||||
|
logger.info(" ⚠ Please run Rebuild Cache in the LoRA Manager")
|
||||||
|
logger.info(" Doctor panel to refresh the model cache.")
|
||||||
|
|
||||||
|
return 0 if fail == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -141,8 +141,9 @@
|
|||||||
border-color: var(--lora-error);
|
border-color: var(--lora-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Disabled state for delete button */
|
/* Disabled state for delete and create-recipe buttons */
|
||||||
.media-control-btn.example-delete-btn.disabled {
|
.media-control-btn.example-delete-btn.disabled,
|
||||||
|
.media-control-btn.create-recipe-btn.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -503,3 +503,169 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* File Count Badge on Version Items */
|
||||||
|
.file-select-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: oklch(var(--lora-accent) / 0.18);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid oklch(var(--lora-accent) / 0.35);
|
||||||
|
user-select: none;
|
||||||
|
box-shadow: 0 1px 2px oklch(var(--lora-accent) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-select-badge:hover {
|
||||||
|
background: oklch(var(--lora-accent) / 0.3);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 2px 6px oklch(var(--lora-accent) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-select-badge:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-select-badge i {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-select-badge .badge-arrow {
|
||||||
|
margin-left: 2px;
|
||||||
|
font-size: 0.65em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File Selection Step */
|
||||||
|
.file-selection-header {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selection-header h3 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selection-version-name {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selection-list {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option.selected {
|
||||||
|
border: 2px solid var(--lora-accent);
|
||||||
|
background: oklch(var(--lora-accent) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-radio {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-radio input[type="radio"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--lora-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tag.format {
|
||||||
|
background: oklch(var(--lora-accent) / 0.1);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tag.fp {
|
||||||
|
background: oklch(0.6 0.15 250 / 0.1);
|
||||||
|
color: oklch(0.55 0.15 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tag.size {
|
||||||
|
background: oklch(0.55 0.1 160 / 0.1);
|
||||||
|
color: oklch(0.5 0.12 160);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-name {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-size {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme adjustments */
|
||||||
|
[data-theme="dark"] .file-option {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .file-tag.fp {
|
||||||
|
background: oklch(0.55 0.12 250 / 0.15);
|
||||||
|
color: oklch(0.7 0.12 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .file-tag.size {
|
||||||
|
background: oklch(0.5 0.08 160 / 0.15);
|
||||||
|
color: oklch(0.65 0.08 160);
|
||||||
|
}
|
||||||
@@ -745,3 +745,8 @@
|
|||||||
.sidebar-tree-container {
|
.sidebar-tree-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Folder context menu - positioned relative to sidebar */
|
||||||
|
#sidebarFolderContextMenu {
|
||||||
|
z-index: var(--z-modal, 1002);
|
||||||
|
}
|
||||||
|
|||||||
@@ -766,6 +766,49 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async refreshUpdatesForFolder(folderPath, { force = false } = {}) {
|
||||||
|
if (!folderPath) {
|
||||||
|
throw new Error('No folder path provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.loadingManager.show('Checking for updates...', 0);
|
||||||
|
state.loadingManager.showCancelButton(() => this.cancelTask());
|
||||||
|
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
folder_path: folderPath,
|
||||||
|
force
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to parse refresh updates response as JSON', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || payload?.success !== true) {
|
||||||
|
if (payload?.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const message = payload?.error || response.statusText || 'Failed to refresh updates';
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing updates for folder:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
state.loadingManager.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fetchCivitaiVersions(modelId, source = null) {
|
async fetchCivitaiVersions(modelId, source = null) {
|
||||||
try {
|
try {
|
||||||
let requestUrl = `${this.apiConfig.endpoints.civitaiVersions}/${modelId}`;
|
let requestUrl = `${this.apiConfig.endpoints.civitaiVersions}/${modelId}`;
|
||||||
@@ -909,7 +952,7 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId, source = null) {
|
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId, source = null, fileParams = null) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
|
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -921,7 +964,8 @@ export class BaseModelApiClient {
|
|||||||
relative_path: relativePath,
|
relative_path: relativePath,
|
||||||
use_default_paths: useDefaultPaths,
|
use_default_paths: useDefaultPaths,
|
||||||
download_id: downloadId,
|
download_id: downloadId,
|
||||||
...(source ? { source } : {})
|
...(source ? { source } : {}),
|
||||||
|
...(fileParams ? { file_params: fileParams } : {})
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -197,8 +197,8 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
|||||||
// Reset page counter
|
// Reset page counter
|
||||||
pageState.currentPage = 1;
|
pageState.currentPage = 1;
|
||||||
|
|
||||||
// Fetch the first page
|
const pageSize = state.virtualScroller?.pageSize || pageState.pageSize || 100;
|
||||||
const result = await fetchPageFunction(1, pageState.pageSize || 50);
|
const result = await fetchPageFunction(1, pageSize);
|
||||||
|
|
||||||
// Update the virtual scroller
|
// Update the virtual scroller
|
||||||
state.virtualScroller.refreshWithData(
|
state.virtualScroller.refreshWithData(
|
||||||
@@ -251,8 +251,8 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
|||||||
pageState.currentPage = 1;
|
pageState.currentPage = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the first page of data
|
const pageSize = state.virtualScroller?.pageSize || pageState.pageSize || 100;
|
||||||
const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50);
|
const result = await fetchPageFunction(pageState.currentPage, pageSize);
|
||||||
|
|
||||||
// Update virtual scroller with the new data
|
// Update virtual scroller with the new data
|
||||||
state.virtualScroller.refreshWithData(
|
state.virtualScroller.refreshWithData(
|
||||||
@@ -294,47 +294,41 @@ export async function resetAndReload(updateFolders = false, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync changes - quick refresh without rebuilding cache (similar to models page)
|
* Refreshes the recipe list by triggering a backend scan, then reloading.
|
||||||
|
* @param {boolean} fullRebuild - If true, fully rebuild the cache; if false, incremental scan
|
||||||
*/
|
*/
|
||||||
export async function syncChanges() {
|
export async function syncChanges() {
|
||||||
try {
|
return refreshRecipes(false);
|
||||||
state.loadingManager.showSimpleLoading('Syncing changes...');
|
|
||||||
|
|
||||||
// Simply reload the recipes without rebuilding cache
|
|
||||||
await resetAndReload(false, { preserveScroll: true });
|
|
||||||
|
|
||||||
showToast('toast.recipes.syncComplete', {}, 'success');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error syncing recipes:', error);
|
|
||||||
showToast('toast.recipes.syncFailed', { message: error.message }, 'error');
|
|
||||||
} finally {
|
|
||||||
state.loadingManager.hide();
|
|
||||||
state.loadingManager.restoreProgressBar();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function refreshRecipes(fullRebuild = true) {
|
||||||
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
const actionLabel = fullRebuild ? 'Rebuilding recipe cache' : 'Refreshing recipes';
|
||||||
*/
|
const actionToast = fullRebuild ? 'Full rebuild' : 'Refresh';
|
||||||
export async function refreshRecipes() {
|
|
||||||
try {
|
|
||||||
state.loadingManager.showSimpleLoading('Refreshing recipes...');
|
|
||||||
|
|
||||||
// Call the API endpoint to rebuild the recipe cache
|
try {
|
||||||
const response = await fetch(RECIPE_ENDPOINTS.scan);
|
state.loadingManager.show(`${actionLabel}...`, 0);
|
||||||
|
|
||||||
|
const url = new URL(RECIPE_ENDPOINTS.scan, window.location.origin);
|
||||||
|
url.searchParams.append('full_rebuild', fullRebuild);
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
throw new Error(`Failed to refresh recipe cache: ${response.status} ${response.statusText}`);
|
||||||
throw new Error(data.error || 'Failed to refresh recipe cache');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// After successful cache rebuild, reload the recipes
|
const data = await response.json();
|
||||||
await resetAndReload(false, { preserveScroll: true });
|
if (data.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showToast('toast.recipes.refreshComplete', {}, 'success');
|
await resetAndReload(false);
|
||||||
|
|
||||||
|
showToast('toast.api.refreshComplete', { action: actionToast }, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error refreshing recipes:', error);
|
console.error('Error refreshing recipes:', error);
|
||||||
showToast('toast.recipes.refreshFailed', { message: error.message }, 'error');
|
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: 'recipe' }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
state.loadingManager.restoreProgressBar();
|
state.loadingManager.restoreProgressBar();
|
||||||
|
|||||||
@@ -306,8 +306,14 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.repaired > 0) {
|
if (result.repaired > 0) {
|
||||||
showToast('recipes.contextMenu.repair.success', {}, 'success');
|
showToast('recipes.contextMenu.repair.success', {}, 'success');
|
||||||
// Refresh the current card or reload
|
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||||
this.resetAndReload();
|
if (detailResponse.ok) {
|
||||||
|
const updatedRecipe = await detailResponse.json();
|
||||||
|
const filePath = this.currentCard?.dataset?.filepath;
|
||||||
|
if (filePath && state.virtualScroller) {
|
||||||
|
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showToast('recipes.contextMenu.repair.skipped', {}, 'info');
|
showToast('recipes.contextMenu.repair.skipped', {}, 'info');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class RecipeCard {
|
|||||||
card.dataset.created = this.recipe.created_date;
|
card.dataset.created = this.recipe.created_date;
|
||||||
card.dataset.id = this.recipe.id || '';
|
card.dataset.id = this.recipe.id || '';
|
||||||
card.dataset.folder = this.recipe.folder || '';
|
card.dataset.folder = this.recipe.folder || '';
|
||||||
|
card.dataset.favorite = this.recipe.favorite ? 'true' : 'false';
|
||||||
|
|
||||||
// Get base model with fallback
|
// Get base model with fallback
|
||||||
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
|
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
|
||||||
@@ -161,6 +162,7 @@ class RecipeCard {
|
|||||||
|
|
||||||
// Update early to provide instant feedback and avoid race conditions with re-renders
|
// Update early to provide instant feedback and avoid race conditions with re-renders
|
||||||
this.recipe.favorite = newFavoriteState;
|
this.recipe.favorite = newFavoriteState;
|
||||||
|
card.dataset.favorite = newFavoriteState ? 'true' : 'false';
|
||||||
|
|
||||||
// Function to update icon state
|
// Function to update icon state
|
||||||
const updateIconUI = (icon, state) => {
|
const updateIconUI = (icon, state) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { translate } from '../utils/i18nHelpers.js';
|
|||||||
import { state } from '../state/index.js';
|
import { state } 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 { escapeHtml, escapeAttribute } from './shared/utils.js';
|
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
||||||
|
|
||||||
export class SidebarManager {
|
export class SidebarManager {
|
||||||
@@ -41,6 +42,7 @@ export class SidebarManager {
|
|||||||
|
|
||||||
// Bind methods
|
// Bind methods
|
||||||
this.handleTreeClick = this.handleTreeClick.bind(this);
|
this.handleTreeClick = this.handleTreeClick.bind(this);
|
||||||
|
this.handleTreeContextMenu = this.handleTreeContextMenu.bind(this);
|
||||||
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
|
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
|
||||||
this.handleDocumentClick = this.handleDocumentClick.bind(this);
|
this.handleDocumentClick = this.handleDocumentClick.bind(this);
|
||||||
this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this);
|
this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this);
|
||||||
@@ -185,6 +187,8 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
if (folderTree) {
|
if (folderTree) {
|
||||||
folderTree.removeEventListener('click', this.handleTreeClick);
|
folderTree.removeEventListener('click', this.handleTreeClick);
|
||||||
|
folderTree.removeEventListener('contextmenu', this.handleTreeContextMenu);
|
||||||
|
folderTree.removeEventListener('dragover', this.handleFolderDragOver);
|
||||||
}
|
}
|
||||||
if (sidebarBreadcrumbNav) {
|
if (sidebarBreadcrumbNav) {
|
||||||
sidebarBreadcrumbNav.removeEventListener('click', this.handleBreadcrumbClick);
|
sidebarBreadcrumbNav.removeEventListener('click', this.handleBreadcrumbClick);
|
||||||
@@ -977,6 +981,7 @@ export class SidebarManager {
|
|||||||
const folderTree = document.getElementById('sidebarFolderTree');
|
const folderTree = document.getElementById('sidebarFolderTree');
|
||||||
if (folderTree) {
|
if (folderTree) {
|
||||||
folderTree.addEventListener('click', this.handleTreeClick);
|
folderTree.addEventListener('click', this.handleTreeClick);
|
||||||
|
folderTree.addEventListener('contextmenu', this.handleTreeContextMenu);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Breadcrumb click handler
|
// Breadcrumb click handler
|
||||||
@@ -1027,6 +1032,19 @@ export class SidebarManager {
|
|||||||
if (displayModeToggleBtn) {
|
if (displayModeToggleBtn) {
|
||||||
displayModeToggleBtn.addEventListener('click', this.handleDisplayModeToggle);
|
displayModeToggleBtn.addEventListener('click', this.handleDisplayModeToggle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidebar folder context menu click handler
|
||||||
|
const sidebarFolderMenu = document.getElementById('sidebarFolderContextMenu');
|
||||||
|
if (sidebarFolderMenu) {
|
||||||
|
sidebarFolderMenu.addEventListener('click', (e) => {
|
||||||
|
const item = e.target.closest('.context-menu-item');
|
||||||
|
if (!item) return;
|
||||||
|
const action = item.dataset.action;
|
||||||
|
if (action) {
|
||||||
|
this.handleFolderContextMenuAction(action);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDocumentClick(event) {
|
handleDocumentClick(event) {
|
||||||
@@ -1398,6 +1416,82 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleTreeContextMenu(event) {
|
||||||
|
const nodeContent = event.target.closest('.sidebar-tree-node, .sidebar-folder-item');
|
||||||
|
if (!nodeContent) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const path = nodeContent.dataset.path;
|
||||||
|
if (path === undefined || path === null || path === '') return;
|
||||||
|
|
||||||
|
this._showFolderContextMenu(event.clientX, event.clientY, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
_showFolderContextMenu(x, y, path) {
|
||||||
|
this._closeFolderContextMenu();
|
||||||
|
|
||||||
|
const menu = document.getElementById('sidebarFolderContextMenu');
|
||||||
|
if (!menu) return;
|
||||||
|
|
||||||
|
menu.style.left = `${x}px`;
|
||||||
|
menu.style.top = `${y}px`;
|
||||||
|
menu.style.display = 'block';
|
||||||
|
menu.dataset.folderPath = path;
|
||||||
|
|
||||||
|
this._folderContextOpen = true;
|
||||||
|
|
||||||
|
// Close on next click outside
|
||||||
|
this._folderContextCloseHandler = (e) => {
|
||||||
|
if (!menu.contains(e.target)) {
|
||||||
|
this._closeFolderContextMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', this._folderContextCloseHandler);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_closeFolderContextMenu() {
|
||||||
|
const menu = document.getElementById('sidebarFolderContextMenu');
|
||||||
|
if (menu) {
|
||||||
|
menu.style.display = 'none';
|
||||||
|
delete menu.dataset.folderPath;
|
||||||
|
}
|
||||||
|
if (this._folderContextCloseHandler) {
|
||||||
|
document.removeEventListener('click', this._folderContextCloseHandler);
|
||||||
|
this._folderContextCloseHandler = null;
|
||||||
|
}
|
||||||
|
this._folderContextOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFolderContextMenuAction(action) {
|
||||||
|
const menu = document.getElementById('sidebarFolderContextMenu');
|
||||||
|
if (!menu) return;
|
||||||
|
|
||||||
|
const path = menu.dataset.folderPath;
|
||||||
|
this._closeFolderContextMenu();
|
||||||
|
|
||||||
|
if (!path) return;
|
||||||
|
|
||||||
|
this._performFolderAction(action, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _performFolderAction(action, path) {
|
||||||
|
switch (action) {
|
||||||
|
case 'check-folder-updates':
|
||||||
|
try {
|
||||||
|
await performFolderUpdateCheck(path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Folder update check failed:', error);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn('Unknown folder action:', action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleBreadcrumbClick(event) {
|
handleBreadcrumbClick(event) {
|
||||||
const breadcrumbItem = event.target.closest('.sidebar-breadcrumb-item');
|
const breadcrumbItem = event.target.closest('.sidebar-breadcrumb-item');
|
||||||
const dropdownItem = event.target.closest('.breadcrumb-dropdown-item');
|
const dropdownItem = event.target.closest('.breadcrumb-dropdown-item');
|
||||||
|
|||||||
@@ -522,7 +522,7 @@ export async function showModelModal(model, modelType) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="showcase-section" data-model-hash="${modelWithFullData.sha256 || ''}" data-filepath="${escapedFilePathAttr}">
|
<div class="showcase-section" data-model-hash="${modelWithFullData.sha256 || ''}" data-model-name="${escapeAttribute(modelWithFullData.file_name || modelWithFullData.model_name || '')}" data-model-type="${modelType}" data-filepath="${escapedFilePathAttr}">
|
||||||
<div class="showcase-tabs">
|
<div class="showcase-tabs">
|
||||||
${tabsContent}
|
${tabsContent}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -135,6 +135,39 @@ export function initLazyLoading(container) {
|
|||||||
lazyElements.forEach(element => observer.observe(element));
|
lazyElements.forEach(element => observer.observe(element));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which Create As Recipe buttons correspond to already-imported
|
||||||
|
* images and disable them.
|
||||||
|
*/
|
||||||
|
async function checkImportedRecipes(container) {
|
||||||
|
const recipeButtons = container.querySelectorAll('.create-recipe-btn');
|
||||||
|
if (!recipeButtons.length) return;
|
||||||
|
|
||||||
|
const imageIds = [];
|
||||||
|
recipeButtons.forEach(btn => {
|
||||||
|
const id = btn.dataset.imageId;
|
||||||
|
if (id) imageIds.push(id);
|
||||||
|
});
|
||||||
|
if (!imageIds.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/lm/recipes/check-image-exists?image_ids=${imageIds.join(',')}`);
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success || !data.results) return;
|
||||||
|
recipeButtons.forEach(btn => {
|
||||||
|
const id = btn.dataset.imageId;
|
||||||
|
if (id && data.results[id]?.in_library) {
|
||||||
|
btn.title = 'Already imported as recipe';
|
||||||
|
btn.classList.add('disabled');
|
||||||
|
btn.setAttribute('aria-disabled', 'true');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to check imported recipes:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the actual rendered rectangle of a media element with object-fit: contain
|
* Get the actual rendered rectangle of a media element with object-fit: contain
|
||||||
* @param {HTMLElement} mediaElement - The img or video element
|
* @param {HTMLElement} mediaElement - The img or video element
|
||||||
@@ -471,6 +504,75 @@ export function initMediaControlHandlers(container) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create As Recipe buttons
|
||||||
|
const recipeButtons = container.querySelectorAll('.create-recipe-btn');
|
||||||
|
recipeButtons.forEach(btn => {
|
||||||
|
btn.addEventListener('click', async function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Ignore clicks when disabled
|
||||||
|
if (this.classList.contains('disabled')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageMetaRaw = this.dataset.imageMeta;
|
||||||
|
const imageUrl = this.dataset.imageUrl;
|
||||||
|
const imageNsfw = this.dataset.imageNsfw;
|
||||||
|
const localPath = this.dataset.localPath || '';
|
||||||
|
const showcaseSection = this.closest('.showcase-section');
|
||||||
|
const modelHash = showcaseSection ? showcaseSection.dataset.modelHash : '';
|
||||||
|
const modelName = showcaseSection ? showcaseSection.dataset.modelName : '';
|
||||||
|
const modelType = showcaseSection ? showcaseSection.dataset.modelType : '';
|
||||||
|
|
||||||
|
if (!imageMetaRaw || !modelHash) {
|
||||||
|
showToast('toast.recipes.createMissingData', {}, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
const originalHtml = this.innerHTML;
|
||||||
|
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||||
|
this.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imageMeta = JSON.parse(decodeURIComponent(imageMetaRaw));
|
||||||
|
|
||||||
|
const response = await fetch('/api/lm/recipes/create-from-example', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
image_data: {
|
||||||
|
meta: imageMeta,
|
||||||
|
url: imageUrl,
|
||||||
|
nsfwLevel: imageNsfw ? parseInt(imageNsfw, 10) : undefined,
|
||||||
|
},
|
||||||
|
model_hash: modelHash,
|
||||||
|
model_name: modelName || modelHash,
|
||||||
|
model_type: modelType,
|
||||||
|
local_image_path: localPath,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.recipe_id) {
|
||||||
|
showToast('toast.recipes.created', { recipeId: result.recipe_id }, 'success');
|
||||||
|
} else {
|
||||||
|
showToast('toast.recipes.createFailed', { error: result.error || 'Unknown error' }, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create recipe:', error);
|
||||||
|
showToast('toast.recipes.createError', { message: error.message }, 'error');
|
||||||
|
} finally {
|
||||||
|
this.innerHTML = originalHtml;
|
||||||
|
this.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check which images are already imported as recipes → disable button
|
||||||
|
checkImportedRecipes(container);
|
||||||
|
|
||||||
// Initialize set preview buttons
|
// Initialize set preview buttons
|
||||||
initSetPreviewHandlers(container);
|
initSetPreviewHandlers(container);
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ function renderMediaItem(img, index, exampleFiles) {
|
|||||||
Math.min(maxHeightPercent, aspectRatio)
|
Math.min(maxHeightPercent, aspectRatio)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Extract CivitAI image ID from CDN URL for import status check
|
||||||
|
const cdnImageId = (img.url || '').match(/\/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)/)?.[1] || '';
|
||||||
|
|
||||||
// Check if media should be blurred
|
// Check if media should be blurred
|
||||||
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
||||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||||
@@ -224,12 +227,25 @@ function renderMediaItem(img, index, exampleFiles) {
|
|||||||
// Determine if this is a custom image (has id property)
|
// Determine if this is a custom image (has id property)
|
||||||
const isCustomImage = Boolean(typeof img.id === 'string' && img.id);
|
const isCustomImage = Boolean(typeof img.id === 'string' && img.id);
|
||||||
|
|
||||||
|
const hasGenMeta = img.hasMeta || (img.meta && (img.meta.prompt || img.meta.seed || img.meta.resources));
|
||||||
|
|
||||||
// Create the media control buttons HTML
|
// Create the media control buttons HTML
|
||||||
const mediaControlsHtml = `
|
const mediaControlsHtml = `
|
||||||
<div class="media-controls">
|
<div class="media-controls">
|
||||||
<button class="media-control-btn set-preview-btn" title="Set as preview">
|
<button class="media-control-btn set-preview-btn" title="Set as preview">
|
||||||
<i class="fas fa-image"></i>
|
<i class="fas fa-image"></i>
|
||||||
</button>
|
</button>
|
||||||
|
${hasGenMeta ? `
|
||||||
|
<button class="media-control-btn create-recipe-btn"
|
||||||
|
title="Create As Recipe"
|
||||||
|
data-image-meta="${encodeURIComponent(JSON.stringify(img.meta || {}))}"
|
||||||
|
data-image-url="${img.url || ''}"
|
||||||
|
data-image-nsfw="${img.nsfwLevel ?? ''}"
|
||||||
|
data-image-id="${cdnImageId}"
|
||||||
|
data-local-path="${localFile ? localFile.path : ''}">
|
||||||
|
<i class="fas fa-book-open"></i>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
<button class="media-control-btn set-nsfw-btn"
|
<button class="media-control-btn set-nsfw-btn"
|
||||||
title="Set content rating"
|
title="Set content rating"
|
||||||
data-media-index="${index}"
|
data-media-index="${index}"
|
||||||
@@ -240,7 +256,7 @@ function renderMediaItem(img, index, exampleFiles) {
|
|||||||
<button class="media-control-btn example-delete-btn ${!isCustomImage ? 'disabled' : ''}"
|
<button class="media-control-btn example-delete-btn ${!isCustomImage ? 'disabled' : ''}"
|
||||||
title="${isCustomImage ? 'Delete this example' : 'Only custom images can be deleted'}"
|
title="${isCustomImage ? 'Delete this example' : 'Only custom images can be deleted'}"
|
||||||
data-short-id="${img.id || ''}"
|
data-short-id="${img.id || ''}"
|
||||||
${!isCustomImage ? 'disabled' : ''}>
|
${!isCustomImage ? 'aria-disabled="true"' : ''}>
|
||||||
<i class="fas fa-trash-alt"></i>
|
<i class="fas fa-trash-alt"></i>
|
||||||
<i class="fas fa-check confirm-icon"></i>
|
<i class="fas fa-check confirm-icon"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ export class BatchImportManager {
|
|||||||
|
|
||||||
// Refresh recipes list to show newly imported recipes
|
// Refresh recipes list to show newly imported recipes
|
||||||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
window.recipeManager.loadRecipes(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show results step
|
// Show results step
|
||||||
|
|||||||
@@ -309,9 +309,22 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
}, 'warning');
|
}, 'warning');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the recipes list to update LoRA status
|
// Update each affected recipe card with fresh data (LoRA inLibrary flags changed)
|
||||||
if (window.recipeManager) {
|
if (state.virtualScroller) {
|
||||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
const { extractRecipeId } = await import('../api/recipeApi.js');
|
||||||
|
for (const recipe of this.pendingRecipes) {
|
||||||
|
const recipeId = extractRecipeId(recipe.file_path);
|
||||||
|
if (!recipeId) continue;
|
||||||
|
try {
|
||||||
|
const detailRes = await fetch(`/api/lm/recipe/${encodeURIComponent(recipeId)}`);
|
||||||
|
if (detailRes.ok) {
|
||||||
|
const updated = await detailRes.json();
|
||||||
|
state.virtualScroller.updateSingleItem(recipe.file_path, updated);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to update recipe card after LoRA download:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export class DownloadManager {
|
|||||||
this.handleStartDownload = this.startDownload.bind(this);
|
this.handleStartDownload = this.startDownload.bind(this);
|
||||||
this.handleBackToUrl = this.backToUrl.bind(this);
|
this.handleBackToUrl = this.backToUrl.bind(this);
|
||||||
this.handleBackToVersions = this.backToVersions.bind(this);
|
this.handleBackToVersions = this.backToVersions.bind(this);
|
||||||
|
this.handleBackToVersionFromFiles = this.backToVersionFromFiles.bind(this);
|
||||||
|
this.handleConfirmFileSelection = this.confirmFileSelection.bind(this);
|
||||||
this.handleCloseModal = this.closeModal.bind(this);
|
this.handleCloseModal = this.closeModal.bind(this);
|
||||||
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
|
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
|
||||||
}
|
}
|
||||||
@@ -80,6 +82,10 @@ export class DownloadManager {
|
|||||||
document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions);
|
document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions);
|
||||||
document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal);
|
document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal);
|
||||||
|
|
||||||
|
// File selection step buttons
|
||||||
|
document.getElementById('backToVersionFromFilesBtn').addEventListener('click', this.handleBackToVersionFromFiles);
|
||||||
|
document.getElementById('confirmFileSelection').addEventListener('click', this.handleConfirmFileSelection);
|
||||||
|
|
||||||
// Default path toggle handler
|
// Default path toggle handler
|
||||||
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
|
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
|
||||||
}
|
}
|
||||||
@@ -129,6 +135,7 @@ export class DownloadManager {
|
|||||||
this.modelId = null;
|
this.modelId = null;
|
||||||
this.modelVersionId = null;
|
this.modelVersionId = null;
|
||||||
this.source = null;
|
this.source = null;
|
||||||
|
this.selectedFile = null;
|
||||||
|
|
||||||
this.selectedFolder = '';
|
this.selectedFolder = '';
|
||||||
|
|
||||||
@@ -247,9 +254,12 @@ export class DownloadManager {
|
|||||||
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
|
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
|
||||||
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
||||||
|
|
||||||
|
// Count model-type files per version
|
||||||
|
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
|
||||||
|
const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {};
|
||||||
const fileSize = version.modelSizeKB ?
|
const fileSize = version.modelSizeKB ?
|
||||||
(version.modelSizeKB / 1024).toFixed(2) :
|
(version.modelSizeKB / 1024).toFixed(2) :
|
||||||
(version.files[0]?.sizeKB / 1024).toFixed(2);
|
((primaryFile.sizeKB || 0) / 1024).toFixed(2);
|
||||||
|
|
||||||
const existsLocally = version.existsLocally;
|
const existsLocally = version.existsLocally;
|
||||||
const hasBeenDownloaded = version.hasBeenDownloaded && !existsLocally;
|
const hasBeenDownloaded = version.hasBeenDownloaded && !existsLocally;
|
||||||
@@ -282,6 +292,12 @@ export class DownloadManager {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileBadge = modelFiles.length > 1 && !existsLocally
|
||||||
|
? `<span class="file-select-badge" data-version-id="${version.id}">
|
||||||
|
<i class="fas fa-th-list"></i> ${modelFiles.length} ${translate('modals.download.fileSelection.files')} <i class="fas fa-chevron-right badge-arrow"></i>
|
||||||
|
</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}
|
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}
|
||||||
${existsLocally ? 'exists-locally' : ''}
|
${existsLocally ? 'exists-locally' : ''}
|
||||||
@@ -302,14 +318,23 @@ export class DownloadManager {
|
|||||||
<div class="version-meta">
|
<div class="version-meta">
|
||||||
<span><i class="fas fa-calendar"></i> ${new Date(version.createdAt).toLocaleDateString()}</span>
|
<span><i class="fas fa-calendar"></i> ${new Date(version.createdAt).toLocaleDateString()}</span>
|
||||||
<span><i class="fas fa-file-archive"></i> ${fileSize} MB</span>
|
<span><i class="fas fa-file-archive"></i> ${fileSize} MB</span>
|
||||||
|
${fileBadge}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Add click handlers for version selection
|
// Add click handlers for version selection and file badge
|
||||||
versionList.addEventListener('click', (event) => {
|
versionList.addEventListener('click', (event) => {
|
||||||
|
const badge = event.target.closest('.file-select-badge');
|
||||||
|
if (badge) {
|
||||||
|
event.stopPropagation();
|
||||||
|
const versionId = badge.dataset.versionId;
|
||||||
|
this.selectVersion(versionId);
|
||||||
|
this.showFileSelectionStep(versionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const versionItem = event.target.closest('.version-item');
|
const versionItem = event.target.closest('.version-item');
|
||||||
if (versionItem) {
|
if (versionItem) {
|
||||||
this.selectVersion(versionItem.dataset.versionId);
|
this.selectVersion(versionItem.dataset.versionId);
|
||||||
@@ -352,6 +377,80 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showFileSelectionStep(versionId) {
|
||||||
|
const version = this.versions.find(v => v.id.toString() === versionId.toString());
|
||||||
|
if (!version) return;
|
||||||
|
|
||||||
|
this.currentVersion = version;
|
||||||
|
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
|
||||||
|
|
||||||
|
document.getElementById('versionStep').style.display = 'none';
|
||||||
|
document.getElementById('fileSelectionStep').style.display = 'block';
|
||||||
|
|
||||||
|
const nameEl = document.getElementById('fileSelectionVersionName');
|
||||||
|
if (nameEl) {
|
||||||
|
nameEl.textContent = `${version.name} · ${version.baseModel || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById('fileSelectionList');
|
||||||
|
container.innerHTML = modelFiles.map(file => {
|
||||||
|
const meta = file.metadata || {};
|
||||||
|
const sizeGB = file.sizeKB ? (file.sizeKB / (1024 * 1024)).toFixed(2) : '--';
|
||||||
|
const isSelected = this.selectedFile?.id === file.id;
|
||||||
|
|
||||||
|
const tags = [];
|
||||||
|
if (meta.size) tags.push(`<span class="file-tag size">${meta.size}</span>`);
|
||||||
|
if (meta.format) tags.push(`<span class="file-tag format">${meta.format}</span>`);
|
||||||
|
if (meta.fp) tags.push(`<span class="file-tag fp">${meta.fp}</span>`);
|
||||||
|
|
||||||
|
const fileName = file.name || '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="file-option ${isSelected ? 'selected' : ''}" data-file-id="${file.id}">
|
||||||
|
<div class="file-option-radio">
|
||||||
|
<input type="radio" name="fileSelection" value="${file.id}" ${isSelected ? 'checked' : ''}>
|
||||||
|
</div>
|
||||||
|
<div class="file-option-info">
|
||||||
|
<div class="file-option-tags">
|
||||||
|
${tags.join(' ')}
|
||||||
|
</div>
|
||||||
|
<div class="file-option-name">${fileName}</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-option-size">${sizeGB} GB</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.querySelectorAll('.file-option').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
container.querySelectorAll('.file-option').forEach(o => o.classList.remove('selected'));
|
||||||
|
el.classList.add('selected');
|
||||||
|
const radio = el.querySelector('input[type="radio"]');
|
||||||
|
if (radio) radio.checked = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmFileSelection() {
|
||||||
|
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
|
||||||
|
if (!selectedRadio) return;
|
||||||
|
|
||||||
|
const version = this.currentVersion;
|
||||||
|
if (!version) return;
|
||||||
|
|
||||||
|
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
|
||||||
|
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
|
||||||
|
|
||||||
|
document.getElementById('fileSelectionStep').style.display = 'none';
|
||||||
|
document.getElementById('locationStep').style.display = 'block';
|
||||||
|
this.proceedToLocationContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
backToVersionFromFiles() {
|
||||||
|
document.getElementById('fileSelectionStep').style.display = 'none';
|
||||||
|
document.getElementById('versionStep').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
async proceedToLocation() {
|
async proceedToLocation() {
|
||||||
if (!this.currentVersion) {
|
if (!this.currentVersion) {
|
||||||
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
|
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
|
||||||
@@ -366,6 +465,10 @@ export class DownloadManager {
|
|||||||
|
|
||||||
document.getElementById('versionStep').style.display = 'none';
|
document.getElementById('versionStep').style.display = 'none';
|
||||||
document.getElementById('locationStep').style.display = 'block';
|
document.getElementById('locationStep').style.display = 'block';
|
||||||
|
await this.proceedToLocationContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
async proceedToLocationContent() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch model roots
|
// Fetch model roots
|
||||||
@@ -450,6 +553,7 @@ export class DownloadManager {
|
|||||||
targetFolder = '',
|
targetFolder = '',
|
||||||
useDefaultPaths = false,
|
useDefaultPaths = false,
|
||||||
source = null,
|
source = null,
|
||||||
|
fileParams = null,
|
||||||
closeModal = false,
|
closeModal = false,
|
||||||
}) {
|
}) {
|
||||||
const config = this.apiClient?.apiConfig?.config;
|
const config = this.apiClient?.apiConfig?.config;
|
||||||
@@ -513,7 +617,8 @@ export class DownloadManager {
|
|||||||
targetFolder,
|
targetFolder,
|
||||||
useDefaultPaths,
|
useDefaultPaths,
|
||||||
downloadId,
|
downloadId,
|
||||||
source
|
source,
|
||||||
|
fileParams
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response?.skipped) {
|
if (response?.skipped) {
|
||||||
@@ -632,6 +737,13 @@ export class DownloadManager {
|
|||||||
} else {
|
} else {
|
||||||
targetFolder = this.folderTreeManager.getSelectedPath();
|
targetFolder = this.folderTreeManager.getSelectedPath();
|
||||||
}
|
}
|
||||||
|
const fileParams = this.selectedFile ? {
|
||||||
|
type: 'Model',
|
||||||
|
format: this.selectedFile.metadata?.format || 'SafeTensor',
|
||||||
|
size: this.selectedFile.metadata?.size || 'full',
|
||||||
|
fp: this.selectedFile.metadata?.fp,
|
||||||
|
} : null;
|
||||||
|
|
||||||
return this.executeDownloadWithProgress({
|
return this.executeDownloadWithProgress({
|
||||||
modelId: this.modelId,
|
modelId: this.modelId,
|
||||||
versionId: this.currentVersion.id,
|
versionId: this.currentVersion.id,
|
||||||
@@ -640,6 +752,7 @@ export class DownloadManager {
|
|||||||
targetFolder,
|
targetFolder,
|
||||||
useDefaultPaths,
|
useDefaultPaths,
|
||||||
source: this.source,
|
source: this.source,
|
||||||
|
fileParams,
|
||||||
closeModal: true,
|
closeModal: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -662,7 +662,7 @@ export class FilterManager {
|
|||||||
|
|
||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
await window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
@@ -746,7 +746,7 @@ export class FilterManager {
|
|||||||
|
|
||||||
// Reload data using the appropriate method for the current page
|
// Reload data using the appropriate method for the current page
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
await window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export class SearchManager {
|
|||||||
|
|
||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
|
|||||||
@@ -2876,7 +2876,7 @@ export class SettingsManager {
|
|||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
} else if (this.currentPage === 'recipes') {
|
} else if (this.currentPage === 'recipes') {
|
||||||
// Reload the recipes without updating folders
|
// Reload the recipes without updating folders
|
||||||
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
await window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'checkpoints') {
|
||||||
// Reload the checkpoints without updating folders
|
// Reload the checkpoints without updating folders
|
||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export class DownloadManager {
|
|||||||
modalManager.closeModal('importModal');
|
modalManager.closeModal('importModal');
|
||||||
|
|
||||||
// Refresh the recipe
|
// Refresh the recipe
|
||||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
window.recipeManager.loadRecipes(true);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
|||||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||||
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||||
import { refreshRecipes, syncChanges, RecipeSidebarApiClient } from './api/recipeApi.js';
|
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||||
import { sidebarManager } from './components/SidebarManager.js';
|
import { sidebarManager } from './components/SidebarManager.js';
|
||||||
|
|
||||||
class RecipePageControls {
|
class RecipePageControls {
|
||||||
@@ -19,16 +19,13 @@ class RecipePageControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async resetAndReload() {
|
async resetAndReload() {
|
||||||
await refreshVirtualScroll({ preserveScroll: true });
|
await refreshVirtualScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshModels(fullRebuild = false) {
|
async refreshModels(fullRebuild = false) {
|
||||||
if (fullRebuild) {
|
await refreshRecipes(fullRebuild);
|
||||||
await refreshRecipes();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await syncChanges();
|
await sidebarManager.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
getSidebarApiClient() {
|
getSidebarApiClient() {
|
||||||
|
|||||||
@@ -100,6 +100,90 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
|
|||||||
return { status, displayName, records, error };
|
return { status, displayName, records, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a model update check scoped to a specific folder.
|
||||||
|
* @param {string} folderPath - The relative folder path to check.
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {Function} [options.onComplete] - Callback invoked after the request settles.
|
||||||
|
* @returns {Promise<{status: string, records: Array, error: Error | null}>}
|
||||||
|
*/
|
||||||
|
export async function performFolderUpdateCheck(folderPath, { onComplete } = {}) {
|
||||||
|
const modelType = getCurrentModelType();
|
||||||
|
const apiConfig = getCompleteApiConfig(modelType);
|
||||||
|
const apiClient = getModelApiClient(modelType);
|
||||||
|
const displayName = apiConfig?.config?.displayName ?? 'Model';
|
||||||
|
|
||||||
|
if (!apiConfig?.endpoints?.refreshUpdates) {
|
||||||
|
console.warn('Refresh updates endpoint not configured for model type:', modelType);
|
||||||
|
onComplete?.({ status: 'unsupported', records: [], error: null });
|
||||||
|
return { status: 'unsupported', records: [], error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingMessage = translate(
|
||||||
|
'sidebar.folderUpdateCheck.loading',
|
||||||
|
{ type: displayName },
|
||||||
|
`Checking ${displayName} updates for this folder...`
|
||||||
|
);
|
||||||
|
|
||||||
|
state.loadingManager?.showSimpleLoading?.(loadingMessage);
|
||||||
|
state.loadingManager?.showCancelButton?.(() => apiClient.cancelTask());
|
||||||
|
|
||||||
|
let status = 'success';
|
||||||
|
let records = [];
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(apiConfig.endpoints.refreshUpdates, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ folder_path: folderPath, force: false })
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch {
|
||||||
|
payload = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || payload.success !== true) {
|
||||||
|
if (payload?.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
return { status: 'cancelled', records: [], error: null };
|
||||||
|
}
|
||||||
|
const errorMessage = payload?.error || response.statusText || 'Unknown error';
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
records = Array.isArray(payload.records) ? payload.records : [];
|
||||||
|
|
||||||
|
if (records.length > 0) {
|
||||||
|
showToast('sidebar.folderUpdateCheck.success', { count: records.length, type: displayName }, 'success');
|
||||||
|
} else {
|
||||||
|
showToast('sidebar.folderUpdateCheck.none', { type: displayName }, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetAndReload(false);
|
||||||
|
} catch (err) {
|
||||||
|
status = 'error';
|
||||||
|
error = err instanceof Error ? err : new Error(String(err));
|
||||||
|
console.error('Error checking folder model updates:', error);
|
||||||
|
showToast(
|
||||||
|
'sidebar.folderUpdateCheck.error',
|
||||||
|
{ message: error?.message ?? 'Unknown error', type: displayName },
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
state.loadingManager?.hide?.();
|
||||||
|
if (typeof state.loadingManager?.restoreProgressBar === 'function') {
|
||||||
|
state.loadingManager.restoreProgressBar();
|
||||||
|
}
|
||||||
|
onComplete?.({ status, records, error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status, records, error };
|
||||||
|
}
|
||||||
|
|
||||||
function getTypePlural(displayName) {
|
function getTypePlural(displayName) {
|
||||||
if (!displayName) {
|
if (!displayName) {
|
||||||
return 'models';
|
return 'models';
|
||||||
|
|||||||
@@ -150,6 +150,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar Folder Context Menu -->
|
||||||
|
<div id="sidebarFolderContextMenu" class="context-menu">
|
||||||
|
<div class="context-menu-item" data-action="check-folder-updates">
|
||||||
|
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="nsfwLevelSelector" class="nsfw-level-selector">
|
<div id="nsfwLevelSelector" class="nsfw-level-selector">
|
||||||
<div class="nsfw-level-header">
|
<div class="nsfw-level-header">
|
||||||
<h3>{{ t('modals.contentRating.title') }}</h3>
|
<h3>{{ t('modals.contentRating.title') }}</h3>
|
||||||
|
|||||||
@@ -29,6 +29,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2.5: File Selection (optional - only when version has multiple model files) -->
|
||||||
|
<div class="download-step" id="fileSelectionStep" style="display: none;">
|
||||||
|
<div class="file-selection-header">
|
||||||
|
<h3 id="fileSelectionTitle">{{ t('modals.download.fileSelection.title') }}</h3>
|
||||||
|
<div class="file-selection-version-name" id="fileSelectionVersionName"></div>
|
||||||
|
</div>
|
||||||
|
<div class="file-selection-list" id="fileSelectionList">
|
||||||
|
<!-- File options will be rendered here dynamically -->
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="secondary-btn" id="backToVersionFromFilesBtn">{{ t('common.actions.back') }}</button>
|
||||||
|
<button class="primary-btn" id="confirmFileSelection">{{ t('modals.download.fileSelection.select') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Step 3: Location Selection -->
|
<!-- Step 3: Location Selection -->
|
||||||
<div class="download-step" id="locationStep" style="display: none;">
|
<div class="download-step" id="locationStep" style="display: none;">
|
||||||
<div class="location-selection">
|
<div class="location-selection">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
|||||||
const showToastMock = vi.hoisted(() => vi.fn());
|
const showToastMock = vi.hoisted(() => vi.fn());
|
||||||
const loadingManagerMock = vi.hoisted(() => ({
|
const loadingManagerMock = vi.hoisted(() => ({
|
||||||
showSimpleLoading: vi.fn(),
|
showSimpleLoading: vi.fn(),
|
||||||
|
show: vi.fn(),
|
||||||
hide: vi.fn(),
|
hide: vi.fn(),
|
||||||
restoreProgressBar: vi.fn(),
|
restoreProgressBar: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -177,9 +178,7 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves scroll position for recipe reloads when requested', async () => {
|
it('reloads recipes without preserving scroll', async () => {
|
||||||
const scrollSnapshot = { scrollContainer: { scrollTop: 480 }, scrollTop: 480 };
|
|
||||||
captureScrollPositionMock.mockReturnValue(scrollSnapshot);
|
|
||||||
global.fetch.mockResolvedValue({
|
global.fetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -189,18 +188,18 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await resetAndReload(false, { preserveScroll: true });
|
await resetAndReload(false);
|
||||||
|
|
||||||
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1);
|
expect(captureScrollPositionMock).not.toHaveBeenCalled();
|
||||||
expect(virtualScrollerMock.refreshWithData).toHaveBeenCalledWith(
|
expect(virtualScrollerMock.refreshWithData).toHaveBeenCalledWith(
|
||||||
[{ id: 'recipe-1' }],
|
[{ id: 'recipe-1' }],
|
||||||
1,
|
1,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
expect(restoreScrollPositionMock).toHaveBeenCalledWith(scrollSnapshot);
|
expect(restoreScrollPositionMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses scroll-preserving reloads for syncChanges', async () => {
|
it('uses scroll-free reloads for syncChanges', async () => {
|
||||||
global.fetch.mockResolvedValue({
|
global.fetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -212,8 +211,8 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
|
|
||||||
await syncChanges();
|
await syncChanges();
|
||||||
|
|
||||||
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1);
|
expect(captureScrollPositionMock).not.toHaveBeenCalled();
|
||||||
expect(restoreScrollPositionMock).toHaveBeenCalledTimes(1);
|
expect(restoreScrollPositionMock).not.toHaveBeenCalled();
|
||||||
expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1);
|
expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class DummyUpdateService:
|
|||||||
*,
|
*,
|
||||||
force_refresh=False,
|
force_refresh=False,
|
||||||
target_model_ids=None,
|
target_model_ids=None,
|
||||||
|
folder_path=None,
|
||||||
):
|
):
|
||||||
self.calls.append(
|
self.calls.append(
|
||||||
{
|
{
|
||||||
@@ -54,6 +55,7 @@ class DummyUpdateService:
|
|||||||
"provider": provider,
|
"provider": provider,
|
||||||
"force_refresh": force_refresh,
|
"force_refresh": force_refresh,
|
||||||
"target_model_ids": target_model_ids,
|
"target_model_ids": target_model_ids,
|
||||||
|
"folder_path": folder_path,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self.records
|
return self.records
|
||||||
|
|||||||
@@ -183,6 +183,13 @@ function parseSearchTokens(term = '') {
|
|||||||
return { include, exclude };
|
return { include, exclude };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapePromptParentheses(text) {
|
||||||
|
// In ComfyUI's CLIP text encoder, bare parentheses are weight adjustment syntax.
|
||||||
|
// Tags containing literal parentheses must be escaped with backslash to prevent
|
||||||
|
// them from being interpreted as weight modifiers. e.g. "foo (bar)" → "foo \(bar\)"
|
||||||
|
return text.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||||
|
}
|
||||||
|
|
||||||
function formatAutocompleteInsertion(text = '') {
|
function formatAutocompleteInsertion(text = '') {
|
||||||
const trimmed = typeof text === 'string' ? text.trim() : '';
|
const trimmed = typeof text === 'string' ? text.trim() : '';
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -253,7 +260,7 @@ function createDefaultBehavior(modelType) {
|
|||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return formatAutocompleteInsertion(trimmed);
|
return formatAutocompleteInsertion(escapePromptParentheses(trimmed));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -352,7 +359,7 @@ const MODEL_BEHAVIORS = {
|
|||||||
custom_words: {
|
custom_words: {
|
||||||
enablePreview: false,
|
enablePreview: false,
|
||||||
async getInsertText(_instance, relativePath) {
|
async getInsertText(_instance, relativePath) {
|
||||||
return formatAutocompleteInsertion(relativePath);
|
return formatAutocompleteInsertion(escapePromptParentheses(relativePath));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
prompt: {
|
prompt: {
|
||||||
@@ -399,6 +406,8 @@ const MODEL_BEHAVIORS = {
|
|||||||
tagText = tagText.replace(/_/g, ' ');
|
tagText = tagText.replace(/_/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tagText = escapePromptParentheses(tagText);
|
||||||
|
|
||||||
return formatAutocompleteInsertion(tagText);
|
return formatAutocompleteInsertion(tagText);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user