feat(lora): support relative paths in <lora:folder/name:strength> syntax (#917)

Autocomplete, copy/send-to-workflow, and recipe syntax now emit
<lora:folder/name:strength> instead of <lora:name:strength>, using
relative paths to disambiguate identically-named loras in different
subfolders without requiring file renames.

Backend: 3-tier hybrid resolution (path → bare → basename fallback)
across get_lora_info, get_lora_info_absolute, get_model_preview_url,
get_model_civitai_url, get_model_info_by_name, get_lora_metadata_by_filename,
and get_hash_by_filename. Also fix get_random_loras and get_cycler_list
to return path-prefixed names for randomizer/cycler consistency.

Frontend: autocomplete, copyLoraSyntax, handleSendToWorkflow emit
folder-prefixed syntax. extract_lora_name preserves relative paths.

Saved image metadata (<lora:...> in EXIF) intentionally keeps basename-only
for compatibility with A1111/Forge ecosystem.
This commit is contained in:
Will Miao
2026-05-20 19:39:12 +08:00
parent 33e5f3d85d
commit 9ce56dd40c
13 changed files with 404 additions and 48 deletions

View File

@@ -45,10 +45,13 @@ logger = logging.getLogger(__name__)
def extract_lora_name(lora_path):
"""Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')"""
# Get the basename without extension
basename = os.path.basename(lora_path)
return os.path.splitext(basename)[0]
normalized = lora_path.replace("\\", "/")
basename = os.path.basename(normalized)
name_no_ext = os.path.splitext(basename)[0]
dirname = os.path.dirname(normalized)
if dirname and dirname not in (".", "/") and not normalized.startswith("/"):
return f"{dirname}/{name_no_ext}"
return name_no_ext
def get_loras_list(kwargs):

View File

@@ -870,22 +870,75 @@ class BaseModelService(ABC):
"""Get the static preview URL for a model file"""
cache = await self.scanner.get_cached_data()
name_normalized = model_name.replace("\\", "/")
name_no_ext = name_normalized
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if name_no_ext.lower().endswith(ext):
name_no_ext = name_no_ext[: -len(ext)]
break
has_path = "/" in name_no_ext
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
best_fallback = None
for model in cache.raw_data:
if model["file_name"] == model_name:
file_name = model.get("file_name", "")
folder = model.get("folder", "")
file_name_no_ext = file_name
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if file_name_no_ext.lower().endswith(ext):
file_name_no_ext = file_name_no_ext[: -len(ext)]
break
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
preview_url = model.get("preview_url")
if preview_url:
from ..config import config
return config.get_preview_static_url(preview_url)
if has_path and file_name_no_ext == basename:
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
best_fallback = model
elif best_fallback is None:
best_fallback = model
if best_fallback:
preview_url = best_fallback.get("preview_url")
if preview_url:
from ..config import config
return config.get_preview_static_url(preview_url)
return "/loras_static/images/no-preview.png"
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
"""Get the Civitai URL for a model file"""
cache = await self.scanner.get_cached_data()
name_normalized = model_name.replace("\\", "/")
name_no_ext = name_normalized
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if name_no_ext.lower().endswith(ext):
name_no_ext = name_no_ext[: -len(ext)]
break
has_path = "/" in name_no_ext
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
best_fallback = None
for model in cache.raw_data:
if model["file_name"] == model_name:
file_name = model.get("file_name", "")
folder = model.get("folder", "")
file_name_no_ext = file_name
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if file_name_no_ext.lower().endswith(ext):
file_name_no_ext = file_name_no_ext[: -len(ext)]
break
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
civitai_data = model.get("civitai", {})
model_id = civitai_data.get("modelId")
version_id = civitai_data.get("id")
@@ -904,6 +957,27 @@ class BaseModelService(ABC):
"version_id": str(version_id) if version_id else None,
}
if has_path and file_name_no_ext == basename:
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
best_fallback = model
elif best_fallback is None:
best_fallback = model
if best_fallback:
civitai_data = best_fallback.get("civitai", {})
model_id = civitai_data.get("modelId")
if model_id:
version_id = civitai_data.get("id")
civitai_host = self.settings.get("civitai_host", "civitai.com")
civitai_url = build_civitai_model_page_url(
model_id, version_id, host=civitai_host
)
return {
"civitai_url": civitai_url,
"model_id": str(model_id),
"version_id": str(version_id) if version_id else None,
}
return {"civitai_url": None, "model_id": None, "version_id": None}
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:

