mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-10 11:32:13 -03:00
feat(modal): open model metadata sidecar location from the model modal
Centralized sidecar storage moves .metadata.json into a mirror tree, so the Location link in the model modal could no longer reveal a model's sidecar. The Location row now shows an inline metadata icon button (centralized mode only) that calls a new POST /api/lm/models/open-sidecar-location endpoint: the backend resolves the sidecar through py/utils/sidecar_paths, reveals the .metadata.json selected in the file manager (falling back to the sidecar directory when it has not been written yet), and returns the path for clipboard mode on headless/Docker sessions. The platform reveal logic is factored into a shared FileSystemHandler._reveal_file helper. All 9 locales translated.
This commit is contained in:
@@ -195,6 +195,14 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
> root** noun while translating (`library root` → `model root`, R5); the count-bearing
|
||||
> `successMulti` uses the singular `success` key when only one copy was removed.
|
||||
|
||||
> **Status (2026-10, model-modal sidecar location):** the model modal's Location row gained
|
||||
> an inline "open metadata location" icon button (rendered only in centralized sidecar
|
||||
> storage mode), adding 5 keys (`modals.model.actions.openSidecarLocation` and the 4
|
||||
> `modals.model.openSidecarLocation.*` toasts). The renderings mirror each locale's sibling
|
||||
> `openFileLocation` block verbatim with the §5 "metadata" noun swapped in, so no new
|
||||
> terminology was introduced. All 9 locales are translated, so the "no remaining
|
||||
> placeholders" claim holds again.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hard rules (do not violate)
|
||||
|
||||
@@ -1892,6 +1892,7 @@
|
||||
"viewOnHuggingFaceText": "Auf Hugging Face ansehen",
|
||||
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
||||
"openFileLocation": "Dateispeicherort öffnen",
|
||||
"openSidecarLocation": "Metadaten-Speicherort öffnen",
|
||||
"sendToWorkflow": "An ComfyUI senden",
|
||||
"sendToWorkflowText": "An ComfyUI senden",
|
||||
"copyHash": "Hash kopieren",
|
||||
@@ -1905,6 +1906,12 @@
|
||||
"copied": "Pfad in die Zwischenablage kopiert: {{path}}",
|
||||
"clipboardFallback": "Pfad: {{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "Metadaten-Speicherort erfolgreich geöffnet",
|
||||
"failed": "Fehler beim Öffnen des Metadaten-Speicherorts",
|
||||
"copied": "Pfad in die Zwischenablage kopiert: {{path}}",
|
||||
"clipboardFallback": "Pfad: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "Kann nicht an ComfyUI senden: Kein Dateipfad verfügbar"
|
||||
},
|
||||
|
||||
@@ -1892,6 +1892,7 @@
|
||||
"viewOnHuggingFaceText": "View on Hugging Face",
|
||||
"viewCreatorProfile": "View Creator Profile",
|
||||
"openFileLocation": "Open File Location",
|
||||
"openSidecarLocation": "Open metadata location",
|
||||
"sendToWorkflow": "Send to ComfyUI",
|
||||
"sendToWorkflowText": "Send to ComfyUI",
|
||||
"copyHash": "Copy hash",
|
||||
@@ -1905,6 +1906,12 @@
|
||||
"copied": "Path copied to clipboard: {{path}}",
|
||||
"clipboardFallback": "Path: {{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "Metadata location opened successfully",
|
||||
"failed": "Failed to open metadata location",
|
||||
"copied": "Path copied to clipboard: {{path}}",
|
||||
"clipboardFallback": "Path: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "Unable to send to ComfyUI: No file path available"
|
||||
},
|
||||
|
||||
@@ -1892,6 +1892,7 @@
|
||||
"viewOnHuggingFaceText": "Ver en Hugging Face",
|
||||
"viewCreatorProfile": "Ver perfil del creador",
|
||||
"openFileLocation": "Abrir ubicación del archivo",
|
||||
"openSidecarLocation": "Abrir ubicación de los metadatos",
|
||||
"sendToWorkflow": "Enviar a ComfyUI",
|
||||
"sendToWorkflowText": "Enviar a ComfyUI",
|
||||
"copyHash": "Copiar hash",
|
||||
@@ -1905,6 +1906,12 @@
|
||||
"copied": "Ruta copiada al portapapeles: {{path}}",
|
||||
"clipboardFallback": "Ruta: {{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "Ubicación de los metadatos abierta exitosamente",
|
||||
"failed": "Error al abrir la ubicación de los metadatos",
|
||||
"copied": "Ruta copiada al portapapeles: {{path}}",
|
||||
"clipboardFallback": "Ruta: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "No se puede enviar a ComfyUI: no hay ruta de archivo disponible"
|
||||
},
|
||||
|
||||
@@ -1892,6 +1892,7 @@
|
||||
"viewOnHuggingFaceText": "Voir sur Hugging Face",
|
||||
"viewCreatorProfile": "Voir le profil du créateur",
|
||||
"openFileLocation": "Ouvrir l'emplacement du fichier",
|
||||
"openSidecarLocation": "Ouvrir l'emplacement des métadonnées",
|
||||
"sendToWorkflow": "Envoyer vers ComfyUI",
|
||||
"sendToWorkflowText": "Envoyer vers ComfyUI",
|
||||
"copyHash": "Copier le hash",
|
||||
@@ -1905,6 +1906,12 @@
|
||||
"copied": "Chemin copié dans le presse-papiers: {{path}}",
|
||||
"clipboardFallback": "Chemin: {{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "Emplacement des métadonnées ouvert avec succès",
|
||||
"failed": "Échec de l'ouverture de l'emplacement des métadonnées",
|
||||
"copied": "Chemin copié dans le presse-papiers: {{path}}",
|
||||
"clipboardFallback": "Chemin: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "Impossible d'envoyer vers ComfyUI : aucun chemin de fichier disponible"
|
||||
},
|
||||
|
||||
@@ -1892,6 +1892,7 @@
|
||||
"viewOnHuggingFaceText": "צפייה ב-Hugging Face",
|
||||
"viewCreatorProfile": "הצג פרופיל יוצר",
|
||||
"openFileLocation": "פתח מיקום קובץ",
|
||||
"openSidecarLocation": "פתח מיקום מטא-נתונים",
|
||||
"sendToWorkflow": "שלח ל-ComfyUI",
|
||||
"sendToWorkflowText": "שלח ל-ComfyUI",
|
||||
"copyHash": "העתק hash",
|
||||
@@ -1905,6 +1906,12 @@
|
||||
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
|
||||
"clipboardFallback": "נתיב: {{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "מיקום המטא-נתונים נפתח בהצלחה",
|
||||
"failed": "פתיחת מיקום המטא-נתונים נכשלה",
|
||||
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
|
||||
"clipboardFallback": "נתיב: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "לא ניתן לשלוח ל-ComfyUI: אין נתיב קובץ זמין"
|
||||
},
|
||||
|
||||
@@ -1892,6 +1892,7 @@
|
||||
"viewOnHuggingFaceText": "Hugging Face で見る",
|
||||
"viewCreatorProfile": "作成者プロフィールを表示",
|
||||
"openFileLocation": "ファイルの場所を開く",
|
||||
"openSidecarLocation": "メタデータの場所を開く",
|
||||
"sendToWorkflow": "ComfyUI に送信",
|
||||
"sendToWorkflowText": "ComfyUI に送信",
|
||||
"copyHash": "ハッシュをコピー",
|
||||
@@ -1905,6 +1906,12 @@
|
||||
"copied": "パスをクリップボードにコピーしました: {{path}}",
|
||||
"clipboardFallback": "パス: {{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "メタデータの場所を正常に開きました",
|
||||
"failed": "メタデータの場所を開くのに失敗しました",
|
||||
"copied": "パスをクリップボードにコピーしました: {{path}}",
|
||||
"clipboardFallback": "パス: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "ComfyUI に送信できません:ファイルパスがありません"
|
||||
},
|
||||
|
||||
@@ -1892,6 +1892,7 @@
|
||||
"viewOnHuggingFaceText": "Hugging Face에서 보기",
|
||||
"viewCreatorProfile": "제작자 프로필 보기",
|
||||
"openFileLocation": "파일 위치 열기",
|
||||
"openSidecarLocation": "메타데이터 위치 열기",
|
||||
"sendToWorkflow": "ComfyUI로 보내기",
|
||||
"sendToWorkflowText": "ComfyUI로 보내기",
|
||||
"copyHash": "해시 복사",
|
||||
@@ -1905,6 +1906,12 @@
|
||||
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
|
||||
"clipboardFallback": "경로: {{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "메타데이터 위치가 성공적으로 열렸습니다",
|
||||
"failed": "메타데이터 위치 열기에 실패했습니다",
|
||||
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
|
||||
"clipboardFallback": "경로: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "ComfyUI로 보낼 수 없습니다: 파일 경로가 없습니다"
|
||||
},
|
||||
|
||||
@@ -1892,6 +1892,7 @@
|
||||
"viewOnHuggingFaceText": "Открыть Hugging Face",
|
||||
"viewCreatorProfile": "Посмотреть профиль создателя",
|
||||
"openFileLocation": "Открыть расположение файла",
|
||||
"openSidecarLocation": "Открыть расположение метаданных",
|
||||
"sendToWorkflow": "Отправить в ComfyUI",
|
||||
"sendToWorkflowText": "Отправить в ComfyUI",
|
||||
"copyHash": "Копировать хеш",
|
||||
@@ -1905,6 +1906,12 @@
|
||||
"copied": "Путь скопирован в буфер обмена: {{path}}",
|
||||
"clipboardFallback": "Путь: {{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "Расположение метаданных успешно открыто",
|
||||
"failed": "Не удалось открыть расположение метаданных",
|
||||
"copied": "Путь скопирован в буфер обмена: {{path}}",
|
||||
"clipboardFallback": "Путь: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "Невозможно отправить в ComfyUI: путь к файлу недоступен"
|
||||
},
|
||||
|
||||
@@ -1892,6 +1892,7 @@
|
||||
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
||||
"viewCreatorProfile": "查看创作者主页",
|
||||
"openFileLocation": "打开文件位置",
|
||||
"openSidecarLocation": "打开元数据位置",
|
||||
"sendToWorkflow": "发送到 ComfyUI",
|
||||
"sendToWorkflowText": "发送到 ComfyUI",
|
||||
"copyHash": "复制哈希值",
|
||||
@@ -1905,6 +1906,12 @@
|
||||
"copied": "路径已复制到剪贴板:{{path}}",
|
||||
"clipboardFallback": "路径:{{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "元数据位置已成功打开",
|
||||
"failed": "打开元数据位置失败",
|
||||
"copied": "路径已复制到剪贴板:{{path}}",
|
||||
"clipboardFallback": "路径:{{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "无法发送到 ComfyUI:没有可用的文件路径"
|
||||
},
|
||||
|
||||
@@ -1892,6 +1892,7 @@
|
||||
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
||||
"viewCreatorProfile": "查看創作者個人檔案",
|
||||
"openFileLocation": "開啟檔案位置",
|
||||
"openSidecarLocation": "開啟中繼資料位置",
|
||||
"sendToWorkflow": "傳送到 ComfyUI",
|
||||
"sendToWorkflowText": "傳送到 ComfyUI",
|
||||
"copyHash": "複製雜湊值",
|
||||
@@ -1905,6 +1906,12 @@
|
||||
"copied": "路徑已複製到剪貼簿:{{path}}",
|
||||
"clipboardFallback": "路徑:{{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "中繼資料位置已成功開啟",
|
||||
"failed": "開啟中繼資料位置失敗",
|
||||
"copied": "路徑已複製到剪貼簿:{{path}}",
|
||||
"clipboardFallback": "路徑:{{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "無法傳送到 ComfyUI:沒有可用的檔案路徑"
|
||||
},
|
||||
|
||||
@@ -77,6 +77,7 @@ from ...utils.sidecar_paths import (
|
||||
get_configured_sidecar_root,
|
||||
get_metadata_path,
|
||||
get_preview_dir,
|
||||
get_sidecar_dir,
|
||||
get_storage_mode,
|
||||
get_unmatched_sidecar_components,
|
||||
)
|
||||
@@ -3497,6 +3498,63 @@ class FileSystemHandler:
|
||||
{"success": True, "message": f"Opened folder: {path}", "path": path}
|
||||
)
|
||||
|
||||
async def _reveal_file(self, file_path: str) -> web.Response:
|
||||
"""Open the platform file manager with ``file_path`` selected."""
|
||||
if os.name == "nt":
|
||||
subprocess.Popen(["explorer", "/select,", file_path])
|
||||
elif os.name == "posix":
|
||||
if _is_docker():
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Running in Docker: Path available for copying",
|
||||
"path": file_path,
|
||||
"mode": "clipboard",
|
||||
}
|
||||
)
|
||||
elif _is_wsl():
|
||||
windows_path = _wsl_to_windows_path(file_path)
|
||||
if windows_path:
|
||||
subprocess.Popen(["explorer.exe", "/select,", windows_path])
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to convert WSL path to Windows path: %s", file_path
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Failed to open file location: path conversion error",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", "-R", file_path])
|
||||
else:
|
||||
if not _has_gui_display():
|
||||
# Headless/SSH session: no file manager to open, so
|
||||
# hand the path to the browser for copying instead
|
||||
# of reporting a success that never happened.
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Headless session: path available for copying",
|
||||
"path": file_path,
|
||||
"mode": "clipboard",
|
||||
}
|
||||
)
|
||||
selected = _select_in_linux_file_manager(file_path)
|
||||
if not selected:
|
||||
folder = os.path.dirname(file_path)
|
||||
subprocess.Popen(["xdg-open", folder])
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Opened folder and selected file: {file_path}",
|
||||
"path": file_path,
|
||||
}
|
||||
)
|
||||
|
||||
async def open_file_location(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -3512,63 +3570,49 @@ class FileSystemHandler:
|
||||
{"success": False, "error": "File does not exist"}, status=404
|
||||
)
|
||||
|
||||
if os.name == "nt":
|
||||
subprocess.Popen(["explorer", "/select,", file_path])
|
||||
elif os.name == "posix":
|
||||
if _is_docker():
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Running in Docker: Path available for copying",
|
||||
"path": file_path,
|
||||
"mode": "clipboard",
|
||||
}
|
||||
)
|
||||
elif _is_wsl():
|
||||
windows_path = _wsl_to_windows_path(file_path)
|
||||
if windows_path:
|
||||
subprocess.Popen(["explorer.exe", "/select,", windows_path])
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to convert WSL path to Windows path: %s", file_path
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Failed to open file location: path conversion error",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", "-R", file_path])
|
||||
else:
|
||||
if not _has_gui_display():
|
||||
# Headless/SSH session: no file manager to open, so
|
||||
# hand the path to the browser for copying instead
|
||||
# of reporting a success that never happened.
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Headless session: path available for copying",
|
||||
"path": file_path,
|
||||
"mode": "clipboard",
|
||||
}
|
||||
)
|
||||
selected = _select_in_linux_file_manager(file_path)
|
||||
if not selected:
|
||||
folder = os.path.dirname(file_path)
|
||||
subprocess.Popen(["xdg-open", folder])
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Opened folder and selected file: {file_path}",
|
||||
}
|
||||
)
|
||||
return await self._reveal_file(file_path)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to open file location: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def open_model_sidecar_location(self, request: web.Request) -> web.Response:
|
||||
"""Reveal the model's ``.metadata.json`` sidecar in the file manager.
|
||||
|
||||
Sidecar/preview path derivation must go through
|
||||
:mod:`py.utils.sidecar_paths` so centralized storage mode resolves to
|
||||
the mirror tree. When the sidecar has not been written yet, the
|
||||
containing sidecar directory is opened instead.
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
file_path = data.get("file_path")
|
||||
if not file_path:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Missing file_path parameter"},
|
||||
status=400,
|
||||
)
|
||||
file_path = os.path.abspath(file_path)
|
||||
if not os.path.isfile(file_path):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "File does not exist"}, status=404
|
||||
)
|
||||
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
if os.path.isfile(metadata_path):
|
||||
return await self._reveal_file(metadata_path)
|
||||
|
||||
sidecar_dir = get_sidecar_dir(file_path)
|
||||
if os.path.isdir(sidecar_dir):
|
||||
return await self._open_path(sidecar_dir)
|
||||
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Sidecar does not exist yet"},
|
||||
status=404,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to open sidecar location: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def open_settings_location(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
@@ -4454,6 +4498,7 @@ class MiscHandlerSet:
|
||||
"open_backup_location": self.filesystem.open_backup_location,
|
||||
"open_wildcards_location": self.filesystem.open_wildcards_location,
|
||||
"open_sidecar_location": self.filesystem.open_sidecar_location,
|
||||
"open_model_sidecar_location": self.filesystem.open_model_sidecar_location,
|
||||
"browse_directory": self.filesystem.browse_directory,
|
||||
"validate_path": self.filesystem.validate_path,
|
||||
"search_custom_words": self.custom_words.search_custom_words,
|
||||
|
||||
@@ -125,6 +125,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/sidecars/open-location", "open_sidecar_location"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/models/open-sidecar-location", "open_model_sidecar_location"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download-model-source", "download_model_source"
|
||||
),
|
||||
|
||||
@@ -349,6 +349,34 @@
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.file-path-wrapper {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.file-path-wrapper .file-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.location-action-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--lora-accent);
|
||||
cursor: pointer;
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.location-action-btn:hover {
|
||||
opacity: 1;
|
||||
background: rgba(66, 153, 225, 0.1);
|
||||
}
|
||||
|
||||
.file-path[data-action="open-file-location"] {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -365,6 +365,16 @@ export async function showModelModal(model, modelType) {
|
||||
};
|
||||
const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || '');
|
||||
const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A');
|
||||
// Centralized sidecar storage moves .metadata.json into a mirror tree, so
|
||||
// the model path link can no longer reveal it — offer a dedicated entry
|
||||
// only in that mode (alongside mode keeps sidecars next to the model).
|
||||
const sidecarLocationButton = state.global.settings.sidecar_storage_mode === 'centralized'
|
||||
? `<button class="location-action-btn" title="${translate('modals.model.actions.openSidecarLocation', {}, 'Open metadata location')}"
|
||||
data-action="open-sidecar-location"
|
||||
data-filepath="${escapedFilePathAttr}">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
</button>`
|
||||
: '';
|
||||
// De-emphasized hash display: a borderless full-width footnote line below
|
||||
// the info grid — sha256 middle-truncated (first 10 + last 6), autov3 in
|
||||
// full (12 chars); the full value is copied via data-hash. Civitai model /
|
||||
@@ -697,11 +707,14 @@ export async function showModelModal(model, modelType) {
|
||||
<div class="info-item">
|
||||
<div class="location-wrapper">
|
||||
<label>${translate('modals.model.metadata.location', {}, 'Location')}</label>
|
||||
<span class="file-path" title="${translate('modals.model.actions.openFileLocation', {}, 'Open file location')}"
|
||||
data-action="open-file-location"
|
||||
data-filepath="${escapedFilePathAttr}">
|
||||
${escapedFolderPath}
|
||||
</span>
|
||||
<div class="file-path-wrapper">
|
||||
<span class="file-path" title="${translate('modals.model.actions.openFileLocation', {}, 'Open file location')}"
|
||||
data-action="open-file-location"
|
||||
data-filepath="${escapedFilePathAttr}">
|
||||
${escapedFolderPath}
|
||||
</span>
|
||||
${sidecarLocationButton}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item base-size">
|
||||
@@ -1014,6 +1027,12 @@ function setupEventHandlers(filePath, modelType) {
|
||||
openFileLocation(filePath);
|
||||
}
|
||||
break;
|
||||
case 'open-sidecar-location':
|
||||
const sidecarModelPath = target.dataset.filepath || getModalFilePath();
|
||||
if (sidecarModelPath) {
|
||||
openSidecarLocation(sidecarModelPath);
|
||||
}
|
||||
break;
|
||||
case 'nav-prev':
|
||||
handleDirectionalNavigation('prev', modelType);
|
||||
break;
|
||||
@@ -1412,6 +1431,37 @@ async function openFileLocation(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call backend to open the model's .metadata.json sidecar location
|
||||
* @param {string} filePath model file path (backend derives the sidecar path)
|
||||
*/
|
||||
async function openSidecarLocation(filePath) {
|
||||
try {
|
||||
const resp = await fetch('/api/lm/models/open-sidecar-location', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ 'file_path': filePath })
|
||||
});
|
||||
if (!resp.ok) throw new Error('Failed to open sidecar location');
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.mode === 'clipboard' && data.path) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.path);
|
||||
showToast('modals.model.openSidecarLocation.copied', { path: data.path }, 'success');
|
||||
} catch (clipboardErr) {
|
||||
console.warn('Clipboard API not available:', clipboardErr);
|
||||
showToast('modals.model.openSidecarLocation.clipboardFallback', { path: data.path }, 'info');
|
||||
}
|
||||
} else {
|
||||
showToast('modals.model.openSidecarLocation.success', {}, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('modals.model.openSidecarLocation.failed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendToWorkflow(target, modelType) {
|
||||
const filePath = getModalFilePath();
|
||||
if (!filePath) {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
MODAL_MODULE,
|
||||
API_FACTORY,
|
||||
UI_HELPERS_MODULE,
|
||||
MODAL_MANAGER_MODULE,
|
||||
SHOWCASE_MODULE,
|
||||
MODEL_TAGS_MODULE,
|
||||
UTILS_MODULE,
|
||||
TRIGGER_WORDS_MODULE,
|
||||
PRESET_TAGS_MODULE,
|
||||
MODEL_VERSIONS_MODULE,
|
||||
RECIPE_TAB_MODULE,
|
||||
I18N_HELPERS_MODULE,
|
||||
STATE_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
MODAL_MODULE: new URL('../../../static/js/components/shared/ModelModal.js', import.meta.url).pathname,
|
||||
API_FACTORY: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
|
||||
MODEL_TAGS_MODULE: new URL('../../../static/js/components/shared/ModelTags.js', import.meta.url).pathname,
|
||||
UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname,
|
||||
TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname,
|
||||
PRESET_TAGS_MODULE: new URL('../../../static/js/components/shared/PresetTags.js', import.meta.url).pathname,
|
||||
MODEL_VERSIONS_MODULE: new URL('../../../static/js/components/shared/ModelVersionsTab.js', import.meta.url).pathname,
|
||||
RECIPE_TAB_MODULE: new URL('../../../static/js/components/shared/RecipeTab.js', import.meta.url).pathname,
|
||||
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: vi.fn(),
|
||||
openCivitai: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: {
|
||||
showModal: vi.fn((id, html) => {
|
||||
document.body.innerHTML = `<div id="${id}">${html}</div>`;
|
||||
}),
|
||||
closeModal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(SHOWCASE_MODULE, () => ({
|
||||
scrollToTop: vi.fn(),
|
||||
loadExampleImages: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(MODEL_TAGS_MODULE, () => ({
|
||||
setupTagEditMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(UTILS_MODULE, async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
renderCompactTags: vi.fn(() => ''),
|
||||
setupTagTooltip: vi.fn(),
|
||||
formatFileSize: vi.fn(() => '1 MB'),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(TRIGGER_WORDS_MODULE, () => ({
|
||||
renderTriggerWords: vi.fn(() => ''),
|
||||
setupTriggerWordsEditMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(PRESET_TAGS_MODULE, () => ({
|
||||
parsePresets: vi.fn(() => ({})),
|
||||
renderPresetTags: vi.fn(() => ''),
|
||||
}));
|
||||
|
||||
vi.mock(MODEL_VERSIONS_MODULE, () => ({
|
||||
initVersionsTab: vi.fn(() => ({
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock(RECIPE_TAB_MODULE, () => ({
|
||||
loadRecipesForModel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||
translate: vi.fn((_, __, fallback) => fallback || ''),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
MODEL_TYPES: {
|
||||
LORA: 'loras',
|
||||
CHECKPOINT: 'checkpoints',
|
||||
EMBEDDING: 'embeddings'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock(API_FACTORY, () => ({
|
||||
getModelApiClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const MODEL_PATH = '/data/loras/flux/model.safetensors';
|
||||
|
||||
function makeModel(overrides = {}) {
|
||||
return {
|
||||
model_name: 'Sidecar Model',
|
||||
file_path: MODEL_PATH,
|
||||
file_name: 'model.safetensors',
|
||||
civitai: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Model modal sidecar location entry', () => {
|
||||
let getModelApiClient;
|
||||
let showToast;
|
||||
let state;
|
||||
let originalMode;
|
||||
|
||||
beforeEach(async () => {
|
||||
document.body.innerHTML = '';
|
||||
({ getModelApiClient } = await import(API_FACTORY));
|
||||
({ showToast } = await import(UI_HELPERS_MODULE));
|
||||
({ state } = await import(STATE_MODULE));
|
||||
getModelApiClient.mockReset();
|
||||
showToast.mockReset();
|
||||
getModelApiClient.mockReturnValue({
|
||||
fetchModelMetadata: vi.fn().mockResolvedValue(null),
|
||||
saveModelMetadata: vi.fn(),
|
||||
});
|
||||
originalMode = state.global.settings.sidecar_storage_mode;
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
state.global.settings.sidecar_storage_mode = originalMode;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function renderModal(model) {
|
||||
const { showModelModal } = await import(MODAL_MODULE);
|
||||
await showModelModal(model, 'loras');
|
||||
}
|
||||
|
||||
it('renders the metadata location button only in centralized sidecar mode', async () => {
|
||||
state.global.settings.sidecar_storage_mode = 'centralized';
|
||||
await renderModal(makeModel());
|
||||
|
||||
const button = document.querySelector('[data-action="open-sidecar-location"]');
|
||||
expect(button).not.toBeNull();
|
||||
expect(button.dataset.filepath).toBe(MODEL_PATH);
|
||||
});
|
||||
|
||||
it.each(['alongside', '', undefined])('hides the button when sidecar mode is %s', async (mode) => {
|
||||
state.global.settings.sidecar_storage_mode = mode;
|
||||
await renderModal(makeModel());
|
||||
|
||||
expect(document.querySelector('[data-action="open-sidecar-location"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('posts the model file path to the sidecar endpoint on click', async () => {
|
||||
state.global.settings.sidecar_storage_mode = 'centralized';
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
await renderModal(makeModel());
|
||||
|
||||
document.querySelector('[data-action="open-sidecar-location"]').click();
|
||||
await vi.waitFor(() => expect(showToast).toHaveBeenCalled());
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/lm/models/open-sidecar-location',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ file_path: MODEL_PATH }),
|
||||
})
|
||||
);
|
||||
expect(showToast).toHaveBeenCalledWith('modals.model.openSidecarLocation.success', {}, 'success');
|
||||
});
|
||||
|
||||
it('copies the resolved sidecar path in clipboard mode', async () => {
|
||||
state.global.settings.sidecar_storage_mode = 'centralized';
|
||||
const sidecarPath = '/sidecars/loras-abcd1234/flux/model.metadata.json';
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, mode: 'clipboard', path: sidecarPath }),
|
||||
});
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||||
await renderModal(makeModel());
|
||||
|
||||
document.querySelector('[data-action="open-sidecar-location"]').click();
|
||||
await vi.waitFor(() => expect(showToast).toHaveBeenCalled());
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(sidecarPath);
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
'modals.model.openSidecarLocation.copied',
|
||||
{ path: sidecarPath },
|
||||
'success'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows an error toast when the endpoint fails', async () => {
|
||||
state.global.settings.sidecar_storage_mode = 'centralized';
|
||||
global.fetch.mockResolvedValue({ ok: false, status: 404 });
|
||||
await renderModal(makeModel());
|
||||
|
||||
document.querySelector('[data-action="open-sidecar-location"]').click();
|
||||
await vi.waitFor(() => expect(showToast).toHaveBeenCalled());
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith('modals.model.openSidecarLocation.failed', {}, 'error');
|
||||
});
|
||||
});
|
||||
@@ -911,6 +911,151 @@ async def test_open_file_location_headless_returns_clipboard_mode(tmp_path, monk
|
||||
assert run_calls == []
|
||||
|
||||
|
||||
def _patch_linux_desktop(monkeypatch):
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False)
|
||||
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_model_sidecar_location_reveals_metadata_file(tmp_path, monkeypatch):
|
||||
"""Centralized mode: the resolved .metadata.json is revealed selected,
|
||||
even though it lives in the mirror tree rather than next to the model."""
|
||||
model_file = tmp_path / "model.safetensors"
|
||||
model_file.write_text("x", encoding="utf-8")
|
||||
metadata_file = tmp_path / "mirror" / "model.metadata.json"
|
||||
metadata_file.parent.mkdir(parents=True)
|
||||
metadata_file.write_text("{}", encoding="utf-8")
|
||||
|
||||
handler = FileSystemHandler(settings_service=SimpleNamespace())
|
||||
_patch_linux_desktop(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"py.routes.handlers.misc_handlers.get_metadata_path",
|
||||
lambda path: str(metadata_file),
|
||||
)
|
||||
|
||||
run_calls = []
|
||||
popen_calls = []
|
||||
monkeypatch.setattr(
|
||||
subprocess, "run", lambda *args, **kwargs: run_calls.append(args[0]) or SimpleNamespace(returncode=0)
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "Popen", lambda *args, **kwargs: popen_calls.append(args))
|
||||
|
||||
request = FakeRequest(json_data={"file_path": str(model_file)})
|
||||
response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType]
|
||||
payload = _json_payload(response)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["path"] == str(metadata_file)
|
||||
assert len(run_calls) == 1
|
||||
assert any("model.metadata.json" in arg for arg in run_calls[0])
|
||||
assert popen_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_model_sidecar_location_falls_back_to_sidecar_dir(tmp_path, monkeypatch):
|
||||
"""When the sidecar has not been written yet, open the sidecar directory
|
||||
itself instead of reporting an error."""
|
||||
model_file = tmp_path / "model.safetensors"
|
||||
model_file.write_text("x", encoding="utf-8")
|
||||
sidecar_dir = tmp_path / "mirror"
|
||||
sidecar_dir.mkdir()
|
||||
|
||||
handler = FileSystemHandler(settings_service=SimpleNamespace())
|
||||
_patch_linux_desktop(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"py.routes.handlers.misc_handlers.get_metadata_path",
|
||||
lambda path: str(sidecar_dir / "model.metadata.json"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"py.routes.handlers.misc_handlers.get_sidecar_dir",
|
||||
lambda path: str(sidecar_dir),
|
||||
)
|
||||
|
||||
popen_calls = []
|
||||
monkeypatch.setattr(
|
||||
subprocess, "Popen", lambda *args, **kwargs: popen_calls.append(args[0])
|
||||
)
|
||||
|
||||
request = FakeRequest(json_data={"file_path": str(model_file)})
|
||||
response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType]
|
||||
payload = _json_payload(response)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["path"] == str(sidecar_dir)
|
||||
assert popen_calls == [["xdg-open", str(sidecar_dir)]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_model_sidecar_location_missing_sidecar_returns_404(tmp_path, monkeypatch):
|
||||
model_file = tmp_path / "model.safetensors"
|
||||
model_file.write_text("x", encoding="utf-8")
|
||||
missing_dir = tmp_path / "missing"
|
||||
|
||||
handler = FileSystemHandler(settings_service=SimpleNamespace())
|
||||
monkeypatch.setattr(
|
||||
"py.routes.handlers.misc_handlers.get_metadata_path",
|
||||
lambda path: str(missing_dir / "model.metadata.json"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"py.routes.handlers.misc_handlers.get_sidecar_dir",
|
||||
lambda path: str(missing_dir),
|
||||
)
|
||||
|
||||
request = FakeRequest(json_data={"file_path": str(model_file)})
|
||||
response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType]
|
||||
payload = _json_payload(response)
|
||||
|
||||
assert response.status == 404
|
||||
assert payload["success"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_model_sidecar_location_headless_returns_clipboard_mode(tmp_path, monkeypatch):
|
||||
model_file = tmp_path / "model.safetensors"
|
||||
model_file.write_text("x", encoding="utf-8")
|
||||
metadata_file = tmp_path / "mirror" / "model.metadata.json"
|
||||
metadata_file.parent.mkdir(parents=True)
|
||||
metadata_file.write_text("{}", encoding="utf-8")
|
||||
|
||||
handler = FileSystemHandler(settings_service=SimpleNamespace())
|
||||
monkeypatch.delenv("DISPLAY", raising=False)
|
||||
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
|
||||
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False)
|
||||
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
"py.routes.handlers.misc_handlers.get_metadata_path",
|
||||
lambda path: str(metadata_file),
|
||||
)
|
||||
|
||||
popen_calls = []
|
||||
monkeypatch.setattr(subprocess, "Popen", lambda *args, **kwargs: popen_calls.append(args))
|
||||
|
||||
request = FakeRequest(json_data={"file_path": str(model_file)})
|
||||
response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType]
|
||||
payload = _json_payload(response)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["mode"] == "clipboard"
|
||||
assert payload["path"] == str(metadata_file)
|
||||
assert popen_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_model_sidecar_location_validates_input(tmp_path):
|
||||
handler = FileSystemHandler(settings_service=SimpleNamespace())
|
||||
|
||||
response = await handler.open_model_sidecar_location(FakeRequest()) # pyright: ignore[reportArgumentType]
|
||||
assert response.status == 400
|
||||
|
||||
request = FakeRequest(json_data={"file_path": str(tmp_path / "missing.safetensors")})
|
||||
response = await handler.open_model_sidecar_location(request) # pyright: ignore[reportArgumentType]
|
||||
assert response.status == 404
|
||||
|
||||
|
||||
class RecordingRouter:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
Reference in New Issue
Block a user