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:
Will Miao
2026-10-10 07:41:48 +08:00
parent 7937aeaa04
commit af90fe873d
17 changed files with 622 additions and 58 deletions
+8
View File
@@ -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 > root** noun while translating (`library root` → `model root`, R5); the count-bearing
> `successMulti` uses the singular `success` key when only one copy was removed. > `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) ## 1. Hard rules (do not violate)
+7
View File
@@ -1892,6 +1892,7 @@
"viewOnHuggingFaceText": "Auf Hugging Face ansehen", "viewOnHuggingFaceText": "Auf Hugging Face ansehen",
"viewCreatorProfile": "Ersteller-Profil anzeigen", "viewCreatorProfile": "Ersteller-Profil anzeigen",
"openFileLocation": "Dateispeicherort öffnen", "openFileLocation": "Dateispeicherort öffnen",
"openSidecarLocation": "Metadaten-Speicherort öffnen",
"sendToWorkflow": "An ComfyUI senden", "sendToWorkflow": "An ComfyUI senden",
"sendToWorkflowText": "An ComfyUI senden", "sendToWorkflowText": "An ComfyUI senden",
"copyHash": "Hash kopieren", "copyHash": "Hash kopieren",
@@ -1905,6 +1906,12 @@
"copied": "Pfad in die Zwischenablage kopiert: {{path}}", "copied": "Pfad in die Zwischenablage kopiert: {{path}}",
"clipboardFallback": "Pfad: {{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": { "sendToWorkflow": {
"noFilePath": "Kann nicht an ComfyUI senden: Kein Dateipfad verfügbar" "noFilePath": "Kann nicht an ComfyUI senden: Kein Dateipfad verfügbar"
}, },
+7
View File
@@ -1892,6 +1892,7 @@
"viewOnHuggingFaceText": "View on Hugging Face", "viewOnHuggingFaceText": "View on Hugging Face",
"viewCreatorProfile": "View Creator Profile", "viewCreatorProfile": "View Creator Profile",
"openFileLocation": "Open File Location", "openFileLocation": "Open File Location",
"openSidecarLocation": "Open metadata location",
"sendToWorkflow": "Send to ComfyUI", "sendToWorkflow": "Send to ComfyUI",
"sendToWorkflowText": "Send to ComfyUI", "sendToWorkflowText": "Send to ComfyUI",
"copyHash": "Copy hash", "copyHash": "Copy hash",
@@ -1905,6 +1906,12 @@
"copied": "Path copied to clipboard: {{path}}", "copied": "Path copied to clipboard: {{path}}",
"clipboardFallback": "Path: {{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": { "sendToWorkflow": {
"noFilePath": "Unable to send to ComfyUI: No file path available" "noFilePath": "Unable to send to ComfyUI: No file path available"
}, },
+7
View File
@@ -1892,6 +1892,7 @@
"viewOnHuggingFaceText": "Ver en Hugging Face", "viewOnHuggingFaceText": "Ver en Hugging Face",
"viewCreatorProfile": "Ver perfil del creador", "viewCreatorProfile": "Ver perfil del creador",
"openFileLocation": "Abrir ubicación del archivo", "openFileLocation": "Abrir ubicación del archivo",
"openSidecarLocation": "Abrir ubicación de los metadatos",
"sendToWorkflow": "Enviar a ComfyUI", "sendToWorkflow": "Enviar a ComfyUI",
"sendToWorkflowText": "Enviar a ComfyUI", "sendToWorkflowText": "Enviar a ComfyUI",
"copyHash": "Copiar hash", "copyHash": "Copiar hash",
@@ -1905,6 +1906,12 @@
"copied": "Ruta copiada al portapapeles: {{path}}", "copied": "Ruta copiada al portapapeles: {{path}}",
"clipboardFallback": "Ruta: {{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": { "sendToWorkflow": {
"noFilePath": "No se puede enviar a ComfyUI: no hay ruta de archivo disponible" "noFilePath": "No se puede enviar a ComfyUI: no hay ruta de archivo disponible"
}, },
+7
View File
@@ -1892,6 +1892,7 @@
"viewOnHuggingFaceText": "Voir sur Hugging Face", "viewOnHuggingFaceText": "Voir sur Hugging Face",
"viewCreatorProfile": "Voir le profil du créateur", "viewCreatorProfile": "Voir le profil du créateur",
"openFileLocation": "Ouvrir l'emplacement du fichier", "openFileLocation": "Ouvrir l'emplacement du fichier",
"openSidecarLocation": "Ouvrir l'emplacement des métadonnées",
"sendToWorkflow": "Envoyer vers ComfyUI", "sendToWorkflow": "Envoyer vers ComfyUI",
"sendToWorkflowText": "Envoyer vers ComfyUI", "sendToWorkflowText": "Envoyer vers ComfyUI",
"copyHash": "Copier le hash", "copyHash": "Copier le hash",
@@ -1905,6 +1906,12 @@
"copied": "Chemin copié dans le presse-papiers: {{path}}", "copied": "Chemin copié dans le presse-papiers: {{path}}",
"clipboardFallback": "Chemin: {{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": { "sendToWorkflow": {
"noFilePath": "Impossible d'envoyer vers ComfyUI : aucun chemin de fichier disponible" "noFilePath": "Impossible d'envoyer vers ComfyUI : aucun chemin de fichier disponible"
}, },
+7
View File
@@ -1892,6 +1892,7 @@
"viewOnHuggingFaceText": "צפייה ב-Hugging Face", "viewOnHuggingFaceText": "צפייה ב-Hugging Face",
"viewCreatorProfile": "הצג פרופיל יוצר", "viewCreatorProfile": "הצג פרופיל יוצר",
"openFileLocation": "פתח מיקום קובץ", "openFileLocation": "פתח מיקום קובץ",
"openSidecarLocation": "פתח מיקום מטא-נתונים",
"sendToWorkflow": "שלח ל-ComfyUI", "sendToWorkflow": "שלח ל-ComfyUI",
"sendToWorkflowText": "שלח ל-ComfyUI", "sendToWorkflowText": "שלח ל-ComfyUI",
"copyHash": "העתק hash", "copyHash": "העתק hash",
@@ -1905,6 +1906,12 @@
"copied": "הנתיב הועתק ללוח העריכה: {{path}}", "copied": "הנתיב הועתק ללוח העריכה: {{path}}",
"clipboardFallback": "נתיב: {{path}}" "clipboardFallback": "נתיב: {{path}}"
}, },
"openSidecarLocation": {
"success": "מיקום המטא-נתונים נפתח בהצלחה",
"failed": "פתיחת מיקום המטא-נתונים נכשלה",
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
"clipboardFallback": "נתיב: {{path}}"
},
"sendToWorkflow": { "sendToWorkflow": {
"noFilePath": "לא ניתן לשלוח ל-ComfyUI: אין נתיב קובץ זמין" "noFilePath": "לא ניתן לשלוח ל-ComfyUI: אין נתיב קובץ זמין"
}, },
+7
View File
@@ -1892,6 +1892,7 @@
"viewOnHuggingFaceText": "Hugging Face で見る", "viewOnHuggingFaceText": "Hugging Face で見る",
"viewCreatorProfile": "作成者プロフィールを表示", "viewCreatorProfile": "作成者プロフィールを表示",
"openFileLocation": "ファイルの場所を開く", "openFileLocation": "ファイルの場所を開く",
"openSidecarLocation": "メタデータの場所を開く",
"sendToWorkflow": "ComfyUI に送信", "sendToWorkflow": "ComfyUI に送信",
"sendToWorkflowText": "ComfyUI に送信", "sendToWorkflowText": "ComfyUI に送信",
"copyHash": "ハッシュをコピー", "copyHash": "ハッシュをコピー",
@@ -1905,6 +1906,12 @@
"copied": "パスをクリップボードにコピーしました: {{path}}", "copied": "パスをクリップボードにコピーしました: {{path}}",
"clipboardFallback": "パス: {{path}}" "clipboardFallback": "パス: {{path}}"
}, },
"openSidecarLocation": {
"success": "メタデータの場所を正常に開きました",
"failed": "メタデータの場所を開くのに失敗しました",
"copied": "パスをクリップボードにコピーしました: {{path}}",
"clipboardFallback": "パス: {{path}}"
},
"sendToWorkflow": { "sendToWorkflow": {
"noFilePath": "ComfyUI に送信できません:ファイルパスがありません" "noFilePath": "ComfyUI に送信できません:ファイルパスがありません"
}, },
+7
View File
@@ -1892,6 +1892,7 @@
"viewOnHuggingFaceText": "Hugging Face에서 보기", "viewOnHuggingFaceText": "Hugging Face에서 보기",
"viewCreatorProfile": "제작자 프로필 보기", "viewCreatorProfile": "제작자 프로필 보기",
"openFileLocation": "파일 위치 열기", "openFileLocation": "파일 위치 열기",
"openSidecarLocation": "메타데이터 위치 열기",
"sendToWorkflow": "ComfyUI로 보내기", "sendToWorkflow": "ComfyUI로 보내기",
"sendToWorkflowText": "ComfyUI로 보내기", "sendToWorkflowText": "ComfyUI로 보내기",
"copyHash": "해시 복사", "copyHash": "해시 복사",
@@ -1905,6 +1906,12 @@
"copied": "경로가 클립보드에 복사되었습니다: {{path}}", "copied": "경로가 클립보드에 복사되었습니다: {{path}}",
"clipboardFallback": "경로: {{path}}" "clipboardFallback": "경로: {{path}}"
}, },
"openSidecarLocation": {
"success": "메타데이터 위치가 성공적으로 열렸습니다",
"failed": "메타데이터 위치 열기에 실패했습니다",
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
"clipboardFallback": "경로: {{path}}"
},
"sendToWorkflow": { "sendToWorkflow": {
"noFilePath": "ComfyUI로 보낼 수 없습니다: 파일 경로가 없습니다" "noFilePath": "ComfyUI로 보낼 수 없습니다: 파일 경로가 없습니다"
}, },
+7
View File
@@ -1892,6 +1892,7 @@
"viewOnHuggingFaceText": "Открыть Hugging Face", "viewOnHuggingFaceText": "Открыть Hugging Face",
"viewCreatorProfile": "Посмотреть профиль создателя", "viewCreatorProfile": "Посмотреть профиль создателя",
"openFileLocation": "Открыть расположение файла", "openFileLocation": "Открыть расположение файла",
"openSidecarLocation": "Открыть расположение метаданных",
"sendToWorkflow": "Отправить в ComfyUI", "sendToWorkflow": "Отправить в ComfyUI",
"sendToWorkflowText": "Отправить в ComfyUI", "sendToWorkflowText": "Отправить в ComfyUI",
"copyHash": "Копировать хеш", "copyHash": "Копировать хеш",
@@ -1905,6 +1906,12 @@
"copied": "Путь скопирован в буфер обмена: {{path}}", "copied": "Путь скопирован в буфер обмена: {{path}}",
"clipboardFallback": "Путь: {{path}}" "clipboardFallback": "Путь: {{path}}"
}, },
"openSidecarLocation": {
"success": "Расположение метаданных успешно открыто",
"failed": "Не удалось открыть расположение метаданных",
"copied": "Путь скопирован в буфер обмена: {{path}}",
"clipboardFallback": "Путь: {{path}}"
},
"sendToWorkflow": { "sendToWorkflow": {
"noFilePath": "Невозможно отправить в ComfyUI: путь к файлу недоступен" "noFilePath": "Невозможно отправить в ComfyUI: путь к файлу недоступен"
}, },
+7
View File
@@ -1892,6 +1892,7 @@
"viewOnHuggingFaceText": "在 Hugging Face 查看", "viewOnHuggingFaceText": "在 Hugging Face 查看",
"viewCreatorProfile": "查看创作者主页", "viewCreatorProfile": "查看创作者主页",
"openFileLocation": "打开文件位置", "openFileLocation": "打开文件位置",
"openSidecarLocation": "打开元数据位置",
"sendToWorkflow": "发送到 ComfyUI", "sendToWorkflow": "发送到 ComfyUI",
"sendToWorkflowText": "发送到 ComfyUI", "sendToWorkflowText": "发送到 ComfyUI",
"copyHash": "复制哈希值", "copyHash": "复制哈希值",
@@ -1905,6 +1906,12 @@
"copied": "路径已复制到剪贴板:{{path}}", "copied": "路径已复制到剪贴板:{{path}}",
"clipboardFallback": "路径:{{path}}" "clipboardFallback": "路径:{{path}}"
}, },
"openSidecarLocation": {
"success": "元数据位置已成功打开",
"failed": "打开元数据位置失败",
"copied": "路径已复制到剪贴板:{{path}}",
"clipboardFallback": "路径:{{path}}"
},
"sendToWorkflow": { "sendToWorkflow": {
"noFilePath": "无法发送到 ComfyUI:没有可用的文件路径" "noFilePath": "无法发送到 ComfyUI:没有可用的文件路径"
}, },
+7
View File
@@ -1892,6 +1892,7 @@
"viewOnHuggingFaceText": "在 Hugging Face 查看", "viewOnHuggingFaceText": "在 Hugging Face 查看",
"viewCreatorProfile": "查看創作者個人檔案", "viewCreatorProfile": "查看創作者個人檔案",
"openFileLocation": "開啟檔案位置", "openFileLocation": "開啟檔案位置",
"openSidecarLocation": "開啟中繼資料位置",
"sendToWorkflow": "傳送到 ComfyUI", "sendToWorkflow": "傳送到 ComfyUI",
"sendToWorkflowText": "傳送到 ComfyUI", "sendToWorkflowText": "傳送到 ComfyUI",
"copyHash": "複製雜湊值", "copyHash": "複製雜湊值",
@@ -1905,6 +1906,12 @@
"copied": "路徑已複製到剪貼簿:{{path}}", "copied": "路徑已複製到剪貼簿:{{path}}",
"clipboardFallback": "路徑:{{path}}" "clipboardFallback": "路徑:{{path}}"
}, },
"openSidecarLocation": {
"success": "中繼資料位置已成功開啟",
"failed": "開啟中繼資料位置失敗",
"copied": "路徑已複製到剪貼簿:{{path}}",
"clipboardFallback": "路徑:{{path}}"
},
"sendToWorkflow": { "sendToWorkflow": {
"noFilePath": "無法傳送到 ComfyUI:沒有可用的檔案路徑" "noFilePath": "無法傳送到 ComfyUI:沒有可用的檔案路徑"
}, },
+60 -15
View File
@@ -77,6 +77,7 @@ from ...utils.sidecar_paths import (
get_configured_sidecar_root, get_configured_sidecar_root,
get_metadata_path, get_metadata_path,
get_preview_dir, get_preview_dir,
get_sidecar_dir,
get_storage_mode, get_storage_mode,
get_unmatched_sidecar_components, get_unmatched_sidecar_components,
) )
@@ -3497,21 +3498,8 @@ class FileSystemHandler:
{"success": True, "message": f"Opened folder: {path}", "path": path} {"success": True, "message": f"Opened folder: {path}", "path": path}
) )
async def open_file_location(self, request: web.Request) -> web.Response: async def _reveal_file(self, file_path: str) -> web.Response:
try: """Open the platform file manager with ``file_path`` selected."""
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
)
if os.name == "nt": if os.name == "nt":
subprocess.Popen(["explorer", "/select,", file_path]) subprocess.Popen(["explorer", "/select,", file_path])
elif os.name == "posix": elif os.name == "posix":
@@ -3563,12 +3551,68 @@ class FileSystemHandler:
{ {
"success": True, "success": True,
"message": f"Opened folder and selected file: {file_path}", "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()
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
)
return await self._reveal_file(file_path)
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to open file location: %s", exc, exc_info=True) logger.error("Failed to open file location: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) 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: async def open_settings_location(self, request: web.Request) -> web.Response:
try: try:
settings_file = getattr(self._settings, "settings_file", None) settings_file = getattr(self._settings, "settings_file", None)
@@ -4454,6 +4498,7 @@ class MiscHandlerSet:
"open_backup_location": self.filesystem.open_backup_location, "open_backup_location": self.filesystem.open_backup_location,
"open_wildcards_location": self.filesystem.open_wildcards_location, "open_wildcards_location": self.filesystem.open_wildcards_location,
"open_sidecar_location": self.filesystem.open_sidecar_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, "browse_directory": self.filesystem.browse_directory,
"validate_path": self.filesystem.validate_path, "validate_path": self.filesystem.validate_path,
"search_custom_words": self.custom_words.search_custom_words, "search_custom_words": self.custom_words.search_custom_words,
+3
View File
@@ -125,6 +125,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"POST", "/api/lm/sidecars/open-location", "open_sidecar_location" "POST", "/api/lm/sidecars/open-location", "open_sidecar_location"
), ),
RouteDefinition(
"POST", "/api/lm/models/open-sidecar-location", "open_model_sidecar_location"
),
RouteDefinition( RouteDefinition(
"POST", "/api/lm/download-model-source", "download_model_source" "POST", "/api/lm/download-model-source", "download_model_source"
), ),
@@ -349,6 +349,34 @@
font-size: 0.9em; 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"] { .file-path[data-action="open-file-location"] {
cursor: pointer; cursor: pointer;
text-decoration: underline; text-decoration: underline;
+50
View File
@@ -365,6 +365,16 @@ export async function showModelModal(model, modelType) {
}; };
const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || ''); const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || '');
const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A'); 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 // De-emphasized hash display: a borderless full-width footnote line below
// the info grid — sha256 middle-truncated (first 10 + last 6), autov3 in // 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 / // 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="info-item">
<div class="location-wrapper"> <div class="location-wrapper">
<label>${translate('modals.model.metadata.location', {}, 'Location')}</label> <label>${translate('modals.model.metadata.location', {}, 'Location')}</label>
<div class="file-path-wrapper">
<span class="file-path" title="${translate('modals.model.actions.openFileLocation', {}, 'Open file location')}" <span class="file-path" title="${translate('modals.model.actions.openFileLocation', {}, 'Open file location')}"
data-action="open-file-location" data-action="open-file-location"
data-filepath="${escapedFilePathAttr}"> data-filepath="${escapedFilePathAttr}">
${escapedFolderPath} ${escapedFolderPath}
</span> </span>
${sidecarLocationButton}
</div>
</div> </div>
</div> </div>
<div class="info-item base-size"> <div class="info-item base-size">
@@ -1014,6 +1027,12 @@ function setupEventHandlers(filePath, modelType) {
openFileLocation(filePath); openFileLocation(filePath);
} }
break; break;
case 'open-sidecar-location':
const sidecarModelPath = target.dataset.filepath || getModalFilePath();
if (sidecarModelPath) {
openSidecarLocation(sidecarModelPath);
}
break;
case 'nav-prev': case 'nav-prev':
handleDirectionalNavigation('prev', modelType); handleDirectionalNavigation('prev', modelType);
break; 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) { async function handleSendToWorkflow(target, modelType) {
const filePath = getModalFilePath(); const filePath = getModalFilePath();
if (!filePath) { 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');
});
});
+145
View File
@@ -911,6 +911,151 @@ async def test_open_file_location_headless_returns_clipboard_mode(tmp_path, monk
assert run_calls == [] 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: class RecordingRouter:
def __init__(self): def __init__(self):
self.calls = [] self.calls = []