View File

@@ -312,8 +312,23 @@ class LoraService(BaseModelService):
"""Return cached raw metadata for a LoRA matching the given filename."""
cache = await self.scanner.get_cached_data(force_refresh=False)
fn_normalized = filename.replace("\\", "/")
fn_no_ext = fn_normalized
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if fn_no_ext.lower().endswith(ext):
fn_no_ext = fn_no_ext[: -len(ext)]
break
for lora in cache.raw_data if cache else []:
if lora.get("file_name") == filename:
file_name = lora.get("file_name", "")
folder = lora.get("folder", "")
file_name_no_ext = file_name
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if file_name_no_ext.lower().endswith(ext):
file_name_no_ext = file_name_no_ext[: -len(ext)]
break
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
if fn_no_ext in (file_name_no_ext, path_name):
return lora
return None
@@ -401,7 +416,10 @@ class LoraService(BaseModelService):
locked_loras = locked_loras[:target_count]
# Filter out locked LoRAs from available pool
locked_names = {lora["name"] for lora in locked_loras}
locked_names = {
os.path.basename(lora["name"]) if "/" in str(lora.get("name", "")) else lora["name"]
for lora in locked_loras
}
available_pool = [
l for l in available_loras if l["file_name"] not in locked_names
]
@@ -456,7 +474,7 @@ class LoraService(BaseModelService):
result_loras.append(
{
"name": lora["file_name"],
"name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
"strength": model_str,
"clipStrength": clip_str,
"active": True,
@@ -672,8 +690,9 @@ class LoraService(BaseModelService):
# Return minimal data needed for cycling
return [
{
"file_name": lora["file_name"],
"file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
"model_name": lora.get("model_name", lora["file_name"]),
"folder": lora.get("folder", ""),
}
for lora in available_loras
]

View File

@@ -209,7 +209,9 @@ class ModelHashIndex:
return self._filename_to_hash.get(filename)
def get_hash_by_filename(self, filename: str) -> Optional[str]:
"""Get hash for a filename without extension"""
"""Get hash for a filename (bare basename or path-prefixed name)"""
if "/" in filename or "\\" in filename:
filename = os.path.splitext(os.path.basename(filename.replace("\\", "/")))[0]
return self._filename_to_hash.get(filename)
def clear(self) -> None:

View File

@@ -1597,12 +1597,39 @@ class ModelScanner:
"""Get model information by name"""
try:
cache = await self.get_cached_data()
name_normalized = name.replace("\\", "/")
name_no_ext = name_normalized
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if name_no_ext.lower().endswith(ext):
name_no_ext = name_no_ext[: -len(ext)]
break
has_path = "/" in name_no_ext
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
best_fallback = None
for model in cache.raw_data:
if model.get("file_name") == name:
file_name = model.get("file_name", "")
folder = model.get("folder", "")
file_name_no_ext = file_name
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if file_name_no_ext.lower().endswith(ext):
file_name_no_ext = file_name_no_ext[: -len(ext)]
break
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
return model
return None
if has_path and file_name_no_ext == basename:
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
best_fallback = model
elif best_fallback is None:
best_fallback = model
return best_fallback
except Exception as e:
logger.error(f"Error getting model info by name: {e}", exc_info=True)
return None

View File

@@ -2517,6 +2517,7 @@ class RecipeScanner:
continue
file_name = None
folder = ""
hash_value = (lora.get("hash") or "").lower()
if (
hash_value
@@ -2526,6 +2527,11 @@ class RecipeScanner:
file_path = self._lora_scanner._hash_index.get_path(hash_value)
if file_path:
file_name = os.path.splitext(os.path.basename(file_path))[0]
if lora_cache is not None:
for cached_lora in getattr(lora_cache, "raw_data", []):
if cached_lora.get("file_path") == file_path:
folder = cached_lora.get("folder", "")
break
if not file_name and lora.get("modelVersionId") and lora_cache is not None:
for cached_lora in getattr(lora_cache, "raw_data", []):
@@ -2540,13 +2546,16 @@ class RecipeScanner:
file_name = os.path.splitext(os.path.basename(cached_path))[
0
]
folder = cached_lora.get("folder", "")
break
if not file_name:
file_name = lora.get("file_name", "unknown-lora")
folder = lora.get("folder", "")
lora_name = f"{folder}/{file_name}" if folder else file_name
strength = lora.get("strength", 1.0)
syntax_parts.append(f"<lora:{file_name}:{strength}>")
syntax_parts.append(f"<lora:{lora_name}:{strength}>")
return syntax_parts

View File

@@ -15,30 +15,64 @@ def get_lora_info(lora_name):
scanner = await ServiceRegistry.get_lora_scanner()
cache = await scanner.get_cached_data()
lora_name_normalized = lora_name.replace("\\", "/")
lora_name_no_ext = lora_name_normalized
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if lora_name_no_ext.lower().endswith(ext):
lora_name_no_ext = lora_name_no_ext[: -len(ext)]
break
has_path = "/" in lora_name_no_ext
basename = os.path.basename(lora_name_no_ext) if has_path else lora_name_no_ext
best_fallback = None
for item in cache.raw_data:
if item.get("file_name") == lora_name:
file_path = item.get("file_path")
if file_path:
# Check all lora roots including extra paths
all_roots = list(config.loras_roots or []) + list(
config.extra_loras_roots or []
file_name = item.get("file_name", "")
folder = item.get("folder", "")
file_name_no_ext = file_name
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if file_name_no_ext.lower().endswith(ext):
file_name_no_ext = file_name_no_ext[: -len(ext)]
break
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
if lora_name_no_ext not in (file_name_no_ext, path_name):
if has_path and file_name_no_ext == basename:
if folder and lora_name_no_ext.startswith(folder.replace("\\", "/") + "/"):
best_fallback = item
elif best_fallback is None:
best_fallback = item
continue
file_path = item.get("file_path")
if not file_path:
continue
all_roots = list(config.loras_roots or []) + list(
config.extra_loras_roots or []
)
for root in all_roots:
root = root.replace(os.sep, "/")
if file_path.startswith(root):
relative_path = os.path.relpath(file_path, root).replace(
os.sep, "/"
)
for root in all_roots:
root = root.replace(os.sep, "/")
if file_path.startswith(root):
relative_path = os.path.relpath(file_path, root).replace(
os.sep, "/"
)
# Get trigger words from civitai metadata
civitai = item.get("civitai", {})
trigger_words = (
civitai.get("trainedWords", []) if civitai else []
)
return relative_path, trigger_words
# If not found in any root, return path with trigger words from cache
civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
trigger_words = (
civitai.get("trainedWords", []) if civitai else []
)
return relative_path, trigger_words
civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
if best_fallback:
file_path = best_fallback.get("file_path")
if file_path:
civitai = best_fallback.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
return lora_name, []
try:
@@ -77,15 +111,54 @@ def get_lora_info_absolute(lora_name):
scanner = await ServiceRegistry.get_lora_scanner()
cache = await scanner.get_cached_data()
lora_name_normalized = lora_name.replace("\\", "/")
lora_name_no_ext = lora_name_normalized
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if lora_name_no_ext.lower().endswith(ext):
lora_name_no_ext = lora_name_no_ext[: -len(ext)]
break
has_path = "/" in lora_name_no_ext
basename = os.path.basename(lora_name_no_ext) if has_path else lora_name_no_ext
best_fallback = None
for item in cache.raw_data:
if item.get("file_name") == lora_name:
file_name = item.get("file_name", "")
folder = item.get("folder", "")
file_name_no_ext = file_name
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
if file_name_no_ext.lower().endswith(ext):
file_name_no_ext = file_name_no_ext[: -len(ext)]
break
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
if lora_name_no_ext == file_name_no_ext:
file_path = item.get("file_path")
if file_path:
# Return absolute path directly
# Get trigger words from civitai metadata
civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
if lora_name_no_ext == path_name:
file_path = item.get("file_path")
if file_path:
civitai = item.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
if has_path and file_name_no_ext == basename:
if folder and lora_name_no_ext.startswith(folder.replace("\\", "/") + "/"):
best_fallback = item
elif best_fallback is None:
best_fallback = item
if best_fallback:
file_path = best_fallback.get("file_path")
if file_path:
civitai = best_fallback.get("civitai", {})
trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words
return lora_name, []
try: