mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
Compare commits
9 Commits
c6c44b741a
..
v1.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 16b0bdf70a | |||
| e09fe5888b | |||
| f67689b0f9 | |||
| 1d6da1787a | |||
| d572292142 | |||
| 1b1a8d63db | |||
| a0a5b13ab0 | |||
| 779bd18e75 | |||
| 01137eed88 |
+246
-235
@@ -7,190 +7,199 @@
|
|||||||
],
|
],
|
||||||
"allSupporters": [
|
"allSupporters": [
|
||||||
"Takkan",
|
"Takkan",
|
||||||
"2018cfh",
|
|
||||||
"megakirbs",
|
"megakirbs",
|
||||||
"Brennok",
|
"Brennok",
|
||||||
"Charles Blakemore",
|
"2018cfh",
|
||||||
"Rob Williams",
|
"Rob Williams",
|
||||||
"Insomnia Art Designs",
|
"Charles Blakemore",
|
||||||
"Arlecchino Shion",
|
"Arlecchino Shion",
|
||||||
|
"Insomnia Art Designs",
|
||||||
|
"Mozzel",
|
||||||
"Gingko Biloba",
|
"Gingko Biloba",
|
||||||
"stone9k",
|
"stone9k",
|
||||||
|
"Kiba",
|
||||||
"onesecondinosaur",
|
"onesecondinosaur",
|
||||||
"Skalabananen",
|
"Skalabananen",
|
||||||
|
"Sterilized",
|
||||||
"Polymorphic Indeterminate",
|
"Polymorphic Indeterminate",
|
||||||
"Liam MacDougal",
|
"Liam MacDougal",
|
||||||
|
"Christian Byrne",
|
||||||
|
"DM",
|
||||||
|
"Sen314",
|
||||||
|
"Estragon",
|
||||||
"Rosenthal",
|
"Rosenthal",
|
||||||
|
"ClockDaemon",
|
||||||
"Francisco Tatis",
|
"Francisco Tatis",
|
||||||
"Tobi_Swagg",
|
"Tobi_Swagg",
|
||||||
|
"SG",
|
||||||
|
"jmack",
|
||||||
"Andrew Wilson",
|
"Andrew Wilson",
|
||||||
"Greybush",
|
"Greybush",
|
||||||
"Ricky Carter",
|
"Ricky Carter",
|
||||||
"JongWon Han",
|
"JongWon Han",
|
||||||
"VantAI",
|
"VantAI",
|
||||||
|
"レプサイ",
|
||||||
|
"Michael Wong",
|
||||||
"Illrigger",
|
"Illrigger",
|
||||||
|
"Tom Corrigan",
|
||||||
|
"JackieWang",
|
||||||
"FreelancerZ",
|
"FreelancerZ",
|
||||||
"Mozzel",
|
"fnkylove",
|
||||||
|
"Lilleman",
|
||||||
|
"Robert Stacey",
|
||||||
|
"PM",
|
||||||
"Marc Whiffen",
|
"Marc Whiffen",
|
||||||
|
"Dogwalkerbr",
|
||||||
"Birdy",
|
"Birdy",
|
||||||
"Kiba",
|
"quarz",
|
||||||
"$MetaSamsara",
|
"$MetaSamsara",
|
||||||
|
"jean jahren",
|
||||||
"Reno Lam",
|
"Reno Lam",
|
||||||
"Aleksander Wujczyk",
|
"Aleksander Wujczyk",
|
||||||
|
"AM Kuro",
|
||||||
|
"JSST",
|
||||||
"sig",
|
"sig",
|
||||||
"Christian Byrne",
|
|
||||||
"DM",
|
|
||||||
"Sen314",
|
|
||||||
"Estragon",
|
|
||||||
"J\\B/ 8r0wns0n",
|
"J\\B/ 8r0wns0n",
|
||||||
"Snaggwort",
|
"Snaggwort",
|
||||||
"Anthony+Rizzo",
|
"Anthony+Rizzo",
|
||||||
"W+K+White",
|
"W+K+White",
|
||||||
"ClockDaemon",
|
"Baekdoosixt",
|
||||||
"Jonathan Ross",
|
"Jonathan Ross",
|
||||||
"KD",
|
"KD",
|
||||||
"Omnidex",
|
"Omnidex",
|
||||||
"Nolife_M",
|
"Nolife_M",
|
||||||
|
"Melville Parrish",
|
||||||
|
"daniel dove",
|
||||||
|
"Lustre",
|
||||||
"Tyler Trebuchon",
|
"Tyler Trebuchon",
|
||||||
"Release Cabrakan",
|
"Release Cabrakan",
|
||||||
"SG",
|
"JW Sin",
|
||||||
|
"Alex",
|
||||||
"carozzz",
|
"carozzz",
|
||||||
|
"Marlon Daniels",
|
||||||
"James Dooley",
|
"James Dooley",
|
||||||
"zenbound",
|
"zenbound",
|
||||||
"Buzzard",
|
"Buzzard",
|
||||||
"jmack",
|
|
||||||
"Adam Shaw",
|
"Adam Shaw",
|
||||||
"Mark Corneglio",
|
"Mark Corneglio",
|
||||||
"RedrockVP",
|
"RedrockVP",
|
||||||
"James Todd",
|
"James Todd",
|
||||||
|
"Wicked Choices by ASLPro3D",
|
||||||
|
"FinalyFree",
|
||||||
"Fyf",
|
"Fyf",
|
||||||
"レプサイ",
|
|
||||||
"Timmy",
|
"Timmy",
|
||||||
"Johnny",
|
"Johnny",
|
||||||
|
"Tak",
|
||||||
"Lisster",
|
"Lisster",
|
||||||
"Michael Wong",
|
"Big Red",
|
||||||
"whudunit",
|
"whudunit",
|
||||||
"Tom Corrigan",
|
"Luc Job",
|
||||||
"JackieWang",
|
"corde",
|
||||||
"fnkylove",
|
|
||||||
"Yushio",
|
"Yushio",
|
||||||
"Vik71it",
|
"Vik71it",
|
||||||
|
"Bishoujoker",
|
||||||
"Echo",
|
"Echo",
|
||||||
"Lilleman",
|
|
||||||
"Robert Stacey",
|
|
||||||
"PM",
|
|
||||||
"Todd Keck",
|
"Todd Keck",
|
||||||
"Briton Heilbrun",
|
"Briton Heilbrun",
|
||||||
|
"wildnut",
|
||||||
"Edgar Tejeda",
|
"Edgar Tejeda",
|
||||||
"Sterilized",
|
|
||||||
"BadassArabianMofo",
|
"BadassArabianMofo",
|
||||||
"Dogwalkerbr",
|
"MiraiKuriyamaSy",
|
||||||
"quarz",
|
|
||||||
"Pascal Dahle",
|
"Pascal Dahle",
|
||||||
"Greg",
|
"Greg",
|
||||||
"jean jahren",
|
"Akira HentAI",
|
||||||
"AM Kuro",
|
"otaku fra",
|
||||||
"JSST",
|
|
||||||
"lmsupporter",
|
"lmsupporter",
|
||||||
|
"andrew.tappan",
|
||||||
"wackop",
|
"wackop",
|
||||||
"Phil",
|
"Phil",
|
||||||
|
"Greenmoustache",
|
||||||
"Carl G.",
|
"Carl G.",
|
||||||
"wfpearl",
|
"wfpearl",
|
||||||
|
"jeaness",
|
||||||
"Dsperado",
|
"Dsperado",
|
||||||
"Baekdoosixt",
|
|
||||||
"Jack B Nimble",
|
"Jack B Nimble",
|
||||||
"Melville Parrish",
|
|
||||||
"daniel dove",
|
|
||||||
"Lustre",
|
|
||||||
"JW Sin",
|
|
||||||
"Alex",
|
|
||||||
"bh",
|
"bh",
|
||||||
"Marlon Daniels",
|
"Jwk0205",
|
||||||
"Starkselle",
|
"Starkselle",
|
||||||
|
"Olive",
|
||||||
"Aaron Bleuer",
|
"Aaron Bleuer",
|
||||||
"LacesOut!",
|
"LacesOut!",
|
||||||
"greebles",
|
"greebles",
|
||||||
"SarcasticHashtag",
|
"SarcasticHashtag",
|
||||||
"Wicked Choices by ASLPro3D",
|
"Some Guy Named Barry",
|
||||||
|
"M Postkasse",
|
||||||
"Jacob Hoehler",
|
"Jacob Hoehler",
|
||||||
"FinalyFree",
|
"Matt Wenzel",
|
||||||
"Weasyl",
|
"Weasyl",
|
||||||
"Lex Song",
|
"Lex Song",
|
||||||
"Cory Paza",
|
"Cory Paza",
|
||||||
"Tak",
|
|
||||||
"Gonzalo Andre Allendes Lopez",
|
"Gonzalo Andre Allendes Lopez",
|
||||||
"Big Red",
|
"Serge Bekenkamp",
|
||||||
"AIJimmy",
|
"AIJimmy",
|
||||||
"Luc Job",
|
|
||||||
"Philip Hempel",
|
"Philip Hempel",
|
||||||
"corde",
|
"dan",
|
||||||
"Bishoujoker",
|
|
||||||
"aai",
|
"aai",
|
||||||
"wildnut",
|
|
||||||
"Ran C",
|
"Ran C",
|
||||||
"ViperC",
|
"ViperC",
|
||||||
"itismyelement",
|
"itismyelement",
|
||||||
"Sangheili460",
|
"Sangheili460",
|
||||||
"MagnaInsomnia",
|
"MagnaInsomnia",
|
||||||
"Karl P.",
|
"Karl P.",
|
||||||
"Akira HentAI",
|
|
||||||
"MiraiKuriyamaSy",
|
|
||||||
"LarsesFPC",
|
"LarsesFPC",
|
||||||
"otaku fra",
|
"Weird_With_A_Beard",
|
||||||
"andrew.tappan",
|
|
||||||
"N/A",
|
"N/A",
|
||||||
"The Spawn",
|
"The Spawn",
|
||||||
"graysock",
|
"graysock",
|
||||||
"Pozadine1",
|
"Pozadine1",
|
||||||
"Greenmoustache",
|
|
||||||
"fancypants",
|
|
||||||
"jeaness",
|
|
||||||
"Joboshy",
|
|
||||||
"Digital",
|
|
||||||
"JaxMax",
|
|
||||||
"Bohemian Corporal",
|
|
||||||
"Dan",
|
|
||||||
"Jwk0205",
|
|
||||||
"Bro Xie",
|
|
||||||
"batblue",
|
|
||||||
"carey6409",
|
|
||||||
"Olive",
|
|
||||||
"太郎 ゲーム",
|
|
||||||
"Some Guy Named Barry",
|
|
||||||
"jinxedx",
|
|
||||||
"M Postkasse",
|
|
||||||
"AELOX",
|
|
||||||
"Dankin-Pics",
|
|
||||||
"Nicfit23",
|
|
||||||
"wamekukyouzin",
|
|
||||||
"drum matthieu",
|
|
||||||
"DogmaR34",
|
|
||||||
"Matt Wenzel",
|
|
||||||
"Frank Nitty",
|
|
||||||
"Christopher Michel",
|
|
||||||
"runte3221",
|
|
||||||
"Serge Bekenkamp",
|
|
||||||
"DougPeterson",
|
|
||||||
"LeoZero",
|
|
||||||
"dl0901dm",
|
|
||||||
"Antonio Pontes",
|
|
||||||
"kushiroK9",
|
|
||||||
"Kevin John Duck",
|
|
||||||
"Dustin Chen",
|
|
||||||
"dan",
|
|
||||||
"Blackfish95",
|
|
||||||
"Mouthlessman",
|
|
||||||
"Paul Kroll",
|
|
||||||
"Fraser Cross",
|
|
||||||
"Bas Imagineer",
|
|
||||||
"Dušan Ryban",
|
|
||||||
"Adam Taylor",
|
|
||||||
"Weird_With_A_Beard",
|
|
||||||
"Qarob",
|
"Qarob",
|
||||||
"AIGooner",
|
"AIGooner",
|
||||||
"Luc",
|
"Luc",
|
||||||
"ProtonPrince",
|
"ProtonPrince",
|
||||||
"DiffDuck",
|
"DiffDuck",
|
||||||
|
"fancypants",
|
||||||
|
"John+Edwards",
|
||||||
|
"Joboshy",
|
||||||
|
"Digital",
|
||||||
|
"JaxMax",
|
||||||
|
"Bohemian Corporal",
|
||||||
|
"Dan",
|
||||||
|
"Bro Xie",
|
||||||
|
"seed123_AIart",
|
||||||
|
"batblue",
|
||||||
|
"carey6409",
|
||||||
|
"太郎 ゲーム",
|
||||||
|
"Roslynd",
|
||||||
|
"jinxedx",
|
||||||
|
"AELOX",
|
||||||
|
"Dankin-Pics",
|
||||||
|
"Nicfit23",
|
||||||
|
"Cristian Vazquez",
|
||||||
|
"wamekukyouzin",
|
||||||
|
"drum matthieu",
|
||||||
|
"DogmaR34",
|
||||||
|
"Frank Nitty",
|
||||||
|
"The Magic Noob",
|
||||||
|
"Christopher Michel",
|
||||||
|
"runte3221",
|
||||||
|
"DougPeterson",
|
||||||
|
"LeoZero",
|
||||||
|
"dl0901dm",
|
||||||
|
"Antonio Pontes",
|
||||||
|
"Bruce",
|
||||||
|
"kushiroK9",
|
||||||
|
"Kevin John Duck",
|
||||||
|
"Dustin Chen",
|
||||||
|
"Blackfish95",
|
||||||
|
"Tori",
|
||||||
|
"Mouthlessman",
|
||||||
|
"Paul Kroll",
|
||||||
|
"Fraser Cross",
|
||||||
|
"Bas Imagineer",
|
||||||
|
"John Statham",
|
||||||
|
"Dušan Ryban",
|
||||||
|
"Adam Taylor",
|
||||||
|
"decoy",
|
||||||
"elu3199",
|
"elu3199",
|
||||||
"Hasturkun",
|
"Hasturkun",
|
||||||
"Jon Sandman",
|
"Jon Sandman",
|
||||||
@@ -201,39 +210,38 @@
|
|||||||
"wundershark",
|
"wundershark",
|
||||||
"mr_dinosaur",
|
"mr_dinosaur",
|
||||||
"Tyrswood",
|
"Tyrswood",
|
||||||
"linnfrey",
|
|
||||||
"griffin+dahlberg",
|
|
||||||
"John+Edwards",
|
|
||||||
"ElitaSSJ4",
|
|
||||||
"Matt+J",
|
|
||||||
"Josef Lanzl",
|
|
||||||
"New folder (1)",
|
|
||||||
"seed123_AIart",
|
|
||||||
"Error_Rule34_Not_found",
|
|
||||||
"Roslynd",
|
|
||||||
"Geolog",
|
|
||||||
"Neco28",
|
|
||||||
"Resist's Creations - Spicy Edition 🔥",
|
|
||||||
"David Ortega",
|
|
||||||
"Wolffen",
|
|
||||||
"Cristian Vazquez",
|
|
||||||
"The Magic Noob",
|
|
||||||
"Jeff",
|
|
||||||
"nwalker94",
|
|
||||||
"Bruce",
|
|
||||||
"Kevin Christopher",
|
|
||||||
"Chad Idk",
|
|
||||||
"Tori",
|
|
||||||
"dd",
|
|
||||||
"John Statham",
|
|
||||||
"sjon kreutz",
|
|
||||||
"Metryman55",
|
|
||||||
"AlexDuKaNa",
|
|
||||||
"decoy",
|
|
||||||
"Ray Wing",
|
"Ray Wing",
|
||||||
"Ranzitho",
|
"Ranzitho",
|
||||||
"Gus",
|
"Gus",
|
||||||
"MJG",
|
"MJG",
|
||||||
|
"linnfrey",
|
||||||
|
"griffin+dahlberg",
|
||||||
|
"ElitaSSJ4",
|
||||||
|
"Matt+J",
|
||||||
|
"Josef Lanzl",
|
||||||
|
"New folder (1)",
|
||||||
|
"sanborondon",
|
||||||
|
"Error_Rule34_Not_found",
|
||||||
|
"jcay015",
|
||||||
|
"Erik Lopez",
|
||||||
|
"Mateo Curić",
|
||||||
|
"Geolog",
|
||||||
|
"Neco28",
|
||||||
|
"Eris3D",
|
||||||
|
"Resist's Creations - Spicy Edition 🔥",
|
||||||
|
"David Ortega",
|
||||||
|
"Wolffen",
|
||||||
|
"a _",
|
||||||
|
"Jeff",
|
||||||
|
"nwalker94",
|
||||||
|
"James Coleman",
|
||||||
|
"Kevin Christopher",
|
||||||
|
"Chad Idk",
|
||||||
|
"dd",
|
||||||
|
"Sam",
|
||||||
|
"sjon kreutz",
|
||||||
|
"Metryman55",
|
||||||
|
"AlexDuKaNa",
|
||||||
"ae",
|
"ae",
|
||||||
"Tr4shP4nda",
|
"Tr4shP4nda",
|
||||||
"Gamalonia",
|
"Gamalonia",
|
||||||
@@ -248,37 +256,41 @@
|
|||||||
"Kland",
|
"Kland",
|
||||||
"Hailshem",
|
"Hailshem",
|
||||||
"Naomi Hale Danchi",
|
"Naomi Hale Danchi",
|
||||||
|
"epicgamer0020690",
|
||||||
|
"Joshua Porrata",
|
||||||
|
"Andrew",
|
||||||
"Brian M",
|
"Brian M",
|
||||||
"sanborondon",
|
"Robert Wegemund",
|
||||||
|
"Littlehuggy",
|
||||||
|
"Brian Buie",
|
||||||
"Thought2Form",
|
"Thought2Form",
|
||||||
"jcay015",
|
|
||||||
"RAIDiation",
|
"RAIDiation",
|
||||||
"Erik Lopez",
|
"Sadlip",
|
||||||
"Mateo Curić",
|
|
||||||
"Eris3D",
|
|
||||||
"Gooohokrbe",
|
"Gooohokrbe",
|
||||||
"m",
|
"m",
|
||||||
"OldBones",
|
"OldBones",
|
||||||
"Pierce McBride",
|
"Pierce McBride",
|
||||||
"Zach Gonser",
|
"Zach Gonser",
|
||||||
"Mikko Hemilä",
|
"Mikko Hemilä",
|
||||||
|
"Jacob McDaniel",
|
||||||
"Jamie Ogletree",
|
"Jamie Ogletree",
|
||||||
"a _",
|
"Temikus",
|
||||||
"James Coleman",
|
"Artokun",
|
||||||
|
"Michael Taylor",
|
||||||
"Martial",
|
"Martial",
|
||||||
"Emil Andersson",
|
"Emil Andersson",
|
||||||
"Ouro Boros",
|
"Ouro Boros",
|
||||||
|
"Atilla Berke Pekduyar",
|
||||||
|
"Decx _",
|
||||||
"Yuji Kaneko",
|
"Yuji Kaneko",
|
||||||
"Rops Alot",
|
"Rops Alot",
|
||||||
"Sam",
|
|
||||||
"Penfore",
|
"Penfore",
|
||||||
"Gordon Cole",
|
"Gordon Cole",
|
||||||
"Ace Ventura",
|
"Ace Ventura",
|
||||||
"AbstractAss",
|
"AbstractAss",
|
||||||
"David LaVallee",
|
"David LaVallee",
|
||||||
"ken",
|
"ken",
|
||||||
"epicgamer0020690",
|
"Crocket",
|
||||||
"Joshua Porrata",
|
|
||||||
"keemun",
|
"keemun",
|
||||||
"SuBu",
|
"SuBu",
|
||||||
"RedPIXel",
|
"RedPIXel",
|
||||||
@@ -297,15 +309,19 @@
|
|||||||
"KitKatM",
|
"KitKatM",
|
||||||
"socrasteeze",
|
"socrasteeze",
|
||||||
"MudkipMedkitz",
|
"MudkipMedkitz",
|
||||||
|
"deanbrian",
|
||||||
|
"Alex Wortman",
|
||||||
|
"Cody",
|
||||||
|
"emadsultan",
|
||||||
|
"InformedViewz",
|
||||||
|
"Bubbafett",
|
||||||
|
"leaf",
|
||||||
|
"Adam Rinehart",
|
||||||
"gzmzmvp",
|
"gzmzmvp",
|
||||||
"takyamtom",
|
"takyamtom",
|
||||||
"Andrew",
|
"Aberr",
|
||||||
"Robert Wegemund",
|
|
||||||
"Littlehuggy",
|
|
||||||
"Gregory Kozhemiak",
|
"Gregory Kozhemiak",
|
||||||
"Brian Buie",
|
|
||||||
"aezin",
|
"aezin",
|
||||||
"Sadlip",
|
|
||||||
"Eric Whitney",
|
"Eric Whitney",
|
||||||
"Joey Callahan",
|
"Joey Callahan",
|
||||||
"Ivan Tadic",
|
"Ivan Tadic",
|
||||||
@@ -315,17 +331,12 @@
|
|||||||
"Elliot E",
|
"Elliot E",
|
||||||
"Morgandel",
|
"Morgandel",
|
||||||
"Theerat Jiramate",
|
"Theerat Jiramate",
|
||||||
"Jacob McDaniel",
|
|
||||||
"X",
|
"X",
|
||||||
"SloanSteddyAI",
|
"SloanSteddyAI",
|
||||||
"Temikus",
|
|
||||||
"Artokun",
|
|
||||||
"Michael Taylor",
|
|
||||||
"Steven Owens",
|
"Steven Owens",
|
||||||
|
"hexxish",
|
||||||
"Derek Baker",
|
"Derek Baker",
|
||||||
"Atilla Berke Pekduyar",
|
|
||||||
"NICHOLAS BAXLEY",
|
"NICHOLAS BAXLEY",
|
||||||
"Decx _",
|
|
||||||
"Ed Wang",
|
"Ed Wang",
|
||||||
"Saya",
|
"Saya",
|
||||||
"Xeeosat",
|
"Xeeosat",
|
||||||
@@ -333,18 +344,10 @@
|
|||||||
"四糸凜音",
|
"四糸凜音",
|
||||||
"esthe",
|
"esthe",
|
||||||
"FrxzenSnxw",
|
"FrxzenSnxw",
|
||||||
"Crocket",
|
|
||||||
"chriphost",
|
"chriphost",
|
||||||
"ResidentDeviant",
|
"ResidentDeviant",
|
||||||
"deanbrian",
|
"Ginnie",
|
||||||
"Alex Wortman",
|
|
||||||
"Cody",
|
|
||||||
"emadsultan",
|
|
||||||
"InformedViewz",
|
|
||||||
"Bubbafett",
|
|
||||||
"leaf",
|
|
||||||
"Skyfire83",
|
"Skyfire83",
|
||||||
"Adam Rinehart",
|
|
||||||
"Pitpe11",
|
"Pitpe11",
|
||||||
"IamAyam",
|
"IamAyam",
|
||||||
"TheD1rtyD03",
|
"TheD1rtyD03",
|
||||||
@@ -356,17 +359,25 @@
|
|||||||
"SpringBootisTrash",
|
"SpringBootisTrash",
|
||||||
"carsten",
|
"carsten",
|
||||||
"ikok",
|
"ikok",
|
||||||
|
"quantenmecha",
|
||||||
|
"Jason+Nash",
|
||||||
|
"DarkRoast",
|
||||||
|
"letzte",
|
||||||
|
"Nasty+Hobbit",
|
||||||
|
"Sora+Yori",
|
||||||
|
"Duk3+Rand0m",
|
||||||
"Nathen+Choi",
|
"Nathen+Choi",
|
||||||
"T",
|
"T",
|
||||||
|
"D",
|
||||||
"David Schenck",
|
"David Schenck",
|
||||||
"Wolfe7D1",
|
"Wolfe7D1",
|
||||||
"Aberr",
|
|
||||||
"Andrew Marshall",
|
"Andrew Marshall",
|
||||||
"Taylor Funk",
|
"Taylor Funk",
|
||||||
"elleshar666",
|
"elleshar666",
|
||||||
"Gerald Welly",
|
"Gerald Welly",
|
||||||
"Tee Gee",
|
"Tee Gee",
|
||||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||||
|
"Михал Михалыч",
|
||||||
"tarek helmi",
|
"tarek helmi",
|
||||||
"Kauffy",
|
"Kauffy",
|
||||||
"Max Marklund",
|
"Max Marklund",
|
||||||
@@ -376,13 +387,15 @@
|
|||||||
"Vane Holzer",
|
"Vane Holzer",
|
||||||
"psytrax",
|
"psytrax",
|
||||||
"Cyrus Fett",
|
"Cyrus Fett",
|
||||||
"hexxish",
|
|
||||||
"lh qwe",
|
"lh qwe",
|
||||||
"conner",
|
"conner",
|
||||||
|
"Xenon Xue",
|
||||||
"Michael Anthony Scott",
|
"Michael Anthony Scott",
|
||||||
"notedfakes",
|
"notedfakes",
|
||||||
"Princess Bright Eyes",
|
"Princess Bright Eyes",
|
||||||
"Michael Scott",
|
"Michael Scott",
|
||||||
|
"Solixer",
|
||||||
|
"Jimmy Borup",
|
||||||
"Wes Sims",
|
"Wes Sims",
|
||||||
"Donor4115",
|
"Donor4115",
|
||||||
"Filippo Ferrari",
|
"Filippo Ferrari",
|
||||||
@@ -393,11 +406,19 @@
|
|||||||
"momokai",
|
"momokai",
|
||||||
"몽타주",
|
"몽타주",
|
||||||
"kudari",
|
"kudari",
|
||||||
|
"Whitepinetrader",
|
||||||
"OrganicArtifact",
|
"OrganicArtifact",
|
||||||
"Ginnie",
|
|
||||||
"Raku",
|
"Raku",
|
||||||
"CHKeeho80",
|
"CHKeeho80",
|
||||||
"nanana",
|
"nanana",
|
||||||
|
"Alex",
|
||||||
|
"Karru",
|
||||||
|
"ChaChanoKo",
|
||||||
|
"ghoulars",
|
||||||
|
"null",
|
||||||
|
"Beau",
|
||||||
|
"redcarrot",
|
||||||
|
"powerbot99",
|
||||||
"Fthehappy",
|
"Fthehappy",
|
||||||
"J",
|
"J",
|
||||||
"Jeff+Kesemeyer",
|
"Jeff+Kesemeyer",
|
||||||
@@ -407,39 +428,32 @@
|
|||||||
"Doug+Rintoul",
|
"Doug+Rintoul",
|
||||||
"Noor",
|
"Noor",
|
||||||
"Yorunai",
|
"Yorunai",
|
||||||
"D",
|
|
||||||
"quantenmecha",
|
|
||||||
"Jason+Nash",
|
|
||||||
"DarkRoast",
|
|
||||||
"letzte",
|
|
||||||
"Nasty+Hobbit",
|
|
||||||
"Sora+Yori",
|
|
||||||
"Duk3+Rand0m",
|
|
||||||
"Richard",
|
"Richard",
|
||||||
"奚明 刘",
|
"奚明 刘",
|
||||||
"준희 김",
|
"준희 김",
|
||||||
"りん あめ",
|
"りん あめ",
|
||||||
"Михал Михалыч",
|
|
||||||
"Matt",
|
"Matt",
|
||||||
"Tomohiro Baba",
|
"Tomohiro Baba",
|
||||||
"Noora",
|
"Noora",
|
||||||
"Frogmilk",
|
"Frogmilk",
|
||||||
"SPJ",
|
"SPJ",
|
||||||
|
"Kor",
|
||||||
"Bryan Rutkowski",
|
"Bryan Rutkowski",
|
||||||
"Noah",
|
"Noah",
|
||||||
"Xenon Xue",
|
"TenaciousD",
|
||||||
"Dmitry Ryzhov",
|
"Dmitry Ryzhov",
|
||||||
"DarkSunset",
|
"DarkSunset",
|
||||||
"Edward Ten Eyck",
|
"Edward Ten Eyck",
|
||||||
"Steam Steam",
|
"Steam Steam",
|
||||||
"CryptoTraderJK",
|
"CryptoTraderJK",
|
||||||
"Davaitamin",
|
"Davaitamin",
|
||||||
"Solixer",
|
"Pete Pain",
|
||||||
"Nathan",
|
"Nathan",
|
||||||
"Jimmy Borup",
|
|
||||||
"tedcor",
|
"tedcor",
|
||||||
|
"RHopkirk",
|
||||||
"jinksta187",
|
"jinksta187",
|
||||||
"Fotek Design",
|
"Fotek Design",
|
||||||
|
"Maxim",
|
||||||
"Manu Thetug",
|
"Manu Thetug",
|
||||||
"Lyavph",
|
"Lyavph",
|
||||||
"Nihongasuki",
|
"Nihongasuki",
|
||||||
@@ -450,8 +464,14 @@
|
|||||||
"starbugx",
|
"starbugx",
|
||||||
"dc7431",
|
"dc7431",
|
||||||
"Inversity",
|
"Inversity",
|
||||||
"Whitepinetrader",
|
|
||||||
"Vir",
|
"Vir",
|
||||||
|
"Sildoren",
|
||||||
|
"Darv",
|
||||||
|
"Seon+Song",
|
||||||
|
"2turbo",
|
||||||
|
"Dmitry+Viznesenskiy",
|
||||||
|
"tanjin90",
|
||||||
|
"sternenkrieger",
|
||||||
"Pascalou",
|
"Pascalou",
|
||||||
"Patrick+Bryan",
|
"Patrick+Bryan",
|
||||||
"lighthawke",
|
"lighthawke",
|
||||||
@@ -468,23 +488,17 @@
|
|||||||
"Bob+Barker",
|
"Bob+Barker",
|
||||||
"Dark_Pest",
|
"Dark_Pest",
|
||||||
"Eldithor",
|
"Eldithor",
|
||||||
"Alex",
|
|
||||||
"Karru",
|
|
||||||
"ChaChanoKo",
|
|
||||||
"ghoulars",
|
|
||||||
"redcarrot",
|
|
||||||
"null",
|
|
||||||
"Beau",
|
|
||||||
"powerbot99",
|
|
||||||
"Ko-fi+Supporter",
|
"Ko-fi+Supporter",
|
||||||
"lrdchs2",
|
"lrdchs2",
|
||||||
"Tú Nguyễn Lý Hoàng",
|
"Tú Nguyễn Lý Hoàng",
|
||||||
|
"shira1011",
|
||||||
"Kalli Core",
|
"Kalli Core",
|
||||||
"Ben D",
|
"Ben D",
|
||||||
"Draven T",
|
"Draven T",
|
||||||
"marioandluigi",
|
"marioandluigi",
|
||||||
"G",
|
"G",
|
||||||
"Ronan Delevacq",
|
"Ronan Delevacq",
|
||||||
|
"Leslie Andrew Ridings",
|
||||||
"Aquatic Coffee",
|
"Aquatic Coffee",
|
||||||
"Dave Abraham",
|
"Dave Abraham",
|
||||||
"Joaquin Hierrezuelo",
|
"Joaquin Hierrezuelo",
|
||||||
@@ -492,25 +506,27 @@
|
|||||||
"StudOx Tech",
|
"StudOx Tech",
|
||||||
"yves.poezevara",
|
"yves.poezevara",
|
||||||
"Jarrid Lee",
|
"Jarrid Lee",
|
||||||
"Kor",
|
"Poophead27 Blyat",
|
||||||
"Joseph Hanson",
|
"Joseph Hanson",
|
||||||
"John Rednoulf",
|
"John Rednoulf",
|
||||||
"Focuschannel",
|
"Focuschannel",
|
||||||
"Boba Smith",
|
"Boba Smith",
|
||||||
|
"matt",
|
||||||
|
"somethingtosay8",
|
||||||
"ivistorm",
|
"ivistorm",
|
||||||
"Anthony Faxlandez",
|
"Anthony Faxlandez",
|
||||||
"Sauv",
|
"Sauv",
|
||||||
"TenaciousD",
|
|
||||||
"Ted Cart",
|
"Ted Cart",
|
||||||
|
"Sage Himeros",
|
||||||
"Zeeble",
|
"Zeeble",
|
||||||
"Pat Hen",
|
"Pat Hen",
|
||||||
"Pete Pain",
|
|
||||||
"Draconach",
|
"Draconach",
|
||||||
"Tigon",
|
"Tigon",
|
||||||
|
"ItsGeneralButtNaked",
|
||||||
"Jordan Shaw",
|
"Jordan Shaw",
|
||||||
"RHopkirk",
|
|
||||||
"g unit",
|
"g unit",
|
||||||
"Maxim",
|
"Dkom22",
|
||||||
|
"Marcos Tortosa Carmona",
|
||||||
"Distortik",
|
"Distortik",
|
||||||
"JC",
|
"JC",
|
||||||
"Prompt Pirate",
|
"Prompt Pirate",
|
||||||
@@ -518,11 +534,22 @@
|
|||||||
"Marcus thronico",
|
"Marcus thronico",
|
||||||
"zenobeus",
|
"zenobeus",
|
||||||
"ryoma",
|
"ryoma",
|
||||||
|
"dg",
|
||||||
"Stryker",
|
"Stryker",
|
||||||
"smart.edge5178",
|
"smart.edge5178",
|
||||||
"Menard",
|
"Menard",
|
||||||
"SomeDude",
|
"SomeDude",
|
||||||
"raf8osz",
|
"raf8osz",
|
||||||
|
"Gold_miner_ego",
|
||||||
|
"bakeliteboy",
|
||||||
|
"TequiTequi",
|
||||||
|
"Homero+Banda",
|
||||||
|
"Nick",
|
||||||
|
"Monix",
|
||||||
|
"Trolinka",
|
||||||
|
"PredragR",
|
||||||
|
"Clauzmak",
|
||||||
|
"Nerick",
|
||||||
"SundayRage",
|
"SundayRage",
|
||||||
"matter",
|
"matter",
|
||||||
"SRCRCOSS",
|
"SRCRCOSS",
|
||||||
@@ -539,13 +566,6 @@
|
|||||||
"Mobius2020",
|
"Mobius2020",
|
||||||
"ExLightSaber",
|
"ExLightSaber",
|
||||||
"YaboiRay",
|
"YaboiRay",
|
||||||
"Sildoren",
|
|
||||||
"Darv",
|
|
||||||
"Seon+Song",
|
|
||||||
"2turbo",
|
|
||||||
"Dmitry+Viznesenskiy",
|
|
||||||
"tanjin90",
|
|
||||||
"sternenkrieger",
|
|
||||||
"boston666",
|
"boston666",
|
||||||
"cocona",
|
"cocona",
|
||||||
"Obsidian.Studios",
|
"Obsidian.Studios",
|
||||||
@@ -553,52 +573,53 @@
|
|||||||
"Aquaneo",
|
"Aquaneo",
|
||||||
"blikkies",
|
"blikkies",
|
||||||
"JBsuede",
|
"JBsuede",
|
||||||
"shira1011",
|
"Wolf and Fox Legends",
|
||||||
|
"ゼクス、六",
|
||||||
"Neko Desco",
|
"Neko Desco",
|
||||||
"Vinarus",
|
"Vinarus",
|
||||||
"Josh Snyder",
|
"Josh Snyder",
|
||||||
"Shock Shockor",
|
"Shock Shockor",
|
||||||
"Goldwaters",
|
"Goldwaters",
|
||||||
"Leslie Andrew Ridings",
|
|
||||||
"Zude",
|
"Zude",
|
||||||
"Poophead27 Blyat",
|
"Room Light",
|
||||||
"Kyler",
|
"Kyler",
|
||||||
"Justin Blaylock",
|
"Justin Blaylock",
|
||||||
"aRtFuL_DodGeR",
|
"aRtFuL_DodGeR",
|
||||||
"Snorklebort",
|
"Snorklebort",
|
||||||
"TheFusion",
|
"TheFusion",
|
||||||
"MR.Bear",
|
"MR.Bear",
|
||||||
"matt",
|
|
||||||
"somethingtosay8",
|
|
||||||
"3zS4QNQ4",
|
"3zS4QNQ4",
|
||||||
"Terminuz",
|
"Terminuz",
|
||||||
"Matt M.",
|
"Matt M.",
|
||||||
"Ivan Imes",
|
"Ivan Imes",
|
||||||
|
"J M",
|
||||||
"Steven",
|
"Steven",
|
||||||
"Borte",
|
"Borte",
|
||||||
"Sage Himeros",
|
"yyuvuvu",
|
||||||
"Billy Gladky",
|
"Billy Gladky",
|
||||||
|
"Nomki",
|
||||||
"Probis",
|
"Probis",
|
||||||
"Jack Lawfield",
|
"Jack Lawfield",
|
||||||
"SkibidiRizzler",
|
"SkibidiRizzler",
|
||||||
"Maxon - Plans",
|
"Maxon - Plans",
|
||||||
"Kalle Björk",
|
"Kalle Björk",
|
||||||
"ItsGeneralButtNaked",
|
|
||||||
"Karlanx",
|
"Karlanx",
|
||||||
"operationancut",
|
"operationancut",
|
||||||
"Nacho Ferrando",
|
"Nacho Ferrando",
|
||||||
"Marcos Tortosa Carmona",
|
|
||||||
"Dkom22",
|
|
||||||
"Youguang",
|
"Youguang",
|
||||||
"andrewzpong",
|
"andrewzpong",
|
||||||
"BossGame",
|
"BossGame",
|
||||||
"lrdchs",
|
"lrdchs",
|
||||||
"Tree Tagger",
|
"Tree Tagger",
|
||||||
|
"Janik",
|
||||||
"AIVORY3D",
|
"AIVORY3D",
|
||||||
"Kevinj",
|
"Kevinj",
|
||||||
"Mitchell Robson",
|
"Mitchell Robson",
|
||||||
"dg",
|
|
||||||
"POPPIN",
|
"POPPIN",
|
||||||
|
"meatyalien",
|
||||||
|
"Tony+V",
|
||||||
|
"draganjankovic1975dj528",
|
||||||
|
"kinz",
|
||||||
"YoruHime",
|
"YoruHime",
|
||||||
"Mark+Staaf",
|
"Mark+Staaf",
|
||||||
"Michael+Fürmann",
|
"Michael+Fürmann",
|
||||||
@@ -611,17 +632,7 @@
|
|||||||
"thomasand01",
|
"thomasand01",
|
||||||
"Shiba+Sama",
|
"Shiba+Sama",
|
||||||
"Celestial+Kitten",
|
"Celestial+Kitten",
|
||||||
"TequiTequi",
|
|
||||||
"Homero+Banda",
|
|
||||||
"bakeliteboy",
|
|
||||||
"Nick",
|
|
||||||
"Gold_miner_ego",
|
|
||||||
"IshouI;_;",
|
"IshouI;_;",
|
||||||
"Monix",
|
|
||||||
"Trolinka",
|
|
||||||
"PredragR",
|
|
||||||
"Clauzmak",
|
|
||||||
"Nerick",
|
|
||||||
"SAVEagleBasement",
|
"SAVEagleBasement",
|
||||||
"Adam+Spreer",
|
"Adam+Spreer",
|
||||||
"BillyBoy84",
|
"BillyBoy84",
|
||||||
@@ -629,18 +640,17 @@
|
|||||||
"Welkor",
|
"Welkor",
|
||||||
"dubious1one",
|
"dubious1one",
|
||||||
"Brandon Thomas",
|
"Brandon Thomas",
|
||||||
|
"Dustin Hendel",
|
||||||
"moranqianlong",
|
"moranqianlong",
|
||||||
"Wolf and Fox Legends",
|
|
||||||
"ゼクス、六",
|
|
||||||
"Liberation",
|
"Liberation",
|
||||||
"Ninja Tom",
|
"Ninja Tom",
|
||||||
"75marc",
|
"75marc",
|
||||||
"Elemnt",
|
"Elemnt",
|
||||||
|
"Bradley Turner",
|
||||||
"swra",
|
"swra",
|
||||||
"JollRodrigo",
|
"JollRodrigo",
|
||||||
"Oliverfish",
|
"Oliverfish",
|
||||||
"uruksayshi",
|
"uruksayshi",
|
||||||
"Room Light",
|
|
||||||
"Patryk Serious",
|
"Patryk Serious",
|
||||||
"nk8",
|
"nk8",
|
||||||
"Kyron Mahan",
|
"Kyron Mahan",
|
||||||
@@ -648,17 +658,18 @@
|
|||||||
"Nimhloth",
|
"Nimhloth",
|
||||||
"TBitz33",
|
"TBitz33",
|
||||||
"Anonym dkjglfleeoeldldldlkf",
|
"Anonym dkjglfleeoeldldldlkf",
|
||||||
|
"Tsani Prodanov",
|
||||||
"Ezokewn",
|
"Ezokewn",
|
||||||
"SendingRavens",
|
"SendingRavens",
|
||||||
"J M",
|
|
||||||
"Slacks",
|
"Slacks",
|
||||||
"Glenn Hoetker",
|
"Glenn Hoetker",
|
||||||
"JackJohnnyJim",
|
"JackJohnnyJim",
|
||||||
"Khánh Đặng",
|
"Khánh Đặng",
|
||||||
|
"Michael Hicks",
|
||||||
"Homero Banda",
|
"Homero Banda",
|
||||||
"Michael Docherty",
|
"Michael Docherty",
|
||||||
"yyuvuvu",
|
"MadGod",
|
||||||
"Nomki",
|
"GhostyGhost",
|
||||||
"Paul Hartsuyker",
|
"Paul Hartsuyker",
|
||||||
"elitassj",
|
"elitassj",
|
||||||
"Never_M",
|
"Never_M",
|
||||||
@@ -667,6 +678,7 @@
|
|||||||
"Andrew Wilkinson",
|
"Andrew Wilkinson",
|
||||||
"David",
|
"David",
|
||||||
"floeki75pad",
|
"floeki75pad",
|
||||||
|
"TheJohnes",
|
||||||
"deadwishd",
|
"deadwishd",
|
||||||
"shinonomeiro",
|
"shinonomeiro",
|
||||||
"Snille",
|
"Snille",
|
||||||
@@ -675,7 +687,6 @@
|
|||||||
"xybrightsummer",
|
"xybrightsummer",
|
||||||
"jreedatchison",
|
"jreedatchison",
|
||||||
"PhilW",
|
"PhilW",
|
||||||
"Janik",
|
|
||||||
"Cruel",
|
"Cruel",
|
||||||
"MRBlack",
|
"MRBlack",
|
||||||
"Kiyoe",
|
"Kiyoe",
|
||||||
@@ -685,6 +696,15 @@
|
|||||||
"Scott",
|
"Scott",
|
||||||
"Muratoraccio",
|
"Muratoraccio",
|
||||||
"D",
|
"D",
|
||||||
|
"Daevalus",
|
||||||
|
"Milky+Mai",
|
||||||
|
"Krash",
|
||||||
|
"PP",
|
||||||
|
"thababydjac",
|
||||||
|
"belligerencebk",
|
||||||
|
"tortor",
|
||||||
|
"Peter",
|
||||||
|
"T",
|
||||||
"zipzorpp",
|
"zipzorpp",
|
||||||
"Anton",
|
"Anton",
|
||||||
"actual",
|
"actual",
|
||||||
@@ -706,11 +726,7 @@
|
|||||||
"plonk",
|
"plonk",
|
||||||
"Anvil+Girl",
|
"Anvil+Girl",
|
||||||
"Kotetsu",
|
"Kotetsu",
|
||||||
"meatyalien",
|
|
||||||
"Tony+V",
|
|
||||||
"draganjankovic1975dj528",
|
|
||||||
"miduzza",
|
"miduzza",
|
||||||
"kinz",
|
|
||||||
"Somebody",
|
"Somebody",
|
||||||
"てぃんてぃんひーろー",
|
"てぃんてぃんひーろー",
|
||||||
"you+halo9",
|
"you+halo9",
|
||||||
@@ -727,12 +743,12 @@
|
|||||||
"4IXplr0r3r",
|
"4IXplr0r3r",
|
||||||
"hayden",
|
"hayden",
|
||||||
"ahoystan",
|
"ahoystan",
|
||||||
|
"Civitaier",
|
||||||
"BakunyuuWaifu",
|
"BakunyuuWaifu",
|
||||||
"edk",
|
"edk",
|
||||||
"Dustin Hendel",
|
"Joey Leto",
|
||||||
"Anagra Nouma",
|
"Anagra Nouma",
|
||||||
"tafapayo",
|
"tafapayo",
|
||||||
"Bradley Turner",
|
|
||||||
"ja s",
|
"ja s",
|
||||||
"Doug Mason",
|
"Doug Mason",
|
||||||
"scoreswazey",
|
"scoreswazey",
|
||||||
@@ -747,8 +763,8 @@
|
|||||||
"David Murcko",
|
"David Murcko",
|
||||||
"Justin Defer",
|
"Justin Defer",
|
||||||
"Ben Brogger",
|
"Ben Brogger",
|
||||||
"Tsani Prodanov",
|
|
||||||
"Jack Dole",
|
"Jack Dole",
|
||||||
|
"dsffsdfsdfsdfsdfsdf",
|
||||||
"V Bj",
|
"V Bj",
|
||||||
"Rj Joplin",
|
"Rj Joplin",
|
||||||
"Kurt",
|
"Kurt",
|
||||||
@@ -757,15 +773,13 @@
|
|||||||
"Taylor Dominy",
|
"Taylor Dominy",
|
||||||
"Faith",
|
"Faith",
|
||||||
"Bouya shaka",
|
"Bouya shaka",
|
||||||
"Michael Hicks",
|
|
||||||
"Maso",
|
"Maso",
|
||||||
"MadGod",
|
|
||||||
"Kevin Wallace",
|
"Kevin Wallace",
|
||||||
"GhostyGhost",
|
|
||||||
"ChicRic",
|
"ChicRic",
|
||||||
"Bastard-Sama",
|
"Bastard-Sama",
|
||||||
"mercur",
|
"mercur",
|
||||||
"Sunny",
|
"Sunny",
|
||||||
|
"Somebody",
|
||||||
"inusanorthcape",
|
"inusanorthcape",
|
||||||
"Kane Sturzebecher",
|
"Kane Sturzebecher",
|
||||||
"Yavizu3d",
|
"Yavizu3d",
|
||||||
@@ -776,7 +790,6 @@
|
|||||||
"Evgeniya Smolentseva",
|
"Evgeniya Smolentseva",
|
||||||
"Raf Stahelin",
|
"Raf Stahelin",
|
||||||
"Вячеслав Маринин",
|
"Вячеслав Маринин",
|
||||||
"TheJohnes",
|
|
||||||
"Cola Matthew",
|
"Cola Matthew",
|
||||||
"OniNoKen",
|
"OniNoKen",
|
||||||
"Iain Wisely",
|
"Iain Wisely",
|
||||||
@@ -819,6 +832,12 @@
|
|||||||
"SelfishMedic",
|
"SelfishMedic",
|
||||||
"adderleighn",
|
"adderleighn",
|
||||||
"EnragedAntelope",
|
"EnragedAntelope",
|
||||||
|
"mcmalt",
|
||||||
|
"cesasol",
|
||||||
|
"Null",
|
||||||
|
"fdfac",
|
||||||
|
"Eli",
|
||||||
|
"Somebody",
|
||||||
"8/4",
|
"8/4",
|
||||||
"ivan.morgado.siles",
|
"ivan.morgado.siles",
|
||||||
"SEI",
|
"SEI",
|
||||||
@@ -830,16 +849,7 @@
|
|||||||
"gdfgfdgfds",
|
"gdfgfdgfds",
|
||||||
"Benjamin+Doerr",
|
"Benjamin+Doerr",
|
||||||
"D",
|
"D",
|
||||||
"Daevalus",
|
|
||||||
"MilkyMai",
|
|
||||||
"Krash",
|
|
||||||
"PP",
|
|
||||||
"babydjac",
|
|
||||||
"belligerencebk",
|
|
||||||
"tortor",
|
|
||||||
"Cryphius",
|
"Cryphius",
|
||||||
"Peter+Timothy+Stover",
|
|
||||||
"Joel+Magnusson",
|
|
||||||
"Connor+Hall",
|
"Connor+Hall",
|
||||||
"Macho+Grump",
|
"Macho+Grump",
|
||||||
"Morcoddd",
|
"Morcoddd",
|
||||||
@@ -879,13 +889,11 @@
|
|||||||
"proto merp",
|
"proto merp",
|
||||||
"_ G3n",
|
"_ G3n",
|
||||||
"Donovan Jenkins",
|
"Donovan Jenkins",
|
||||||
"Civitaier",
|
|
||||||
"Hans Meier",
|
"Hans Meier",
|
||||||
"jboul",
|
"jboul",
|
||||||
"Michael Eid",
|
"Michael Eid",
|
||||||
"Super Sigma Reborne",
|
"Super Sigma Reborne",
|
||||||
"Veloce",
|
"Veloce",
|
||||||
"Joey Leto",
|
|
||||||
"Bob barker",
|
"Bob barker",
|
||||||
"Michael Rivera",
|
"Michael Rivera",
|
||||||
"karim ben brik",
|
"karim ben brik",
|
||||||
@@ -916,6 +924,7 @@
|
|||||||
"DrB",
|
"DrB",
|
||||||
"wknight",
|
"wknight",
|
||||||
"Moneymaker412K",
|
"Moneymaker412K",
|
||||||
|
"Jacid",
|
||||||
"unkeiknown",
|
"unkeiknown",
|
||||||
"Towelie",
|
"Towelie",
|
||||||
"Alex Ross",
|
"Alex Ross",
|
||||||
@@ -926,10 +935,12 @@
|
|||||||
"john Greene",
|
"john Greene",
|
||||||
"jimyjomson",
|
"jimyjomson",
|
||||||
"JaeHyun Jang",
|
"JaeHyun Jang",
|
||||||
|
"sbone",
|
||||||
"BigBoss",
|
"BigBoss",
|
||||||
"Chase Kwon",
|
"Chase Kwon",
|
||||||
"Bob Ling",
|
"Bob Ling",
|
||||||
"Inyoshu",
|
"Inyoshu",
|
||||||
|
"nick Meadows",
|
||||||
"Chad Barnes",
|
"Chad Barnes",
|
||||||
"redlines3",
|
"redlines3",
|
||||||
"Adam Gardner",
|
"Adam Gardner",
|
||||||
@@ -944,6 +955,7 @@
|
|||||||
"Somebody",
|
"Somebody",
|
||||||
"Somebody",
|
"Somebody",
|
||||||
"Somebody",
|
"Somebody",
|
||||||
|
"Somebody",
|
||||||
"CoffeeMage",
|
"CoffeeMage",
|
||||||
"Ken+Suzuki",
|
"Ken+Suzuki",
|
||||||
"hannibal",
|
"hannibal",
|
||||||
@@ -954,8 +966,7 @@
|
|||||||
"L C",
|
"L C",
|
||||||
"Dude",
|
"Dude",
|
||||||
"Somebody",
|
"Somebody",
|
||||||
"Somebody",
|
|
||||||
"CK"
|
"CK"
|
||||||
],
|
],
|
||||||
"totalCount": 954
|
"totalCount": 965
|
||||||
}
|
}
|
||||||
+53
-1
@@ -71,9 +71,18 @@ Enriches models linked to an external model site with metadata extracted by an L
|
|||||||
| Platform | Link | AI enrichment | Direct download |
|
| Platform | Link | AI enrichment | Direct download |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Hugging Face | yes | yes | yes |
|
| Hugging Face | yes | yes | yes |
|
||||||
| ModelScope | yes | yes | yes |
|
| ModelScope (`modelscope.cn`) | yes | yes | yes |
|
||||||
|
| ModelScope International (`modelscope.ai`) | yes | yes | yes |
|
||||||
| TensorArt | yes | no (see below) | no |
|
| TensorArt | yes | no (see below) | no |
|
||||||
|
|
||||||
|
`modelscope.cn` and `modelscope.ai` are **separate catalogues, not mirrors** — a
|
||||||
|
repository published on one is routinely absent from the other — so each is
|
||||||
|
registered as its own source (`ModelScopeSource` / `ModelScopeIntlSource` in
|
||||||
|
`py/services/model_sources/modelscope.py`). The host therefore decides which
|
||||||
|
API and CDN a model resolves against, and the two deployments get separate
|
||||||
|
version groups (`ms:` / `msai:`) and default download directories. Keep the two
|
||||||
|
tables in `modelSourceHelpers.js` and `registry.py` in step when adding a site.
|
||||||
|
|
||||||
TensorArt is link-only: `tensor.art` sits behind a Cloudflare managed challenge and its internal API requires session authorization, so the backend cannot read its model pages. Linking still stores the canonical page URL and the "View on TensorArt" link works.
|
TensorArt is link-only: `tensor.art` sits behind a Cloudflare managed challenge and its internal API requires session authorization, so the backend cannot read its model pages. Linking still stores the canonical page URL and the "View on TensorArt" link works.
|
||||||
|
|
||||||
**What it does**:
|
**What it does**:
|
||||||
@@ -133,7 +142,9 @@ gaps the LLM leaves behind:
|
|||||||
|
|
||||||
| Field | Deterministic source | LLM role |
|
| Field | Deterministic source | LLM role |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| `model_name` | site display name (`Name`), written only while the value is still the file stem | — |
|
||||||
| `modelDescription` | author summary + README as HTML | — |
|
| `modelDescription` | author summary + README as HTML | — |
|
||||||
|
| `civitai.name` | the matched version's label (`modelVersion.showName`) | — |
|
||||||
| `civitai.images` | site example images, then README images | — |
|
| `civitai.images` | site example images, then README images | — |
|
||||||
| `preview_url` | first available example image | may propose one from the README |
|
| `preview_url` | first available example image | may propose one from the README |
|
||||||
| `tags` | site-curated tags, always merged in | proposes additional content tags |
|
| `tags` | site-curated tags, always merged in | proposes additional content tags |
|
||||||
@@ -147,6 +158,47 @@ Models with no source, an unknown source, or a source without model-card access
|
|||||||
|
|
||||||
**Model types**: LoRA, Checkpoint, Embedding
|
**Model types**: LoRA, Checkpoint, Embedding
|
||||||
|
|
||||||
|
### Download-time hydration
|
||||||
|
|
||||||
|
The same deterministic mapping runs automatically when a model is downloaded
|
||||||
|
from a model source, so a ModelScope or Hugging Face download lands with the
|
||||||
|
populated card a CivitAI download produces instead of a bare filename and
|
||||||
|
hash. Nothing needs to be triggered by hand and no provider is called.
|
||||||
|
|
||||||
|
`py/services/model_sources/hydration.py` owns this path:
|
||||||
|
|
||||||
|
* `_save_source_metadata()` in `py/routes/handlers/model_source_handlers.py`
|
||||||
|
creates the sidecar (hash, source link, scanner-cache entry) and then calls
|
||||||
|
`hydrate_from_source()`. It also runs for a file that was already on disk, so
|
||||||
|
models downloaded before this existed get topped up on the next attempt.
|
||||||
|
* Metadata is created through the **owning scanner**
|
||||||
|
(`scanner._create_default_metadata()`) rather than
|
||||||
|
`MetadataManager.create_default_metadata()`, so the per-type lazy-hash rule
|
||||||
|
applies: `CheckpointScanner` and `OtherScanner` store
|
||||||
|
`hash_status="pending"` with an empty `sha256` for their multi-GB files, and
|
||||||
|
the generic helper would read a 10 GB checkpoint end to end inside the
|
||||||
|
download request. Hydration copes with the empty hash — `_matching_versions()`
|
||||||
|
falls back to the repository basename, which the download just wrote.
|
||||||
|
* Hydration reuses `PostProcessor` with an empty `llm_output`, so the two paths
|
||||||
|
cannot drift apart. It reports `metadata_source = "source:<platform>"` rather
|
||||||
|
than the skill's `agent:enrich_hf_metadata`, and — because no provider ran —
|
||||||
|
it does not stamp `llm_enriched_at`.
|
||||||
|
* `model_name` is only written while it still equals the file stem: once a user
|
||||||
|
renames a model, that choice is kept.
|
||||||
|
* Only a model whose stored `source_platform`/`source_url` match the repository
|
||||||
|
being downloaded is updated; a local file that merely shares a name must not
|
||||||
|
receive another model's card.
|
||||||
|
* The README and repository payload describe the *repository*, so a short-lived
|
||||||
|
process-wide `ModelSourceCache` (`shared_source_cache`, 300 s, 32 entries)
|
||||||
|
keeps a batch over one repository to two HTTP requests.
|
||||||
|
* Every failure — unreachable site, changed payload shape, broken post-processor
|
||||||
|
— is logged and swallowed. Metadata hydration can never fail a download.
|
||||||
|
* Neither stage advances the byte counter, so both are announced to the
|
||||||
|
progress UI (`_report_phase()` → `{"status": "metadata", "stage": ...}`) as
|
||||||
|
they start. Without that the bar sits at 100% reporting `0 B/s` for several
|
||||||
|
seconds and the download looks stuck. `stage` and `platform` are
|
||||||
|
machine-readable; the wording is localised in `LoadingManager`.
|
||||||
|
|
||||||
## Adding a New Skill
|
## Adding a New Skill
|
||||||
|
|
||||||
### 1. Create the skill directory
|
### 1. Create the skill directory
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This document is the canonical set of conventions for translating LoRA Manager U
|
|||||||
It applies to **human translators and AI agents** alike. Read it before editing anything in
|
It applies to **human translators and AI agents** alike. Read it before editing anything in
|
||||||
`locales/`.
|
`locales/`.
|
||||||
|
|
||||||
Source of truth: `locales/en.json` (10 locales, 2022 leaf keys; all locales share the exact
|
Source of truth: `locales/en.json` (10 locales, 2025 leaf keys; all locales share the exact
|
||||||
same key structure).
|
same key structure).
|
||||||
|
|
||||||
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||||
@@ -49,6 +49,13 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
|||||||
> placeholders left behind by the feature series, and all 35 are now translated in all 9
|
> placeholders left behind by the feature series, and all 35 are now translated in all 9
|
||||||
> locales, so the "no remaining placeholders" claim above holds again. Terminology in §2,
|
> locales, so the "no remaining placeholders" claim above holds again. Terminology in §2,
|
||||||
> "Folder sidebar feature".
|
> "Folder sidebar feature".
|
||||||
|
>
|
||||||
|
> **Status (2026-09, chip reordering):** model tags and trigger words now share one drag/`⠿`
|
||||||
|
> grip reorder affordance, which added the single `common.reorder.dragHandle` key (it lives
|
||||||
|
> under `common` because both editors render it). All 9 locales are translated (renderings in
|
||||||
|
> §2, "Chip reordering"). Reordering is pointer-only by design: an `Alt + Arrow` shortcut was
|
||||||
|
> prototyped and removed because it collided with the browser's Alt + Arrow handling and the
|
||||||
|
> modal's arrow-key navigation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -355,6 +362,26 @@ clause (and its `—`) when the copy is edited. The `{name}` / `{count}` / `{mes
|
|||||||
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
|
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
|
||||||
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
|
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
|
||||||
|
|
||||||
|
### Chip reordering (model tags / trigger words)
|
||||||
|
|
||||||
|
Model tags and trigger-word chips share a single reorder affordance (drag the chip, or its
|
||||||
|
`⠿` grip where the chip body is click-to-edit), so the copy sits in `common.reorder.dragHandle`
|
||||||
|
instead of a feature namespace. It is used twice per editor: as the grip tooltip and as the
|
||||||
|
hint shown in the edit controls row. There is deliberately **no keyboard shortcut** — an
|
||||||
|
`Alt + Arrow` binding fought the browser's own Alt + Arrow handling and the modal's arrow-key
|
||||||
|
navigation, so reordering is pointer-only and the grip is a decorative, non-focusable
|
||||||
|
affordance. Do not reintroduce a shortcut or a "position X of Y" screen-reader string without
|
||||||
|
re-adding the corresponding keys.
|
||||||
|
|
||||||
|
`dragHandle` is a fragment, not a sentence: it labels both the grip and the hint, so keep it
|
||||||
|
short and imperative and do not append a keyboard hint in any locale.
|
||||||
|
|
||||||
|
| Term | Rendering |
|
||||||
|
|---|---|
|
||||||
|
| drag to reorder | zh-CN 拖拽以调整顺序 · zh-TW 拖曳以調整順序 · ja ドラッグして並べ替え · ko 드래그하여 순서 변경 · fr Glisser pour réordonner · de Zum Neuordnen ziehen · es Arrastra para reordenar · ru Перетащите, чтобы изменить порядок · he גרור כדי לשנות סדר |
|
||||||
|
|
||||||
|
The grip itself is an icon and is never translated.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Cross-cutting confusion hot-spots (must-fix list)
|
## 3. Cross-cutting confusion hot-spots (must-fix list)
|
||||||
|
|||||||
+10
-1
@@ -2,6 +2,9 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"confirm": "Bestätigen",
|
"confirm": "Bestätigen",
|
||||||
|
"reorder": {
|
||||||
|
"dragHandle": "Zum Neuordnen ziehen"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
@@ -915,7 +918,9 @@
|
|||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"id": "ID"
|
"id": "ID",
|
||||||
|
"baseModel": "Basismodell",
|
||||||
|
"unknown": "Unbekannt"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"openFileLocation": "Dateispeicherort öffnen",
|
"openFileLocation": "Dateispeicherort öffnen",
|
||||||
@@ -1481,6 +1486,10 @@
|
|||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "Aktuelle Datei:",
|
"currentFile": "Aktuelle Datei:",
|
||||||
"downloading": "Wird heruntergeladen: {name}",
|
"downloading": "Wird heruntergeladen: {name}",
|
||||||
|
"metadata": "Metadaten: {name}",
|
||||||
|
"indexingFile": "Modelldatei wird gelesen...",
|
||||||
|
"fetchingSourceMetadata": "Metadaten werden von {source} abgerufen...",
|
||||||
|
"fetchingMetadata": "Metadaten werden abgerufen...",
|
||||||
"transferred": "Heruntergeladen: {downloaded} / {total}",
|
"transferred": "Heruntergeladen: {downloaded} / {total}",
|
||||||
"transferredSimple": "Heruntergeladen: {downloaded}",
|
"transferredSimple": "Heruntergeladen: {downloaded}",
|
||||||
"transferredUnknown": "Heruntergeladen: --",
|
"transferredUnknown": "Heruntergeladen: --",
|
||||||
|
|||||||
+10
-1
@@ -2,6 +2,9 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
|
"reorder": {
|
||||||
|
"dragHandle": "Drag to reorder"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@@ -915,7 +918,9 @@
|
|||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"id": "ID"
|
"id": "ID",
|
||||||
|
"baseModel": "Base Model",
|
||||||
|
"unknown": "Unknown"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"openFileLocation": "Open File Location",
|
"openFileLocation": "Open File Location",
|
||||||
@@ -1481,6 +1486,10 @@
|
|||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "Current file:",
|
"currentFile": "Current file:",
|
||||||
"downloading": "Downloading: {name}",
|
"downloading": "Downloading: {name}",
|
||||||
|
"metadata": "Metadata: {name}",
|
||||||
|
"indexingFile": "Reading model file...",
|
||||||
|
"fetchingSourceMetadata": "Fetching metadata from {source}...",
|
||||||
|
"fetchingMetadata": "Fetching metadata...",
|
||||||
"transferred": "Transferred: {downloaded} / {total}",
|
"transferred": "Transferred: {downloaded} / {total}",
|
||||||
"transferredSimple": "Transferred: {downloaded}",
|
"transferredSimple": "Transferred: {downloaded}",
|
||||||
"transferredUnknown": "Transferred: --",
|
"transferredUnknown": "Transferred: --",
|
||||||
|
|||||||
+10
-1
@@ -2,6 +2,9 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"confirm": "Confirmar",
|
"confirm": "Confirmar",
|
||||||
|
"reorder": {
|
||||||
|
"dragHandle": "Arrastra para reordenar"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
@@ -915,7 +918,9 @@
|
|||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"id": "ID"
|
"id": "ID",
|
||||||
|
"baseModel": "Modelo base",
|
||||||
|
"unknown": "Desconocido"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"openFileLocation": "Abrir ubicación del archivo",
|
"openFileLocation": "Abrir ubicación del archivo",
|
||||||
@@ -1481,6 +1486,10 @@
|
|||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "Archivo actual:",
|
"currentFile": "Archivo actual:",
|
||||||
"downloading": "Descargando: {name}",
|
"downloading": "Descargando: {name}",
|
||||||
|
"metadata": "Metadatos: {name}",
|
||||||
|
"indexingFile": "Leyendo el archivo de modelo...",
|
||||||
|
"fetchingSourceMetadata": "Obteniendo metadatos de {source}...",
|
||||||
|
"fetchingMetadata": "Obteniendo metadatos...",
|
||||||
"transferred": "Descargado: {downloaded} / {total}",
|
"transferred": "Descargado: {downloaded} / {total}",
|
||||||
"transferredSimple": "Descargado: {downloaded}",
|
"transferredSimple": "Descargado: {downloaded}",
|
||||||
"transferredUnknown": "Descargado: --",
|
"transferredUnknown": "Descargado: --",
|
||||||
|
|||||||
+10
-1
@@ -2,6 +2,9 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"confirm": "Confirmer",
|
"confirm": "Confirmer",
|
||||||
|
"reorder": {
|
||||||
|
"dragHandle": "Glisser pour réordonner"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
@@ -915,7 +918,9 @@
|
|||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"id": "ID"
|
"id": "ID",
|
||||||
|
"baseModel": "Modèle de base",
|
||||||
|
"unknown": "Inconnu"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"openFileLocation": "Ouvrir l’emplacement du fichier",
|
"openFileLocation": "Ouvrir l’emplacement du fichier",
|
||||||
@@ -1481,6 +1486,10 @@
|
|||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "Fichier actuel :",
|
"currentFile": "Fichier actuel :",
|
||||||
"downloading": "Téléchargement : {name}",
|
"downloading": "Téléchargement : {name}",
|
||||||
|
"metadata": "Métadonnées : {name}",
|
||||||
|
"indexingFile": "Lecture du fichier de modèle...",
|
||||||
|
"fetchingSourceMetadata": "Récupération des métadonnées depuis {source}...",
|
||||||
|
"fetchingMetadata": "Récupération des métadonnées...",
|
||||||
"transferred": "Téléchargé : {downloaded} / {total}",
|
"transferred": "Téléchargé : {downloaded} / {total}",
|
||||||
"transferredSimple": "Téléchargé : {downloaded}",
|
"transferredSimple": "Téléchargé : {downloaded}",
|
||||||
"transferredUnknown": "Téléchargé : --",
|
"transferredUnknown": "Téléchargé : --",
|
||||||
|
|||||||
+10
-1
@@ -2,6 +2,9 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"cancel": "ביטול",
|
"cancel": "ביטול",
|
||||||
"confirm": "אישור",
|
"confirm": "אישור",
|
||||||
|
"reorder": {
|
||||||
|
"dragHandle": "גרור כדי לשנות סדר"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "שמירה",
|
"save": "שמירה",
|
||||||
"cancel": "ביטול",
|
"cancel": "ביטול",
|
||||||
@@ -915,7 +918,9 @@
|
|||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"id": "ID"
|
"id": "ID",
|
||||||
|
"baseModel": "מודל בסיס",
|
||||||
|
"unknown": "לא ידוע"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"openFileLocation": "פתח מיקום קובץ",
|
"openFileLocation": "פתח מיקום קובץ",
|
||||||
@@ -1481,6 +1486,10 @@
|
|||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "הקובץ הנוכחי:",
|
"currentFile": "הקובץ הנוכחי:",
|
||||||
"downloading": "מוריד: {name}",
|
"downloading": "מוריד: {name}",
|
||||||
|
"metadata": "מטא-נתונים: {name}",
|
||||||
|
"indexingFile": "קורא קובץ מודל...",
|
||||||
|
"fetchingSourceMetadata": "מביא מטא-נתונים מ-{source}...",
|
||||||
|
"fetchingMetadata": "מביא מטא-נתונים...",
|
||||||
"transferred": "הורד: {downloaded} / {total}",
|
"transferred": "הורד: {downloaded} / {total}",
|
||||||
"transferredSimple": "הורד: {downloaded}",
|
"transferredSimple": "הורד: {downloaded}",
|
||||||
"transferredUnknown": "הורד: --",
|
"transferredUnknown": "הורד: --",
|
||||||
|
|||||||
+10
-1
@@ -2,6 +2,9 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"confirm": "確認",
|
"confirm": "確認",
|
||||||
|
"reorder": {
|
||||||
|
"dragHandle": "ドラッグして並べ替え"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
@@ -915,7 +918,9 @@
|
|||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"id": "ID"
|
"id": "ID",
|
||||||
|
"baseModel": "ベースモデル",
|
||||||
|
"unknown": "不明"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"openFileLocation": "ファイルの場所を開く",
|
"openFileLocation": "ファイルの場所を開く",
|
||||||
@@ -1481,6 +1486,10 @@
|
|||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "現在のファイル:",
|
"currentFile": "現在のファイル:",
|
||||||
"downloading": "ダウンロード中: {name}",
|
"downloading": "ダウンロード中: {name}",
|
||||||
|
"metadata": "メタデータ: {name}",
|
||||||
|
"indexingFile": "モデルファイルを読み込み中...",
|
||||||
|
"fetchingSourceMetadata": "{source} からメタデータを取得中...",
|
||||||
|
"fetchingMetadata": "メタデータを取得中...",
|
||||||
"transferred": "ダウンロード済み: {downloaded} / {total}",
|
"transferred": "ダウンロード済み: {downloaded} / {total}",
|
||||||
"transferredSimple": "ダウンロード済み: {downloaded}",
|
"transferredSimple": "ダウンロード済み: {downloaded}",
|
||||||
"transferredUnknown": "ダウンロード済み: --",
|
"transferredUnknown": "ダウンロード済み: --",
|
||||||
|
|||||||
+10
-1
@@ -2,6 +2,9 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
"confirm": "확인",
|
"confirm": "확인",
|
||||||
|
"reorder": {
|
||||||
|
"dragHandle": "드래그하여 순서 변경"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "저장",
|
"save": "저장",
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
@@ -915,7 +918,9 @@
|
|||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"id": "ID"
|
"id": "ID",
|
||||||
|
"baseModel": "베이스 모델",
|
||||||
|
"unknown": "알 수 없음"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"openFileLocation": "파일 위치 열기",
|
"openFileLocation": "파일 위치 열기",
|
||||||
@@ -1481,6 +1486,10 @@
|
|||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "현재 파일:",
|
"currentFile": "현재 파일:",
|
||||||
"downloading": "다운로드 중: {name}",
|
"downloading": "다운로드 중: {name}",
|
||||||
|
"metadata": "메타데이터: {name}",
|
||||||
|
"indexingFile": "모델 파일 읽는 중...",
|
||||||
|
"fetchingSourceMetadata": "{source}에서 메타데이터 가져오는 중...",
|
||||||
|
"fetchingMetadata": "메타데이터 가져오는 중...",
|
||||||
"transferred": "다운로드됨: {downloaded} / {total}",
|
"transferred": "다운로드됨: {downloaded} / {total}",
|
||||||
"transferredSimple": "다운로드됨: {downloaded}",
|
"transferredSimple": "다운로드됨: {downloaded}",
|
||||||
"transferredUnknown": "다운로드됨: --",
|
"transferredUnknown": "다운로드됨: --",
|
||||||
|
|||||||
+10
-1
@@ -2,6 +2,9 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
|
"reorder": {
|
||||||
|
"dragHandle": "Перетащите, чтобы изменить порядок"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
@@ -915,7 +918,9 @@
|
|||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"id": "ID"
|
"id": "ID",
|
||||||
|
"baseModel": "Базовая модель",
|
||||||
|
"unknown": "Неизвестно"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"openFileLocation": "Открыть расположение файла",
|
"openFileLocation": "Открыть расположение файла",
|
||||||
@@ -1481,6 +1486,10 @@
|
|||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "Текущий файл:",
|
"currentFile": "Текущий файл:",
|
||||||
"downloading": "Скачивается: {name}",
|
"downloading": "Скачивается: {name}",
|
||||||
|
"metadata": "Метаданные: {name}",
|
||||||
|
"indexingFile": "Чтение файла модели...",
|
||||||
|
"fetchingSourceMetadata": "Получение метаданных из {source}...",
|
||||||
|
"fetchingMetadata": "Получение метаданных...",
|
||||||
"transferred": "Скачано: {downloaded} / {total}",
|
"transferred": "Скачано: {downloaded} / {total}",
|
||||||
"transferredSimple": "Скачано: {downloaded}",
|
"transferredSimple": "Скачано: {downloaded}",
|
||||||
"transferredUnknown": "Скачано: --",
|
"transferredUnknown": "Скачано: --",
|
||||||
|
|||||||
+10
-1
@@ -2,6 +2,9 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
|
"reorder": {
|
||||||
|
"dragHandle": "拖拽以调整顺序"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
@@ -915,7 +918,9 @@
|
|||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"id": "ID"
|
"id": "ID",
|
||||||
|
"baseModel": "基础模型",
|
||||||
|
"unknown": "未知"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"openFileLocation": "打开文件位置",
|
"openFileLocation": "打开文件位置",
|
||||||
@@ -1481,6 +1486,10 @@
|
|||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "当前文件:",
|
"currentFile": "当前文件:",
|
||||||
"downloading": "下载中:{name}",
|
"downloading": "下载中:{name}",
|
||||||
|
"metadata": "元数据:{name}",
|
||||||
|
"indexingFile": "正在读取模型文件...",
|
||||||
|
"fetchingSourceMetadata": "正在从 {source} 获取元数据...",
|
||||||
|
"fetchingMetadata": "正在获取元数据...",
|
||||||
"transferred": "已下载:{downloaded} / {total}",
|
"transferred": "已下载:{downloaded} / {total}",
|
||||||
"transferredSimple": "已下载:{downloaded}",
|
"transferredSimple": "已下载:{downloaded}",
|
||||||
"transferredUnknown": "已下载:--",
|
"transferredUnknown": "已下载:--",
|
||||||
|
|||||||
+10
-1
@@ -2,6 +2,9 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "確認",
|
"confirm": "確認",
|
||||||
|
"reorder": {
|
||||||
|
"dragHandle": "拖曳以調整順序"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"save": "儲存",
|
"save": "儲存",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
@@ -915,7 +918,9 @@
|
|||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"id": "ID"
|
"id": "ID",
|
||||||
|
"baseModel": "基礎模型",
|
||||||
|
"unknown": "未知"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"openFileLocation": "開啟檔案位置",
|
"openFileLocation": "開啟檔案位置",
|
||||||
@@ -1481,6 +1486,10 @@
|
|||||||
"progress": {
|
"progress": {
|
||||||
"currentFile": "目前檔案:",
|
"currentFile": "目前檔案:",
|
||||||
"downloading": "下載中:{name}",
|
"downloading": "下載中:{name}",
|
||||||
|
"metadata": "中繼資料:{name}",
|
||||||
|
"indexingFile": "正在讀取模型檔案...",
|
||||||
|
"fetchingSourceMetadata": "正在從 {source} 取得中繼資料...",
|
||||||
|
"fetchingMetadata": "正在取得中繼資料...",
|
||||||
"transferred": "已下載:{downloaded} / {total}",
|
"transferred": "已下載:{downloaded} / {total}",
|
||||||
"transferredSimple": "已下載:{downloaded}",
|
"transferredSimple": "已下載:{downloaded}",
|
||||||
"transferredUnknown": "已下載:--",
|
"transferredUnknown": "已下載:--",
|
||||||
|
|||||||
@@ -1910,6 +1910,11 @@ class ModelDownloadHandler:
|
|||||||
response_payload["status"] = status
|
response_payload["status"] = status
|
||||||
if "message" in progress_data:
|
if "message" in progress_data:
|
||||||
response_payload["message"] = progress_data["message"]
|
response_payload["message"] = progress_data["message"]
|
||||||
|
# Post-transfer stage (indexing / source metadata); polling
|
||||||
|
# consumers need it to tell "working" from "stuck".
|
||||||
|
for field in ("stage", "platform"):
|
||||||
|
if field in progress_data:
|
||||||
|
response_payload[field] = progress_data[field]
|
||||||
elif status is None and "message" in progress_data:
|
elif status is None and "message" in progress_data:
|
||||||
response_payload["message"] = progress_data["message"]
|
response_payload["message"] = progress_data["message"]
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from ...services.model_sources import (
|
|||||||
SourceRef,
|
SourceRef,
|
||||||
detect_source,
|
detect_source,
|
||||||
get_download_source,
|
get_download_source,
|
||||||
|
hydrate_from_source,
|
||||||
is_valid_source_id,
|
is_valid_source_id,
|
||||||
list_sources,
|
list_sources,
|
||||||
normalize_metadata_source,
|
normalize_metadata_source,
|
||||||
@@ -85,25 +86,77 @@ def _infer_model_type(model_root: str) -> tuple[Any, str]:
|
|||||||
return _DEFAULT_MODEL_CLASS, _DEFAULT_SCANNER_GETTER
|
return _DEFAULT_MODEL_CLASS, _DEFAULT_SCANNER_GETTER
|
||||||
|
|
||||||
|
|
||||||
|
async def _report_phase(
|
||||||
|
download_id: str | None, stage: str, platform: str = ""
|
||||||
|
) -> None:
|
||||||
|
"""Tell the progress UI which post-transfer stage is running.
|
||||||
|
|
||||||
|
A download's byte counter stops the moment the last byte lands, but the
|
||||||
|
backend still has to index the file and read the model site's API. Without
|
||||||
|
this the bar sits at 100% reporting "0 B/s" and the download looks stuck for
|
||||||
|
several seconds. *stage* is machine-readable — the UI localises it — and
|
||||||
|
*platform* lets it name the site the metadata comes from.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not download_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await ws_manager.broadcast_download_progress(
|
||||||
|
download_id,
|
||||||
|
{
|
||||||
|
"status": "metadata",
|
||||||
|
"stage": stage,
|
||||||
|
"platform": platform,
|
||||||
|
"progress": 100,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - progress must never be fatal
|
||||||
|
logger.debug("Failed to report the '%s' phase: %s", stage, exc)
|
||||||
|
|
||||||
|
|
||||||
async def _save_source_metadata(
|
async def _save_source_metadata(
|
||||||
dest_path: str, ref: SourceRef, model_root: str
|
dest_path: str, ref: SourceRef, model_root: str, *, download_id: str | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create a proper .metadata.json and add the model to the scanner cache.
|
"""Create a proper .metadata.json and add the model to the scanner cache.
|
||||||
|
|
||||||
Uses ``MetadataManager.create_default_metadata()`` which computes the
|
The metadata is created through the owning scanner rather than
|
||||||
SHA256 hash, extracts safetensors header metadata (base_model), and
|
``MetadataManager.create_default_metadata()``, because that is the only
|
||||||
produces a fully-populated ``LoraMetadata`` (or ``CheckpointMetadata`` /
|
factory that knows when hashing must be deferred: ``CheckpointScanner`` and
|
||||||
``EmbeddingMetadata``) object. We then overlay the external-source fields
|
``OtherScanner`` deliberately record ``hash_status="pending"`` with an empty
|
||||||
and register the model in the in-memory scanner cache so it appears
|
``sha256`` for their multi-GB files, and the generic helper would read a
|
||||||
immediately without a full filesystem walk.
|
10 GB checkpoint end to end *inside the download request*. Scanners for the
|
||||||
|
small types delegate straight back to it, so nothing changes for them.
|
||||||
|
|
||||||
|
The external-source fields are then overlaid and the model is registered in
|
||||||
|
the in-memory scanner cache so it appears immediately without a full
|
||||||
|
filesystem walk.
|
||||||
|
|
||||||
|
Finally the site's own published metadata is applied (see
|
||||||
|
:func:`~py.services.model_sources.hydration.hydrate_from_source`), so a
|
||||||
|
ModelScope or Hugging Face download lands with the same populated model
|
||||||
|
card a CivitAI download produces instead of a bare filename and hash.
|
||||||
|
|
||||||
|
Both post-transfer stages are reported through *download_id* when the UI is
|
||||||
|
watching one, because neither advances the byte counter.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
model_class, scanner_getter_name = _infer_model_type(model_root)
|
model_class, scanner_getter_name = _infer_model_type(model_root)
|
||||||
|
|
||||||
# 1. Create proper metadata (computes SHA256, reads safetensors headers)
|
scanner = None
|
||||||
metadata = await MetadataManager.create_default_metadata(
|
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
|
||||||
dest_path, model_class=model_class
|
if scanner_getter is not None:
|
||||||
)
|
scanner = await scanner_getter()
|
||||||
|
|
||||||
|
# 1. Create proper metadata (reads safetensors headers; hashes only for
|
||||||
|
# the model types whose scanner does not defer it)
|
||||||
|
await _report_phase(download_id, "indexing", ref.platform)
|
||||||
|
create_metadata = getattr(scanner, "_create_default_metadata", None)
|
||||||
|
if create_metadata is not None:
|
||||||
|
metadata = await create_metadata(dest_path)
|
||||||
|
else:
|
||||||
|
metadata = await MetadataManager.create_default_metadata(
|
||||||
|
dest_path, model_class=model_class
|
||||||
|
)
|
||||||
if metadata is None:
|
if metadata is None:
|
||||||
logger.warning("create_default_metadata returned None for %s", dest_path)
|
logger.warning("create_default_metadata returned None for %s", dest_path)
|
||||||
return
|
return
|
||||||
@@ -120,8 +173,8 @@ async def _save_source_metadata(
|
|||||||
# 3. Save metadata atomically
|
# 3. Save metadata atomically
|
||||||
await MetadataManager.save_metadata(dest_path, metadata)
|
await MetadataManager.save_metadata(dest_path, metadata)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Saved %s metadata (source=%s) for %s",
|
"Saved %s metadata (source=%s, hash_status=%s) for %s",
|
||||||
ref.platform, ref.url, dest_path,
|
ref.platform, ref.url, getattr(metadata, "hash_status", "?"), dest_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Determine relative folder path for cache
|
# 4. Determine relative folder path for cache
|
||||||
@@ -132,13 +185,16 @@ async def _save_source_metadata(
|
|||||||
folder = rel.replace(os.sep, "/") if rel != "." else ""
|
folder = rel.replace(os.sep, "/") if rel != "." else ""
|
||||||
|
|
||||||
# 5. Add to scanner cache (same as CivitAI's _execute_download does)
|
# 5. Add to scanner cache (same as CivitAI's _execute_download does)
|
||||||
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
|
if scanner is not None:
|
||||||
if scanner_getter is not None:
|
metadata_dict = normalize_metadata_source(metadata.to_dict())
|
||||||
scanner = await scanner_getter()
|
await scanner.add_model_to_cache(metadata_dict, folder)
|
||||||
if scanner is not None:
|
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
|
||||||
metadata_dict = normalize_metadata_source(metadata.to_dict())
|
|
||||||
await scanner.add_model_to_cache(metadata_dict, folder)
|
# 6. Top up from the site's public API. Runs last so the scanner-cache
|
||||||
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
|
# refresh it performs lands on the entry created above. It never
|
||||||
|
# raises and never fails the download.
|
||||||
|
await _report_phase(download_id, "source", ref.platform)
|
||||||
|
await hydrate_from_source(dest_path, ref=ref)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to save source metadata for %s: %s", dest_path, exc)
|
logger.warning("Failed to save source metadata for %s: %s", dest_path, exc)
|
||||||
@@ -466,15 +522,6 @@ class ModelSourceHandler:
|
|||||||
os.makedirs(target_dir, exist_ok=True)
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
dest_path = os.path.join(target_dir, file_base)
|
dest_path = os.path.join(target_dir, file_base)
|
||||||
|
|
||||||
# Check if already exists (simple skip)
|
|
||||||
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
|
|
||||||
logger.info("download_model_source: file already exists, skipping — %s", dest_path)
|
|
||||||
return web.json_response({
|
|
||||||
"success": True,
|
|
||||||
"message": f"File already exists: {dest_path}",
|
|
||||||
"path": dest_path,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Built per request: sites that redirect to a CDN hand out a
|
# Built per request: sites that redirect to a CDN hand out a
|
||||||
# time-limited token in the redirect, so the URL must never be cached.
|
# time-limited token in the redirect, so the URL must never be cached.
|
||||||
resolve_url = source.file_download_url(repo, filename, revision)
|
resolve_url = source.file_download_url(repo, filename, revision)
|
||||||
@@ -482,6 +529,20 @@ class ModelSourceHandler:
|
|||||||
platform=source.platform, source_id=repo, url=source.canonical_url(repo)
|
platform=source.platform, source_id=repo, url=source.canonical_url(repo)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if already exists (simple skip)
|
||||||
|
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
|
||||||
|
logger.info("download_model_source: file already exists, skipping — %s", dest_path)
|
||||||
|
# The sidecar may predate the source metadata being fetched, or may
|
||||||
|
# have been deleted, so top it up instead of skipping past it.
|
||||||
|
# Hydration no-ops when there is no sidecar to update.
|
||||||
|
await _report_phase(download_id, "source", source.platform)
|
||||||
|
await hydrate_from_source(dest_path, ref=ref)
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"message": f"File already exists: {dest_path}",
|
||||||
|
"path": dest_path,
|
||||||
|
})
|
||||||
|
|
||||||
# Set up progress callback if download_id is provided
|
# Set up progress callback if download_id is provided
|
||||||
progress_callback = None
|
progress_callback = None
|
||||||
if download_id:
|
if download_id:
|
||||||
@@ -530,7 +591,9 @@ class ModelSourceHandler:
|
|||||||
progress_callback=progress_callback,
|
progress_callback=progress_callback,
|
||||||
)
|
)
|
||||||
if ok:
|
if ok:
|
||||||
await _save_source_metadata(dest_path, ref, model_root)
|
await _save_source_metadata(
|
||||||
|
dest_path, ref, model_root, download_id=download_id
|
||||||
|
)
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Downloaded to {dest_path}",
|
"message": f"Downloaded to {dest_path}",
|
||||||
@@ -557,7 +620,9 @@ class ModelSourceHandler:
|
|||||||
progress_callback=progress_callback,
|
progress_callback=progress_callback,
|
||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
await _save_source_metadata(dest_path, ref, model_root)
|
await _save_source_metadata(
|
||||||
|
dest_path, ref, model_root, download_id=download_id
|
||||||
|
)
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Downloaded to {result}",
|
"message": f"Downloaded to {result}",
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ from ..model_sources import (
|
|||||||
resolve_source_ref,
|
resolve_source_ref,
|
||||||
source_label,
|
source_label,
|
||||||
)
|
)
|
||||||
|
from ..model_sources.hydration import load_model_card, resolve_site_base_model
|
||||||
from ..websocket_manager import ws_manager
|
from ..websocket_manager import ws_manager
|
||||||
from .base_model_resolver import resolve_base_model
|
|
||||||
from .post_processor import PostProcessor
|
from .post_processor import PostProcessor
|
||||||
from .skill_registry import SkillRegistry
|
from .skill_registry import SkillRegistry
|
||||||
from .skills.enrich_hf_metadata.readme_processor import (
|
from .skills.enrich_hf_metadata.readme_processor import (
|
||||||
@@ -466,12 +466,7 @@ class AgentService:
|
|||||||
raw_basename = os.path.splitext(os.path.basename(model_path))[0]
|
raw_basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
variables["asset_base_url"] = source.asset_base_url(ref.source_id)
|
variables["asset_base_url"] = source.asset_base_url(ref.source_id)
|
||||||
|
|
||||||
cache_key = f"{ref.platform}:{ref.source_id}"
|
readme = await load_model_card(source, ref.source_id, cache)
|
||||||
readme = cache.readmes.get(cache_key) if cache is not None else None
|
|
||||||
if readme is None:
|
|
||||||
readme = await source.fetch_model_card(ref.source_id)
|
|
||||||
if cache is not None and readme:
|
|
||||||
cache.readmes[cache_key] = readme
|
|
||||||
|
|
||||||
# Sites such as ModelScope keep part of the model card outside the
|
# Sites such as ModelScope keep part of the model card outside the
|
||||||
# README (author summary, curated tags, per-file example images). The
|
# README (author summary, curated tags, per-file example images). The
|
||||||
@@ -507,17 +502,7 @@ class AgentService:
|
|||||||
async def _resolve_site_base_model(self, source_context: ModelCardContext) -> str:
|
async def _resolve_site_base_model(self, source_context: ModelCardContext) -> str:
|
||||||
"""Resolve the site's base-model hints to a canonical name, or ``""``."""
|
"""Resolve the site's base-model hints to a canonical name, or ``""``."""
|
||||||
|
|
||||||
from ...metadata_ops import list_base_models
|
return await resolve_site_base_model(source_context)
|
||||||
|
|
||||||
hints = [*source_context.base_model_aliases, source_context.base_model]
|
|
||||||
if not any(hints):
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
known_names = await list_base_models()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Failed to list base models for site resolution: %s", exc)
|
|
||||||
return ""
|
|
||||||
return resolve_base_model(hints, known_names)
|
|
||||||
|
|
||||||
async def _build_prompt_context(
|
async def _build_prompt_context(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class PostProcessor:
|
|||||||
readme_content: str = "",
|
readme_content: str = "",
|
||||||
source_context: Optional["ModelCardContext"] = None,
|
source_context: Optional["ModelCardContext"] = None,
|
||||||
resolved_base_model: str = "",
|
resolved_base_model: str = "",
|
||||||
|
metadata_source: str = "agent:enrich_hf_metadata",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Route *llm_output* to the correct skill post-processor.
|
"""Route *llm_output* to the correct skill post-processor.
|
||||||
|
|
||||||
@@ -63,13 +64,18 @@ class PostProcessor:
|
|||||||
hints resolve to, used when the LLM did not supply one (which is the
|
hints resolve to, used when the LLM did not supply one (which is the
|
||||||
normal case when the LLM was skipped).
|
normal case when the LLM was skipped).
|
||||||
|
|
||||||
|
*metadata_source* records who produced the metadata. The AI skill
|
||||||
|
keeps its historical value; the deterministic download-time hydration
|
||||||
|
passes its own so the two remain distinguishable. ``llm_enriched_at``
|
||||||
|
is only stamped when *llm_output* actually carries a provider answer.
|
||||||
|
|
||||||
Returns a dict with keys ``success`` (bool), ``updated_fields`` (list),
|
Returns a dict with keys ``success`` (bool), ``updated_fields`` (list),
|
||||||
``preview_downloaded`` (bool), and ``errors`` (list).
|
``preview_downloaded`` (bool), and ``errors`` (list).
|
||||||
"""
|
"""
|
||||||
if skill_name == "enrich_hf_metadata":
|
if skill_name == "enrich_hf_metadata":
|
||||||
return await self._process_enrich_hf_metadata(
|
return await self._process_enrich_hf_metadata(
|
||||||
model_path, llm_output, metadata, readme_content, source_context,
|
model_path, llm_output, metadata, readme_content, source_context,
|
||||||
resolved_base_model,
|
resolved_base_model, metadata_source,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -89,6 +95,7 @@ class PostProcessor:
|
|||||||
readme_content: str = "",
|
readme_content: str = "",
|
||||||
source_context: Optional["ModelCardContext"] = None,
|
source_context: Optional["ModelCardContext"] = None,
|
||||||
resolved_base_model: str = "",
|
resolved_base_model: str = "",
|
||||||
|
metadata_source: str = "agent:enrich_hf_metadata",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
from ...metadata_ops import (
|
from ...metadata_ops import (
|
||||||
apply_metadata_updates,
|
apply_metadata_updates,
|
||||||
@@ -135,6 +142,17 @@ class PostProcessor:
|
|||||||
if new_base and self._should_overwrite(current_base, is_source_model):
|
if new_base and self._should_overwrite(current_base, is_source_model):
|
||||||
updates["base_model"] = new_base
|
updates["base_model"] = new_base
|
||||||
|
|
||||||
|
# model_name — the site's own display name, so a source download never
|
||||||
|
# shows up under its local filename. Written only while the name is
|
||||||
|
# still the untouched file stem: once a user renames a model that
|
||||||
|
# choice is theirs to keep.
|
||||||
|
site_name = ((source_context.model_name if source_context else "") or "").strip()
|
||||||
|
if is_source_model and site_name:
|
||||||
|
current_name = (metadata.get("model_name") or "").strip()
|
||||||
|
file_stem = (metadata.get("file_name") or "").strip()
|
||||||
|
if not current_name or current_name == file_stem:
|
||||||
|
updates["model_name"] = site_name
|
||||||
|
|
||||||
# trigger words → civitai.trainedWords
|
# trigger words → civitai.trainedWords
|
||||||
new_triggers = llm_output.get("trigger_words", [])
|
new_triggers = llm_output.get("trigger_words", [])
|
||||||
trigger_words_empty = True
|
trigger_words_empty = True
|
||||||
@@ -142,14 +160,9 @@ class PostProcessor:
|
|||||||
cleaned = [t.strip() for t in new_triggers if t.strip()]
|
cleaned = [t.strip() for t in new_triggers if t.strip()]
|
||||||
cleaned = [t for t in cleaned if t.lower() not in ("none", "null", "n/a")]
|
cleaned = [t for t in cleaned if t.lower() not in ("none", "null", "n/a")]
|
||||||
trigger_words_empty = not cleaned
|
trigger_words_empty = not cleaned
|
||||||
current_civitai = metadata.get("civitai") or {}
|
current_triggers = (metadata.get("civitai") or {}).get("trainedWords") or []
|
||||||
current_triggers = current_civitai.get("trainedWords") or []
|
|
||||||
if self._should_overwrite_list(current_triggers, is_source_model):
|
if self._should_overwrite_list(current_triggers, is_source_model):
|
||||||
trig_civitai = dict(current_civitai)
|
self._merge_civitai(updates, metadata, trainedWords=cleaned)
|
||||||
if "civitai" in updates and isinstance(updates["civitai"], dict):
|
|
||||||
trig_civitai.update(updates["civitai"])
|
|
||||||
trig_civitai["trainedWords"] = cleaned
|
|
||||||
updates["civitai"] = trig_civitai
|
|
||||||
|
|
||||||
# modelDescription — the author's own summary (when the site keeps one
|
# modelDescription — the author's own summary (when the site keeps one
|
||||||
# outside the README, e.g. ModelScope's ``Description``) followed by the
|
# outside the README, e.g. ModelScope's ``Description``) followed by the
|
||||||
@@ -175,12 +188,16 @@ class PostProcessor:
|
|||||||
if not short_desc:
|
if not short_desc:
|
||||||
short_desc = site_description
|
short_desc = site_description
|
||||||
if short_desc and is_source_model:
|
if short_desc and is_source_model:
|
||||||
current_civitai = metadata.get("civitai") or {}
|
self._merge_civitai(updates, metadata, description=short_desc)
|
||||||
desc_civitai = dict(current_civitai)
|
|
||||||
if "civitai" in updates and isinstance(updates["civitai"], dict):
|
# The version label completes the card the way a CivitAI download does:
|
||||||
desc_civitai.update(updates["civitai"])
|
# the UI renders `civitai.name` as the version chip. It is per file,
|
||||||
desc_civitai["description"] = short_desc
|
# so a collection repository shows that checkpoint's own label.
|
||||||
updates["civitai"] = desc_civitai
|
site_version = (
|
||||||
|
(source_context.version_name if source_context else "") or ""
|
||||||
|
).strip()
|
||||||
|
if is_source_model and site_version:
|
||||||
|
self._merge_civitai(updates, metadata, name=site_version)
|
||||||
|
|
||||||
# gallery images → civitai.images (site example images, YAML frontmatter
|
# gallery images → civitai.images (site example images, YAML frontmatter
|
||||||
# widget entries, and Sample Gallery markdown tables in the README body)
|
# widget entries, and Sample Gallery markdown tables in the README body)
|
||||||
@@ -244,12 +261,7 @@ class PostProcessor:
|
|||||||
all_images = _dedupe_images(site_images + readme_images)
|
all_images = _dedupe_images(site_images + readme_images)
|
||||||
if all_images:
|
if all_images:
|
||||||
gallery_images = all_images
|
gallery_images = all_images
|
||||||
current_civitai = metadata.get("civitai") or {}
|
self._merge_civitai(updates, metadata, images=all_images)
|
||||||
gallery_civitai = dict(current_civitai)
|
|
||||||
if "civitai" in updates and isinstance(updates["civitai"], dict):
|
|
||||||
gallery_civitai.update(updates["civitai"])
|
|
||||||
gallery_civitai["images"] = all_images
|
|
||||||
updates["civitai"] = gallery_civitai
|
|
||||||
|
|
||||||
# tags — the site's curated tags are authoritative content vocabulary, so
|
# tags — the site's curated tags are authoritative content vocabulary, so
|
||||||
# they are kept alongside whatever the LLM proposed (the LLM is skipped
|
# they are kept alongside whatever the LLM proposed (the LLM is skipped
|
||||||
@@ -269,9 +281,12 @@ class PostProcessor:
|
|||||||
if len(merged) > len(existing_tags) or is_source_model:
|
if len(merged) > len(existing_tags) or is_source_model:
|
||||||
updates["tags"] = merged
|
updates["tags"] = merged
|
||||||
|
|
||||||
# metadata_source & llm_enriched_at (always set)
|
# metadata_source is recorded for provenance; llm_enriched_at only means
|
||||||
updates["metadata_source"] = "agent:enrich_hf_metadata"
|
# something when a provider actually answered, so the deterministic
|
||||||
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat()
|
# download-time hydration does not claim an enrichment that never ran.
|
||||||
|
updates["metadata_source"] = metadata_source
|
||||||
|
if llm_output:
|
||||||
|
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
# LLM confidence, stored for the enrichment evaluation harness. The key
|
# LLM confidence, stored for the enrichment evaluation harness. The key
|
||||||
# must NOT start with an underscore: `BaseModelMetadata.from_dict()`
|
# must NOT start with an underscore: `BaseModelMetadata.from_dict()`
|
||||||
@@ -292,12 +307,7 @@ class PostProcessor:
|
|||||||
if instance_prompt:
|
if instance_prompt:
|
||||||
site_triggers = [instance_prompt]
|
site_triggers = [instance_prompt]
|
||||||
if site_triggers:
|
if site_triggers:
|
||||||
current_civitai = metadata.get("civitai") or {}
|
self._merge_civitai(updates, metadata, trainedWords=site_triggers)
|
||||||
trig_civitai = dict(current_civitai)
|
|
||||||
if "civitai" in updates and isinstance(updates["civitai"], dict):
|
|
||||||
trig_civitai.update(updates["civitai"])
|
|
||||||
trig_civitai["trainedWords"] = site_triggers
|
|
||||||
updates["civitai"] = trig_civitai
|
|
||||||
|
|
||||||
preview_remote_url = (llm_output.get("preview_url") or "").strip()
|
preview_remote_url = (llm_output.get("preview_url") or "").strip()
|
||||||
# Fallback: if the LLM couldn't find a preview image in the cleaned
|
# Fallback: if the LLM couldn't find a preview image in the cleaned
|
||||||
@@ -371,6 +381,25 @@ class PostProcessor:
|
|||||||
"", "unknown",
|
"", "unknown",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_civitai(
|
||||||
|
updates: Dict[str, Any], metadata: Dict[str, Any], **fields: Any
|
||||||
|
) -> None:
|
||||||
|
"""Layer *fields* onto the ``civitai`` block being assembled.
|
||||||
|
|
||||||
|
Description, version label, trigger words and gallery images all live
|
||||||
|
in the same dict and are contributed by separate branches, so each one
|
||||||
|
starts from what is already on disk and then applies whatever an
|
||||||
|
earlier branch queued in *updates*.
|
||||||
|
"""
|
||||||
|
|
||||||
|
merged = dict(metadata.get("civitai") or {})
|
||||||
|
queued = updates.get("civitai")
|
||||||
|
if isinstance(queued, dict):
|
||||||
|
merged.update(queued)
|
||||||
|
merged.update(fields)
|
||||||
|
updates["civitai"] = merged
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _should_overwrite_list(current_list: List[str], is_source_model: bool) -> bool:
|
def _should_overwrite_list(current_list: List[str], is_source_model: bool) -> bool:
|
||||||
"""Return ``True`` when a list field should be overwritten."""
|
"""Return ``True`` when a list field should be overwritten."""
|
||||||
|
|||||||
@@ -19,6 +19,28 @@ from .model_sources import has_external_source
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_ordered_unique(existing: Iterable[str], new: Iterable[str]) -> list[str]:
|
||||||
|
"""Concatenate two word lists, dropping duplicates without reordering.
|
||||||
|
|
||||||
|
Trigger word order is meaningful: the sequence stored in
|
||||||
|
``civitai.trainedWords`` is the order used when building prompts, and users
|
||||||
|
can reorder it in the UI. A plain ``set`` union used to shuffle that order on
|
||||||
|
every metadata refresh, so existing words are kept first (in their saved
|
||||||
|
order) and newly discovered ones are appended.
|
||||||
|
"""
|
||||||
|
|
||||||
|
merged: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
for word in list(existing) + list(new):
|
||||||
|
if word in seen:
|
||||||
|
continue
|
||||||
|
seen.add(word)
|
||||||
|
merged.append(word)
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
class MetadataProviderProtocol(Protocol):
|
class MetadataProviderProtocol(Protocol):
|
||||||
"""Subset of metadata provider interface consumed by the sync service."""
|
"""Subset of metadata provider interface consumed by the sync service."""
|
||||||
|
|
||||||
@@ -115,9 +137,10 @@ class MetadataSyncService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if "trainedWords" in existing_civitai:
|
if "trainedWords" in existing_civitai:
|
||||||
existing_trained = existing_civitai.get("trainedWords", [])
|
existing_trained = existing_civitai.get("trainedWords", []) or []
|
||||||
new_trained = civitai_metadata.get("trainedWords", [])
|
new_trained = civitai_metadata.get("trainedWords", []) or []
|
||||||
merged_trained = list(set(existing_trained + new_trained))
|
# Order preserving merge: the saved order drives prompt order.
|
||||||
|
merged_trained = _merge_ordered_unique(existing_trained, new_trained)
|
||||||
merged_civitai["trainedWords"] = merged_trained
|
merged_civitai["trainedWords"] = merged_trained
|
||||||
|
|
||||||
local_metadata["civitai"] = merged_civitai
|
local_metadata["civitai"] = merged_civitai
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ from .base import (
|
|||||||
is_valid_source_id,
|
is_valid_source_id,
|
||||||
)
|
)
|
||||||
from .huggingface import HuggingFaceSource
|
from .huggingface import HuggingFaceSource
|
||||||
from .modelscope import ModelScopeSource
|
from .hydration import (
|
||||||
|
hydrate_from_source,
|
||||||
|
load_model_card,
|
||||||
|
resolve_site_base_model,
|
||||||
|
)
|
||||||
|
from .modelscope import ModelScopeIntlSource, ModelScopeSource
|
||||||
from .registry import (
|
from .registry import (
|
||||||
LEGACY_HF_URL_FIELD,
|
LEGACY_HF_URL_FIELD,
|
||||||
SOURCE_PLATFORM_FIELD,
|
SOURCE_PLATFORM_FIELD,
|
||||||
@@ -52,6 +57,7 @@ __all__ = [
|
|||||||
"ModelSourceCache",
|
"ModelSourceCache",
|
||||||
"ModelSourceError",
|
"ModelSourceError",
|
||||||
"HuggingFaceSource",
|
"HuggingFaceSource",
|
||||||
|
"ModelScopeIntlSource",
|
||||||
"ModelScopeSource",
|
"ModelScopeSource",
|
||||||
"SOURCE_PLATFORM_FIELD",
|
"SOURCE_PLATFORM_FIELD",
|
||||||
"SOURCE_URL_FIELD",
|
"SOURCE_URL_FIELD",
|
||||||
@@ -68,9 +74,12 @@ __all__ = [
|
|||||||
"get_source",
|
"get_source",
|
||||||
"get_source_platform",
|
"get_source_platform",
|
||||||
"has_external_source",
|
"has_external_source",
|
||||||
|
"hydrate_from_source",
|
||||||
"is_valid_source_id",
|
"is_valid_source_id",
|
||||||
"list_sources",
|
"list_sources",
|
||||||
|
"load_model_card",
|
||||||
"normalize_metadata_source",
|
"normalize_metadata_source",
|
||||||
|
"resolve_site_base_model",
|
||||||
"resolve_source_ref",
|
"resolve_source_ref",
|
||||||
"source_group_key",
|
"source_group_key",
|
||||||
"source_label",
|
"source_label",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ USER_AGENT = "ComfyUI-LoRA-Manager/1.0"
|
|||||||
GROUP_PREFIXES: dict[str, str] = {
|
GROUP_PREFIXES: dict[str, str] = {
|
||||||
"huggingface": "hf",
|
"huggingface": "hf",
|
||||||
"modelscope": "ms",
|
"modelscope": "ms",
|
||||||
|
"modelscope-ai": "msai",
|
||||||
"tensorart": "ta",
|
"tensorart": "ta",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +78,30 @@ class ModelCardContext:
|
|||||||
description: str = ""
|
description: str = ""
|
||||||
"""Author-written summary shown on the model page, outside the README."""
|
"""Author-written summary shown on the model page, outside the README."""
|
||||||
|
|
||||||
|
model_name: str = ""
|
||||||
|
"""Site-published display name for the repository.
|
||||||
|
|
||||||
|
Sites publish this next to the repository id (ModelScope's ``Name``).
|
||||||
|
It is what a CivitAI download would store as the model's name, so the
|
||||||
|
card never has to fall back to the local filename.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_name_localized: str = ""
|
||||||
|
"""Site-published localized name (ModelScope's ``ChineseName``)."""
|
||||||
|
|
||||||
|
version_name: str = ""
|
||||||
|
"""Site-published label for the requested file's version.
|
||||||
|
|
||||||
|
Resolved per file, like :attr:`example_images`: a repository publishes
|
||||||
|
one label per checkpoint (ModelScope's ``modelVersion.showName``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
license: str = ""
|
||||||
|
"""License the site records for the repository."""
|
||||||
|
|
||||||
|
model_type: str = ""
|
||||||
|
"""Site-reported model type, e.g. ModelScope's ``AigcType`` (``LoRA``)."""
|
||||||
|
|
||||||
base_model: str = ""
|
base_model: str = ""
|
||||||
"""Base model as reported by the site (possibly a site-local id)."""
|
"""Base model as reported by the site (possibly a site-local id)."""
|
||||||
|
|
||||||
@@ -104,6 +129,11 @@ class ModelCardContext:
|
|||||||
return not any(
|
return not any(
|
||||||
(
|
(
|
||||||
self.description,
|
self.description,
|
||||||
|
self.model_name,
|
||||||
|
self.model_name_localized,
|
||||||
|
self.version_name,
|
||||||
|
self.license,
|
||||||
|
self.model_type,
|
||||||
self.base_model,
|
self.base_model,
|
||||||
self.base_model_aliases,
|
self.base_model_aliases,
|
||||||
self.official_tags,
|
self.official_tags,
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""Deterministic metadata hydration for freshly downloaded source models.
|
||||||
|
|
||||||
|
A CivitAI download writes a fully-populated metadata sidecar as part of the
|
||||||
|
download itself: the name, the description, the tags, the trigger words and
|
||||||
|
the example images all arrive with the file. A download from an external
|
||||||
|
model source (ModelScope, Hugging Face) has the same information behind a
|
||||||
|
public API, but historically landed as a bare filename plus a source URL that
|
||||||
|
the user had to enrich by hand ("Enrich Metadata with AI").
|
||||||
|
|
||||||
|
This module closes that gap without involving an LLM. It fetches the linked
|
||||||
|
site's model card, hands it to the same :class:`~py.services.agent.post_processor.PostProcessor`
|
||||||
|
the AI skill uses, and writes the result. Everything it applies is data the
|
||||||
|
site published, so it is safe to run automatically on every download and to
|
||||||
|
treat as a fallback for the gaps the LLM would otherwise fill.
|
||||||
|
|
||||||
|
Nothing here may break a download: every failure is logged and normalised to
|
||||||
|
"the site had nothing to contribute".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
from .base import ModelCardContext, ModelSourceCache
|
||||||
|
from .registry import get_source, resolve_source_ref
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||||
|
from .base import ModelSource, SourceRef
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: How long a fetched repository payload stays usable. A download batch walks
|
||||||
|
#: a repository's files one HTTP request at a time, and the README plus the
|
||||||
|
#: detail payload describe the *repository*, not the file, so re-fetching them
|
||||||
|
#: per file would be pure waste. They expire so an edited model card is still
|
||||||
|
#: picked up by the next batch.
|
||||||
|
SHARED_CACHE_TTL = 300.0
|
||||||
|
|
||||||
|
#: Upper bound on memoised repositories; a long-running server must not grow
|
||||||
|
#: without limit.
|
||||||
|
SHARED_CACHE_MAX_ENTRIES = 32
|
||||||
|
|
||||||
|
#: ``"<platform>:<source_id>"`` → ``(expiry, memo)``.
|
||||||
|
_shared_caches: dict[str, tuple[float, ModelSourceCache]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def shared_source_cache(platform: str, source_id: str) -> ModelSourceCache:
|
||||||
|
"""Return a short-lived per-repository memo for download-time hydration."""
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
key = f"{platform}:{source_id}"
|
||||||
|
entry = _shared_caches.get(key)
|
||||||
|
if entry is not None and entry[0] > now:
|
||||||
|
return entry[1]
|
||||||
|
|
||||||
|
for expired in [k for k, (expiry, _) in _shared_caches.items() if expiry <= now]:
|
||||||
|
_shared_caches.pop(expired, None)
|
||||||
|
if len(_shared_caches) >= SHARED_CACHE_MAX_ENTRIES:
|
||||||
|
oldest = min(_shared_caches, key=lambda k: _shared_caches[k][0])
|
||||||
|
_shared_caches.pop(oldest, None)
|
||||||
|
|
||||||
|
cache = ModelSourceCache()
|
||||||
|
_shared_caches[key] = (now + SHARED_CACHE_TTL, cache)
|
||||||
|
return cache
|
||||||
|
|
||||||
|
|
||||||
|
def reset_shared_caches() -> None:
|
||||||
|
"""Drop every memoised repository — used by tests."""
|
||||||
|
|
||||||
|
_shared_caches.clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def load_model_card(
|
||||||
|
source: "ModelSource",
|
||||||
|
source_id: str,
|
||||||
|
cache: Optional[ModelSourceCache] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Return *source_id*'s README, reusing *cache* when one is supplied.
|
||||||
|
|
||||||
|
Only successful reads are memoised, leaving a transient failure to be
|
||||||
|
retried for the next file of the same repository.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key = f"{source.platform}:{source_id}"
|
||||||
|
if cache is not None:
|
||||||
|
cached = cache.readmes.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
readme = await source.fetch_model_card(source_id)
|
||||||
|
if cache is not None and readme:
|
||||||
|
cache.readmes[key] = readme
|
||||||
|
return readme or ""
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_site_base_model(context: ModelCardContext) -> str:
|
||||||
|
"""Resolve the site's base-model hints to a canonical name, or ``""``.
|
||||||
|
|
||||||
|
Sites name base models in their own vocabulary (ModelScope publishes both
|
||||||
|
``krea/Krea-2-Turbo`` and the ``KREA_2_TURBO`` enum). The resolver is
|
||||||
|
strict and only ever returns a name the canonical vocabulary already
|
||||||
|
contains, so an uncertain hint yields ``""`` rather than a plausible-looking
|
||||||
|
wrong value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
hints = [*context.base_model_aliases, context.base_model]
|
||||||
|
if not any(hints):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Imported lazily: pulling in the agent package at module scope would make
|
||||||
|
# the model-source package import itself while it is still initialising.
|
||||||
|
try:
|
||||||
|
from ...metadata_ops import list_base_models
|
||||||
|
from ..agent.base_model_resolver import resolve_base_model
|
||||||
|
|
||||||
|
known_names = await list_base_models()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Could not resolve a site base model: %s", exc)
|
||||||
|
return ""
|
||||||
|
return resolve_base_model(hints, known_names)
|
||||||
|
|
||||||
|
|
||||||
|
async def hydrate_from_source(
|
||||||
|
file_path: str,
|
||||||
|
*,
|
||||||
|
ref: "SourceRef",
|
||||||
|
cache: Optional[ModelSourceCache] = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Apply the linked site's published metadata to a downloaded model.
|
||||||
|
|
||||||
|
This is the deterministic counterpart of the ``enrich_hf_metadata`` skill:
|
||||||
|
it produces the same populated model card a CivitAI download produces,
|
||||||
|
without an LLM and without user action.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: The just-downloaded model file, whose sidecar already
|
||||||
|
carries the SHA256 used to match the right file in a collection
|
||||||
|
repository.
|
||||||
|
ref: The source the file came from.
|
||||||
|
cache: Optional per-call memo; defaults to a short-lived shared one so
|
||||||
|
a batch over one repository fetches its card only once.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The names of the metadata fields that changed. Never raises — a site
|
||||||
|
that is down, or an API that changed shape, must not fail a download.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
source = get_source(ref.platform)
|
||||||
|
if source is None or not source.supports_enrichment:
|
||||||
|
return []
|
||||||
|
|
||||||
|
from ...metadata_ops import read_metadata
|
||||||
|
|
||||||
|
metadata = await read_metadata(file_path)
|
||||||
|
if not metadata:
|
||||||
|
logger.debug("No metadata to hydrate for %s", file_path)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Only a model that is actually linked to this repository may be
|
||||||
|
# updated. The download path writes those fields just before calling
|
||||||
|
# us; a file that merely shares a name with the requested one must not
|
||||||
|
# be given another model's card.
|
||||||
|
linked = resolve_source_ref(metadata)
|
||||||
|
if linked is None or (linked.platform, linked.source_id) != (
|
||||||
|
ref.platform,
|
||||||
|
ref.source_id,
|
||||||
|
):
|
||||||
|
logger.debug(
|
||||||
|
"Not hydrating %s: linked to %s, not %s",
|
||||||
|
file_path, linked.url if linked else "no model source", ref.url,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
memo = cache if cache is not None else shared_source_cache(
|
||||||
|
ref.platform, ref.source_id
|
||||||
|
)
|
||||||
|
readme = await load_model_card(source, ref.source_id, memo)
|
||||||
|
context = await source.fetch_model_card_context(
|
||||||
|
ref.source_id,
|
||||||
|
os.path.basename(file_path),
|
||||||
|
sha256=(metadata.get("sha256") or "").strip(),
|
||||||
|
cache=memo,
|
||||||
|
)
|
||||||
|
if context.is_empty() and not readme:
|
||||||
|
logger.debug(
|
||||||
|
"No published metadata for %s on %s", ref.source_id, ref.platform
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
resolved_base_model = await resolve_site_base_model(context)
|
||||||
|
|
||||||
|
from ..agent.post_processor import PostProcessor
|
||||||
|
|
||||||
|
result = await PostProcessor().process(
|
||||||
|
skill_name="enrich_hf_metadata",
|
||||||
|
model_path=file_path,
|
||||||
|
llm_output={},
|
||||||
|
metadata=metadata,
|
||||||
|
readme_content=readme,
|
||||||
|
source_context=context,
|
||||||
|
resolved_base_model=resolved_base_model,
|
||||||
|
metadata_source=f"source:{ref.platform}",
|
||||||
|
)
|
||||||
|
if not result.get("success", True):
|
||||||
|
logger.debug(
|
||||||
|
"Hydration reported failure for %s: %s",
|
||||||
|
file_path, result.get("errors"),
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
updated = list(result.get("updated_fields") or [])
|
||||||
|
logger.info(
|
||||||
|
"Hydrated %s from %s (%s): %s",
|
||||||
|
file_path, source.label or ref.platform, ref.source_id,
|
||||||
|
", ".join(updated) or "nothing to change",
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
except Exception as exc: # pragma: no cover - defensive by design
|
||||||
|
logger.warning("Source hydration failed for %s: %s", file_path, exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SHARED_CACHE_MAX_ENTRIES",
|
||||||
|
"SHARED_CACHE_TTL",
|
||||||
|
"hydrate_from_source",
|
||||||
|
"load_model_card",
|
||||||
|
"reset_shared_caches",
|
||||||
|
"resolve_site_base_model",
|
||||||
|
"shared_source_cache",
|
||||||
|
]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""ModelScope (魔搭社区) model source.
|
"""ModelScope (魔搭社区) model sources.
|
||||||
|
|
||||||
ModelScope exposes the same "model card as README.md" convention as
|
ModelScope exposes the same "model card as README.md" convention as
|
||||||
Hugging Face, including a YAML frontmatter block that often carries
|
Hugging Face, including a YAML frontmatter block that often carries
|
||||||
@@ -10,11 +10,13 @@ none of which requires an API key for public models:
|
|||||||
the same content through the API, used as a fallback when the resolve
|
the same content through the API, used as a fallback when the resolve
|
||||||
URL is unavailable.
|
URL is unavailable.
|
||||||
* ``/api/v1/models/{owner}/{name}`` — the model-detail payload behind the
|
* ``/api/v1/models/{owner}/{name}`` — the model-detail payload behind the
|
||||||
model page. It carries the author's summary (``Description``), the
|
model page. It carries the repository's display name (``Name`` /
|
||||||
site-curated tags (``OfficialTags``), and, per published version, the
|
``ChineseName``), the author's summary (``Description``), the license, the
|
||||||
model filenames (``MuseInfo.versions[].stats.fileList``) together with
|
AIGC type, the site tags (``OfficialTags``, falling back to ``Tags``), and,
|
||||||
that file's example images (``coverImages``) and trigger words. See
|
per published version, the model filenames
|
||||||
:meth:`ModelScopeSource.fetch_model_card_context`.
|
(``MuseInfo.versions[].stats.fileList``) together with that version's label
|
||||||
|
(``modelVersion.showName``), example images (``coverImages``) and trigger
|
||||||
|
words. See :meth:`ModelScopeSource.fetch_model_card_context`.
|
||||||
* ``/api/v1/models/{owner}/{name}/repo/files?Revision=..`` — the file
|
* ``/api/v1/models/{owner}/{name}/repo/files?Revision=..`` — the file
|
||||||
listing backing the download picker. It reports real sizes for LFS
|
listing backing the download picker. It reports real sizes for LFS
|
||||||
files (not the pointer size), so no extra HEAD request is needed.
|
files (not the pointer size), so no extra HEAD request is needed.
|
||||||
@@ -28,6 +30,12 @@ valid; the CDN URL must never be cached.
|
|||||||
The README and the detail payload both describe the whole repository rather
|
The README and the detail payload both describe the whole repository rather
|
||||||
than one file, so a per-run ``ModelSourceCache`` keeps them from being read
|
than one file, so a per-run ``ModelSourceCache`` keeps them from being read
|
||||||
again for every checkpoint of a collection repository.
|
again for every checkpoint of a collection repository.
|
||||||
|
|
||||||
|
Two deployments are served by this module. ``modelscope.cn`` (with
|
||||||
|
``modelscope.com`` as a redirect alias) and ``modelscope.ai`` are *separate
|
||||||
|
catalogues*, not mirrors, so they are registered as distinct sources:
|
||||||
|
:class:`ModelScopeSource` and :class:`ModelScopeIntlSource`. Every URL either
|
||||||
|
class builds is derived from its ``base_url``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -36,7 +44,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING, Any, Optional
|
from typing import TYPE_CHECKING, Any, Iterable, Optional
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
ModelCardContext,
|
ModelCardContext,
|
||||||
@@ -52,18 +60,28 @@ if TYPE_CHECKING: # pragma: no cover - typing only
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_URL_PATTERN = re.compile(
|
#: ModelScope runs two independent catalogues. ``modelscope.com`` is a
|
||||||
r"https?://(?:www\.)?modelscope\.(?:cn|com)/models/(?P<id>[^/?#\s]+/[^/?#\s]+)"
|
#: redirect alias of the mainland site, but ``modelscope.ai`` is the
|
||||||
)
|
#: *international* deployment with its own repository catalogue — a repository
|
||||||
|
#: published on one is routinely absent from the other (``referall13/EM1``
|
||||||
|
#: exists only on ``.ai``, ``jj3550945163/Krea-2-LORA`` only on ``.cn``). The
|
||||||
|
#: host therefore decides which site, API and CDN a model belongs to, and the
|
||||||
|
#: two deployments are registered as separate sources rather than folded into
|
||||||
|
#: one id.
|
||||||
|
_MAINLAND_HOSTS = r"modelscope\.(?:cn|com)"
|
||||||
|
_INTERNATIONAL_HOSTS = r"modelscope\.ai"
|
||||||
|
|
||||||
#: Trailing view segments the site appends to a model URL; accepted verbatim
|
#: Trailing view segments the site appends to a model URL; accepted verbatim
|
||||||
#: when the user pastes a browser tab URL.
|
#: when the user pastes a browser tab URL.
|
||||||
_VIEW_SEGMENTS = r"(?:summary|files|model-file|readme|community|evaluation)?"
|
_VIEW_SEGMENTS = r"(?:summary|files|model-file|readme|community|evaluation)?"
|
||||||
|
|
||||||
_STRICT_URL_PATTERN = re.compile(
|
|
||||||
r"https?://(?:www\.)?modelscope\.(?:cn|com)/models/(?P<id>[^/?#\s]+/[^/?#\s]+)"
|
def _url_patterns(hosts: str) -> tuple[re.Pattern[str], re.Pattern[str]]:
|
||||||
rf"/?{_VIEW_SEGMENTS}/?$"
|
"""Build the lenient and strict model-URL patterns for *hosts*."""
|
||||||
)
|
|
||||||
|
body = rf"https?://(?:www\.)?(?:{hosts})/models/(?P<id>[^/?#\s]+/[^/?#\s]+)"
|
||||||
|
return re.compile(body), re.compile(rf"{body}/?{_VIEW_SEGMENTS}/?$")
|
||||||
|
|
||||||
|
|
||||||
#: ``master`` is ModelScope's default branch; ``main`` is tried as a fallback
|
#: ``master`` is ModelScope's default branch; ``main`` is tried as a fallback
|
||||||
#: for repos imported from Hugging Face.
|
#: for repos imported from Hugging Face.
|
||||||
@@ -71,7 +89,12 @@ _REVISIONS = ("master", "main")
|
|||||||
|
|
||||||
|
|
||||||
class ModelScopeSource(ModelSource):
|
class ModelScopeSource(ModelSource):
|
||||||
"""ModelScope (``modelscope.cn``)."""
|
"""ModelScope's mainland site (``modelscope.cn``).
|
||||||
|
|
||||||
|
``modelscope.com`` is accepted as an alias of it. The international
|
||||||
|
deployment is :class:`ModelScopeIntlSource`; everything below is written in
|
||||||
|
terms of ``base_url`` so both share one implementation.
|
||||||
|
"""
|
||||||
|
|
||||||
platform = "modelscope"
|
platform = "modelscope"
|
||||||
label = "ModelScope"
|
label = "ModelScope"
|
||||||
@@ -79,15 +102,18 @@ class ModelScopeSource(ModelSource):
|
|||||||
supports_download = True
|
supports_download = True
|
||||||
default_revision = "master"
|
default_revision = "master"
|
||||||
default_subdir = "modelscope"
|
default_subdir = "modelscope"
|
||||||
url_pattern = _URL_PATTERN
|
|
||||||
strict_url_pattern = _STRICT_URL_PATTERN
|
#: Origin every outgoing URL is built from.
|
||||||
|
base_url = "https://modelscope.cn"
|
||||||
|
|
||||||
|
url_pattern, strict_url_pattern = _url_patterns(_MAINLAND_HOSTS)
|
||||||
|
|
||||||
def canonical_url(self, source_id: str) -> str:
|
def canonical_url(self, source_id: str) -> str:
|
||||||
return f"https://modelscope.cn/models/{source_id}"
|
return f"{self.base_url}/models/{source_id}"
|
||||||
|
|
||||||
def asset_base_url(self, source_id: str, revision: str = "") -> str:
|
def asset_base_url(self, source_id: str, revision: str = "") -> str:
|
||||||
return (
|
return (
|
||||||
f"https://modelscope.cn/models/{source_id}/resolve/"
|
f"{self.base_url}/models/{source_id}/resolve/"
|
||||||
f"{self.resolve_revision(revision)}"
|
f"{self.resolve_revision(revision)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,7 +122,7 @@ class ModelScopeSource(ModelSource):
|
|||||||
|
|
||||||
for revision in _REVISIONS:
|
for revision in _REVISIONS:
|
||||||
text = await fetch_text(
|
text = await fetch_text(
|
||||||
f"https://modelscope.cn/models/{source_id}/resolve/{revision}/README.md"
|
f"{self.base_url}/models/{source_id}/resolve/{revision}/README.md"
|
||||||
)
|
)
|
||||||
if text:
|
if text:
|
||||||
return text
|
return text
|
||||||
@@ -105,7 +131,7 @@ class ModelScopeSource(ModelSource):
|
|||||||
# environments where the CDN resolve host is blocked.
|
# environments where the CDN resolve host is blocked.
|
||||||
for revision in _REVISIONS:
|
for revision in _REVISIONS:
|
||||||
text = await fetch_text(
|
text = await fetch_text(
|
||||||
"https://modelscope.cn/api/v1/models/"
|
f"{self.base_url}/api/v1/models/"
|
||||||
f"{source_id}/repo?Revision={revision}&FilePath=README.md"
|
f"{source_id}/repo?Revision={revision}&FilePath=README.md"
|
||||||
)
|
)
|
||||||
if text:
|
if text:
|
||||||
@@ -158,7 +184,7 @@ class ModelScopeSource(ModelSource):
|
|||||||
return cache.provider[cache_key]
|
return cache.provider[cache_key]
|
||||||
|
|
||||||
status, payload = await fetch_json(
|
status, payload = await fetch_json(
|
||||||
f"https://modelscope.cn/api/v1/models/{source_id}"
|
f"{self.base_url}/api/v1/models/{source_id}"
|
||||||
)
|
)
|
||||||
if status != 200 or not isinstance(payload, dict):
|
if status != 200 or not isinstance(payload, dict):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -185,7 +211,7 @@ class ModelScopeSource(ModelSource):
|
|||||||
|
|
||||||
revision = self.resolve_revision(revision)
|
revision = self.resolve_revision(revision)
|
||||||
status, payload = await fetch_json(
|
status, payload = await fetch_json(
|
||||||
"https://modelscope.cn/api/v1/models/"
|
f"{self.base_url}/api/v1/models/"
|
||||||
f"{source_id}/repo/files?Revision={revision}"
|
f"{source_id}/repo/files?Revision={revision}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -208,18 +234,37 @@ class ModelScopeSource(ModelSource):
|
|||||||
self, source_id: str, filename: str, revision: str = ""
|
self, source_id: str, filename: str, revision: str = ""
|
||||||
) -> str:
|
) -> str:
|
||||||
return (
|
return (
|
||||||
f"https://modelscope.cn/models/{source_id}/resolve/"
|
f"{self.base_url}/models/{source_id}/resolve/"
|
||||||
f"{self.resolve_revision(revision)}/{filename}"
|
f"{self.resolve_revision(revision)}/{filename}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def page_url_for_file(self, source_id: str, filename: str) -> str:
|
def page_url_for_file(self, source_id: str, filename: str) -> str:
|
||||||
return (
|
return (
|
||||||
f"https://modelscope.cn/models/{source_id}/file/view/"
|
f"{self.base_url}/models/{source_id}/file/view/"
|
||||||
f"{self.default_revision}/{filename}"
|
f"{self.default_revision}/{filename}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["ModelScopeSource"]
|
class ModelScopeIntlSource(ModelScopeSource):
|
||||||
|
"""ModelScope's international site (``modelscope.ai``).
|
||||||
|
|
||||||
|
A separate catalogue rather than a mirror, so it is registered under its
|
||||||
|
own platform id: the two deployments must not share a version group, a
|
||||||
|
"use default paths" directory, or a stored ``source_url``. The detail API,
|
||||||
|
the file listing, the resolve URLs and the CDN redirect all behave exactly
|
||||||
|
like the mainland site, which is why every URL here is derived from
|
||||||
|
:attr:`base_url` instead of being duplicated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
platform = "modelscope-ai"
|
||||||
|
label = "ModelScope (International)"
|
||||||
|
default_subdir = "modelscope-ai"
|
||||||
|
base_url = "https://www.modelscope.ai"
|
||||||
|
|
||||||
|
url_pattern, strict_url_pattern = _url_patterns(_INTERNATIONAL_HOSTS)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ModelScopeIntlSource", "ModelScopeSource"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -229,6 +274,33 @@ __all__ = ["ModelScopeSource"]
|
|||||||
#: Trigger-word values that mean "the author left this blank".
|
#: Trigger-word values that mean "the author left this blank".
|
||||||
_EMPTY_TRIGGER_VALUES = frozenset({"none", "null", "n/a"})
|
_EMPTY_TRIGGER_VALUES = frozenset({"none", "null", "n/a"})
|
||||||
|
|
||||||
|
#: Repository tags that only restate what the model *is* (its library, task or
|
||||||
|
#: framework) rather than what it depicts. ModelScope mixes both into the
|
||||||
|
#: plain ``Tags`` list, and a card tagged "lora" or "text-to-image" is noise.
|
||||||
|
_GENERIC_TAGS = frozenset(
|
||||||
|
{
|
||||||
|
"any-to-any",
|
||||||
|
"checkpoint",
|
||||||
|
"controlnet",
|
||||||
|
"diffusers",
|
||||||
|
"embedding",
|
||||||
|
"image-text-to-text",
|
||||||
|
"image-to-image",
|
||||||
|
"image-to-video",
|
||||||
|
"lora",
|
||||||
|
"lycoris",
|
||||||
|
"onnx",
|
||||||
|
"pytorch",
|
||||||
|
"safetensors",
|
||||||
|
"tensorflow",
|
||||||
|
"text-to-image",
|
||||||
|
"text-to-speech",
|
||||||
|
"text-to-video",
|
||||||
|
"textual-inversion",
|
||||||
|
"vae",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _clean_text(value: Any) -> str:
|
def _clean_text(value: Any) -> str:
|
||||||
"""Return a stripped string for *value*, or ``""`` for anything else."""
|
"""Return a stripped string for *value*, or ``""`` for anything else."""
|
||||||
@@ -259,9 +331,13 @@ def _build_card_context(
|
|||||||
|
|
||||||
context = ModelCardContext(
|
context = ModelCardContext(
|
||||||
description=_clean_text(data.get("Description")),
|
description=_clean_text(data.get("Description")),
|
||||||
|
model_name=_clean_text(data.get("Name")),
|
||||||
|
model_name_localized=_clean_text(data.get("ChineseName")),
|
||||||
|
license=_clean_text(data.get("License")),
|
||||||
|
model_type=_clean_text(data.get("AigcType")),
|
||||||
base_model=_first_string(data.get("BaseModel")),
|
base_model=_first_string(data.get("BaseModel")),
|
||||||
base_model_aliases=_base_model_aliases(data),
|
base_model_aliases=_base_model_aliases(data),
|
||||||
official_tags=_official_tags(data.get("OfficialTags")),
|
official_tags=_official_tags(data),
|
||||||
)
|
)
|
||||||
|
|
||||||
versions = _matching_versions(
|
versions = _matching_versions(
|
||||||
@@ -271,6 +347,7 @@ def _build_card_context(
|
|||||||
sha256=sha256,
|
sha256=sha256,
|
||||||
)
|
)
|
||||||
if versions:
|
if versions:
|
||||||
|
context.version_name = _version_label(versions)
|
||||||
context.example_images = _cover_image_urls(versions)
|
context.example_images = _cover_image_urls(versions)
|
||||||
context.trigger_words = _version_trigger_words(versions)
|
context.trigger_words = _version_trigger_words(versions)
|
||||||
return context
|
return context
|
||||||
@@ -303,26 +380,63 @@ def _base_model_aliases(data: dict[str, Any]) -> list[str]:
|
|||||||
return aliases
|
return aliases
|
||||||
|
|
||||||
|
|
||||||
def _official_tags(value: Any) -> list[str]:
|
def _official_tags(data: dict[str, Any]) -> list[str]:
|
||||||
"""Extract the site-curated tag values from ``OfficialTags``.
|
"""Return the content tags the site publishes for the repository.
|
||||||
|
|
||||||
ModelScope's entries are dicts carrying an English ``Tag`` plus a
|
``OfficialTags`` is ModelScope's curated content vocabulary and is
|
||||||
``ChineseName``; the English value is the curated content vocabulary, so
|
preferred whenever it is populated. Plenty of AIGC repositories leave it
|
||||||
that is the one surfaced here.
|
empty and carry only the plain ``Tags`` list, which mixes content tags with
|
||||||
|
framework and task categories; those categories are dropped so a card is
|
||||||
|
not handed "lora" and "text-to-image" as if they described the model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
curated = _dedupe(_tag_values(data.get("OfficialTags")))
|
||||||
|
if curated:
|
||||||
|
return curated
|
||||||
|
|
||||||
|
generic = set(_GENERIC_TAGS)
|
||||||
|
for value in (
|
||||||
|
data.get("AigcType"),
|
||||||
|
data.get("Libraries"),
|
||||||
|
data.get("Frameworks"),
|
||||||
|
):
|
||||||
|
for item in value if isinstance(value, list) else [value]:
|
||||||
|
text = _clean_text(item).lower()
|
||||||
|
if text:
|
||||||
|
generic.add(text)
|
||||||
|
|
||||||
|
return _dedupe(
|
||||||
|
tag for tag in _tag_values(data.get("Tags")) if tag.lower() not in generic
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_values(value: Any) -> list[str]:
|
||||||
|
"""Return the tag strings from either shape ModelScope publishes.
|
||||||
|
|
||||||
|
``OfficialTags`` is a list of ``{"Tag": ..., "ChineseName": ...}`` dicts
|
||||||
|
carrying an English value; the plain ``Tags`` list is already strings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
tags: list[str] = []
|
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
return tags
|
return []
|
||||||
|
tags: list[str] = []
|
||||||
for entry in value:
|
for entry in value:
|
||||||
if not isinstance(entry, dict):
|
tag = _clean_text(entry.get("Tag") if isinstance(entry, dict) else entry)
|
||||||
continue
|
if tag:
|
||||||
tag = _clean_text(entry.get("Tag"))
|
|
||||||
if tag and tag not in tags:
|
|
||||||
tags.append(tag)
|
tags.append(tag)
|
||||||
return tags
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe(values: Iterable[str]) -> list[str]:
|
||||||
|
"""Drop empties and repeats, keeping the first spelling seen."""
|
||||||
|
|
||||||
|
unique: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
if value and value not in unique:
|
||||||
|
unique.append(value)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
def _version_files(version: dict[str, Any]) -> list[str]:
|
def _version_files(version: dict[str, Any]) -> list[str]:
|
||||||
"""Return the model filenames covered by one ``MuseInfo.versions`` entry.
|
"""Return the model filenames covered by one ``MuseInfo.versions`` entry.
|
||||||
|
|
||||||
@@ -359,6 +473,23 @@ def _version_show_name(version: dict[str, Any]) -> str:
|
|||||||
return _clean_text(model_version.get("showName")).lower()
|
return _clean_text(model_version.get("showName")).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _version_label(versions: list[dict[str, Any]]) -> str:
|
||||||
|
"""Return the first published version label, preserving its spelling.
|
||||||
|
|
||||||
|
Unlike :func:`_version_show_name` this is for display, so the label is
|
||||||
|
not lowercased.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for version in versions:
|
||||||
|
model_version = version.get("modelVersion")
|
||||||
|
if not isinstance(model_version, dict):
|
||||||
|
continue
|
||||||
|
label = _clean_text(model_version.get("showName"))
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _file_digests(data: dict[str, Any]) -> dict[str, str]:
|
def _file_digests(data: dict[str, Any]) -> dict[str, str]:
|
||||||
"""Return ``basename -> sha256`` for every published weight file.
|
"""Return ``basename -> sha256`` for every published weight file.
|
||||||
|
|
||||||
|
|||||||
@@ -13,15 +13,18 @@ from typing import Any, Dict, Mapping, Optional
|
|||||||
|
|
||||||
from .base import GROUP_PREFIXES, ModelSource, SourceRef, clean_source_url
|
from .base import GROUP_PREFIXES, ModelSource, SourceRef, clean_source_url
|
||||||
from .huggingface import HuggingFaceSource
|
from .huggingface import HuggingFaceSource
|
||||||
from .modelscope import ModelScopeSource
|
from .modelscope import ModelScopeIntlSource, ModelScopeSource
|
||||||
from .tensorart import TensorArtSource
|
from .tensorart import TensorArtSource
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
#: Order matters only for disambiguation; the URL patterns are disjoint.
|
#: Order matters only for disambiguation; the URL patterns are disjoint.
|
||||||
|
#: ``modelscope.ai`` is a separate catalogue from ``modelscope.cn`` rather than
|
||||||
|
#: an alias, which is why it gets its own entry (see ``modelscope.py``).
|
||||||
_SOURCES: tuple[ModelSource, ...] = (
|
_SOURCES: tuple[ModelSource, ...] = (
|
||||||
HuggingFaceSource(),
|
HuggingFaceSource(),
|
||||||
ModelScopeSource(),
|
ModelScopeSource(),
|
||||||
|
ModelScopeIntlSource(),
|
||||||
TensorArtSource(),
|
TensorArtSource(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,13 @@ class WebSocketManager:
|
|||||||
progress_entry['status'] = data['status']
|
progress_entry['status'] = data['status']
|
||||||
if 'message' in data:
|
if 'message' in data:
|
||||||
progress_entry['message'] = data['message']
|
progress_entry['message'] = data['message']
|
||||||
|
# Post-transfer stage reporting (see `model_source_handlers._report_phase`):
|
||||||
|
# the byte counter has stopped by then, so the stage is the only thing
|
||||||
|
# that still says the download is working.
|
||||||
|
if 'stage' in data:
|
||||||
|
progress_entry['stage'] = data['stage']
|
||||||
|
if 'platform' in data:
|
||||||
|
progress_entry['platform'] = data['platform']
|
||||||
|
|
||||||
self._download_progress[download_id] = progress_entry
|
self._download_progress[download_id] = progress_entry
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.2.2"
|
version = "1.2.3"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -31,6 +31,10 @@
|
|||||||
/* Textarea Styling */
|
/* Textarea Styling */
|
||||||
#batchUrlInput {
|
#batchUrlInput {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
/* Content-box sizing made the border box wider than the modal's content
|
||||||
|
box, so the right border/halo fell outside the clipped area and was cut
|
||||||
|
off. Include padding and border in the declared width. */
|
||||||
|
box-sizing: border-box;
|
||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
|||||||
@@ -97,6 +97,32 @@
|
|||||||
width: 0%;
|
width: 0%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The transfer is done but the backend is still indexing the file and reading
|
||||||
|
the model site's API. A sheen over the full bar reads as "busy" where a
|
||||||
|
motionless 100% bar reads as "stuck". */
|
||||||
|
.current-item-bar.is-indeterminate {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-item-bar.is-indeterminate::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(255, 255, 255, 0.5) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
animation: progress-sheen 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress-sheen {
|
||||||
|
from { transform: translateX(-100%); }
|
||||||
|
to { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
.current-item-percent {
|
.current-item-percent {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-color-secondary, var(--text-color));
|
color: var(--text-color-secondary, var(--text-color));
|
||||||
@@ -131,4 +157,8 @@
|
|||||||
.current-item-bar {
|
.current-item-bar {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.current-item-bar.is-indeterminate::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
.input-group input,
|
.input-group input,
|
||||||
.input-group select {
|
.input-group select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
/* Include padding/border in the declared width so full-width fields do not
|
||||||
|
spill past the modal's content box, where their right border gets
|
||||||
|
clipped by the step's overflow-x: hidden. */
|
||||||
|
box-sizing: border-box;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
@@ -720,6 +724,9 @@
|
|||||||
/* Textarea for multi-URL input */
|
/* Textarea for multi-URL input */
|
||||||
#modelUrl {
|
#modelUrl {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
/* Content-box sizing pushed the border box 2px past the step's content
|
||||||
|
edge, clipping the right border. Include padding/border in the width. */
|
||||||
|
box-sizing: border-box;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
@@ -768,6 +775,16 @@
|
|||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fields sit flush against the scrollable step's content edge; the global
|
||||||
|
focus outline (offset: 2px) has its left/right edges clipped by the step's
|
||||||
|
overflow-x. Draw the ring inset so the full outline stays visible.
|
||||||
|
(Same fix as #importModal in import-modal.css.) */
|
||||||
|
#downloadModal input:focus-visible,
|
||||||
|
#downloadModal select:focus-visible,
|
||||||
|
#downloadModal textarea:focus-visible {
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
#downloadModal .download-step .modal-actions {
|
#downloadModal .download-step .modal-actions {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|||||||
@@ -28,6 +28,38 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tags row: the base model badge shares one line with the compact tags. */
|
||||||
|
.recipe-tags-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-tags-row #recipeTagsContainer {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header base model badge: reuses the card .base-model-label pill shape but
|
||||||
|
swaps the on-image overlay styling (text shadow, backdrop blur) for the
|
||||||
|
accent-tinted chip look used by resource rows in this modal. */
|
||||||
|
.recipe-base-model-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: 160px;
|
||||||
|
text-shadow: none;
|
||||||
|
backdrop-filter: none;
|
||||||
|
background: oklch(var(--lora-accent) / 0.1);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-base-model-badge.is-unknown {
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
.recipe-modal-header h2 {
|
.recipe-modal-header h2 {
|
||||||
margin: 0 0 var(--space-1);
|
margin: 0 0 var(--space-1);
|
||||||
padding: var(--space-1);
|
padding: var(--space-1);
|
||||||
|
|||||||
@@ -92,38 +92,104 @@
|
|||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: grab;
|
|
||||||
transition: transform 0.18s ease;
|
transition: transform 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-item:active {
|
/* --- Shared chip reordering (tags + trigger words) ------------------------ */
|
||||||
|
/* Chips in a list that is actually sortable advertise the grab gesture only
|
||||||
|
then, so lists that cannot be reordered never lie about it. */
|
||||||
|
|
||||||
|
.metadata-items.pointer-sort-enabled .metadata-item {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-items.pointer-sort-enabled .metadata-item:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-item-dragging {
|
/* Grip handle: always in the DOM, revealed when the list is sortable */
|
||||||
|
.reorder-handle {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: -2px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.4;
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: grab;
|
||||||
|
/* Keep a touch drag on the handle from scrolling the surrounding panel */
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
transition: opacity 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-sortable-words .reorder-handle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag chips have no flex gap (unlike trigger word tags), so the grip needs its
|
||||||
|
own spacing before the tag text */
|
||||||
|
.metadata-item .reorder-handle {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder-handle:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hint shown in the edit controls row while reordering is available */
|
||||||
|
.reorder-hint {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-right: auto;
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-sortable-words .reorder-hint {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Snapped-to-grid transition for the remaining chips while dragging */
|
||||||
|
.reorder-sorting > * {
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The lifted chip that follows the pointer */
|
||||||
|
.reorder-dragging {
|
||||||
box-shadow: var(--shadow-dialog);
|
box-shadow: var(--shadow-dialog);
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
opacity: 0.95;
|
opacity: 0.95;
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-item-placeholder {
|
/* Drop target left behind by the lifted chip */
|
||||||
|
.reorder-placeholder {
|
||||||
border: 1px dashed var(--lora-accent);
|
border: 1px dashed var(--lora-accent);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-items-sorting .metadata-item {
|
body.reorder-drag-active {
|
||||||
transition: transform 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.metadata-drag-active {
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.metadata-drag-active * {
|
body.reorder-drag-active * {
|
||||||
cursor: grabbing !important;
|
cursor: grabbing !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -494,6 +494,7 @@ class RecipeModal {
|
|||||||
this.syncGenerationParams(hydratedRecipe.gen_params);
|
this.syncGenerationParams(hydratedRecipe.gen_params);
|
||||||
this.syncResourcesSection(hydratedRecipe);
|
this.syncResourcesSection(hydratedRecipe);
|
||||||
this.syncHeaderActions();
|
this.syncHeaderActions();
|
||||||
|
this.syncBaseModelBadge();
|
||||||
this.syncMetaFooter();
|
this.syncMetaFooter();
|
||||||
|
|
||||||
// Show the modal
|
// Show the modal
|
||||||
@@ -520,6 +521,32 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the recipe-level base model badge in the header tags row.
|
||||||
|
* Unlike the width-constrained card overlay (which abbreviates), the
|
||||||
|
* modal has room for the full base model name — matching the model
|
||||||
|
* modal's info grid and this modal's resource rows. Falls back to a
|
||||||
|
* dimmed "Unknown" instead of hiding so the header layout does not
|
||||||
|
* shift when hydration fills the value in.
|
||||||
|
*/
|
||||||
|
syncBaseModelBadge() {
|
||||||
|
const badge = document.getElementById('recipeBaseModelBadge');
|
||||||
|
if (!badge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawLabel = (this.currentRecipe?.base_model || '').trim();
|
||||||
|
const unknownLabel = translate('recipes.modal.metadata.unknown', {}, 'Unknown');
|
||||||
|
const baseModelLabel = rawLabel || unknownLabel;
|
||||||
|
const fieldLabel = translate('recipes.modal.metadata.baseModel', {}, 'Base Model');
|
||||||
|
|
||||||
|
badge.textContent = baseModelLabel;
|
||||||
|
badge.title = `${fieldLabel}: ${baseModelLabel}`;
|
||||||
|
badge.setAttribute('aria-label', badge.title);
|
||||||
|
badge.classList.toggle('is-unknown', !rawLabel);
|
||||||
|
badge.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the meta footer: clickable file location (opens the recipe JSON
|
* Render the meta footer: clickable file location (opens the recipe JSON
|
||||||
* in the OS file manager) plus the truncated recipe ID with copy button.
|
* in the OS file manager) plus the truncated recipe ID with copy button.
|
||||||
@@ -661,6 +688,10 @@ class RecipeModal {
|
|||||||
nextRecipe.has_workflow = fullRecipe.has_workflow;
|
nextRecipe.has_workflow = fullRecipe.has_workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fullRecipe.base_model !== undefined) {
|
||||||
|
nextRecipe.base_model = fullRecipe.base_model;
|
||||||
|
}
|
||||||
|
|
||||||
if (fullRecipe.checkpoint !== undefined) {
|
if (fullRecipe.checkpoint !== undefined) {
|
||||||
nextRecipe.checkpoint = fullRecipe.checkpoint;
|
nextRecipe.checkpoint = fullRecipe.checkpoint;
|
||||||
} else {
|
} else {
|
||||||
@@ -718,6 +749,7 @@ class RecipeModal {
|
|||||||
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
|
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
|
||||||
}
|
}
|
||||||
this.syncHeaderActions();
|
this.syncHeaderActions();
|
||||||
|
this.syncBaseModelBadge();
|
||||||
this.syncMetaFooter();
|
this.syncMetaFooter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import { getModelApiClient } from '../../api/modelApiFactory.js';
|
|||||||
import { translate } from '../../utils/i18nHelpers.js';
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
import { getPriorityTagSuggestions } from '../../utils/priorityTagHelpers.js';
|
import { getPriorityTagSuggestions } from '../../utils/priorityTagHelpers.js';
|
||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
|
import { enablePointerSort } from './pointerSort.js';
|
||||||
|
import {
|
||||||
|
refreshReorderState,
|
||||||
|
renderReorderHandle,
|
||||||
|
renderReorderHint,
|
||||||
|
} from './reorderSupport.js';
|
||||||
|
|
||||||
const MODEL_TYPE_SUGGESTION_KEY_MAP = {
|
const MODEL_TYPE_SUGGESTION_KEY_MAP = {
|
||||||
loras: 'lora',
|
loras: 'lora',
|
||||||
@@ -18,16 +24,22 @@ const MODEL_TYPE_SUGGESTION_KEY_MAP = {
|
|||||||
};
|
};
|
||||||
const METADATA_ITEM_SELECTOR = '.metadata-item';
|
const METADATA_ITEM_SELECTOR = '.metadata-item';
|
||||||
const METADATA_ITEMS_CONTAINER_SELECTOR = '.metadata-items';
|
const METADATA_ITEMS_CONTAINER_SELECTOR = '.metadata-items';
|
||||||
const METADATA_ITEM_DRAGGING_CLASS = 'metadata-item-dragging';
|
|
||||||
const METADATA_ITEM_PLACEHOLDER_CLASS = 'metadata-item-placeholder';
|
/**
|
||||||
const METADATA_ITEMS_SORTING_CLASS = 'metadata-items-sorting';
|
* Tag items have no click action of their own, so the whole chip stays
|
||||||
const BODY_DRAGGING_CLASS = 'metadata-drag-active';
|
* draggable (handleSelector is null); the small threshold keeps a click on the
|
||||||
|
* grip from starting a drag (it just focuses the grip). Touch users drag by the
|
||||||
|
* grip, which is the element that opts out of scrolling via touch-action.
|
||||||
|
*/
|
||||||
|
const TAG_SORT_CONFIG = {
|
||||||
|
itemSelector: METADATA_ITEM_SELECTOR,
|
||||||
|
dragThreshold: 5,
|
||||||
|
};
|
||||||
|
|
||||||
let activeModelTypeKey = '';
|
let activeModelTypeKey = '';
|
||||||
let priorityTagSuggestions = [];
|
let priorityTagSuggestions = [];
|
||||||
let priorityTagSuggestionsLoaded = false;
|
let priorityTagSuggestionsLoaded = false;
|
||||||
let priorityTagSuggestionsPromise = null;
|
let priorityTagSuggestionsPromise = null;
|
||||||
let activeTagDragState = null;
|
|
||||||
|
|
||||||
// Configurable options for tag editing (set by setupTagEditMode)
|
// Configurable options for tag editing (set by setupTagEditMode)
|
||||||
let tagEditOptions = {
|
let tagEditOptions = {
|
||||||
@@ -423,6 +435,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
|
|||||||
<div class="metadata-items">
|
<div class="metadata-items">
|
||||||
${currentTags.map(tag => `
|
${currentTags.map(tag => `
|
||||||
<div class="metadata-item" data-tag="${tag}">
|
<div class="metadata-item" data-tag="${tag}">
|
||||||
|
${renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
|
||||||
<span class="metadata-item-content">${tag}</span>
|
<span class="metadata-item-content">${tag}</span>
|
||||||
<button class="metadata-delete-btn">
|
<button class="metadata-delete-btn">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
@@ -431,6 +444,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
|
|||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata-edit-controls">
|
<div class="metadata-edit-controls">
|
||||||
|
${renderReorderHint(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
|
||||||
<button class="save-tags-btn" title="Save changes">
|
<button class="save-tags-btn" title="Save changes">
|
||||||
<i class="fas fa-save"></i> Save
|
<i class="fas fa-save"></i> Save
|
||||||
</button>
|
</button>
|
||||||
@@ -543,8 +557,11 @@ function setupDeleteButtons() {
|
|||||||
btn.addEventListener('click', function(e) {
|
btn.addEventListener('click', function(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const tag = this.closest('.metadata-item');
|
const tag = this.closest('.metadata-item');
|
||||||
|
const scope = tag?.closest('.model-tags-container');
|
||||||
tag.remove();
|
tag.remove();
|
||||||
|
|
||||||
|
refreshTagReorderState(scope);
|
||||||
|
|
||||||
// Update status of items in the suggestion dropdown
|
// Update status of items in the suggestion dropdown
|
||||||
updateSuggestionsDropdown();
|
updateSuggestionsDropdown();
|
||||||
});
|
});
|
||||||
@@ -563,204 +580,31 @@ function setupTagDragAndDrop(scopeContainer) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.querySelectorAll(METADATA_ITEM_SELECTOR).forEach((item) => {
|
const scope = container.closest('.model-tags-container') || container;
|
||||||
item.removeAttribute('draggable');
|
|
||||||
if (item.classList.contains(METADATA_ITEM_PLACEHOLDER_CLASS)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (item.dataset.pointerDragInit === 'true') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
item.addEventListener('pointerdown', handleTagPointerDown);
|
enablePointerSort(container, {
|
||||||
item.dataset.pointerDragInit = 'true';
|
...TAG_SORT_CONFIG,
|
||||||
|
onSorted: () => {
|
||||||
|
updateSuggestionsDropdown();
|
||||||
|
refreshTagReorderState(scope);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
refreshTagReorderState(scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTagPointerDown(event) {
|
/**
|
||||||
if (event.button !== 0) {
|
* Refresh the "sortable" flag (and therefore the grip + hint) of a tags section
|
||||||
return;
|
* @param {Element} [tagsSection] - The .model-tags-container element
|
||||||
}
|
*/
|
||||||
|
function refreshTagReorderState(tagsSection) {
|
||||||
if (event.target.closest('.metadata-delete-btn')) {
|
refreshReorderState({
|
||||||
return;
|
container: tagsSection?.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR),
|
||||||
}
|
scope: tagsSection || undefined,
|
||||||
|
itemSelector: METADATA_ITEM_SELECTOR,
|
||||||
const item = event.currentTarget;
|
|
||||||
const container = item?.closest(METADATA_ITEMS_CONTAINER_SELECTOR);
|
|
||||||
if (!item || !container) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
startPointerDrag({ item, container, startEvent: event });
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPointerDrag({ item, container, startEvent }) {
|
|
||||||
if (activeTagDragState) {
|
|
||||||
finishPointerDrag();
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemRect = item.getBoundingClientRect();
|
|
||||||
const placeholder = document.createElement('div');
|
|
||||||
placeholder.className = `metadata-item ${METADATA_ITEM_PLACEHOLDER_CLASS}`;
|
|
||||||
placeholder.style.width = `${itemRect.width}px`;
|
|
||||||
placeholder.style.height = `${itemRect.height}px`;
|
|
||||||
|
|
||||||
container.insertBefore(placeholder, item);
|
|
||||||
|
|
||||||
item.classList.add(METADATA_ITEM_DRAGGING_CLASS);
|
|
||||||
item.style.width = `${itemRect.width}px`;
|
|
||||||
item.style.height = `${itemRect.height}px`;
|
|
||||||
item.style.position = 'fixed';
|
|
||||||
item.style.left = `${itemRect.left}px`;
|
|
||||||
item.style.top = `${itemRect.top}px`;
|
|
||||||
item.style.pointerEvents = 'none';
|
|
||||||
item.style.zIndex = '1000';
|
|
||||||
|
|
||||||
container.classList.add(METADATA_ITEMS_SORTING_CLASS);
|
|
||||||
if (document.body) {
|
|
||||||
document.body.classList.add(BODY_DRAGGING_CLASS);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dragState = {
|
|
||||||
container,
|
|
||||||
item,
|
|
||||||
placeholder,
|
|
||||||
offsetX: startEvent.clientX - itemRect.left,
|
|
||||||
offsetY: startEvent.clientY - itemRect.top,
|
|
||||||
lastKnownPointer: { x: startEvent.clientX, y: startEvent.clientY },
|
|
||||||
rafId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
activeTagDragState = dragState;
|
|
||||||
|
|
||||||
document.addEventListener('pointermove', handlePointerMove);
|
|
||||||
document.addEventListener('pointerup', handlePointerUp);
|
|
||||||
document.addEventListener('pointercancel', handlePointerUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerMove(event) {
|
|
||||||
if (!activeTagDragState) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTagDragState.lastKnownPointer = { x: event.clientX, y: event.clientY };
|
|
||||||
|
|
||||||
if (activeTagDragState.rafId !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTagDragState.rafId = requestAnimationFrame(() => {
|
|
||||||
if (!activeTagDragState) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeTagDragState.rafId = null;
|
|
||||||
updateDraggingItemPosition();
|
|
||||||
updatePlaceholderPosition();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerUp() {
|
|
||||||
finishPointerDrag();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDraggingItemPosition() {
|
|
||||||
if (!activeTagDragState) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { item, offsetX, offsetY, lastKnownPointer } = activeTagDragState;
|
|
||||||
const left = lastKnownPointer.x - offsetX;
|
|
||||||
const top = lastKnownPointer.y - offsetY;
|
|
||||||
item.style.left = `${left}px`;
|
|
||||||
item.style.top = `${top}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePlaceholderPosition() {
|
|
||||||
if (!activeTagDragState) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { container, placeholder, item, lastKnownPointer } = activeTagDragState;
|
|
||||||
const siblings = Array.from(
|
|
||||||
container.querySelectorAll(
|
|
||||||
`${METADATA_ITEM_SELECTOR}:not(.${METADATA_ITEM_PLACEHOLDER_CLASS})`
|
|
||||||
)
|
|
||||||
).filter((element) => element !== item);
|
|
||||||
|
|
||||||
let insertAfter = null;
|
|
||||||
|
|
||||||
for (const sibling of siblings) {
|
|
||||||
const rect = sibling.getBoundingClientRect();
|
|
||||||
|
|
||||||
if (lastKnownPointer.y < rect.top) {
|
|
||||||
container.insertBefore(placeholder, sibling);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastKnownPointer.y <= rect.bottom) {
|
|
||||||
if (lastKnownPointer.x < rect.left + rect.width / 2) {
|
|
||||||
container.insertBefore(placeholder, sibling);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
insertAfter = sibling;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
insertAfter = sibling;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!insertAfter) {
|
|
||||||
container.insertBefore(placeholder, container.firstElementChild);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.insertBefore(placeholder, insertAfter.nextSibling);
|
|
||||||
}
|
|
||||||
|
|
||||||
function finishPointerDrag() {
|
|
||||||
if (!activeTagDragState) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { container, item, placeholder, rafId } = activeTagDragState;
|
|
||||||
|
|
||||||
document.removeEventListener('pointermove', handlePointerMove);
|
|
||||||
document.removeEventListener('pointerup', handlePointerUp);
|
|
||||||
document.removeEventListener('pointercancel', handlePointerUp);
|
|
||||||
|
|
||||||
container.classList.remove(METADATA_ITEMS_SORTING_CLASS);
|
|
||||||
if (document.body) {
|
|
||||||
document.body.classList.remove(BODY_DRAGGING_CLASS);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rafId !== null) {
|
|
||||||
cancelAnimationFrame(rafId);
|
|
||||||
activeTagDragState.rafId = null;
|
|
||||||
updateDraggingItemPosition();
|
|
||||||
updatePlaceholderPosition();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (placeholder && placeholder.parentNode === container) {
|
|
||||||
container.insertBefore(item, placeholder);
|
|
||||||
container.removeChild(placeholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
item.classList.remove(METADATA_ITEM_DRAGGING_CLASS);
|
|
||||||
item.style.position = '';
|
|
||||||
item.style.width = '';
|
|
||||||
item.style.height = '';
|
|
||||||
item.style.left = '';
|
|
||||||
item.style.top = '';
|
|
||||||
item.style.pointerEvents = '';
|
|
||||||
item.style.zIndex = '';
|
|
||||||
|
|
||||||
activeTagDragState = null;
|
|
||||||
|
|
||||||
updateSuggestionsDropdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new tag
|
* Add a new tag
|
||||||
* @param {string} tag - Tag to add
|
* @param {string} tag - Tag to add
|
||||||
@@ -799,6 +643,7 @@ function addNewTag(tag, scopeElement = null) {
|
|||||||
newTag.className = 'metadata-item';
|
newTag.className = 'metadata-item';
|
||||||
newTag.dataset.tag = tag;
|
newTag.dataset.tag = tag;
|
||||||
newTag.innerHTML = `
|
newTag.innerHTML = `
|
||||||
|
${renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
|
||||||
<span class="metadata-item-content">${tag}</span>
|
<span class="metadata-item-content">${tag}</span>
|
||||||
<button class="metadata-delete-btn">
|
<button class="metadata-delete-btn">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
|
|||||||
@@ -7,10 +7,35 @@ import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
|||||||
import { translate } from '../../utils/i18nHelpers.js';
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
import { escapeAttribute, escapeHtml } from './utils.js';
|
import { escapeAttribute, escapeHtml } from './utils.js';
|
||||||
|
import {
|
||||||
|
enablePointerSort,
|
||||||
|
disablePointerSort,
|
||||||
|
} from './pointerSort.js';
|
||||||
|
import {
|
||||||
|
refreshReorderState,
|
||||||
|
renderReorderHandle,
|
||||||
|
renderReorderHint,
|
||||||
|
} from './reorderSupport.js';
|
||||||
|
|
||||||
const MAX_WORDS_PER_TRIGGER_GROUP = 500;
|
const MAX_WORDS_PER_TRIGGER_GROUP = 500;
|
||||||
const MAX_TRIGGER_WORD_GROUPS = 100;
|
const MAX_TRIGGER_WORD_GROUPS = 100;
|
||||||
const TRIGGER_WORD_CLICK_DELAY_MS = 220;
|
const TRIGGER_WORD_CLICK_DELAY_MS = 220;
|
||||||
|
const TRIGGER_WORD_DRAG_HANDLE_SELECTOR = '.reorder-handle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag-to-reorder configuration for trigger word tags.
|
||||||
|
* Handlers are installed when entering edit mode and removed again on exit, so
|
||||||
|
* display mode keeps its click-to-copy / double-click-to-edit behaviour.
|
||||||
|
* The item body is click-to-edit here, so only the grip starts a drag, and the
|
||||||
|
* small threshold keeps a click on the grip from lifting the tag.
|
||||||
|
*/
|
||||||
|
const TRIGGER_WORD_DRAG_CONFIG = {
|
||||||
|
itemSelector: '.trigger-word-tag',
|
||||||
|
handleSelector: TRIGGER_WORD_DRAG_HANDLE_SELECTOR,
|
||||||
|
ignoreSelector: '.metadata-delete-btn, .trigger-word-edit-input',
|
||||||
|
blockedItemSelector: '.is-editing',
|
||||||
|
dragThreshold: 5,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch trained words for a model
|
* Fetch trained words for a model
|
||||||
@@ -182,6 +207,16 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
|||||||
return dropdown;
|
return dropdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the drag handle of a trigger word tag.
|
||||||
|
* The handle is always in the DOM but only visible (and clickable) in edit mode,
|
||||||
|
* so switching modes never has to rebuild the tag markup.
|
||||||
|
* @returns {string} Handle markup
|
||||||
|
*/
|
||||||
|
function renderTriggerWordDragHandle() {
|
||||||
|
return renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render trigger words
|
* Render trigger words
|
||||||
* @param {Array} words - Array of trigger words
|
* @param {Array} words - Array of trigger words
|
||||||
@@ -203,6 +238,7 @@ export function renderTriggerWords(words, filePath) {
|
|||||||
<div class="trigger-words-tags" style="display:none;"></div>
|
<div class="trigger-words-tags" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata-edit-controls" style="display:none;">
|
<div class="metadata-edit-controls" style="display:none;">
|
||||||
|
${renderReorderHint(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
|
||||||
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
|
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
|
||||||
<i class="fas fa-save"></i> ${translate('common.actions.save')}
|
<i class="fas fa-save"></i> ${translate('common.actions.save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -228,6 +264,7 @@ export function renderTriggerWords(words, filePath) {
|
|||||||
const escapedAttr = escapeAttribute(word);
|
const escapedAttr = escapeAttribute(word);
|
||||||
return `
|
return `
|
||||||
<div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyOrEditWord')}">
|
<div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyOrEditWord')}">
|
||||||
|
${renderTriggerWordDragHandle()}
|
||||||
<span class="trigger-word-content">${escapedWord}</span>
|
<span class="trigger-word-content">${escapedWord}</span>
|
||||||
<span class="trigger-word-copy">
|
<span class="trigger-word-copy">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
@@ -240,6 +277,7 @@ export function renderTriggerWords(words, filePath) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata-edit-controls" style="display:none;">
|
<div class="metadata-edit-controls" style="display:none;">
|
||||||
|
${renderReorderHint(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
|
||||||
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
|
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
|
||||||
<i class="fas fa-save"></i> ${translate('common.actions.save')}
|
<i class="fas fa-save"></i> ${translate('common.actions.save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -316,6 +354,10 @@ export function setupTriggerWordsEditMode() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enable drag-to-reorder (grip handle) for the current words
|
||||||
|
enableTriggerWordSort(triggerWordsSection);
|
||||||
|
refreshTriggerWordHandleLabels(triggerWordsSection);
|
||||||
|
|
||||||
// Load trained words and display dropdown when entering edit mode
|
// Load trained words and display dropdown when entering edit mode
|
||||||
// Add loading indicator
|
// Add loading indicator
|
||||||
const loadingIndicator = document.createElement('div');
|
const loadingIndicator = document.createElement('div');
|
||||||
@@ -379,6 +421,10 @@ export function setupTriggerWordsEditMode() {
|
|||||||
if (tagsContainer) tagsContainer.style.display = 'none';
|
if (tagsContainer) tagsContainer.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Leaving edit mode: tags are no longer reorderable
|
||||||
|
disableTriggerWordSort(triggerWordsSection);
|
||||||
|
refreshTriggerWordHandleLabels(triggerWordsSection);
|
||||||
|
|
||||||
// Remove dropdown if present
|
// Remove dropdown if present
|
||||||
const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown');
|
const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown');
|
||||||
if (dropdown) dropdown.remove();
|
if (dropdown) dropdown.remove();
|
||||||
@@ -433,8 +479,13 @@ export function setupTriggerWordsEditMode() {
|
|||||||
function deleteTriggerWord(e) {
|
function deleteTriggerWord(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const tag = this.closest('.trigger-word-tag');
|
const tag = this.closest('.trigger-word-tag');
|
||||||
|
const section = tag?.closest('.trigger-words');
|
||||||
tag.remove();
|
tag.remove();
|
||||||
|
|
||||||
|
if (section) {
|
||||||
|
refreshTriggerWordHandleLabels(section);
|
||||||
|
}
|
||||||
|
|
||||||
// Update status of items in the trained words dropdown
|
// Update status of items in the trained words dropdown
|
||||||
updateTrainedWordsDropdown();
|
updateTrainedWordsDropdown();
|
||||||
}
|
}
|
||||||
@@ -493,6 +544,47 @@ function restoreOriginalTriggerWords(section, originalWords) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the "sortable" flag (and therefore the grip + hint) of a section.
|
||||||
|
* Reordering is drag-only and only offered while editing: the tag body itself
|
||||||
|
* is click-to-edit, so the grip must not appear in display mode.
|
||||||
|
* @param {HTMLElement} section - The .trigger-words section
|
||||||
|
*/
|
||||||
|
function refreshTriggerWordHandleLabels(section) {
|
||||||
|
refreshReorderState({
|
||||||
|
container: section.querySelector('.trigger-words-tags'),
|
||||||
|
scope: section,
|
||||||
|
itemSelector: TRIGGER_WORD_DRAG_CONFIG.itemSelector,
|
||||||
|
isActive: () => section.classList.contains('edit-mode'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable drag-to-reorder for the tags of a section (edit mode only)
|
||||||
|
* @param {HTMLElement} section - The .trigger-words section
|
||||||
|
*/
|
||||||
|
function enableTriggerWordSort(section) {
|
||||||
|
const tagsContainer = section.querySelector('.trigger-words-tags');
|
||||||
|
if (!tagsContainer) return;
|
||||||
|
|
||||||
|
enablePointerSort(tagsContainer, {
|
||||||
|
...TRIGGER_WORD_DRAG_CONFIG,
|
||||||
|
onSorted: () => refreshTriggerWordHandleLabels(section),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove drag-to-reorder handlers when leaving edit mode
|
||||||
|
* @param {HTMLElement} section - The .trigger-words section
|
||||||
|
*/
|
||||||
|
function disableTriggerWordSort(section) {
|
||||||
|
const tagsContainer = section.querySelector('.trigger-words-tags');
|
||||||
|
if (!tagsContainer) return;
|
||||||
|
|
||||||
|
disablePointerSort(tagsContainer, TRIGGER_WORD_DRAG_CONFIG);
|
||||||
|
refreshTriggerWordHandleLabels(section);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a trigger word tag element
|
* Create a trigger word tag element
|
||||||
* @param {string} word - Trigger word
|
* @param {string} word - Trigger word
|
||||||
@@ -507,6 +599,7 @@ function createTriggerWordTag(word, isEditMode = false) {
|
|||||||
|
|
||||||
const escapedWord = escapeHtml(word);
|
const escapedWord = escapeHtml(word);
|
||||||
tag.innerHTML = `
|
tag.innerHTML = `
|
||||||
|
${renderTriggerWordDragHandle()}
|
||||||
<span class="trigger-word-content">${escapedWord}</span>
|
<span class="trigger-word-content">${escapedWord}</span>
|
||||||
<span class="trigger-word-copy" style="${isEditMode ? 'display:none;' : ''}">
|
<span class="trigger-word-copy" style="${isEditMode ? 'display:none;' : ''}">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
@@ -637,7 +730,7 @@ function validateTriggerWord(word, tagsContainer, currentTag = null) {
|
|||||||
* @param {Event} e - Click event
|
* @param {Event} e - Click event
|
||||||
*/
|
*/
|
||||||
function startEditTriggerWord(e) {
|
function startEditTriggerWord(e) {
|
||||||
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input')) return;
|
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input') || e.target.closest(TRIGGER_WORD_DRAG_HANDLE_SELECTOR)) return;
|
||||||
|
|
||||||
const tag = this.closest('.trigger-word-tag');
|
const tag = this.closest('.trigger-word-tag');
|
||||||
const section = tag?.closest('.trigger-words');
|
const section = tag?.closest('.trigger-words');
|
||||||
@@ -684,6 +777,11 @@ function startEditTriggerWord(e) {
|
|||||||
tag.classList.remove('is-editing');
|
tag.classList.remove('is-editing');
|
||||||
tag.style.removeProperty('--trigger-word-edit-width');
|
tag.style.removeProperty('--trigger-word-edit-width');
|
||||||
tag.style.removeProperty('--trigger-word-edit-height');
|
tag.style.removeProperty('--trigger-word-edit-height');
|
||||||
|
|
||||||
|
if (section) {
|
||||||
|
refreshTriggerWordHandleLabels(section);
|
||||||
|
}
|
||||||
|
|
||||||
updateTrainedWordsDropdown();
|
updateTrainedWordsDropdown();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -763,6 +861,12 @@ function addNewTriggerWord(word) {
|
|||||||
const newTag = createTriggerWordTag(word, triggerWordsSection.classList.contains('edit-mode'));
|
const newTag = createTriggerWordTag(word, triggerWordsSection.classList.contains('edit-mode'));
|
||||||
tagsContainer.appendChild(newTag);
|
tagsContainer.appendChild(newTag);
|
||||||
|
|
||||||
|
if (triggerWordsSection.classList.contains('edit-mode')) {
|
||||||
|
// Wire the freshly added tag for reordering too
|
||||||
|
enableTriggerWordSort(triggerWordsSection);
|
||||||
|
refreshTriggerWordHandleLabels(triggerWordsSection);
|
||||||
|
}
|
||||||
|
|
||||||
// Update status of items in the trained words dropdown
|
// Update status of items in the trained words dropdown
|
||||||
updateTrainedWordsDropdown();
|
updateTrainedWordsDropdown();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
/**
|
||||||
|
* pointerSort.js
|
||||||
|
* Shared pointer-based drag-and-drop sorting for wrapped item lists
|
||||||
|
* (model tags, trigger words, ...).
|
||||||
|
*
|
||||||
|
* The engine lifts the dragged item into a fixed-position "ghost", leaves a
|
||||||
|
* correctly sized placeholder behind, and moves that placeholder around based
|
||||||
|
* on the pointer position. Because the dragged node is re-inserted where the
|
||||||
|
* placeholder ended up, the resulting DOM order *is* the new sort order — the
|
||||||
|
* save path simply reads the items in DOM order.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS = {
|
||||||
|
// Selector of the sortable items inside the container.
|
||||||
|
itemSelector: '.metadata-item',
|
||||||
|
// When set, a drag can only start from inside this element (a handle).
|
||||||
|
// When null the whole item is draggable.
|
||||||
|
handleSelector: null,
|
||||||
|
// Elements inside an item that must never start a drag.
|
||||||
|
ignoreSelector: '.metadata-delete-btn',
|
||||||
|
// Items matching this selector cannot be dragged (e.g. while being edited).
|
||||||
|
blockedItemSelector: null,
|
||||||
|
draggingClass: 'reorder-dragging',
|
||||||
|
placeholderClass: 'reorder-placeholder',
|
||||||
|
containerSortingClass: 'reorder-sorting',
|
||||||
|
// Added to <body> while dragging to disable text selection globally.
|
||||||
|
bodySortingClass: 'reorder-drag-active',
|
||||||
|
// Pointer travel (px) required before a drag starts. 0 = start on pointerdown.
|
||||||
|
dragThreshold: 0,
|
||||||
|
// Called after a successful drop with (item, container).
|
||||||
|
onSorted: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Marks a container whose items are actually sortable, so styles can offer the
|
||||||
|
// grab affordance only where dragging really works.
|
||||||
|
const CONTAINER_ENABLED_CLASS = 'pointer-sort-enabled';
|
||||||
|
|
||||||
|
let activeDragState = null;
|
||||||
|
let pendingDragState = null;
|
||||||
|
|
||||||
|
function resolveConfig(options = {}) {
|
||||||
|
return { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemInitKey(config) {
|
||||||
|
// Any option that changes how a pointerdown is interpreted is part of the
|
||||||
|
// key, so re-enabling a container with new options replaces the handler
|
||||||
|
// instead of silently keeping the old one.
|
||||||
|
return [
|
||||||
|
config.itemSelector,
|
||||||
|
config.handleSelector || '',
|
||||||
|
config.ignoreSelector || '',
|
||||||
|
config.blockedItemSelector || '',
|
||||||
|
config.dragThreshold,
|
||||||
|
].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the items of a container draggable within it.
|
||||||
|
* Safe to call repeatedly (e.g. after adding an item): already-configured items
|
||||||
|
* are skipped, and newly added items get wired up.
|
||||||
|
* @param {HTMLElement} container - Element holding the sortable items
|
||||||
|
* @param {Object} [options] - See DEFAULT_OPTIONS
|
||||||
|
*/
|
||||||
|
export function enablePointerSort(container, options = {}) {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const config = resolveConfig(options);
|
||||||
|
const initKey = itemInitKey(config);
|
||||||
|
container.__pointerSortConfig = config;
|
||||||
|
container.classList.add(CONTAINER_ENABLED_CLASS);
|
||||||
|
|
||||||
|
container.querySelectorAll(config.itemSelector).forEach((item) => {
|
||||||
|
item.removeAttribute('draggable');
|
||||||
|
if (item.classList.contains(config.placeholderClass)) return;
|
||||||
|
if (item.__pointerSortKey === initKey) return;
|
||||||
|
|
||||||
|
if (item.__pointerSortHandler) {
|
||||||
|
item.removeEventListener('pointerdown', item.__pointerSortHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = (event) => handlePointerDown(event, item, container, config);
|
||||||
|
item.addEventListener('pointerdown', handler);
|
||||||
|
item.__pointerSortKey = initKey;
|
||||||
|
item.__pointerSortHandler = handler;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove drag handlers previously installed by enablePointerSort().
|
||||||
|
* @param {HTMLElement} container - Element holding the sortable items
|
||||||
|
* @param {Object} [options] - Used when the container has no stored config
|
||||||
|
*/
|
||||||
|
export function disablePointerSort(container, options = {}) {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const config = resolveConfig(container.__pointerSortConfig || options);
|
||||||
|
container.querySelectorAll(config.itemSelector).forEach((item) => {
|
||||||
|
if (item.__pointerSortHandler) {
|
||||||
|
item.removeEventListener('pointerdown', item.__pointerSortHandler);
|
||||||
|
}
|
||||||
|
delete item.__pointerSortHandler;
|
||||||
|
delete item.__pointerSortKey;
|
||||||
|
});
|
||||||
|
|
||||||
|
delete container.__pointerSortConfig;
|
||||||
|
container.classList.remove(CONTAINER_ENABLED_CLASS);
|
||||||
|
cancelPendingDrag(container);
|
||||||
|
|
||||||
|
if (activeDragState && activeDragState.container === container) {
|
||||||
|
finishPointerDrag();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event, item, container, config) {
|
||||||
|
if (activeDragState || pendingDragState) return;
|
||||||
|
if (typeof event.button === 'number' && event.button !== 0) return;
|
||||||
|
if (config.ignoreSelector && event.target.closest(config.ignoreSelector)) return;
|
||||||
|
if (config.handleSelector && !event.target.closest(config.handleSelector)) return;
|
||||||
|
if (config.blockedItemSelector && item.matches(config.blockedItemSelector)) return;
|
||||||
|
if (item.classList.contains(config.placeholderClass)) return;
|
||||||
|
|
||||||
|
if (config.dragThreshold > 0) {
|
||||||
|
startPendingDrag({ item, container, config, startEvent: event });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent the browser's native text selection / image drag from kicking in.
|
||||||
|
event.preventDefault();
|
||||||
|
startPointerDrag({ item, container, config, startEvent: event });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPendingDrag({ item, container, config, startEvent }) {
|
||||||
|
const state = {
|
||||||
|
item,
|
||||||
|
container,
|
||||||
|
config,
|
||||||
|
startX: startEvent.clientX,
|
||||||
|
startY: startEvent.clientY,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.onMove = (event) => {
|
||||||
|
const dx = event.clientX - state.startX;
|
||||||
|
const dy = event.clientY - state.startY;
|
||||||
|
if (Math.hypot(dx, dy) < config.dragThreshold) return;
|
||||||
|
|
||||||
|
cleanupPendingDrag();
|
||||||
|
event.preventDefault();
|
||||||
|
clearTextSelection();
|
||||||
|
startPointerDrag({ item, container, config, startEvent: event });
|
||||||
|
};
|
||||||
|
state.onUp = () => cleanupPendingDrag();
|
||||||
|
|
||||||
|
pendingDragState = state;
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', state.onMove);
|
||||||
|
document.addEventListener('pointerup', state.onUp);
|
||||||
|
document.addEventListener('pointercancel', state.onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupPendingDrag() {
|
||||||
|
if (!pendingDragState) return;
|
||||||
|
|
||||||
|
const { onMove, onUp } = pendingDragState;
|
||||||
|
document.removeEventListener('pointermove', onMove);
|
||||||
|
document.removeEventListener('pointerup', onUp);
|
||||||
|
document.removeEventListener('pointercancel', onUp);
|
||||||
|
pendingDragState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPendingDrag(container) {
|
||||||
|
if (pendingDragState && (!container || pendingDragState.container === container)) {
|
||||||
|
cleanupPendingDrag();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTextSelection() {
|
||||||
|
if (typeof window === 'undefined' || !window.getSelection) return;
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.removeAllRanges) selection.removeAllRanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPointerDrag({ item, container, config, startEvent }) {
|
||||||
|
if (activeDragState) finishPointerDrag();
|
||||||
|
|
||||||
|
const itemRect = item.getBoundingClientRect();
|
||||||
|
const placeholder = document.createElement('div');
|
||||||
|
const placeholderClasses = Array.from(item.classList).filter(
|
||||||
|
(name) => name !== config.draggingClass && name !== config.placeholderClass,
|
||||||
|
);
|
||||||
|
placeholderClasses.push(config.placeholderClass);
|
||||||
|
placeholder.className = placeholderClasses.join(' ');
|
||||||
|
placeholder.style.width = `${itemRect.width}px`;
|
||||||
|
placeholder.style.height = `${itemRect.height}px`;
|
||||||
|
|
||||||
|
container.insertBefore(placeholder, item);
|
||||||
|
|
||||||
|
item.classList.add(config.draggingClass);
|
||||||
|
item.style.width = `${itemRect.width}px`;
|
||||||
|
item.style.height = `${itemRect.height}px`;
|
||||||
|
item.style.position = 'fixed';
|
||||||
|
item.style.left = `${itemRect.left}px`;
|
||||||
|
item.style.top = `${itemRect.top}px`;
|
||||||
|
item.style.pointerEvents = 'none';
|
||||||
|
item.style.zIndex = '1000';
|
||||||
|
|
||||||
|
container.classList.add(config.containerSortingClass);
|
||||||
|
if (config.bodySortingClass && document.body) {
|
||||||
|
document.body.classList.add(config.bodySortingClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swallow the click generated by this pointer sequence so dropping an item
|
||||||
|
// never triggers its own click handler (copy-to-clipboard, inline editing).
|
||||||
|
// Scoped to the dragged container so unrelated clicks are never affected.
|
||||||
|
const swallowClick = (event) => {
|
||||||
|
if (event.target !== container && !container.contains(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
document.removeEventListener('click', swallowClick, true);
|
||||||
|
};
|
||||||
|
document.addEventListener('click', swallowClick, true);
|
||||||
|
|
||||||
|
activeDragState = {
|
||||||
|
container,
|
||||||
|
item,
|
||||||
|
placeholder,
|
||||||
|
config,
|
||||||
|
offsetX: startEvent.clientX - itemRect.left,
|
||||||
|
offsetY: startEvent.clientY - itemRect.top,
|
||||||
|
lastKnownPointer: { x: startEvent.clientX, y: startEvent.clientY },
|
||||||
|
rafId: null,
|
||||||
|
swallowClick,
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', handlePointerMove);
|
||||||
|
document.addEventListener('pointerup', handlePointerUp);
|
||||||
|
document.addEventListener('pointercancel', handlePointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event) {
|
||||||
|
if (!activeDragState) return;
|
||||||
|
|
||||||
|
activeDragState.lastKnownPointer = { x: event.clientX, y: event.clientY };
|
||||||
|
|
||||||
|
if (activeDragState.rafId !== null) return;
|
||||||
|
|
||||||
|
activeDragState.rafId = requestAnimationFrame(() => {
|
||||||
|
if (!activeDragState) return;
|
||||||
|
activeDragState.rafId = null;
|
||||||
|
updateDraggingItemPosition();
|
||||||
|
updatePlaceholderPosition();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp() {
|
||||||
|
finishPointerDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDraggingItemPosition() {
|
||||||
|
if (!activeDragState) return;
|
||||||
|
|
||||||
|
const { item, offsetX, offsetY, lastKnownPointer } = activeDragState;
|
||||||
|
const left = lastKnownPointer.x - offsetX;
|
||||||
|
const top = lastKnownPointer.y - offsetY;
|
||||||
|
item.style.left = `${left}px`;
|
||||||
|
item.style.top = `${top}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlaceholderPosition() {
|
||||||
|
if (!activeDragState) return;
|
||||||
|
|
||||||
|
const { container, placeholder, item, config, lastKnownPointer } = activeDragState;
|
||||||
|
const siblings = Array.from(
|
||||||
|
container.querySelectorAll(
|
||||||
|
`${config.itemSelector}:not(.${config.placeholderClass})`,
|
||||||
|
),
|
||||||
|
).filter((element) => element !== item);
|
||||||
|
|
||||||
|
let insertAfter = null;
|
||||||
|
|
||||||
|
for (const sibling of siblings) {
|
||||||
|
const rect = sibling.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (lastKnownPointer.y < rect.top) {
|
||||||
|
container.insertBefore(placeholder, sibling);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastKnownPointer.y <= rect.bottom) {
|
||||||
|
if (lastKnownPointer.x < rect.left + rect.width / 2) {
|
||||||
|
container.insertBefore(placeholder, sibling);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
insertAfter = sibling;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
insertAfter = sibling;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!insertAfter) {
|
||||||
|
container.insertBefore(placeholder, container.firstElementChild);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.insertBefore(placeholder, insertAfter.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishPointerDrag() {
|
||||||
|
if (!activeDragState) return;
|
||||||
|
|
||||||
|
const { container, item, placeholder, config, rafId, swallowClick } = activeDragState;
|
||||||
|
|
||||||
|
document.removeEventListener('pointermove', handlePointerMove);
|
||||||
|
document.removeEventListener('pointerup', handlePointerUp);
|
||||||
|
document.removeEventListener('pointercancel', handlePointerUp);
|
||||||
|
|
||||||
|
container.classList.remove(config.containerSortingClass);
|
||||||
|
if (config.bodySortingClass && document.body) {
|
||||||
|
document.body.classList.remove(config.bodySortingClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
activeDragState.rafId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always settle the placeholder from the last known pointer: the drop must
|
||||||
|
// reflect the final pointer position even when no animation frame ran
|
||||||
|
// (fast drags, or drags that started from the threshold-crossing move).
|
||||||
|
updateDraggingItemPosition();
|
||||||
|
updatePlaceholderPosition();
|
||||||
|
|
||||||
|
if (placeholder && placeholder.parentNode === container) {
|
||||||
|
container.insertBefore(item, placeholder);
|
||||||
|
container.removeChild(placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
item.classList.remove(config.draggingClass);
|
||||||
|
item.style.position = '';
|
||||||
|
item.style.width = '';
|
||||||
|
item.style.height = '';
|
||||||
|
item.style.left = '';
|
||||||
|
item.style.top = '';
|
||||||
|
item.style.pointerEvents = '';
|
||||||
|
item.style.zIndex = '';
|
||||||
|
|
||||||
|
activeDragState = null;
|
||||||
|
|
||||||
|
if (typeof config.onSorted === 'function') {
|
||||||
|
config.onSorted(item, container);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupSwallowClick(swallowClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The click that follows a drop is dispatched right after pointerup, so the
|
||||||
|
* guard has to survive until the next macrotask.
|
||||||
|
* @param {Function} handler - Capture-phase click handler to remove
|
||||||
|
*/
|
||||||
|
function cleanupSwallowClick(handler) {
|
||||||
|
if (!handler) return;
|
||||||
|
setTimeout(() => document.removeEventListener('click', handler, true), 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* reorderSupport.js
|
||||||
|
* Shared drag affordance for chip lists sorted with pointerSort.
|
||||||
|
*
|
||||||
|
* The drag gesture itself lives in pointerSort.js; this module owns the parts
|
||||||
|
* every sortable list needs on top of it:
|
||||||
|
* - the `⠿` grip markup,
|
||||||
|
* - the "sortable" flag that reveals the grip only when reordering is possible.
|
||||||
|
*
|
||||||
|
* Convention used by both callers: a list always shows the grip while it is
|
||||||
|
* sortable. Whether the item *body* is draggable as well depends on the item:
|
||||||
|
* - body has no click action (model/recipe tags) -> whole item is draggable,
|
||||||
|
* - body is click-to-edit (trigger words) -> only the grip starts a drag.
|
||||||
|
*
|
||||||
|
* Reordering is deliberately pointer-only: a keyboard shortcut would have to
|
||||||
|
* fight the browser's own Alt + Arrow handling and the modal's arrow-key
|
||||||
|
* navigation, so the grip is a plain decorative affordance rather than a
|
||||||
|
* focusable control.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { escapeAttribute, escapeHtml } from './utils.js';
|
||||||
|
|
||||||
|
const SORTABLE_CLASS = 'has-sortable-words';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the shared reorder grip
|
||||||
|
* @param {string} label - Tooltip text
|
||||||
|
* @returns {string} Handle markup
|
||||||
|
*/
|
||||||
|
export function renderReorderHandle(label) {
|
||||||
|
const safeLabel = escapeAttribute(label || '');
|
||||||
|
return `<span class="reorder-handle" aria-hidden="true" title="${safeLabel}"><i class="fas fa-grip-vertical"></i></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the shared reorder hint shown in an edit controls row
|
||||||
|
* @param {string} label - Hint text
|
||||||
|
* @returns {string} Hint markup
|
||||||
|
*/
|
||||||
|
export function renderReorderHint(label) {
|
||||||
|
return `<span class="reorder-hint"><i class="fas fa-grip-vertical"></i> ${escapeHtml(label || '')}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show or hide the grip and hint of a list.
|
||||||
|
* They are only offered while the list is editable and holds more than one
|
||||||
|
* item, so the UI never shows an affordance that cannot do anything.
|
||||||
|
* @param {Object} options - Options
|
||||||
|
* @param {HTMLElement} options.container - Element holding the sortable items
|
||||||
|
* @param {HTMLElement} [options.scope] - Element that receives the sortable flag
|
||||||
|
* @param {string} options.itemSelector - Selector of the sortable items
|
||||||
|
* @param {Function} [options.isActive] - Whether reordering is currently allowed
|
||||||
|
*/
|
||||||
|
export function refreshReorderState({
|
||||||
|
container,
|
||||||
|
scope = container,
|
||||||
|
itemSelector,
|
||||||
|
isActive = () => true,
|
||||||
|
}) {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const items = container.querySelectorAll(itemSelector);
|
||||||
|
scope.classList.toggle(SORTABLE_CLASS, isActive() && items.length > 1);
|
||||||
|
}
|
||||||
@@ -340,6 +340,27 @@ export class DownloadManager {
|
|||||||
|
|
||||||
// ---- External repository download flow (Hugging Face / ModelScope) ----
|
// ---- External repository download flow (Hugging Face / ModelScope) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report a post-transfer stage frame to the progress UI.
|
||||||
|
*
|
||||||
|
* The backend keeps working after the last byte lands — it indexes the
|
||||||
|
* file and reads the model site's API — and announces those stages with
|
||||||
|
* `status: 'metadata'`. Without them the bar sits at 100% showing "0 B/s"
|
||||||
|
* and the download looks stuck. The stage and platform are machine
|
||||||
|
* readable so LoadingManager can localise the wording.
|
||||||
|
*
|
||||||
|
* @returns {boolean} `true` when the frame was a stage frame.
|
||||||
|
*/
|
||||||
|
_applyMetadataStage(data, updateProgress, completed, name) {
|
||||||
|
if (data?.status !== 'metadata') return false;
|
||||||
|
updateProgress(100, completed, name, {}, {
|
||||||
|
phase: 'metadata',
|
||||||
|
stage: data.stage || '',
|
||||||
|
platform: data.platform || '',
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Rendering group key: the same repo on two sites is two groups. */
|
/** Rendering group key: the same repo on two sites is two groups. */
|
||||||
_externalGroupKey(item) {
|
_externalGroupKey(item) {
|
||||||
return `${item.source}:${item.repo || 'unknown'}`;
|
return `${item.source}:${item.repo || 'unknown'}`;
|
||||||
@@ -1708,6 +1729,12 @@ export class DownloadManager {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Indexing / site metadata: the transfer is over but the
|
||||||
|
// backend is still working, so say so instead of
|
||||||
|
// leaving the bar frozen at 100%.
|
||||||
|
if (this._applyMetadataStage(data, updateProgress, snapshotCompleted, filename)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.status === 'progress') {
|
if (data.status === 'progress') {
|
||||||
const metrics = {
|
const metrics = {
|
||||||
bytesDownloaded: data.bytes_downloaded,
|
bytesDownloaded: data.bytes_downloaded,
|
||||||
@@ -2331,6 +2358,9 @@ export class DownloadManager {
|
|||||||
const snapshotCompleted = completedDownloads;
|
const snapshotCompleted = completedDownloads;
|
||||||
wsHf.onmessage = (event) => {
|
wsHf.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
if (this._applyMetadataStage(data, updateProgress, snapshotCompleted, name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.status === 'progress') {
|
if (data.status === 'progress') {
|
||||||
const metrics = {
|
const metrics = {
|
||||||
bytesDownloaded: data.bytes_downloaded,
|
bytesDownloaded: data.bytes_downloaded,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { formatFileSize } from '../utils/formatters.js';
|
import { formatFileSize } from '../utils/formatters.js';
|
||||||
|
import { getModelSource } from '../utils/modelSourceHelpers.js';
|
||||||
|
|
||||||
// Loading management
|
// Loading management
|
||||||
export class LoadingManager {
|
export class LoadingManager {
|
||||||
@@ -278,6 +279,35 @@ export class LoadingManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describe a post-transfer stage in the status line.
|
||||||
|
*
|
||||||
|
* The byte counter stops as soon as the last byte lands, but the
|
||||||
|
* backend still hashes the file and reads the model site's API. Naming
|
||||||
|
* that work is what stops the bar looking frozen at 100%.
|
||||||
|
*/
|
||||||
|
const describeMetadataStage = (stage, platform) => {
|
||||||
|
if (stage === 'indexing') {
|
||||||
|
return translate(
|
||||||
|
'modals.download.progress.indexingFile',
|
||||||
|
{},
|
||||||
|
'Reading model file...'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const label = getModelSource(platform)?.label || platform || '';
|
||||||
|
return label
|
||||||
|
? translate(
|
||||||
|
'modals.download.progress.fetchingSourceMetadata',
|
||||||
|
{ source: label },
|
||||||
|
`Fetching metadata from ${label}...`
|
||||||
|
)
|
||||||
|
: translate(
|
||||||
|
'modals.download.progress.fetchingMetadata',
|
||||||
|
{},
|
||||||
|
'Fetching metadata...'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize transfer stats with empty data
|
// Initialize transfer stats with empty data
|
||||||
updateTransferStats();
|
updateTransferStats();
|
||||||
|
|
||||||
@@ -285,19 +315,62 @@ export class LoadingManager {
|
|||||||
this.loadingContent.appendChild(this.cancelButton);
|
this.loadingContent.appendChild(this.cancelButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return update function
|
/**
|
||||||
return (currentProgress, currentIndex = 0, currentName = '', metrics = {}) => {
|
* Update the progress UI.
|
||||||
|
*
|
||||||
|
* @param {number} currentProgress Percentage of the current item.
|
||||||
|
* @param {number} [currentIndex] Items finished so far.
|
||||||
|
* @param {string} [currentName] File being processed.
|
||||||
|
* @param {object} [metrics] Byte counters; only meaningful while
|
||||||
|
* transferring.
|
||||||
|
* @param {object} [phase] `{ phase: 'metadata', stage, platform }` once
|
||||||
|
* the transfer has finished, so the UI can show what is still running
|
||||||
|
* instead of a 0 B/s speed.
|
||||||
|
*/
|
||||||
|
return (
|
||||||
|
currentProgress,
|
||||||
|
currentIndex = 0,
|
||||||
|
currentName = '',
|
||||||
|
metrics = {},
|
||||||
|
phase = null
|
||||||
|
) => {
|
||||||
|
const isMetadata = phase?.phase === 'metadata';
|
||||||
|
|
||||||
// Update current item progress
|
// Update current item progress
|
||||||
currentItemProgress.style.width = `${currentProgress}%`;
|
currentItemProgress.style.width = `${currentProgress}%`;
|
||||||
currentItemPercent.textContent = `${Math.floor(currentProgress)}%`;
|
currentItemPercent.textContent = `${Math.floor(currentProgress)}%`;
|
||||||
|
currentItemProgress.classList.toggle('is-indeterminate', isMetadata);
|
||||||
|
|
||||||
// Update current item label if name provided
|
// Update current item label if name provided
|
||||||
if (currentName) {
|
if (currentName) {
|
||||||
currentItemLabel.textContent = translate(
|
currentItemLabel.textContent = isMetadata
|
||||||
'modals.download.progress.downloading',
|
? translate(
|
||||||
{ name: currentName },
|
'modals.download.progress.metadata',
|
||||||
`Downloading: ${currentName}`
|
{ name: currentName },
|
||||||
|
`Metadata: ${currentName}`
|
||||||
|
)
|
||||||
|
: translate(
|
||||||
|
'modals.download.progress.downloading',
|
||||||
|
{ name: currentName },
|
||||||
|
`Downloading: ${currentName}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No bytes are moving any more, so report the stage instead of a
|
||||||
|
// rate that has dropped to zero.
|
||||||
|
if (isMetadata) {
|
||||||
|
updateTransferStats({ bytesDownloaded: metrics.bytesDownloaded, totalBytes: metrics.totalBytes });
|
||||||
|
const stageText = describeMetadataStage(phase.stage, phase.platform);
|
||||||
|
speedDetail.textContent = stageText;
|
||||||
|
// Keep the batch position visible; the status line is the one
|
||||||
|
// place a caller also writes to.
|
||||||
|
this.setStatus(
|
||||||
|
totalItems > 1
|
||||||
|
? `${Math.min(currentIndex + 1, totalItems)}/${totalItems}: ${stageText}`
|
||||||
|
: stageText
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
updateTransferStats(metrics);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update overall label if multiple items
|
// Update overall label if multiple items
|
||||||
@@ -311,8 +384,6 @@ export class LoadingManager {
|
|||||||
// Single item, just update main progress
|
// Single item, just update main progress
|
||||||
this.setProgress(currentProgress);
|
this.setProgress(currentProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTransferStats(metrics);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,26 @@ export const MODEL_SOURCES = [
|
|||||||
filePage: (id, filename) =>
|
filePage: (id, filename) =>
|
||||||
`https://modelscope.cn/models/${id}/file/view/master/${filename}`,
|
`https://modelscope.cn/models/${id}/file/view/master/${filename}`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// A separate catalogue from `modelscope.cn`, not an alias: a repository
|
||||||
|
// published on one is routinely absent from the other, so the host is part
|
||||||
|
// of the model's identity. Mirrors ModelScopeIntlSource in the backend.
|
||||||
|
platform: 'modelscope-ai',
|
||||||
|
label: 'ModelScope (International)',
|
||||||
|
groupPrefix: 'msai',
|
||||||
|
supportsEnrichment: true,
|
||||||
|
supportsDownload: true,
|
||||||
|
defaultRevision: 'master',
|
||||||
|
defaultSubdir: 'modelscope-ai',
|
||||||
|
exampleUrl: 'https://www.modelscope.ai/models/user/repo',
|
||||||
|
placeholder: 'https://www.modelscope.ai/models/user/repo',
|
||||||
|
pattern: /^https?:\/\/(?:www\.)?modelscope\.ai\/models\/([^/?#\s]+\/[^/?#\s]+)/i,
|
||||||
|
filePattern:
|
||||||
|
/^https?:\/\/(?:www\.)?modelscope\.ai\/models\/([^/?#\s]+\/[^/?#\s]+)\/resolve\/([^/?#\s]+)\/(.+)$/i,
|
||||||
|
canonical: (id) => `https://www.modelscope.ai/models/${id}`,
|
||||||
|
filePage: (id, filename) =>
|
||||||
|
`https://www.modelscope.ai/models/${id}/file/view/master/${filename}`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
platform: 'tensorart',
|
platform: 'tensorart',
|
||||||
label: 'TensorArt',
|
label: 'TensorArt',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<div id="hfSupportedSources">
|
<div id="hfSupportedSources">
|
||||||
<strong>https://huggingface.co/user/repo</strong><br>
|
<strong>https://huggingface.co/user/repo</strong><br>
|
||||||
<strong>https://modelscope.cn/models/user/repo</strong><br>
|
<strong>https://modelscope.cn/models/user/repo</strong><br>
|
||||||
|
<strong>https://www.modelscope.ai/models/user/repo</strong><br>
|
||||||
<strong>https://tensor.art/models/827823520299086029</strong>
|
<strong>https://tensor.art/models/827823520299086029</strong>
|
||||||
</div>
|
</div>
|
||||||
{{ t('modals.linkModelSource.enrichNote') }}
|
{{ t('modals.linkModelSource.enrichNote') }}
|
||||||
|
|||||||
@@ -26,8 +26,13 @@
|
|||||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Recipe Tags Container (rendered by renderCompactTags) -->
|
<!-- Tags row: the base model badge is an independent sibling of the
|
||||||
<div id="recipeTagsContainer"></div>
|
tags container so renderCompactTags re-renders and tag edit mode
|
||||||
|
never touch it. Badge is populated by RecipeModal.syncBaseModelBadge(). -->
|
||||||
|
<div class="recipe-tags-row">
|
||||||
|
<span id="recipeBaseModelBadge" class="base-model-label recipe-base-model-badge" hidden></span>
|
||||||
|
<div id="recipeTagsContainer"></div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const {
|
||||||
|
MODEL_TAGS_MODULE,
|
||||||
|
I18N_HELPERS_MODULE,
|
||||||
|
UI_HELPERS_MODULE,
|
||||||
|
MODEL_API_MODULE,
|
||||||
|
PRIORITY_TAGS_MODULE,
|
||||||
|
STATE_MODULE,
|
||||||
|
saveModelMetadataMock,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
MODEL_TAGS_MODULE: new URL('../../../static/js/components/shared/ModelTags.js', import.meta.url).pathname,
|
||||||
|
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||||
|
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||||
|
MODEL_API_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||||
|
PRIORITY_TAGS_MODULE: new URL('../../../static/js/utils/priorityTagHelpers.js', import.meta.url).pathname,
|
||||||
|
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||||
|
saveModelMetadataMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||||
|
translate: vi.fn((key, params, fallback) => fallback || key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
copyToClipboard: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(MODEL_API_MODULE, () => ({
|
||||||
|
getModelApiClient: vi.fn(() => ({
|
||||||
|
saveModelMetadata: saveModelMetadataMock,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(PRIORITY_TAGS_MODULE, () => ({
|
||||||
|
getPriorityTagSuggestions: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(STATE_MODULE, () => ({
|
||||||
|
state: { currentPageType: 'loras' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TAG_SECTION_HTML = (tags) => `
|
||||||
|
<div class="model-tags-container">
|
||||||
|
<div class="model-tags-header">
|
||||||
|
<div class="model-tags-compact">
|
||||||
|
${tags.map((tag) => `<span class="model-tag-compact">${tag}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
<button class="edit-tags-btn" data-file-path="test.safetensors" title="Edit tags">
|
||||||
|
<i class="fas fa-pencil-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="model-tags-tooltip">
|
||||||
|
<div class="tooltip-content">
|
||||||
|
${tags.map((tag) => `<span class="tooltip-tag">${tag}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("ModelTags reordering", () => {
|
||||||
|
let setupTagEditMode;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.clearAllMocks();
|
||||||
|
saveModelMetadataMock.mockResolvedValue({});
|
||||||
|
|
||||||
|
const module = await import(MODEL_TAGS_MODULE);
|
||||||
|
setupTagEditMode = module.setupTagEditMode;
|
||||||
|
});
|
||||||
|
|
||||||
|
function section() {
|
||||||
|
return document.querySelector('.model-tags-container');
|
||||||
|
}
|
||||||
|
|
||||||
|
function items() {
|
||||||
|
return Array.from(document.querySelectorAll('.metadata-item'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function order() {
|
||||||
|
return items().map((item) => item.dataset.tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handles() {
|
||||||
|
return Array.from(document.querySelectorAll('.reorder-handle'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function firePointer(type, target, init = {}) {
|
||||||
|
target.dispatchEvent(new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
...init,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragToEnd(target) {
|
||||||
|
firePointer('pointerdown', target, { clientY: 10 });
|
||||||
|
firePointer('pointermove', target, { clientY: 999 });
|
||||||
|
firePointer('pointerup', target, { clientY: 999 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterEditMode(tags = ['alpha', 'beta', 'gamma']) {
|
||||||
|
document.body.innerHTML = TAG_SECTION_HTML(tags);
|
||||||
|
setupTagEditMode('loras');
|
||||||
|
|
||||||
|
document.querySelector('.edit-tags-btn')
|
||||||
|
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('.metadata-edit-container')).toBeTruthy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders a grip handle per tag and flags the section as sortable", async () => {
|
||||||
|
await enterEditMode(['alpha', 'beta']);
|
||||||
|
|
||||||
|
expect(items()).toHaveLength(2);
|
||||||
|
expect(handles()).toHaveLength(2);
|
||||||
|
expect(section().classList.contains('has-sortable-words')).toBe(true);
|
||||||
|
expect(
|
||||||
|
document.querySelector('.metadata-items').classList.contains('pointer-sort-enabled'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not offer reordering for a single tag", async () => {
|
||||||
|
await enterEditMode(['alpha']);
|
||||||
|
|
||||||
|
expect(handles()).toHaveLength(1);
|
||||||
|
expect(section().classList.contains('has-sortable-words')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the whole chip draggable, not just the grip", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
|
||||||
|
// Drag from the chip body (no handle involved)
|
||||||
|
dragToEnd(items()[0].querySelector('.metadata-item-content'));
|
||||||
|
|
||||||
|
expect(order()).toEqual(['beta', 'gamma', 'alpha']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorders by dragging the grip", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
|
||||||
|
dragToEnd(handles()[0]);
|
||||||
|
|
||||||
|
expect(order()).toEqual(['beta', 'gamma', 'alpha']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reorder when the grip is only clicked", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
|
||||||
|
const handle = handles()[0];
|
||||||
|
firePointer('pointerdown', handle, { clientY: 10 });
|
||||||
|
firePointer('pointermove', handle, { clientY: 12 });
|
||||||
|
firePointer('pointerup', handle, { clientY: 12 });
|
||||||
|
|
||||||
|
expect(order()).toEqual(['alpha', 'beta', 'gamma']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the new order after a drag", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
|
||||||
|
dragToEnd(handles()[0]);
|
||||||
|
expect(order()).toEqual(['beta', 'gamma', 'alpha']);
|
||||||
|
|
||||||
|
document.querySelector('.save-tags-btn')
|
||||||
|
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(saveModelMetadataMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saveModelMetadataMock).toHaveBeenCalledWith('test.safetensors', {
|
||||||
|
tags: ['beta', 'gamma', 'alpha'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the sortable flag when tags are deleted", async () => {
|
||||||
|
await enterEditMode(['alpha', 'beta']);
|
||||||
|
|
||||||
|
items()[1].querySelector('.metadata-delete-btn')
|
||||||
|
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
expect(order()).toEqual(['alpha']);
|
||||||
|
expect(section().classList.contains('has-sortable-words')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const POINTER_SORT_MODULE = new URL(
|
||||||
|
'../../../static/js/components/shared/pointerSort.js',
|
||||||
|
import.meta.url,
|
||||||
|
).pathname;
|
||||||
|
|
||||||
|
describe("pointerSort", () => {
|
||||||
|
let enablePointerSort;
|
||||||
|
let disablePointerSort;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
const module = await import(POINTER_SORT_MODULE);
|
||||||
|
enablePointerSort = module.enablePointerSort;
|
||||||
|
disablePointerSort = module.disablePointerSort;
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildList(words) {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="list">
|
||||||
|
${words.map((word) => `
|
||||||
|
<div class="item" data-id="${word}">
|
||||||
|
<span class="handle">::</span>
|
||||||
|
<span class="label">${word}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return {
|
||||||
|
container: document.querySelector('.list'),
|
||||||
|
items: Array.from(document.querySelectorAll('.item')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit class names so the assertions test the configurable engine
|
||||||
|
* rather than the metadata-* defaults used by the tag editor.
|
||||||
|
*/
|
||||||
|
function sortOptions(extra = {}) {
|
||||||
|
return {
|
||||||
|
itemSelector: '.item',
|
||||||
|
draggingClass: 'item-dragging',
|
||||||
|
placeholderClass: 'item-placeholder',
|
||||||
|
containerSortingClass: 'list-sorting',
|
||||||
|
bodySortingClass: 'drag-active',
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function order() {
|
||||||
|
return Array.from(document.querySelectorAll('.item')).map((el) => el.dataset.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firePointer(type, target, init = {}) {
|
||||||
|
target.dispatchEvent(new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
...init,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* jsdom reports zero-sized rects for every element, which makes the engine
|
||||||
|
* treat a high clientY as "past the last item".
|
||||||
|
*/
|
||||||
|
function dragToEnd(target) {
|
||||||
|
firePointer('pointerdown', target);
|
||||||
|
firePointer('pointermove', target, { clientY: 999 });
|
||||||
|
firePointer('pointerup', target, { clientY: 999 });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reorders items when the whole item is draggable", () => {
|
||||||
|
const { container } = buildList(['a', 'b', 'c']);
|
||||||
|
enablePointerSort(container, sortOptions());
|
||||||
|
|
||||||
|
const first = document.querySelector('.item');
|
||||||
|
dragToEnd(first);
|
||||||
|
|
||||||
|
expect(order()).toEqual(['b', 'c', 'a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the metadata-* defaults the tag editor relies on", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="metadata-items">
|
||||||
|
<div class="metadata-item" data-id="a">a</div>
|
||||||
|
<div class="metadata-item" data-id="b">b</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const container = document.querySelector('.metadata-items');
|
||||||
|
|
||||||
|
// No options at all: ModelTags.js calls the engine exactly like this
|
||||||
|
enablePointerSort(container);
|
||||||
|
|
||||||
|
const first = container.querySelector('.metadata-item');
|
||||||
|
firePointer('pointerdown', first);
|
||||||
|
firePointer('pointermove', first, { clientY: 999 });
|
||||||
|
firePointer('pointerup', first, { clientY: 999 });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Array.from(container.querySelectorAll('.metadata-item')).map((el) => el.dataset.id),
|
||||||
|
).toEqual(['b', 'a']);
|
||||||
|
expect(container.classList.contains('pointer-sort-enabled')).toBe(true);
|
||||||
|
expect(document.querySelector('.reorder-placeholder')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only starts a drag from the configured handle", () => {
|
||||||
|
const { container } = buildList(['a', 'b', 'c']);
|
||||||
|
enablePointerSort(container, sortOptions({ handleSelector: '.handle' }));
|
||||||
|
|
||||||
|
// Pressing the item body must not start a drag
|
||||||
|
dragToEnd(document.querySelector('.item .label'));
|
||||||
|
expect(order()).toEqual(['a', 'b', 'c']);
|
||||||
|
expect(document.querySelector('.item-dragging')).toBeNull();
|
||||||
|
|
||||||
|
// Pressing the handle does
|
||||||
|
dragToEnd(document.querySelector('.item .handle'));
|
||||||
|
expect(order()).toEqual(['b', 'c', 'a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never drags items matching the blocked selector or the ignore selector", () => {
|
||||||
|
const { container } = buildList(['a', 'b']);
|
||||||
|
enablePointerSort(container, sortOptions({ blockedItemSelector: '.locked' }));
|
||||||
|
|
||||||
|
document.querySelector('.item').classList.add('locked');
|
||||||
|
dragToEnd(document.querySelector('.item'));
|
||||||
|
expect(order()).toEqual(['a', 'b']);
|
||||||
|
|
||||||
|
document.querySelector('.item').classList.remove('locked');
|
||||||
|
enablePointerSort(container, sortOptions({ ignoreSelector: '.label' }));
|
||||||
|
dragToEnd(document.querySelector('.item .label'));
|
||||||
|
expect(order()).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for the drag threshold before lifting an item", () => {
|
||||||
|
const { container } = buildList(['a', 'b']);
|
||||||
|
enablePointerSort(container, sortOptions({ dragThreshold: 10 }));
|
||||||
|
|
||||||
|
const first = document.querySelector('.item');
|
||||||
|
firePointer('pointerdown', first, { clientX: 10, clientY: 10 });
|
||||||
|
|
||||||
|
firePointer('pointermove', first, { clientX: 15, clientY: 10 });
|
||||||
|
expect(document.querySelector('.item-dragging')).toBeNull();
|
||||||
|
|
||||||
|
firePointer('pointermove', first, { clientX: 60, clientY: 10 });
|
||||||
|
expect(document.querySelector('.item-dragging')).not.toBeNull();
|
||||||
|
|
||||||
|
firePointer('pointerup', first, { clientX: 60, clientY: 10 });
|
||||||
|
expect(order()).toEqual(['b', 'a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSorted once a drop completes", () => {
|
||||||
|
const { container } = buildList(['a', 'b']);
|
||||||
|
const onSorted = [];
|
||||||
|
enablePointerSort(container, sortOptions({
|
||||||
|
onSorted: (item) => onSorted.push(item.dataset.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
dragToEnd(document.querySelector('.item'));
|
||||||
|
expect(onSorted).toEqual(['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops handling drags after disablePointerSort", () => {
|
||||||
|
const { container } = buildList(['a', 'b']);
|
||||||
|
enablePointerSort(container, sortOptions());
|
||||||
|
|
||||||
|
disablePointerSort(container, sortOptions());
|
||||||
|
dragToEnd(document.querySelector('.item'));
|
||||||
|
|
||||||
|
expect(order()).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
const copyToClipboardMock = vi.fn();
|
||||||
|
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
|
||||||
|
|
||||||
|
const loadingManagerStub = {
|
||||||
|
showSimpleLoading: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
show: vi.fn(),
|
||||||
|
restoreProgressBar: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const recipeItem = {
|
||||||
|
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||||
|
file_path: '/recipes/a1b2c3d4-e5f6-7890-abcd-ef1234567890.png',
|
||||||
|
title: 'Demo Recipe',
|
||||||
|
tags: [],
|
||||||
|
loras: [],
|
||||||
|
base_model: 'Illustrious',
|
||||||
|
};
|
||||||
|
|
||||||
|
const virtualScrollerStub = {
|
||||||
|
updateSingleItem: vi.fn(),
|
||||||
|
getNavigationState: vi.fn(() => ({
|
||||||
|
index: 0,
|
||||||
|
hasPrev: false,
|
||||||
|
hasNext: false,
|
||||||
|
loadedItems: 1,
|
||||||
|
totalItems: 1,
|
||||||
|
})),
|
||||||
|
getAdjacentItemByFilePath: vi.fn(async () => null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateStub = {
|
||||||
|
global: { settings: {}, loadingManager: loadingManagerStub },
|
||||||
|
loadingManager: loadingManagerStub,
|
||||||
|
virtualScroller: virtualScrollerStub,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalManagerMock = {
|
||||||
|
showModal: vi.fn(),
|
||||||
|
closeModal: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRecipeDetailsMock = vi.fn(async () => ({}));
|
||||||
|
const updateRecipeMetadataMock = vi.fn(() => Promise.resolve({ success: true }));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
copyToClipboard: copyToClipboardMock,
|
||||||
|
sendLoraToWorkflow: vi.fn(),
|
||||||
|
sendModelPathToWorkflow: vi.fn(),
|
||||||
|
openCivitaiByMetadata: vi.fn(),
|
||||||
|
stripLoraTags: vi.fn((text) => text),
|
||||||
|
sendPromptToWorkflow: vi.fn(),
|
||||||
|
sendGenParamsToWorkflow: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: translateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => ({
|
||||||
|
state: stateStub,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||||
|
setSessionItem: vi.fn(),
|
||||||
|
removeSessionItem: vi.fn(),
|
||||||
|
getStorageItem: vi.fn(() => null),
|
||||||
|
setStorageItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||||
|
fetchRecipeDetails: fetchRecipeDetailsMock,
|
||||||
|
updateRecipeMetadata: updateRecipeMetadataMock,
|
||||||
|
sendRecipeWorkflow: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||||
|
MODEL_TYPES: {
|
||||||
|
LORA: 'loras',
|
||||||
|
CHECKPOINT: 'checkpoints',
|
||||||
|
EMBEDDING: 'embeddings',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function recipeModalFixture() {
|
||||||
|
return `
|
||||||
|
<div id="recipeModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<header class="recipe-modal-header">
|
||||||
|
<div class="recipe-modal-header-row">
|
||||||
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div class="modal-nav-controls">
|
||||||
|
<button class="modal-nav-btn" id="recipeNavPrevBtn" disabled></button>
|
||||||
|
<button class="modal-nav-btn" id="recipeNavNextBtn" disabled></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions">
|
||||||
|
<button class="modal-send-btn" id="sendRecipeBtn"><i class="fas fa-paper-plane"></i></button>
|
||||||
|
<button class="modal-copy-btn" id="copyRecipeSyntaxBtn"><i class="fas fa-copy"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="recipe-tags-row">
|
||||||
|
<span id="recipeBaseModelBadge" class="base-model-label recipe-base-model-badge" hidden></span>
|
||||||
|
<div id="recipeTagsContainer"></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="recipe-media-column">
|
||||||
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-section recipe-gen-params">
|
||||||
|
<div class="gen-params-container">
|
||||||
|
<div class="param-group info-item">
|
||||||
|
<div class="param-content" id="recipePrompt"></div>
|
||||||
|
<div class="param-editor" id="recipePromptEditor">
|
||||||
|
<textarea class="param-textarea" id="recipePromptInput"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="param-group info-item">
|
||||||
|
<div class="param-content" id="recipeNegativePrompt"></div>
|
||||||
|
<div class="param-editor" id="recipeNegativePromptEditor">
|
||||||
|
<textarea class="param-textarea" id="recipeNegativePromptInput"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="other-params" id="recipeOtherParams"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-section recipe-bottom-section">
|
||||||
|
<div class="recipe-section-actions">
|
||||||
|
<span id="recipeLorasCount"></span>
|
||||||
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn"></button>
|
||||||
|
</div>
|
||||||
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="recipe-meta-footer" id="recipeMetaFooter" hidden></footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushAsyncTasks() {
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdModals = [];
|
||||||
|
|
||||||
|
async function createRecipeModal() {
|
||||||
|
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
|
||||||
|
const recipeModal = new RecipeModal();
|
||||||
|
createdModals.push(recipeModal);
|
||||||
|
return recipeModal;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RecipeModal base model badge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.innerHTML = recipeModalFixture();
|
||||||
|
global.modalManager = modalManagerMock;
|
||||||
|
global.fetch = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
createdModals.forEach(recipeModal => recipeModal.cleanupNavigationShortcuts());
|
||||||
|
createdModals.length = 0;
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
delete global.modalManager;
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the full base model name with a labeled tooltip', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.showRecipeDetails(recipeItem);
|
||||||
|
|
||||||
|
const badge = document.getElementById('recipeBaseModelBadge');
|
||||||
|
expect(badge.hidden).toBe(false);
|
||||||
|
expect(badge.textContent).toBe('Illustrious');
|
||||||
|
expect(badge.title).toBe('Base Model: Illustrious');
|
||||||
|
expect(badge.getAttribute('aria-label')).toBe('Base Model: Illustrious');
|
||||||
|
expect(badge.classList.contains('is-unknown')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a dimmed Unknown badge when no base model is set', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.showRecipeDetails({ ...recipeItem, base_model: '' });
|
||||||
|
|
||||||
|
const badge = document.getElementById('recipeBaseModelBadge');
|
||||||
|
expect(badge.hidden).toBe(false);
|
||||||
|
expect(badge.textContent).toBe('Unknown');
|
||||||
|
expect(badge.title).toBe('Base Model: Unknown');
|
||||||
|
expect(badge.classList.contains('is-unknown')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the badge once hydration provides the base model', async () => {
|
||||||
|
fetchRecipeDetailsMock.mockResolvedValueOnce({
|
||||||
|
id: recipeItem.id,
|
||||||
|
file_path: recipeItem.file_path,
|
||||||
|
base_model: 'Pony',
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.showRecipeDetails({ ...recipeItem, base_model: '' });
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
|
const badge = document.getElementById('recipeBaseModelBadge');
|
||||||
|
expect(badge.textContent).toBe('Pony');
|
||||||
|
expect(badge.title).toBe('Base Model: Pony');
|
||||||
|
expect(badge.classList.contains('is-unknown')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the list-provided base model when hydration omits it', async () => {
|
||||||
|
fetchRecipeDetailsMock.mockResolvedValueOnce({
|
||||||
|
id: recipeItem.id,
|
||||||
|
file_path: recipeItem.file_path,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.showRecipeDetails(recipeItem);
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
|
const badge = document.getElementById('recipeBaseModelBadge');
|
||||||
|
expect(badge.textContent).toBe('Illustrious');
|
||||||
|
expect(badge.title).toBe('Base Model: Illustrious');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const {
|
||||||
|
TRIGGER_WORDS_MODULE,
|
||||||
|
I18N_HELPERS_MODULE,
|
||||||
|
UI_HELPERS_MODULE,
|
||||||
|
MODEL_API_MODULE,
|
||||||
|
saveModelMetadataMock,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname,
|
||||||
|
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||||
|
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||||
|
MODEL_API_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||||
|
saveModelMetadataMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||||
|
translate: vi.fn((key, params, fallback) => fallback || key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
copyToClipboard: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(MODEL_API_MODULE, () => ({
|
||||||
|
getModelApiClient: vi.fn(() => ({
|
||||||
|
saveModelMetadata: saveModelMetadataMock,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("TriggerWords reordering", () => {
|
||||||
|
let renderTriggerWords;
|
||||||
|
let setupTriggerWordsEditMode;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.clearAllMocks();
|
||||||
|
saveModelMetadataMock.mockResolvedValue({});
|
||||||
|
global.fetch = vi.fn(async () => ({
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
trained_words: [],
|
||||||
|
class_tokens: null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const module = await import(TRIGGER_WORDS_MODULE);
|
||||||
|
renderTriggerWords = module.renderTriggerWords;
|
||||||
|
setupTriggerWordsEditMode = module.setupTriggerWordsEditMode;
|
||||||
|
});
|
||||||
|
|
||||||
|
function section() {
|
||||||
|
return document.querySelector('.trigger-words');
|
||||||
|
}
|
||||||
|
|
||||||
|
function order() {
|
||||||
|
return Array.from(document.querySelectorAll('.trigger-word-tag'))
|
||||||
|
.map((tag) => tag.dataset.word);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handles() {
|
||||||
|
return Array.from(document.querySelectorAll('.reorder-handle'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function firePointer(type, target, init = {}) {
|
||||||
|
target.dispatchEvent(new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
...init,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragToEnd(handle) {
|
||||||
|
firePointer('pointerdown', handle);
|
||||||
|
firePointer('pointermove', handle, { clientY: 999 });
|
||||||
|
firePointer('pointerup', handle, { clientY: 999 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterEditMode(words = ["alpha", "beta", "gamma"]) {
|
||||||
|
document.body.innerHTML = renderTriggerWords(words, "test.safetensors");
|
||||||
|
setupTriggerWordsEditMode();
|
||||||
|
|
||||||
|
document.querySelector('.edit-trigger-words-btn')
|
||||||
|
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('.metadata-suggestions-dropdown')).toBeTruthy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders a handle per word but only offers reordering for 2+ words", async () => {
|
||||||
|
await enterEditMode(["alpha", "beta"]);
|
||||||
|
|
||||||
|
expect(handles()).toHaveLength(2);
|
||||||
|
expect(section().classList.contains('has-sortable-words')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not offer reordering when there is a single word", async () => {
|
||||||
|
await enterEditMode(["alpha"]);
|
||||||
|
|
||||||
|
expect(handles()).toHaveLength(1);
|
||||||
|
expect(section().classList.contains('has-sortable-words')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the grip a decorative drag affordance, not a keyboard control", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
|
||||||
|
const grip = handles()[0];
|
||||||
|
expect(grip.tagName).toBe('SPAN');
|
||||||
|
expect(grip.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(grip.hasAttribute('tabindex')).toBe(false);
|
||||||
|
expect(grip.getAttribute('title')).toBe('Drag to reorder');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorders a word by dragging its handle and swallows the follow-up click", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
|
||||||
|
dragToEnd(handles()[0]);
|
||||||
|
|
||||||
|
expect(order()).toEqual(["beta", "gamma", "alpha"]);
|
||||||
|
|
||||||
|
// The click generated by the drag must not open the inline editor
|
||||||
|
const movedTag = document.querySelector('.trigger-word-tag[data-word="alpha"]');
|
||||||
|
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
movedTag.dispatchEvent(clickEvent);
|
||||||
|
|
||||||
|
expect(clickEvent.defaultPrevented).toBe(true);
|
||||||
|
expect(movedTag.querySelector('.trigger-word-edit-input')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not start a drag from the tag body", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
|
||||||
|
const content = document.querySelector('.trigger-word-content');
|
||||||
|
firePointer('pointerdown', content);
|
||||||
|
expect(document.body.classList.contains('reorder-drag-active')).toBe(false);
|
||||||
|
|
||||||
|
firePointer('pointermove', content, { clientY: 999 });
|
||||||
|
firePointer('pointerup', content, { clientY: 999 });
|
||||||
|
|
||||||
|
expect(order()).toEqual(["alpha", "beta", "gamma"]);
|
||||||
|
expect(document.querySelector('.reorder-dragging')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a click on the grip without movement as a click, not a drag", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
|
||||||
|
const grip = handles()[0];
|
||||||
|
firePointer('pointerdown', grip, { clientY: 10 });
|
||||||
|
firePointer('pointermove', grip, { clientY: 12 });
|
||||||
|
firePointer('pointerup', grip, { clientY: 12 });
|
||||||
|
grip.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
expect(order()).toEqual(["alpha", "beta", "gamma"]);
|
||||||
|
expect(document.querySelector('.trigger-word-edit-input')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the new order after a drag", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
|
||||||
|
dragToEnd(handles()[0]);
|
||||||
|
expect(order()).toEqual(["beta", "gamma", "alpha"]);
|
||||||
|
|
||||||
|
document.querySelector('.metadata-save-btn')
|
||||||
|
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(saveModelMetadataMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saveModelMetadataMock).toHaveBeenCalledWith("test.safetensors", {
|
||||||
|
civitai: { trainedWords: ["beta", "gamma", "alpha"] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the original order when edit mode is canceled", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
|
||||||
|
dragToEnd(handles()[0]);
|
||||||
|
expect(order()).toEqual(["beta", "gamma", "alpha"]);
|
||||||
|
|
||||||
|
document.querySelector('.edit-trigger-words-btn')
|
||||||
|
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
expect(order()).toEqual(["alpha", "beta", "gamma"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wires reordering for words added while editing", async () => {
|
||||||
|
await enterEditMode(["alpha", "beta"]);
|
||||||
|
|
||||||
|
const input = document.querySelector('.metadata-input');
|
||||||
|
input.value = 'gamma';
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(order()).toEqual(["alpha", "beta", "gamma"]);
|
||||||
|
expect(handles()).toHaveLength(3);
|
||||||
|
expect(section().classList.contains('has-sortable-words')).toBe(true);
|
||||||
|
|
||||||
|
// The freshly added word is draggable too
|
||||||
|
const newHandle = document.querySelector(
|
||||||
|
'.trigger-word-tag[data-word="gamma"] .reorder-handle',
|
||||||
|
);
|
||||||
|
firePointer('pointerdown', newHandle);
|
||||||
|
firePointer('pointermove', newHandle, { clientY: -999 });
|
||||||
|
firePointer('pointerup', newHandle, { clientY: -999 });
|
||||||
|
|
||||||
|
expect(order()).toEqual(["gamma", "alpha", "beta"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops handling drags once edit mode is left", async () => {
|
||||||
|
await enterEditMode();
|
||||||
|
|
||||||
|
document.querySelector('.edit-trigger-words-btn')
|
||||||
|
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
dragToEnd(handles()[0]);
|
||||||
|
|
||||||
|
expect(order()).toEqual(["alpha", "beta", "gamma"]);
|
||||||
|
expect(document.body.classList.contains('reorder-drag-active')).toBe(false);
|
||||||
|
// The grip must not be offered anymore outside edit mode
|
||||||
|
expect(section().classList.contains('has-sortable-words')).toBe(false);
|
||||||
|
expect(
|
||||||
|
document.querySelector('.trigger-words-tags').classList.contains('pointer-sort-enabled'),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -266,4 +266,101 @@ describe('DownloadManager external model source downloads', () => {
|
|||||||
expect(manager._externalGroupKey(ms)).toBe('modelscope:u/r');
|
expect(manager._externalGroupKey(ms)).toBe('modelscope:u/r');
|
||||||
expect(manager._externalGroupKey(hf)).not.toBe(manager._externalGroupKey(ms));
|
expect(manager._externalGroupKey(hf)).not.toBe(manager._externalGroupKey(ms));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('post-transfer stage reporting', () => {
|
||||||
|
it('ignores ordinary frames', () => {
|
||||||
|
const updateProgress = vi.fn();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
manager._applyMetadataStage(
|
||||||
|
{ status: 'progress', progress: 40, bytes_per_second: 10 },
|
||||||
|
updateProgress,
|
||||||
|
0,
|
||||||
|
'f.safetensors'
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(updateProgress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes a metadata stage to the progress bar at 100%', () => {
|
||||||
|
const updateProgress = vi.fn();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
manager._applyMetadataStage(
|
||||||
|
{ status: 'metadata', stage: 'source', platform: 'modelscope' },
|
||||||
|
updateProgress,
|
||||||
|
3,
|
||||||
|
'f.safetensors'
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(updateProgress).toHaveBeenCalledWith(100, 3, 'f.safetensors', {}, {
|
||||||
|
phase: 'metadata',
|
||||||
|
stage: 'source',
|
||||||
|
platform: 'modelscope',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates a stage frame with no stage or platform', () => {
|
||||||
|
const updateProgress = vi.fn();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
manager._applyMetadataStage({ status: 'metadata' }, updateProgress, 0, 'f')
|
||||||
|
).toBe(true);
|
||||||
|
expect(updateProgress).toHaveBeenCalledWith(100, 0, 'f', {}, {
|
||||||
|
phase: 'metadata',
|
||||||
|
stage: '',
|
||||||
|
platform: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a metadata frame received while the request is in flight', async () => {
|
||||||
|
// End-to-end through the websocket handler: the backend keeps the socket
|
||||||
|
// open while it hydrates, and the frame has to reach the progress bar.
|
||||||
|
const sockets = [];
|
||||||
|
class RecordingWebSocket {
|
||||||
|
constructor(url) {
|
||||||
|
this.url = url;
|
||||||
|
this.onopen = null;
|
||||||
|
this.onmessage = null;
|
||||||
|
this.onerror = null;
|
||||||
|
this.close = vi.fn();
|
||||||
|
sockets.push(this);
|
||||||
|
queueMicrotask(() => this.onopen && this.onopen());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vi.stubGlobal('WebSocket', RecordingWebSocket);
|
||||||
|
|
||||||
|
const updateProgress = vi.fn();
|
||||||
|
mockLoadingManager.showDownloadProgress.mockReturnValue(updateProgress);
|
||||||
|
|
||||||
|
mockApiClient.downloadModelSource.mockImplementation(async () => {
|
||||||
|
sockets.at(-1).onmessage({
|
||||||
|
data: JSON.stringify({
|
||||||
|
status: 'metadata',
|
||||||
|
stage: 'source',
|
||||||
|
platform: 'modelscope',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.sourcePlatform = 'modelscope';
|
||||||
|
manager.sourceRepoId = 'u/r';
|
||||||
|
manager.sourceSelectedFiles = ['a.safetensors'];
|
||||||
|
|
||||||
|
await manager._downloadExternalRepoFiles({
|
||||||
|
modelRoot: '/models',
|
||||||
|
targetFolder: '',
|
||||||
|
useDefaultPaths: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateProgress).toHaveBeenCalledWith(100, 0, 'a.safetensors', {}, {
|
||||||
|
phase: 'metadata',
|
||||||
|
stage: 'source',
|
||||||
|
platform: 'modelscope',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockLoadingManager.showDownloadProgress.mockReturnValue(vi.fn());
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { I18N_MODULE } = vi.hoisted(() => ({
|
||||||
|
I18N_MODULE: new URL(
|
||||||
|
'../../../static/js/utils/i18nHelpers.js',
|
||||||
|
import.meta.url
|
||||||
|
).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Interpolate the English fallback the way the real helper does when a locale
|
||||||
|
// has not been loaded, so assertions can name the visible text.
|
||||||
|
vi.mock(I18N_MODULE, () => ({
|
||||||
|
translate: vi.fn((key, params = {}, fallback) => {
|
||||||
|
if (typeof fallback !== 'string') return key;
|
||||||
|
return Object.entries(params).reduce(
|
||||||
|
(text, [name, value]) => text.replace(`{${name}}`, String(value)),
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { LoadingManager } = await import(
|
||||||
|
'../../../static/js/managers/LoadingManager.js'
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A download's byte counter stops when the last byte lands, but the backend
|
||||||
|
* still hashes the file and reads the model site's API. These tests pin the
|
||||||
|
* rendering that says so, instead of leaving the bar at 100% showing 0 B/s.
|
||||||
|
*/
|
||||||
|
describe('LoadingManager download progress phases', () => {
|
||||||
|
let manager;
|
||||||
|
let updateProgress;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
LoadingManager.instance = null;
|
||||||
|
manager = new LoadingManager();
|
||||||
|
updateProgress = manager.showDownloadProgress(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const speedText = () =>
|
||||||
|
document.querySelector('.download-transfer-speed')?.textContent;
|
||||||
|
const itemLabel = () =>
|
||||||
|
document.querySelector('.current-item-label')?.textContent;
|
||||||
|
const itemPercent = () =>
|
||||||
|
document.querySelector('.current-item-percent')?.textContent;
|
||||||
|
const itemBar = () => document.querySelector('.current-item-bar');
|
||||||
|
|
||||||
|
it('shows the byte rate while transferring', () => {
|
||||||
|
updateProgress(42, 0, 'model.safetensors', {
|
||||||
|
bytesDownloaded: 1024,
|
||||||
|
totalBytes: 2048,
|
||||||
|
bytesPerSecond: 512,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(itemLabel()).toBe('Downloading: model.safetensors');
|
||||||
|
expect(itemPercent()).toBe('42%');
|
||||||
|
expect(speedText()).toMatch(/^Speed: /);
|
||||||
|
expect(itemBar().classList.contains('is-indeterminate')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the indexing stage instead of a stopped speed', () => {
|
||||||
|
updateProgress(100, 0, 'model.safetensors', {}, {
|
||||||
|
phase: 'metadata',
|
||||||
|
stage: 'indexing',
|
||||||
|
platform: 'modelscope',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(itemLabel()).toBe('Metadata: model.safetensors');
|
||||||
|
expect(itemPercent()).toBe('100%');
|
||||||
|
expect(speedText()).toBe('Reading model file...');
|
||||||
|
expect(manager.statusText.textContent).toBe('Reading model file...');
|
||||||
|
expect(itemBar().classList.contains('is-indeterminate')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the site the metadata is fetched from', () => {
|
||||||
|
updateProgress(100, 0, 'model.safetensors', {}, {
|
||||||
|
phase: 'metadata',
|
||||||
|
stage: 'source',
|
||||||
|
platform: 'modelscope-ai',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(speedText()).toBe('Fetching metadata from ModelScope (International)...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a generic message for an unknown site', () => {
|
||||||
|
updateProgress(100, 0, 'model.safetensors', {}, {
|
||||||
|
phase: 'metadata',
|
||||||
|
stage: 'source',
|
||||||
|
platform: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(speedText()).toBe('Fetching metadata...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns to the transfer rendering for the next file', () => {
|
||||||
|
updateProgress(100, 0, 'a.safetensors', {}, {
|
||||||
|
phase: 'metadata',
|
||||||
|
stage: 'source',
|
||||||
|
platform: 'modelscope',
|
||||||
|
});
|
||||||
|
|
||||||
|
updateProgress(0, 1, 'b.safetensors');
|
||||||
|
|
||||||
|
expect(itemLabel()).toBe('Downloading: b.safetensors');
|
||||||
|
expect(speedText()).toMatch(/^Speed: /);
|
||||||
|
expect(itemBar().classList.contains('is-indeterminate')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the byte counters visible during the metadata stage', () => {
|
||||||
|
updateProgress(100, 0, 'model.safetensors', {
|
||||||
|
bytesDownloaded: 2048,
|
||||||
|
totalBytes: 2048,
|
||||||
|
bytesPerSecond: 0,
|
||||||
|
}, {
|
||||||
|
phase: 'metadata',
|
||||||
|
stage: 'source',
|
||||||
|
platform: 'modelscope',
|
||||||
|
});
|
||||||
|
|
||||||
|
const transferred = document.querySelector('.download-transfer-bytes');
|
||||||
|
expect(transferred.textContent).toContain('/');
|
||||||
|
// The 0 B/s figure is what made the pause look like a stall.
|
||||||
|
expect(speedText()).not.toContain('0 B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the batch position visible in the status line', () => {
|
||||||
|
updateProgress = manager.showDownloadProgress(4);
|
||||||
|
|
||||||
|
updateProgress(100, 2, 'c.safetensors', {}, {
|
||||||
|
phase: 'metadata',
|
||||||
|
stage: 'source',
|
||||||
|
platform: 'huggingface',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.statusText.textContent).toBe(
|
||||||
|
'3/4: Fetching metadata from Hugging Face...'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,7 @@ describe('modelSourceHelpers', () => {
|
|||||||
expect(MODEL_SOURCES.map((s) => s.platform)).toEqual([
|
expect(MODEL_SOURCES.map((s) => s.platform)).toEqual([
|
||||||
'huggingface',
|
'huggingface',
|
||||||
'modelscope',
|
'modelscope',
|
||||||
|
'modelscope-ai',
|
||||||
'tensorart',
|
'tensorart',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -44,6 +45,16 @@ describe('modelSourceHelpers', () => {
|
|||||||
expect(info.url).toBe('https://modelscope.cn/models/user/repo');
|
expect(info.url).toBe('https://modelscope.cn/models/user/repo');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('recognises ModelScope International as its own platform', () => {
|
||||||
|
const info = parseModelSourceUrl(
|
||||||
|
'https://www.modelscope.ai/models/referall13/EM1/files'
|
||||||
|
);
|
||||||
|
expect(info.platform).toBe('modelscope-ai');
|
||||||
|
expect(info.groupPrefix).toBe('msai');
|
||||||
|
expect(info.sourceId).toBe('referall13/EM1');
|
||||||
|
expect(info.url).toBe('https://www.modelscope.ai/models/referall13/EM1');
|
||||||
|
});
|
||||||
|
|
||||||
it('recognises TensorArt URLs and keeps only the numeric id', () => {
|
it('recognises TensorArt URLs and keeps only the numeric id', () => {
|
||||||
const info = parseModelSourceUrl(
|
const info = parseModelSourceUrl(
|
||||||
'https://tensor.art/models/827823520299086029/Vivid-Impressions-Storybook-Sstyle-V1.0'
|
'https://tensor.art/models/827823520299086029/Vivid-Impressions-Storybook-Sstyle-V1.0'
|
||||||
|
|||||||
@@ -174,6 +174,54 @@ describe('DownloadManager.detectUrlType — external model source URLs', () => {
|
|||||||
expect(result.platform).toBe('huggingface');
|
expect(result.platform).toBe('huggingface');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// modelscope.ai is a separate catalogue from modelscope.cn, not an alias,
|
||||||
|
// so it carries its own platform id all the way to the backend.
|
||||||
|
it('detects a ModelScope International repo URL', () => {
|
||||||
|
const result = DownloadManager.detectUrlType(
|
||||||
|
'https://www.modelscope.ai/models/referall13/EM1'
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'model-source-repo',
|
||||||
|
platform: 'modelscope-ai',
|
||||||
|
repo: 'referall13/EM1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects a ModelScope International repo URL without the www prefix', () => {
|
||||||
|
const result = DownloadManager.detectUrlType(
|
||||||
|
'https://modelscope.ai/models/ErLubu/krea2_style_260911_02'
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'model-source-repo',
|
||||||
|
platform: 'modelscope-ai',
|
||||||
|
repo: 'ErLubu/krea2_style_260911_02',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects a ModelScope International file URL', () => {
|
||||||
|
const result = DownloadManager.detectUrlType(
|
||||||
|
'https://www.modelscope.ai/models/referall13/EM1/resolve/master/EM1_c1-st1000.safetensors'
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'model-source-file',
|
||||||
|
platform: 'modelscope-ai',
|
||||||
|
repo: 'referall13/EM1',
|
||||||
|
revision: 'master',
|
||||||
|
filename: 'EM1_c1-st1000.safetensors',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the two ModelScope deployments distinct', () => {
|
||||||
|
const mainland = DownloadManager.detectUrlType(
|
||||||
|
'https://modelscope.cn/models/referall13/EM1'
|
||||||
|
);
|
||||||
|
const intl = DownloadManager.detectUrlType(
|
||||||
|
'https://www.modelscope.ai/models/referall13/EM1'
|
||||||
|
);
|
||||||
|
expect(mainland.platform).toBe('modelscope');
|
||||||
|
expect(intl.platform).toBe('modelscope-ai');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects path traversal in either platform', () => {
|
it('rejects path traversal in either platform', () => {
|
||||||
expect(
|
expect(
|
||||||
DownloadManager.detectUrlType('https://modelscope.cn/models/../etc/passwd')
|
DownloadManager.detectUrlType('https://modelscope.cn/models/../etc/passwd')
|
||||||
|
|||||||
@@ -307,7 +307,12 @@ async def test_get_model_sources_lists_capabilities():
|
|||||||
sources = _json_payload(response)
|
sources = _json_payload(response)
|
||||||
|
|
||||||
by_platform = {s["platform"]: s for s in sources}
|
by_platform = {s["platform"]: s for s in sources}
|
||||||
assert set(by_platform) == {"huggingface", "modelscope", "tensorart"}
|
assert set(by_platform) == {
|
||||||
|
"huggingface",
|
||||||
|
"modelscope",
|
||||||
|
"modelscope-ai",
|
||||||
|
"tensorart",
|
||||||
|
}
|
||||||
assert by_platform["huggingface"]["supports_enrichment"] is True
|
assert by_platform["huggingface"]["supports_enrichment"] is True
|
||||||
assert by_platform["modelscope"]["supports_enrichment"] is True
|
assert by_platform["modelscope"]["supports_enrichment"] is True
|
||||||
# TensorArt is link-only: no accessible model card for the backend.
|
# TensorArt is link-only: no accessible model card for the backend.
|
||||||
@@ -315,6 +320,12 @@ async def test_get_model_sources_lists_capabilities():
|
|||||||
assert by_platform["modelscope"]["supports_download"] is True
|
assert by_platform["modelscope"]["supports_download"] is True
|
||||||
assert by_platform["modelscope"]["default_revision"] == "master"
|
assert by_platform["modelscope"]["default_revision"] == "master"
|
||||||
assert by_platform["tensorart"]["supports_download"] is False
|
assert by_platform["tensorart"]["supports_download"] is False
|
||||||
|
# The international deployment is advertised with its own example URL, so
|
||||||
|
# the Link dialog names the host a user actually has open.
|
||||||
|
assert by_platform["modelscope-ai"]["supports_download"] is True
|
||||||
|
assert by_platform["modelscope-ai"]["example_url"].startswith(
|
||||||
|
"https://www.modelscope.ai/"
|
||||||
|
)
|
||||||
assert all(s["example_url"] for s in sources)
|
assert all(s["example_url"] for s in sources)
|
||||||
|
|
||||||
|
|
||||||
@@ -492,6 +503,44 @@ async def test_download_model_source_modelscope_default_paths(tmp_path, monkeypa
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_model_source_modelscope_intl_uses_its_own_host(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""`.ai` is a separate catalogue, so the download must not go to `.cn`."""
|
||||||
|
captured = _stub_download_backend(monkeypatch)
|
||||||
|
saved = AsyncMock()
|
||||||
|
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", saved)
|
||||||
|
|
||||||
|
response = await ModelSourceHandler().download_model_source(
|
||||||
|
FakeRequest(
|
||||||
|
json_data={
|
||||||
|
"platform": "modelscope-ai",
|
||||||
|
"repo": "referall13/EM1",
|
||||||
|
"filename": "EM1_c1-st1000.safetensors",
|
||||||
|
"model_root": str(tmp_path),
|
||||||
|
"use_default_paths": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert captured["url"] == (
|
||||||
|
"https://www.modelscope.ai/models/referall13/EM1/resolve/master/"
|
||||||
|
"EM1_c1-st1000.safetensors"
|
||||||
|
)
|
||||||
|
# Its own default directory, so the same owner/name on both deployments
|
||||||
|
# cannot overwrite each other.
|
||||||
|
assert captured["save_path"] == str(
|
||||||
|
tmp_path / "modelscope-ai" / "referall13" / "EM1" / "EM1_c1-st1000.safetensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
ref = saved.await_args.args[1]
|
||||||
|
assert ref.platform == "modelscope-ai"
|
||||||
|
assert ref.source_id == "referall13/EM1"
|
||||||
|
assert ref.url == "https://www.modelscope.ai/models/referall13/EM1"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_download_model_source_defaults_to_huggingface(tmp_path, monkeypatch):
|
async def test_download_model_source_defaults_to_huggingface(tmp_path, monkeypatch):
|
||||||
"""The legacy /api/lm/download-hf-model payload has no `platform` key."""
|
"""The legacy /api/lm/download-hf-model payload has no `platform` key."""
|
||||||
@@ -609,18 +658,19 @@ async def test_save_source_metadata_writes_platform_fields(
|
|||||||
base_model="SDXL 1.0",
|
base_model="SDXL 1.0",
|
||||||
preview_url="",
|
preview_url="",
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
scanner = SimpleNamespace(
|
||||||
model_source_handlers.MetadataManager,
|
# A real scanner owns metadata creation (see the lazy-hash test below).
|
||||||
"create_default_metadata",
|
_create_default_metadata=AsyncMock(return_value=metadata),
|
||||||
AsyncMock(return_value=metadata),
|
add_model_to_cache=AsyncMock(),
|
||||||
)
|
)
|
||||||
scanner = SimpleNamespace(add_model_to_cache=AsyncMock())
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=scanner)
|
ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=scanner)
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
model_source_handlers, "_infer_model_type", lambda _root: (LoraMetadata, "get_lora_scanner")
|
model_source_handlers, "_infer_model_type", lambda _root: (LoraMetadata, "get_lora_scanner")
|
||||||
)
|
)
|
||||||
|
hydrate = AsyncMock()
|
||||||
|
monkeypatch.setattr(model_source_handlers, "hydrate_from_source", hydrate)
|
||||||
|
|
||||||
ref = SourceRef(platform=platform, source_id="u/r", url=url)
|
ref = SourceRef(platform=platform, source_id="u/r", url=url)
|
||||||
await model_source_handlers._save_source_metadata(str(model_path), ref, str(tmp_path))
|
await model_source_handlers._save_source_metadata(str(model_path), ref, str(tmp_path))
|
||||||
@@ -630,6 +680,471 @@ async def test_save_source_metadata_writes_platform_fields(
|
|||||||
assert saved["source_url"] == url
|
assert saved["source_url"] == url
|
||||||
assert bool(saved.get("hf_url", "")) is expect_hf_alias
|
assert bool(saved.get("hf_url", "")) is expect_hf_alias
|
||||||
|
|
||||||
|
assert scanner._create_default_metadata.await_args.args == (str(model_path),)
|
||||||
|
|
||||||
cached = scanner.add_model_to_cache.await_args.args[0]
|
cached = scanner.add_model_to_cache.await_args.args[0]
|
||||||
assert cached["source_platform"] == platform
|
assert cached["source_platform"] == platform
|
||||||
assert cached["source_url"] == url
|
assert cached["source_url"] == url
|
||||||
|
|
||||||
|
# The site's own API is consulted last, so the scanner-cache refresh it
|
||||||
|
# performs lands on the entry created above.
|
||||||
|
assert hydrate.await_args.args == (str(model_path),)
|
||||||
|
assert hydrate.await_args.kwargs["ref"] == ref
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_checkpoint_download_defers_the_hash(tmp_path, monkeypatch):
|
||||||
|
"""A multi-GB checkpoint must not be hashed inside the download request.
|
||||||
|
|
||||||
|
``CheckpointScanner`` records ``hash_status="pending"`` and lets the hash be
|
||||||
|
computed on demand; going through the generic
|
||||||
|
``MetadataManager.create_default_metadata`` would read the whole file before
|
||||||
|
the download response could return, which is exactly the pause this code
|
||||||
|
path is supposed to avoid.
|
||||||
|
"""
|
||||||
|
from py.services.checkpoint_scanner import CheckpointScanner
|
||||||
|
from py.utils.models import CheckpointMetadata
|
||||||
|
|
||||||
|
model_path = tmp_path / "big_checkpoint.safetensors"
|
||||||
|
model_path.write_bytes(b"stub")
|
||||||
|
|
||||||
|
real_scanner = CheckpointScanner()
|
||||||
|
scanner = SimpleNamespace(
|
||||||
|
_create_default_metadata=real_scanner._create_default_metadata,
|
||||||
|
add_model_to_cache=AsyncMock(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ServiceRegistry, "get_checkpoint_scanner", AsyncMock(return_value=scanner)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_source_handlers,
|
||||||
|
"_infer_model_type",
|
||||||
|
lambda _root: (CheckpointMetadata, "get_checkpoint_scanner"),
|
||||||
|
)
|
||||||
|
generic = AsyncMock(
|
||||||
|
# Stands in for the eager helper: if the handler reaches for it, the
|
||||||
|
# sidecar ends up hashed and the assertions below say so plainly.
|
||||||
|
return_value=LoraMetadata(
|
||||||
|
file_name="big_checkpoint",
|
||||||
|
model_name="big_checkpoint",
|
||||||
|
file_path=str(model_path),
|
||||||
|
size=4,
|
||||||
|
modified=1.0,
|
||||||
|
sha256="d" * 64,
|
||||||
|
base_model="Unknown",
|
||||||
|
preview_url="",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_source_handlers.MetadataManager, "create_default_metadata", generic
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(model_source_handlers, "hydrate_from_source", AsyncMock())
|
||||||
|
|
||||||
|
ref = SourceRef(
|
||||||
|
platform="huggingface",
|
||||||
|
source_id="u/r",
|
||||||
|
url="https://huggingface.co/u/r",
|
||||||
|
)
|
||||||
|
await model_source_handlers._save_source_metadata(str(model_path), ref, str(tmp_path))
|
||||||
|
|
||||||
|
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
|
||||||
|
assert saved["sha256"] == ""
|
||||||
|
assert saved["hash_status"] == "pending"
|
||||||
|
assert saved["from_civitai"] is False
|
||||||
|
# The download link is still recorded on top of the deferred hash.
|
||||||
|
assert saved["source_platform"] == "huggingface"
|
||||||
|
assert saved["source_url"] == "https://huggingface.co/u/r"
|
||||||
|
|
||||||
|
# The scanner cache must carry the pending state too, or the cache fill
|
||||||
|
# would compute the hash after all.
|
||||||
|
cached = scanner.add_model_to_cache.await_args.args[0]
|
||||||
|
assert cached["hash_status"] == "pending"
|
||||||
|
assert cached["sha256"] == ""
|
||||||
|
|
||||||
|
generic.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Post-transfer phase reporting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_hydration_pipeline(tmp_path, monkeypatch):
|
||||||
|
"""Wire `_save_source_metadata`'s collaborators and record call order."""
|
||||||
|
model_path = tmp_path / "downloaded.safetensors"
|
||||||
|
model_path.write_bytes(b"x" * 32)
|
||||||
|
|
||||||
|
metadata = LoraMetadata(
|
||||||
|
file_name="downloaded",
|
||||||
|
model_name="Downloaded",
|
||||||
|
file_path=str(model_path),
|
||||||
|
size=32,
|
||||||
|
modified=1.0,
|
||||||
|
sha256="a" * 64,
|
||||||
|
base_model="SDXL 1.0",
|
||||||
|
preview_url="",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_source_handlers.MetadataManager,
|
||||||
|
"create_default_metadata",
|
||||||
|
AsyncMock(return_value=metadata),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ServiceRegistry,
|
||||||
|
"get_lora_scanner",
|
||||||
|
AsyncMock(return_value=SimpleNamespace(add_model_to_cache=AsyncMock())),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_source_handlers,
|
||||||
|
"_infer_model_type",
|
||||||
|
lambda _root: (LoraMetadata, "get_lora_scanner"),
|
||||||
|
)
|
||||||
|
|
||||||
|
events: list = []
|
||||||
|
|
||||||
|
async def fake_broadcast(download_id, data):
|
||||||
|
events.append(("broadcast", data["stage"], data, download_id))
|
||||||
|
|
||||||
|
async def fake_hydrate(*_args, **_kwargs):
|
||||||
|
events.append(("hydrate", None, None, None))
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_source_handlers.ws_manager, "broadcast_download_progress", fake_broadcast
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(model_source_handlers, "hydrate_from_source", fake_hydrate)
|
||||||
|
return model_path, events
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_source_metadata_reports_post_transfer_stages(tmp_path, monkeypatch):
|
||||||
|
"""The byte counter stops before indexing and the site fetch, so the UI has
|
||||||
|
to be told what is still running — otherwise the bar looks stuck."""
|
||||||
|
model_path, events = _stub_hydration_pipeline(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
ref = SourceRef(
|
||||||
|
platform="modelscope", source_id="u/r", url="https://modelscope.cn/models/u/r"
|
||||||
|
)
|
||||||
|
await model_source_handlers._save_source_metadata(
|
||||||
|
str(model_path), ref, str(tmp_path), download_id="dl-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Each stage is announced *before* its work starts, so the label is never
|
||||||
|
# describing something that already finished.
|
||||||
|
assert [event[:2] for event in events] == [
|
||||||
|
("broadcast", "indexing"),
|
||||||
|
("broadcast", "source"),
|
||||||
|
("hydrate", None),
|
||||||
|
]
|
||||||
|
for kind, stage, data, download_id in events:
|
||||||
|
if kind != "broadcast":
|
||||||
|
continue
|
||||||
|
assert download_id == "dl-1"
|
||||||
|
assert data["status"] == "metadata"
|
||||||
|
assert data["progress"] == 100
|
||||||
|
assert data["platform"] == "modelscope"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_source_metadata_is_silent_without_a_watcher(tmp_path, monkeypatch):
|
||||||
|
"""No `download_id` means no UI is watching; nothing should be broadcast."""
|
||||||
|
model_path, events = _stub_hydration_pipeline(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
ref = SourceRef(
|
||||||
|
platform="modelscope", source_id="u/r", url="https://modelscope.cn/models/u/r"
|
||||||
|
)
|
||||||
|
await model_source_handlers._save_source_metadata(str(model_path), ref, str(tmp_path))
|
||||||
|
|
||||||
|
assert events == [("hydrate", None, None, None)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_report_phase_never_breaks_a_download(monkeypatch):
|
||||||
|
"""Progress reporting is cosmetic; a dead socket must not fail the file."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_source_handlers.ws_manager,
|
||||||
|
"broadcast_download_progress",
|
||||||
|
AsyncMock(side_effect=RuntimeError("socket gone")),
|
||||||
|
)
|
||||||
|
|
||||||
|
await model_source_handlers._report_phase("dl-1", "source", "modelscope")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_passes_its_watch_id_into_metadata_work(tmp_path, monkeypatch):
|
||||||
|
"""The stages are only visible if the handler hands its id down."""
|
||||||
|
_stub_download_backend(monkeypatch)
|
||||||
|
saved = AsyncMock()
|
||||||
|
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", saved)
|
||||||
|
|
||||||
|
await ModelSourceHandler().download_model_source(
|
||||||
|
FakeRequest(
|
||||||
|
json_data={
|
||||||
|
"platform": "modelscope",
|
||||||
|
"repo": "owner/name",
|
||||||
|
"filename": "model.safetensors",
|
||||||
|
"model_root": str(tmp_path),
|
||||||
|
"download_id": "dl-42",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert saved.await_args.kwargs["download_id"] == "dl-42"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skipped_download_still_reports_the_site_stage(tmp_path, monkeypatch):
|
||||||
|
"""An already-present file is hydrated too, so it needs the same signal."""
|
||||||
|
_stub_download_backend(monkeypatch)
|
||||||
|
hydrate = AsyncMock()
|
||||||
|
monkeypatch.setattr(model_source_handlers, "hydrate_from_source", hydrate)
|
||||||
|
broadcast = AsyncMock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_source_handlers.ws_manager, "broadcast_download_progress", broadcast
|
||||||
|
)
|
||||||
|
|
||||||
|
existing = tmp_path / "model.safetensors"
|
||||||
|
existing.write_bytes(b"x" * 32)
|
||||||
|
|
||||||
|
await ModelSourceHandler().download_model_source(
|
||||||
|
FakeRequest(
|
||||||
|
json_data={
|
||||||
|
"platform": "modelscope",
|
||||||
|
"repo": "owner/name",
|
||||||
|
"filename": "model.safetensors",
|
||||||
|
"model_root": str(tmp_path),
|
||||||
|
"download_id": "dl-7",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert broadcast.await_args.args[1]["stage"] == "source"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_source_metadata_survives_a_hydration_failure(tmp_path, monkeypatch):
|
||||||
|
"""Metadata hydration must never be able to fail a completed download."""
|
||||||
|
model_path = tmp_path / "downloaded.safetensors"
|
||||||
|
model_path.write_bytes(b"x" * 32)
|
||||||
|
|
||||||
|
metadata = LoraMetadata(
|
||||||
|
file_name="downloaded",
|
||||||
|
model_name="Downloaded",
|
||||||
|
file_path=str(model_path),
|
||||||
|
size=32,
|
||||||
|
modified=1.0,
|
||||||
|
sha256="a" * 64,
|
||||||
|
base_model="SDXL 1.0",
|
||||||
|
preview_url="",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_source_handlers.MetadataManager,
|
||||||
|
"create_default_metadata",
|
||||||
|
AsyncMock(return_value=metadata),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ServiceRegistry,
|
||||||
|
"get_lora_scanner",
|
||||||
|
AsyncMock(return_value=SimpleNamespace(add_model_to_cache=AsyncMock())),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_source_handlers, "_infer_model_type", lambda _root: (LoraMetadata, "get_lora_scanner")
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_source_handlers,
|
||||||
|
"hydrate_from_source",
|
||||||
|
AsyncMock(side_effect=RuntimeError("site down")),
|
||||||
|
)
|
||||||
|
|
||||||
|
ref = SourceRef(
|
||||||
|
platform="modelscope", source_id="u/r", url="https://modelscope.cn/models/u/r"
|
||||||
|
)
|
||||||
|
await model_source_handlers._save_source_metadata(str(model_path), ref, str(tmp_path))
|
||||||
|
|
||||||
|
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
|
||||||
|
assert saved["source_platform"] == "modelscope"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_downloading_an_existing_file_still_hydrates(tmp_path, monkeypatch):
|
||||||
|
"""A pre-existing file may still be missing the site's metadata."""
|
||||||
|
_stub_download_backend(monkeypatch)
|
||||||
|
saved = AsyncMock()
|
||||||
|
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", saved)
|
||||||
|
hydrate = AsyncMock()
|
||||||
|
monkeypatch.setattr(model_source_handlers, "hydrate_from_source", hydrate)
|
||||||
|
|
||||||
|
existing = tmp_path / "model.safetensors"
|
||||||
|
existing.write_bytes(b"x" * 32)
|
||||||
|
|
||||||
|
response = await ModelSourceHandler().download_model_source(
|
||||||
|
FakeRequest(
|
||||||
|
json_data={
|
||||||
|
"platform": "modelscope",
|
||||||
|
"repo": "owner/name",
|
||||||
|
"filename": "model.safetensors",
|
||||||
|
"model_root": str(tmp_path),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
saved.assert_not_awaited()
|
||||||
|
assert hydrate.await_args.args == (str(existing),)
|
||||||
|
assert hydrate.await_args.kwargs["ref"].source_id == "owner/name"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Download-time metadata hydration (end to end)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _modelscope_card_payload() -> dict:
|
||||||
|
"""A trimmed ModelScope model-detail response for the hydration test."""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Code": 200,
|
||||||
|
"Data": {
|
||||||
|
"Name": "Krea-2-LORA",
|
||||||
|
"ChineseName": "krea脸模",
|
||||||
|
"AigcType": "LoRA",
|
||||||
|
"Description": "权重0.5-1.2。配合《风格滤镜》lora一起使用。",
|
||||||
|
"BaseModel": ["krea/Krea-2-Turbo"],
|
||||||
|
"OfficialTags": [{"Tag": "photography"}, {"Tag": "woman"}],
|
||||||
|
"ModelInfos": {
|
||||||
|
"safetensor": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "Krea-2-LORA_c1-st1000.safetensors",
|
||||||
|
"sha256": "a" * 64,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MuseInfo": {
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"stats": {"fileList": ["Krea-2-LORA_c1-st1000.safetensors"]},
|
||||||
|
"modelVersion": {
|
||||||
|
"showName": "c1-st1000",
|
||||||
|
"triggerWords": '["kreaface","kreamodel"]',
|
||||||
|
},
|
||||||
|
"coverImages": [
|
||||||
|
{"url": "https://resources.modelscope.cn/cover-images/b.png"},
|
||||||
|
{"url": "https://resources.modelscope.cn/cover-images/c.png"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_hydrates_the_card_from_the_site(tmp_path, monkeypatch):
|
||||||
|
"""A ModelScope download must land with a populated model card.
|
||||||
|
|
||||||
|
Only the network, the scanner and the file transfer are faked, so this
|
||||||
|
exercises the real handler, the real `ModelScopeSource` and the real
|
||||||
|
post-processor together. Breaking the wiring between them fails here even
|
||||||
|
when each half still passes its own unit tests.
|
||||||
|
"""
|
||||||
|
model_path = tmp_path / "Krea-2-LORA_c1-st1000.safetensors"
|
||||||
|
|
||||||
|
async def fake_download_file(**kwargs):
|
||||||
|
with open(kwargs["save_path"], "wb") as handle:
|
||||||
|
handle.write(b"stub")
|
||||||
|
return True, kwargs["save_path"]
|
||||||
|
|
||||||
|
class _Downloader:
|
||||||
|
download_file = staticmethod(fake_download_file)
|
||||||
|
|
||||||
|
class _Settings:
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
|
async def fake_get_downloader():
|
||||||
|
return _Downloader()
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_source_handlers, "get_downloader", fake_get_downloader)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_source_handlers, "get_settings_manager", lambda: _Settings()
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_source_handlers,
|
||||||
|
"_infer_model_type",
|
||||||
|
lambda _root: (LoraMetadata, "get_lora_scanner"),
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner = SimpleNamespace(
|
||||||
|
get_cached_data=AsyncMock(
|
||||||
|
return_value=SimpleNamespace(raw_data=[{"file_path": str(model_path)}])
|
||||||
|
),
|
||||||
|
add_model_to_cache=AsyncMock(),
|
||||||
|
update_single_model_cache=AsyncMock(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=scanner)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_fetch_text(url, **_kwargs):
|
||||||
|
return "# Krea-2-LORA\n\n权重0.5-1.2。"
|
||||||
|
|
||||||
|
async def fake_fetch_json(url, **_kwargs):
|
||||||
|
return 200, _modelscope_card_payload()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.modelscope.fetch_text", fake_fetch_text
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.metadata_ops.list_base_models", AsyncMock(return_value=["Krea 2"])
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.metadata_ops.download_preview",
|
||||||
|
AsyncMock(return_value=str(tmp_path / "preview.webp")),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await ModelSourceHandler().download_model_source(
|
||||||
|
FakeRequest(
|
||||||
|
json_data={
|
||||||
|
"platform": "modelscope",
|
||||||
|
"repo": "jj3550945163/Krea-2-LORA",
|
||||||
|
"filename": "Krea-2-LORA_c1-st1000.safetensors",
|
||||||
|
"model_root": str(tmp_path),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
|
||||||
|
|
||||||
|
# The download's own provenance is unchanged.
|
||||||
|
assert saved["source_platform"] == "modelscope"
|
||||||
|
assert saved["source_url"] == "https://modelscope.cn/models/jj3550945163/Krea-2-LORA"
|
||||||
|
assert saved["from_civitai"] is False
|
||||||
|
|
||||||
|
# The site's published metadata, with no LLM involved.
|
||||||
|
assert saved["model_name"] == "Krea-2-LORA"
|
||||||
|
assert saved["base_model"] == "Krea 2"
|
||||||
|
assert saved["tags"] == ["photography", "woman"]
|
||||||
|
assert saved["civitai"]["name"] == "c1-st1000"
|
||||||
|
assert saved["civitai"]["trainedWords"] == ["kreaface", "kreamodel"]
|
||||||
|
assert saved["civitai"]["description"] == "权重0.5-1.2。配合《风格滤镜》lora一起使用。"
|
||||||
|
assert [img["url"] for img in saved["civitai"]["images"]] == [
|
||||||
|
"https://resources.modelscope.cn/cover-images/b.png",
|
||||||
|
"https://resources.modelscope.cn/cover-images/c.png",
|
||||||
|
]
|
||||||
|
assert saved["preview_url"] == str(tmp_path / "preview.webp")
|
||||||
|
assert saved["usage_tips"] == (
|
||||||
|
'{"strength_min": 0.5, "strength_max": 1.2, "strength_range": "0.5-1.2"}'
|
||||||
|
)
|
||||||
|
assert saved["metadata_source"] == "source:modelscope"
|
||||||
|
# No provider answered, so claiming an AI enrichment would be a lie.
|
||||||
|
assert "llm_enriched_at" not in saved
|
||||||
|
|
||||||
|
# The enriched card reaches the scanner cache, not just the file.
|
||||||
|
assert scanner.update_single_model_cache.await_count == 1
|
||||||
|
cached = scanner.update_single_model_cache.await_args.args[2]
|
||||||
|
assert cached["model_name"] == "Krea-2-LORA"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pytest
|
|||||||
|
|
||||||
from py.services.connectivity_guard import OFFLINE_COOLDOWN_ERROR, OFFLINE_FRIENDLY_MESSAGE
|
from py.services.connectivity_guard import OFFLINE_COOLDOWN_ERROR, OFFLINE_FRIENDLY_MESSAGE
|
||||||
from py.services.errors import RateLimitError
|
from py.services.errors import RateLimitError
|
||||||
from py.services.metadata_sync_service import MetadataSyncService
|
from py.services.metadata_sync_service import MetadataSyncService, _merge_ordered_unique
|
||||||
|
|
||||||
|
|
||||||
class DummySettings:
|
class DummySettings:
|
||||||
@@ -112,6 +112,49 @@ async def test_update_model_metadata_merges_and_persists():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_ordered_unique_keeps_first_seen_order():
|
||||||
|
assert _merge_ordered_unique(["b", "a"], ["a", "c", "b", "d"]) == [
|
||||||
|
"b",
|
||||||
|
"a",
|
||||||
|
"c",
|
||||||
|
"d",
|
||||||
|
]
|
||||||
|
assert _merge_ordered_unique([], ["x"]) == ["x"]
|
||||||
|
assert _merge_ordered_unique(["x"], []) == ["x"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_model_metadata_preserves_trained_word_order():
|
||||||
|
"""Trigger word order (prompt order) must survive a metadata refresh."""
|
||||||
|
|
||||||
|
helpers = build_service()
|
||||||
|
|
||||||
|
local = {
|
||||||
|
"civitai": {"trainedWords": ["zeta style", "alpha", "beta"]},
|
||||||
|
"model_name": "Local",
|
||||||
|
}
|
||||||
|
remote = {
|
||||||
|
"source": "api",
|
||||||
|
"trainedWords": ["beta", "gamma", "alpha"],
|
||||||
|
"model": {"name": "Remote Model"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await helpers.service.update_model_metadata(
|
||||||
|
"path/to/model.metadata.json",
|
||||||
|
local,
|
||||||
|
remote,
|
||||||
|
helpers.default_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Saved order first, newly discovered words appended, duplicates dropped
|
||||||
|
assert result["civitai"]["trainedWords"] == [
|
||||||
|
"zeta style",
|
||||||
|
"alpha",
|
||||||
|
"beta",
|
||||||
|
"gamma",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_model_metadata_propagates_civitai_autov3():
|
async def test_update_model_metadata_propagates_civitai_autov3():
|
||||||
helpers = build_service()
|
helpers = build_service()
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ class TestDetectSource:
|
|||||||
"modelscope",
|
"modelscope",
|
||||||
"jj3550945163/Krea-2-LORA",
|
"jj3550945163/Krea-2-LORA",
|
||||||
),
|
),
|
||||||
|
# modelscope.ai is a separate catalogue with its own platform id.
|
||||||
|
(
|
||||||
|
"https://www.modelscope.ai/models/referall13/EM1",
|
||||||
|
"modelscope-ai",
|
||||||
|
"referall13/EM1",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://modelscope.ai/models/ErLubu/krea2_style_260911_02/summary",
|
||||||
|
"modelscope-ai",
|
||||||
|
"ErLubu/krea2_style_260911_02",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"https://tensor.art/models/827823520299086029/Vivid-Impressions-Storybook-Sstyle-V1.0",
|
"https://tensor.art/models/827823520299086029/Vivid-Impressions-Storybook-Sstyle-V1.0",
|
||||||
"tensorart",
|
"tensorart",
|
||||||
@@ -97,6 +108,21 @@ class TestDetectSource:
|
|||||||
== "https://tensor.art/models/123"
|
== "https://tensor.art/models/123"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_modelscope_com_is_an_alias_of_the_mainland_site(self):
|
||||||
|
"""``.com`` 301-redirects to ``.cn``, so it is not a third catalogue."""
|
||||||
|
ref = detect_source("https://www.modelscope.com/models/u/r")
|
||||||
|
assert ref.platform == "modelscope"
|
||||||
|
assert ref.url == "https://modelscope.cn/models/u/r"
|
||||||
|
|
||||||
|
def test_the_two_modelscope_catalogues_do_not_cross_match(self):
|
||||||
|
"""A host must never be accepted by the other deployment's patterns."""
|
||||||
|
mainland = get_source("modelscope")
|
||||||
|
international = get_source("modelscope-ai")
|
||||||
|
|
||||||
|
assert mainland.parse("https://www.modelscope.ai/models/u/r") is None
|
||||||
|
assert international.parse("https://modelscope.cn/models/u/r") is None
|
||||||
|
assert international.parse("https://www.modelscope.com/models/u/r") is None
|
||||||
|
|
||||||
|
|
||||||
class TestStrictParsing:
|
class TestStrictParsing:
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -106,6 +132,8 @@ class TestStrictParsing:
|
|||||||
"https://huggingface.co/user/repo/",
|
"https://huggingface.co/user/repo/",
|
||||||
"https://modelscope.cn/models/user/repo",
|
"https://modelscope.cn/models/user/repo",
|
||||||
"https://modelscope.cn/models/user/repo/summary",
|
"https://modelscope.cn/models/user/repo/summary",
|
||||||
|
"https://www.modelscope.ai/models/user/repo",
|
||||||
|
"https://www.modelscope.ai/models/user/repo/files",
|
||||||
"https://tensor.art/models/827823520299086029",
|
"https://tensor.art/models/827823520299086029",
|
||||||
"https://tensor.art/models/827823520299086029/Vivid-Impressions",
|
"https://tensor.art/models/827823520299086029/Vivid-Impressions",
|
||||||
],
|
],
|
||||||
@@ -145,6 +173,16 @@ class TestCapabilities:
|
|||||||
assert source.default_revision == "master"
|
assert source.default_revision == "master"
|
||||||
assert source.default_subdir == "modelscope"
|
assert source.default_subdir == "modelscope"
|
||||||
|
|
||||||
|
def test_modelscope_intl_is_the_same_site_on_another_catalogue(self):
|
||||||
|
source = get_source("modelscope-ai")
|
||||||
|
assert source.supports_enrichment is True
|
||||||
|
assert source.supports_download is True
|
||||||
|
assert source.default_revision == "master"
|
||||||
|
# A distinct directory: the same owner/name can exist on both
|
||||||
|
# deployments with different content.
|
||||||
|
assert source.default_subdir == "modelscope-ai"
|
||||||
|
assert source.base_url == "https://www.modelscope.ai"
|
||||||
|
|
||||||
def test_tensorart_is_link_only(self):
|
def test_tensorart_is_link_only(self):
|
||||||
source = get_source("tensorart")
|
source = get_source("tensorart")
|
||||||
assert source.supports_enrichment is False
|
assert source.supports_enrichment is False
|
||||||
@@ -152,11 +190,17 @@ class TestCapabilities:
|
|||||||
|
|
||||||
def test_registry_lists_every_source(self):
|
def test_registry_lists_every_source(self):
|
||||||
platforms = {s.platform for s in list_sources()}
|
platforms = {s.platform for s in list_sources()}
|
||||||
assert platforms == {"huggingface", "modelscope", "tensorart"}
|
assert platforms == {
|
||||||
|
"huggingface",
|
||||||
|
"modelscope",
|
||||||
|
"modelscope-ai",
|
||||||
|
"tensorart",
|
||||||
|
}
|
||||||
|
|
||||||
def test_labels_are_brand_names(self):
|
def test_labels_are_brand_names(self):
|
||||||
assert source_label("huggingface") == "Hugging Face"
|
assert source_label("huggingface") == "Hugging Face"
|
||||||
assert source_label("modelscope") == "ModelScope"
|
assert source_label("modelscope") == "ModelScope"
|
||||||
|
assert source_label("modelscope-ai") == "ModelScope (International)"
|
||||||
assert source_label("tensorart") == "TensorArt"
|
assert source_label("tensorart") == "TensorArt"
|
||||||
assert source_label("unknown", "fallback") == "fallback"
|
assert source_label("unknown", "fallback") == "fallback"
|
||||||
|
|
||||||
@@ -311,6 +355,44 @@ class TestFetchModelCard:
|
|||||||
in calls
|
in calls
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modelscope_intl_fetches_from_its_own_catalogue(self, monkeypatch):
|
||||||
|
"""The mainland site 404s for a `.ai`-only repository, so every fetch
|
||||||
|
has to stay on the host the URL came from."""
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
async def fake_fetch_text(url: str, **_kwargs) -> str:
|
||||||
|
calls.append(url)
|
||||||
|
return "# card"
|
||||||
|
|
||||||
|
json_calls: list[str] = []
|
||||||
|
|
||||||
|
async def fake_fetch_json(url: str, **_kwargs):
|
||||||
|
json_calls.append(url)
|
||||||
|
return 200, {"Data": {"Name": "EM1", "MuseInfo": {"versions": []}}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.modelscope.fetch_text", fake_fetch_text
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
|
||||||
|
)
|
||||||
|
|
||||||
|
source = get_source("modelscope-ai")
|
||||||
|
await source.fetch_model_card("referall13/EM1")
|
||||||
|
await source.fetch_model_card_context("referall13/EM1")
|
||||||
|
await source.list_files("referall13/EM1")
|
||||||
|
|
||||||
|
assert calls == [
|
||||||
|
"https://www.modelscope.ai/models/referall13/EM1/resolve/master/README.md"
|
||||||
|
]
|
||||||
|
assert json_calls == [
|
||||||
|
"https://www.modelscope.ai/api/v1/models/referall13/EM1",
|
||||||
|
"https://www.modelscope.ai/api/v1/models/referall13/EM1/repo/files"
|
||||||
|
"?Revision=master",
|
||||||
|
]
|
||||||
|
assert not any("modelscope.cn" in url for url in calls + json_calls)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tensorart_never_fetches(self):
|
async def test_tensorart_never_fetches(self):
|
||||||
# TensorArt enrichment is disabled: the provider must not issue any
|
# TensorArt enrichment is disabled: the provider must not issue any
|
||||||
@@ -335,6 +417,12 @@ class TestAssetBaseUrl:
|
|||||||
== "https://modelscope.cn/models/u/r/resolve/master"
|
== "https://modelscope.cn/models/u/r/resolve/master"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_modelscope_intl_uses_master_revision(self):
|
||||||
|
assert (
|
||||||
|
get_source("modelscope-ai").asset_base_url("u/r")
|
||||||
|
== "https://www.modelscope.ai/models/u/r/resolve/master"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Model card context (site extras kept outside the README)
|
# Model card context (site extras kept outside the README)
|
||||||
@@ -354,6 +442,9 @@ def _modelscope_detail_payload() -> dict:
|
|||||||
"Data": {
|
"Data": {
|
||||||
"Name": "Krea-2-LORA",
|
"Name": "Krea-2-LORA",
|
||||||
"ChineseName": "krea脸模",
|
"ChineseName": "krea脸模",
|
||||||
|
"AigcType": "LoRA",
|
||||||
|
"License": "Apache License 2.0",
|
||||||
|
"Tags": ["LoRA", "text-to-image", "portrait"],
|
||||||
"Description": "权重0.5-1.2。配合《风格滤镜》lora一起使用。",
|
"Description": "权重0.5-1.2。配合《风格滤镜》lora一起使用。",
|
||||||
"BaseModel": ["krea/Krea-2-Turbo"],
|
"BaseModel": ["krea/Krea-2-Turbo"],
|
||||||
"License": "Apache License 2.0",
|
"License": "Apache License 2.0",
|
||||||
@@ -422,6 +513,81 @@ class TestFetchModelCardContext:
|
|||||||
# OfficialTag values only, de-duplicated, order preserved.
|
# OfficialTag values only, de-duplicated, order preserved.
|
||||||
assert context.official_tags == ["photography", "woman"]
|
assert context.official_tags == ["photography", "woman"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modelscope_reads_site_identity_fields(self, monkeypatch):
|
||||||
|
async def fake_fetch_json(url, **_kwargs):
|
||||||
|
return 200, _modelscope_detail_payload()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
|
||||||
|
)
|
||||||
|
|
||||||
|
context = await ModelScopeSource().fetch_model_card_context(
|
||||||
|
"u/r", "Krea-2-LORA_c1-st1000.safetensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert context.model_name == "Krea-2-LORA"
|
||||||
|
assert context.model_name_localized == "krea脸模"
|
||||||
|
assert context.license == "Apache License 2.0"
|
||||||
|
assert context.model_type == "LoRA"
|
||||||
|
# The version label is taken from the file that was matched, not from
|
||||||
|
# whichever version happens to come first in the payload.
|
||||||
|
assert context.version_name == "c1-st1000"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modelscope_version_label_is_empty_for_an_unknown_file(
|
||||||
|
self, monkeypatch
|
||||||
|
):
|
||||||
|
async def fake_fetch_json(url, **_kwargs):
|
||||||
|
return 200, _modelscope_detail_payload()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
|
||||||
|
)
|
||||||
|
|
||||||
|
context = await ModelScopeSource().fetch_model_card_context(
|
||||||
|
"u/r", "other.safetensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert context.version_name == ""
|
||||||
|
# The repository-wide fields are still published.
|
||||||
|
assert context.model_name == "Krea-2-LORA"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modelscope_falls_back_to_plain_tags(self, monkeypatch):
|
||||||
|
"""An empty ``OfficialTags`` must not mean "no tags at all".
|
||||||
|
|
||||||
|
The plain ``Tags`` list mixes genuine content tags with library and
|
||||||
|
task categories; the latter are dropped so the card is not tagged
|
||||||
|
"lora" / "text-to-image".
|
||||||
|
"""
|
||||||
|
payload = _modelscope_detail_payload()
|
||||||
|
payload["Data"]["OfficialTags"] = None
|
||||||
|
|
||||||
|
async def fake_fetch_json(url, **_kwargs):
|
||||||
|
return 200, payload
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
|
||||||
|
)
|
||||||
|
|
||||||
|
context = await ModelScopeSource().fetch_model_card_context("u/r")
|
||||||
|
|
||||||
|
assert context.official_tags == ["portrait"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modelscope_curated_tags_win_over_plain_tags(self, monkeypatch):
|
||||||
|
async def fake_fetch_json(url, **_kwargs):
|
||||||
|
return 200, _modelscope_detail_payload()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
|
||||||
|
)
|
||||||
|
|
||||||
|
context = await ModelScopeSource().fetch_model_card_context("u/r")
|
||||||
|
|
||||||
|
assert "portrait" not in context.official_tags
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modelscope_matches_example_images_by_filename(self, monkeypatch):
|
async def test_modelscope_matches_example_images_by_filename(self, monkeypatch):
|
||||||
async def fake_fetch_json(url, **_kwargs):
|
async def fake_fetch_json(url, **_kwargs):
|
||||||
@@ -655,6 +821,22 @@ class TestDownloadUrls:
|
|||||||
"https://modelscope.cn/models/u/r/resolve/master/sub/f.safetensors"
|
"https://modelscope.cn/models/u/r/resolve/master/sub/f.safetensors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_modelscope_intl_builds_every_url_on_its_own_host(self):
|
||||||
|
"""The two deployments serve different catalogues, so a URL built for
|
||||||
|
one must never point at the other."""
|
||||||
|
source = get_source("modelscope-ai")
|
||||||
|
|
||||||
|
assert source.canonical_url("u/r") == "https://www.modelscope.ai/models/u/r"
|
||||||
|
assert source.file_download_url("u/r", "sub/f.safetensors") == (
|
||||||
|
"https://www.modelscope.ai/models/u/r/resolve/master/sub/f.safetensors"
|
||||||
|
)
|
||||||
|
assert source.asset_base_url("u/r") == (
|
||||||
|
"https://www.modelscope.ai/models/u/r/resolve/master"
|
||||||
|
)
|
||||||
|
assert source.page_url_for_file("u/r", "sub/f.safetensors") == (
|
||||||
|
"https://www.modelscope.ai/models/u/r/file/view/master/sub/f.safetensors"
|
||||||
|
)
|
||||||
|
|
||||||
def test_explicit_revision_wins(self):
|
def test_explicit_revision_wins(self):
|
||||||
assert ModelScopeSource().file_download_url("u/r", "f.bin", "v1") == (
|
assert ModelScopeSource().file_download_url("u/r", "f.bin", "v1") == (
|
||||||
"https://modelscope.cn/models/u/r/resolve/v1/f.bin"
|
"https://modelscope.cn/models/u/r/resolve/v1/f.bin"
|
||||||
@@ -701,12 +883,13 @@ class TestSourceIdValidation:
|
|||||||
class TestDownloadSourceRegistry:
|
class TestDownloadSourceRegistry:
|
||||||
def test_downloadable_sources_excludes_link_only_sites(self):
|
def test_downloadable_sources_excludes_link_only_sites(self):
|
||||||
platforms = {source.platform for source in downloadable_sources()}
|
platforms = {source.platform for source in downloadable_sources()}
|
||||||
assert platforms == {"huggingface", "modelscope"}
|
assert platforms == {"huggingface", "modelscope", "modelscope-ai"}
|
||||||
|
|
||||||
def test_get_download_source_rejects_link_only_platform(self):
|
def test_get_download_source_rejects_link_only_platform(self):
|
||||||
assert get_download_source("tensorart") is None
|
assert get_download_source("tensorart") is None
|
||||||
assert get_download_source("nope") is None
|
assert get_download_source("nope") is None
|
||||||
assert get_download_source("modelscope").platform == "modelscope"
|
assert get_download_source("modelscope").platform == "modelscope"
|
||||||
|
assert get_download_source("modelscope-ai").platform == "modelscope-ai"
|
||||||
assert get_download_source("huggingface").platform == "huggingface"
|
assert get_download_source("huggingface").platform == "huggingface"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""Tests for download-time metadata hydration.
|
||||||
|
|
||||||
|
`py/services/model_sources/hydration.py` is the deterministic counterpart of
|
||||||
|
the `enrich_hf_metadata` skill: it turns a freshly downloaded ModelScope /
|
||||||
|
Hugging Face file into the populated model card a CivitAI download produces,
|
||||||
|
without an LLM and without the user running anything.
|
||||||
|
|
||||||
|
These tests cover the orchestration — which source data is fetched, what is
|
||||||
|
handed to the post-processor, and that nothing here can fail a download. The
|
||||||
|
field-by-field mapping lives in `tests/services/test_post_processor.py`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.services.model_sources import ModelCardContext, ModelSourceCache, SourceRef
|
||||||
|
from py.services.model_sources import hydration
|
||||||
|
from py.services.model_sources.base import ModelSource
|
||||||
|
from py.services.model_sources.hydration import (
|
||||||
|
SHARED_CACHE_MAX_ENTRIES,
|
||||||
|
hydrate_from_source,
|
||||||
|
load_model_card,
|
||||||
|
reset_shared_caches,
|
||||||
|
resolve_site_base_model,
|
||||||
|
shared_source_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
REF = SourceRef(
|
||||||
|
platform="modelscope",
|
||||||
|
source_id="user/repo",
|
||||||
|
url="https://modelscope.cn/models/user/repo",
|
||||||
|
)
|
||||||
|
|
||||||
|
SIDECAR = {
|
||||||
|
"sha256": "a" * 64,
|
||||||
|
"base_model": "Unknown",
|
||||||
|
# Written by the download handler just before hydration runs.
|
||||||
|
"source_platform": "modelscope",
|
||||||
|
"source_url": "https://modelscope.cn/models/user/repo",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSource(ModelSource):
|
||||||
|
"""Minimal provider that records what hydration asked of it."""
|
||||||
|
|
||||||
|
platform = "modelscope"
|
||||||
|
label = "ModelScope"
|
||||||
|
supports_enrichment = True
|
||||||
|
|
||||||
|
def __init__(self, *, context=None, readme="", fail=False):
|
||||||
|
self.context = context if context is not None else ModelCardContext()
|
||||||
|
self.readme = readme
|
||||||
|
self.fail = fail
|
||||||
|
self.readme_calls = 0
|
||||||
|
self.context_calls = 0
|
||||||
|
self.context_kwargs: dict = {}
|
||||||
|
|
||||||
|
async def fetch_model_card(self, source_id):
|
||||||
|
self.readme_calls += 1
|
||||||
|
if self.fail:
|
||||||
|
raise RuntimeError("network down")
|
||||||
|
return self.readme
|
||||||
|
|
||||||
|
async def fetch_model_card_context(
|
||||||
|
self, source_id, filename="", *, sha256="", cache=None
|
||||||
|
):
|
||||||
|
self.context_calls += 1
|
||||||
|
self.context_kwargs = {"filename": filename, "sha256": sha256}
|
||||||
|
if self.fail:
|
||||||
|
raise RuntimeError("network down")
|
||||||
|
return self.context
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolated_shared_caches():
|
||||||
|
reset_shared_caches()
|
||||||
|
yield
|
||||||
|
reset_shared_caches()
|
||||||
|
|
||||||
|
|
||||||
|
def _async(value):
|
||||||
|
async def _call(*_args, **_kwargs):
|
||||||
|
return value
|
||||||
|
|
||||||
|
return _call
|
||||||
|
|
||||||
|
|
||||||
|
def _wire(monkeypatch, source, *, metadata=SIDECAR, result=None):
|
||||||
|
"""Patch hydration's collaborators; return the recorded process() calls."""
|
||||||
|
|
||||||
|
monkeypatch.setattr(hydration, "get_source", lambda _platform: source)
|
||||||
|
monkeypatch.setattr("py.metadata_ops.read_metadata", _async(metadata))
|
||||||
|
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
class _Processor:
|
||||||
|
async def process(self, **kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
return {"success": True, "updated_fields": ["model_name"]}
|
||||||
|
|
||||||
|
monkeypatch.setattr("py.services.agent.post_processor.PostProcessor", _Processor)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Orchestration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHydrateFromSource:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_applies_the_site_card_without_an_llm(self, monkeypatch):
|
||||||
|
source = _FakeSource(
|
||||||
|
context=ModelCardContext(
|
||||||
|
model_name="Krea-2-LORA",
|
||||||
|
version_name="c1-st1000",
|
||||||
|
description="权重0.5-1.2。",
|
||||||
|
official_tags=["photography"],
|
||||||
|
),
|
||||||
|
readme="# Krea-2-LORA",
|
||||||
|
)
|
||||||
|
calls = _wire(monkeypatch, source)
|
||||||
|
|
||||||
|
updated = await hydrate_from_source("/models/lora.safetensors", ref=REF)
|
||||||
|
|
||||||
|
assert updated == ["model_name"]
|
||||||
|
assert len(calls) == 1
|
||||||
|
call = calls[0]
|
||||||
|
# No provider is consulted: everything applied is what the site published.
|
||||||
|
assert call["llm_output"] == {}
|
||||||
|
assert call["skill_name"] == "enrich_hf_metadata"
|
||||||
|
assert call["readme_content"] == "# Krea-2-LORA"
|
||||||
|
assert call["source_context"].model_name == "Krea-2-LORA"
|
||||||
|
assert call["metadata_source"] == "source:modelscope"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_matches_the_file_by_hash_and_basename(self, monkeypatch):
|
||||||
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
||||||
|
_wire(monkeypatch, source)
|
||||||
|
|
||||||
|
await hydrate_from_source("/models/sub/Krea-2-LORA_c1-st1000.safetensors", ref=REF)
|
||||||
|
|
||||||
|
assert source.context_kwargs == {
|
||||||
|
"filename": "Krea-2-LORA_c1-st1000.safetensors",
|
||||||
|
"sha256": "a" * 64,
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_early_for_an_unknown_platform(self, monkeypatch):
|
||||||
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
||||||
|
calls = _wire(monkeypatch, source)
|
||||||
|
monkeypatch.setattr(hydration, "get_source", lambda _platform: None)
|
||||||
|
|
||||||
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_early_for_a_link_only_source(self, monkeypatch):
|
||||||
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
||||||
|
source.supports_enrichment = False
|
||||||
|
calls = _wire(monkeypatch, source)
|
||||||
|
|
||||||
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_early_without_a_sidecar(self, monkeypatch):
|
||||||
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
||||||
|
calls = _wire(monkeypatch, source, metadata={})
|
||||||
|
|
||||||
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_early_when_the_site_published_nothing(self, monkeypatch):
|
||||||
|
source = _FakeSource(context=ModelCardContext(), readme="")
|
||||||
|
calls = _wire(monkeypatch, source)
|
||||||
|
|
||||||
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_deferred_hash_still_matches_by_filename(self, monkeypatch):
|
||||||
|
"""Checkpoints and other large files are stored with
|
||||||
|
``hash_status="pending"`` and an empty ``sha256`` (see
|
||||||
|
``CheckpointScanner._create_default_metadata``), so hydration has to
|
||||||
|
work from the filename alone."""
|
||||||
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
||||||
|
calls = _wire(
|
||||||
|
monkeypatch,
|
||||||
|
source,
|
||||||
|
metadata={**SIDECAR, "sha256": "", "hash_status": "pending"},
|
||||||
|
)
|
||||||
|
|
||||||
|
await hydrate_from_source("/models/big_checkpoint.safetensors", ref=REF)
|
||||||
|
|
||||||
|
assert calls[0]["source_context"].model_name == "X"
|
||||||
|
assert source.context_kwargs == {
|
||||||
|
"filename": "big_checkpoint.safetensors",
|
||||||
|
"sha256": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_early_when_the_model_is_not_linked(self, monkeypatch):
|
||||||
|
"""A file that merely shares a name must not get another model's card."""
|
||||||
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
||||||
|
calls = _wire(
|
||||||
|
monkeypatch, source, metadata={"sha256": "a" * 64, "base_model": "Unknown"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_early_when_linked_to_another_repository(self, monkeypatch):
|
||||||
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
||||||
|
calls = _wire(
|
||||||
|
monkeypatch,
|
||||||
|
source,
|
||||||
|
metadata={
|
||||||
|
**SIDECAR,
|
||||||
|
"source_url": "https://modelscope.cn/models/user/other",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_early_when_linked_to_another_platform(self, monkeypatch):
|
||||||
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
||||||
|
calls = _wire(
|
||||||
|
monkeypatch,
|
||||||
|
source,
|
||||||
|
metadata={
|
||||||
|
"sha256": "a" * 64,
|
||||||
|
"source_platform": "huggingface",
|
||||||
|
"source_url": "https://huggingface.co/user/repo",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_readme_alone_is_enough_to_run(self, monkeypatch):
|
||||||
|
source = _FakeSource(context=ModelCardContext(), readme="# hi")
|
||||||
|
calls = _wire(monkeypatch, source)
|
||||||
|
|
||||||
|
await hydrate_from_source("/models/lora.safetensors", ref=REF)
|
||||||
|
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert calls[0]["readme_content"] == "# hi"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_failing_site_never_breaks_the_download(self, monkeypatch):
|
||||||
|
source = _FakeSource(fail=True)
|
||||||
|
calls = _wire(monkeypatch, source)
|
||||||
|
|
||||||
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_failing_post_processor_never_breaks_the_download(
|
||||||
|
self, monkeypatch
|
||||||
|
):
|
||||||
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
||||||
|
_wire(monkeypatch, source, result={"success": False, "errors": ["boom"]})
|
||||||
|
|
||||||
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_updates_are_reported_for_logging(self, monkeypatch):
|
||||||
|
source = _FakeSource(context=ModelCardContext(model_name="X"))
|
||||||
|
_wire(
|
||||||
|
monkeypatch,
|
||||||
|
source,
|
||||||
|
result={"success": True, "updated_fields": ["tags", "civitai"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await hydrate_from_source("/models/lora.safetensors", ref=REF) == [
|
||||||
|
"tags",
|
||||||
|
"civitai",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-repository memo
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSharedSourceCache:
|
||||||
|
def test_same_repository_reuses_one_memo(self):
|
||||||
|
assert shared_source_cache("modelscope", "u/r") is shared_source_cache(
|
||||||
|
"modelscope", "u/r"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_different_repositories_get_different_memos(self):
|
||||||
|
assert shared_source_cache("modelscope", "u/r") is not shared_source_cache(
|
||||||
|
"modelscope", "u/other"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_entry_expires(self, monkeypatch):
|
||||||
|
clock = {"now": 1000.0}
|
||||||
|
monkeypatch.setattr(hydration.time, "monotonic", lambda: clock["now"])
|
||||||
|
|
||||||
|
first = shared_source_cache("modelscope", "u/r")
|
||||||
|
clock["now"] += hydration.SHARED_CACHE_TTL + 1
|
||||||
|
|
||||||
|
assert shared_source_cache("modelscope", "u/r") is not first
|
||||||
|
|
||||||
|
def test_cache_is_bounded(self):
|
||||||
|
for index in range(SHARED_CACHE_MAX_ENTRIES + 5):
|
||||||
|
shared_source_cache("modelscope", f"u/r{index}")
|
||||||
|
|
||||||
|
assert len(hydration._shared_caches) == SHARED_CACHE_MAX_ENTRIES
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadModelCard:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_successful_read_is_memoised(self):
|
||||||
|
source = _FakeSource(readme="# hi")
|
||||||
|
cache = ModelSourceCache()
|
||||||
|
|
||||||
|
assert await load_model_card(source, "u/r", cache) == "# hi"
|
||||||
|
assert await load_model_card(source, "u/r", cache) == "# hi"
|
||||||
|
assert source.readme_calls == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_read_is_retried(self):
|
||||||
|
"""A transient failure must not be cached as "this repo has no card"."""
|
||||||
|
source = _FakeSource(readme="")
|
||||||
|
cache = ModelSourceCache()
|
||||||
|
|
||||||
|
await load_model_card(source, "u/r", cache)
|
||||||
|
await load_model_card(source, "u/r", cache)
|
||||||
|
|
||||||
|
assert source.readme_calls == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_works_without_a_cache(self):
|
||||||
|
source = _FakeSource(readme="# hi")
|
||||||
|
|
||||||
|
assert await load_model_card(source, "u/r") == "# hi"
|
||||||
|
assert source.readme_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Base-model resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveSiteBaseModel:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_maps_the_sites_own_vocabulary(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.metadata_ops.list_base_models",
|
||||||
|
_async(["Krea 2", "Flux.1 D"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
context = ModelCardContext(
|
||||||
|
base_model="krea/Krea-2-Turbo",
|
||||||
|
base_model_aliases=["KREA_2_TURBO", "krea/Krea-2-Turbo"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await resolve_site_base_model(context) == "Krea 2"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_hint_defers_instead_of_guessing(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.metadata_ops.list_base_models", _async(["Flux.1 D"])
|
||||||
|
)
|
||||||
|
|
||||||
|
context = ModelCardContext(base_model="something/else")
|
||||||
|
|
||||||
|
assert await resolve_site_base_model(context) == ""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_hints_needs_no_vocabulary_lookup(self, monkeypatch):
|
||||||
|
async def _boom(*_args, **_kwargs): # pragma: no cover - must not run
|
||||||
|
raise AssertionError("list_base_models should not be called")
|
||||||
|
|
||||||
|
monkeypatch.setattr("py.metadata_ops.list_base_models", _boom)
|
||||||
|
|
||||||
|
assert await resolve_site_base_model(ModelCardContext()) == ""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_vocabulary_failure_is_not_fatal(self, monkeypatch):
|
||||||
|
async def _boom(*_args, **_kwargs):
|
||||||
|
raise RuntimeError("civitai down")
|
||||||
|
|
||||||
|
monkeypatch.setattr("py.metadata_ops.list_base_models", _boom)
|
||||||
|
|
||||||
|
assert await resolve_site_base_model(ModelCardContext(base_model="x")) == ""
|
||||||
@@ -1121,3 +1121,118 @@ pip install modelscope
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "modelDescription" not in mock_apply.call_args[0][1]
|
assert "modelDescription" not in mock_apply.call_args[0][1]
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Site identity and provenance fields
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiteIdentityFields:
|
||||||
|
"""The fields that make a source download look like a CivitAI one."""
|
||||||
|
|
||||||
|
METADATA = {
|
||||||
|
"from_civitai": False,
|
||||||
|
"source_platform": "modelscope",
|
||||||
|
"source_url": "https://modelscope.cn/models/user/repo",
|
||||||
|
"file_name": "Krea-2-LORA_c1-st1000",
|
||||||
|
"model_name": "Krea-2-LORA_c1-st1000",
|
||||||
|
"base_model": "Unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run(processor, *, metadata, context, llm_output=None, **kwargs):
|
||||||
|
async def _call():
|
||||||
|
with (
|
||||||
|
mock.patch("py.metadata_ops.apply_metadata_updates") as mock_apply,
|
||||||
|
mock.patch("py.metadata_ops.download_preview", return_value=None),
|
||||||
|
mock.patch("py.metadata_ops.refresh_cache"),
|
||||||
|
):
|
||||||
|
await processor.process(
|
||||||
|
skill_name="enrich_hf_metadata",
|
||||||
|
model_path="/p.safetensors",
|
||||||
|
llm_output=llm_output if llm_output is not None else {},
|
||||||
|
metadata=metadata,
|
||||||
|
source_context=context,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
return mock_apply.call_args[0][1]
|
||||||
|
|
||||||
|
return _call()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_model_name_is_taken_from_the_site(self, processor):
|
||||||
|
applied = await self._run(
|
||||||
|
processor,
|
||||||
|
metadata=dict(self.METADATA),
|
||||||
|
context=ModelCardContext(model_name="Krea-2-LORA"),
|
||||||
|
)
|
||||||
|
assert applied["model_name"] == "Krea-2-LORA"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_model_name_is_also_written_when_absent(self, processor):
|
||||||
|
metadata = {**self.METADATA, "model_name": ""}
|
||||||
|
applied = await self._run(
|
||||||
|
processor,
|
||||||
|
metadata=metadata,
|
||||||
|
context=ModelCardContext(model_name="Krea-2-LORA"),
|
||||||
|
)
|
||||||
|
assert applied["model_name"] == "Krea-2-LORA"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_renamed_model_keeps_the_users_name(self, processor):
|
||||||
|
metadata = {**self.METADATA, "model_name": "my own name"}
|
||||||
|
applied = await self._run(
|
||||||
|
processor,
|
||||||
|
metadata=metadata,
|
||||||
|
context=ModelCardContext(model_name="Krea-2-LORA"),
|
||||||
|
)
|
||||||
|
assert "model_name" not in applied
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_version_label_becomes_the_civitai_name(self, processor):
|
||||||
|
applied = await self._run(
|
||||||
|
processor,
|
||||||
|
metadata=dict(self.METADATA),
|
||||||
|
context=ModelCardContext(
|
||||||
|
model_name="Krea-2-LORA",
|
||||||
|
version_name="c1-st1000",
|
||||||
|
description="权重0.5-1.2。",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert applied["civitai"]["name"] == "c1-st1000"
|
||||||
|
# Every civitai branch contributes to one dict, so an earlier branch
|
||||||
|
# must survive a later one.
|
||||||
|
assert applied["civitai"]["description"] == "权重0.5-1.2。"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_llm_enriched_at_is_stamped_only_when_the_llm_answered(
|
||||||
|
self, processor
|
||||||
|
):
|
||||||
|
applied = await self._run(
|
||||||
|
processor,
|
||||||
|
metadata=dict(self.METADATA),
|
||||||
|
context=ModelCardContext(model_name="Krea-2-LORA"),
|
||||||
|
)
|
||||||
|
assert applied["metadata_source"] == "agent:enrich_hf_metadata"
|
||||||
|
assert "llm_enriched_at" not in applied
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_llm_answer_stamps_llm_enriched_at(self, processor):
|
||||||
|
applied = await self._run(
|
||||||
|
processor,
|
||||||
|
metadata=dict(self.METADATA),
|
||||||
|
context=ModelCardContext(model_name="Krea-2-LORA"),
|
||||||
|
llm_output={"base_model": "", "confidence": "high"},
|
||||||
|
)
|
||||||
|
assert "llm_enriched_at" in applied
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_metadata_source_can_be_overridden(self, processor):
|
||||||
|
applied = await self._run(
|
||||||
|
processor,
|
||||||
|
metadata=dict(self.METADATA),
|
||||||
|
context=ModelCardContext(model_name="Krea-2-LORA"),
|
||||||
|
metadata_source="source:modelscope",
|
||||||
|
)
|
||||||
|
assert applied["metadata_source"] == "source:modelscope"
|
||||||
|
|||||||
Reference in New Issue
Block a user