fix(modal): keep cached metadata when the model drive is offline

A model on a switched-off drive lost its creator and license icons in the
modal even though "search by creator" found it: the list payload is
trimmed to id/modelId/name/trainedWords, and the only full-payload source
was the .metadata.json next to the model file.

- carry `creator` in the lightweight list payload so the card/modal
  fallback keeps who published the model;
- add a cache fallback to GET /api/lm/{type}/metadata: when the sidecar
  cannot be read (offline drive, unmounted share, missing/corrupt file),
  rebuild the CivitAI payload from the cache entry and decode
  `license_flags` back into the permission fields the icons render from.
  The response reports `partial` and `offline` so callers can explain the
  degradation instead of silently dropping fields;
- skip the stat() for paths outside every live root in alongside storage
  so a dead share cannot stall the modal; centralized storage still reads
  the real sidecar and serves the full payload while the drive is off;
- merge the fetched payload into the list data instead of replacing it
  (a partial response must never drop cached fields), and show an
  offline/partial notice plus a description-tab explanation.
This commit is contained in:
Will Miao
2026-11-14 03:16:27 +08:00
parent 32b5ec28cd
commit e21be66012
28 changed files with 985 additions and 19 deletions
+5
View File
@@ -2218,6 +2218,7 @@
"description": {
"noDescription": "Keine Modellbeschreibung verfügbar",
"failedToLoad": "Fehler beim Laden der Modellbeschreibung",
"offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.",
"editTitle": "Modellbeschreibung bearbeiten",
"validation": {
"cannotBeEmpty": "Beschreibung darf nicht leer sein"
@@ -2250,6 +2251,10 @@
"noReLicense": "Gleiche Berechtigungen erforderlich",
"restrictionsLabel": "Lizenzbeschränkungen"
},
"notices": {
"offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.",
"partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable."
},
"loading": {
"exampleImages": "Beispielbilder werden geladen...",
"description": "Modellbeschreibung wird geladen...",
+5
View File
@@ -2218,6 +2218,7 @@
"description": {
"noDescription": "No model description available",
"failedToLoad": "Failed to load model description",
"offline": "The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.",
"editTitle": "Edit model description",
"validation": {
"cannotBeEmpty": "Description cannot be empty"
@@ -2250,6 +2251,10 @@
"noReLicense": "Same permissions required",
"restrictionsLabel": "License restrictions"
},
"notices": {
"offline": "The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.",
"partial": "The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable."
},
"loading": {
"exampleImages": "Loading example images...",
"description": "Loading model description...",
+5
View File
@@ -2218,6 +2218,7 @@
"description": {
"noDescription": "No hay descripción del modelo disponible",
"failedToLoad": "Error al cargar la descripción del modelo",
"offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.",
"editTitle": "Editar descripción del modelo",
"validation": {
"cannotBeEmpty": "La descripción no puede estar vacía"
@@ -2250,6 +2251,10 @@
"noReLicense": "Se requieren mismos permisos",
"restrictionsLabel": "Restricciones de licencia"
},
"notices": {
"offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.",
"partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable."
},
"loading": {
"exampleImages": "Cargando imágenes de ejemplo...",
"description": "Cargando descripción del modelo...",
+5
View File
@@ -2218,6 +2218,7 @@
"description": {
"noDescription": "Aucune description de modèle disponible",
"failedToLoad": "Échec du chargement de la description du modèle",
"offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.",
"editTitle": "Modifier la description du modèle",
"validation": {
"cannotBeEmpty": "La description ne peut pas être vide"
@@ -2250,6 +2251,10 @@
"noReLicense": "Mêmes autorisations requises",
"restrictionsLabel": "Restrictions de licence"
},
"notices": {
"offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.",
"partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable."
},
"loading": {
"exampleImages": "Chargement des images d'exemple...",
"description": "Chargement de la description du modèle...",
+5
View File
@@ -2218,6 +2218,7 @@
"description": {
"noDescription": "אין תיאור מודל זמין",
"failedToLoad": "טעינת תיאור המודל נכשלה",
"offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.",
"editTitle": "ערוך תיאור מודל",
"validation": {
"cannotBeEmpty": "התיאור לא יכול להיות ריק"
@@ -2250,6 +2251,10 @@
"noReLicense": "נדרשות אותן הרשאות",
"restrictionsLabel": "הגבלות רישיון"
},
"notices": {
"offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.",
"partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable."
},
"loading": {
"exampleImages": "טוען תמונות דוגמה...",
"description": "טוען תיאור מודל...",
+5
View File
@@ -2218,6 +2218,7 @@
"description": {
"noDescription": "モデルの説明がありません",
"failedToLoad": "モデルの説明の読み込みに失敗しました",
"offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.",
"editTitle": "モデルの説明を編集",
"validation": {
"cannotBeEmpty": "説明を空にすることはできません"
@@ -2250,6 +2251,10 @@
"noReLicense": "同じ権限が必要",
"restrictionsLabel": "ライセンス制限"
},
"notices": {
"offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.",
"partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable."
},
"loading": {
"exampleImages": "例画像を読み込み中...",
"description": "モデル説明を読み込み中...",
+5
View File
@@ -2218,6 +2218,7 @@
"description": {
"noDescription": "사용 가능한 모델 설명이 없습니다",
"failedToLoad": "모델 설명 로딩 실패",
"offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.",
"editTitle": "모델 설명 편집",
"validation": {
"cannotBeEmpty": "설명은 비어있을 수 없습니다"
@@ -2250,6 +2251,10 @@
"noReLicense": "동일한 권한 필요",
"restrictionsLabel": "라이선스 제한"
},
"notices": {
"offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.",
"partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable."
},
"loading": {
"exampleImages": "예시 이미지 로딩 중...",
"description": "모델 설명 로딩 중...",
+5
View File
@@ -2218,6 +2218,7 @@
"description": {
"noDescription": "Описание модели недоступно",
"failedToLoad": "Не удалось загрузить описание модели",
"offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.",
"editTitle": "Редактировать описание модели",
"validation": {
"cannotBeEmpty": "Описание не может быть пустым"
@@ -2250,6 +2251,10 @@
"noReLicense": "Требуются те же права",
"restrictionsLabel": "Лицензионные ограничения"
},
"notices": {
"offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.",
"partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable."
},
"loading": {
"exampleImages": "Загрузка примеров изображений...",
"description": "Загрузка описания модели...",
+5
View File
@@ -2218,6 +2218,7 @@
"description": {
"noDescription": "暂无模型描述",
"failedToLoad": "加载模型描述失败",
"offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.",
"editTitle": "编辑模型描述",
"validation": {
"cannotBeEmpty": "描述不能为空"
@@ -2250,6 +2251,10 @@
"noReLicense": "需要相同权限",
"restrictionsLabel": "许可证限制"
},
"notices": {
"offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.",
"partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable."
},
"loading": {
"exampleImages": "正在加载示例图片...",
"description": "正在加载模型描述...",
+5
View File
@@ -2218,6 +2218,7 @@
"description": {
"noDescription": "無模型描述",
"failedToLoad": "載入模型描述失敗",
"offline": "[TODO: Translate] The description is stored with the model file, which is on an offline disk. Reconnect the disk to read it.",
"editTitle": "編輯模型描述",
"validation": {
"cannotBeEmpty": "描述不可為空"
@@ -2250,6 +2251,10 @@
"noReLicense": "需要相同授權",
"restrictionsLabel": "授權限制"
},
"notices": {
"offline": "[TODO: Translate] The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.",
"partial": "[TODO: Translate] The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable."
},
"loading": {
"exampleImages": "載入範例圖片中...",
"description": "載入模型描述中...",
+17 -2
View File
@@ -1720,9 +1720,24 @@ class ModelQueryHandler:
file_path = request.query.get("file_path")
if not file_path:
return web.Response(text="File path is required", status=400)
metadata = await self._service.get_model_metadata(file_path)
metadata, partial, offline = await self._service.get_model_metadata_with_fallback(
file_path
)
if metadata is not None:
return web.json_response({"success": True, "metadata": metadata})
return web.json_response(
{
"success": True,
"metadata": metadata,
# ``partial``: rebuilt from the cache because the
# .metadata.json could not be read (offline drive,
# unmounted share, missing/corrupt sidecar).
# ``offline``: the model path is not under a reachable
# root, so file actions will fail even when the sidecar
# itself is readable (centralized sidecar storage).
"partial": partial,
"offline": offline,
}
)
return web.json_response(
{
"success": False,
+120 -2
View File
@@ -22,7 +22,9 @@ from .model_query import (
)
from .settings_manager import get_settings_manager
from .model_sources import source_group_key
from ..utils.civitai_utils import build_civitai_model_page_url
from ..utils.civitai_utils import build_civitai_model_page_url, decode_license_flags
from ..utils.root_paths import normalize_root_identity
from ..utils.sidecar_paths import is_centralized
logger = logging.getLogger(__name__)
@@ -979,7 +981,10 @@ class BaseModelService(ABC):
return {}
fields = (
["id", "modelId", "name", "trainedWords"]
# ``creator`` travels with the lightweight payload so the model modal
# can still show who published the model when the on-disk metadata
# (the only full-payload source) lives on an offline drive.
["id", "modelId", "name", "creator", "trainedWords"]
if minimal
else [
"id",
@@ -1242,6 +1247,119 @@ class BaseModelService(ABC):
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
async def get_model_metadata_with_fallback(
self, file_path: str
) -> tuple[Optional[Dict[str, Any]], bool, bool]:
"""Load model metadata, degrading to cached values when the disk is gone.
Returns ``(metadata, partial, offline)``:
* ``metadata`` — the on-disk CivitAI payload, or a payload rebuilt from
the in-memory/SQLite cache when ``.metadata.json`` cannot be read.
* ``partial`` — ``True`` when the payload came from the cache, i.e. it
only carries what the lightweight cache entry retains.
* ``offline`` — ``True`` when the file does not sit under a currently
reachable model root (switched-off drive, unmounted share).
A model whose drive is offline is deliberately *not* treated as deleted:
the scanner keeps its cache entry, so the modal can still show the
creator, version name, trigger words, license permissions and ids.
"""
offline = self._is_model_path_offline(file_path)
if offline and not is_centralized():
# Alongside storage keeps the sidecar next to the model, so an
# unreachable model path means an unreachable sidecar. Skip the
# stat() that can block for seconds on a dead network share and go
# straight to the cache: once the drive is reconnected the refresh
# menu / a scan admits the root and this reads the sidecar again.
metadata = None
else:
# Centralized storage can still serve the real payload while the
# model drive is off — the sidecar lives on the app's own disk.
metadata = await self.get_model_metadata(file_path)
if metadata is not None:
return metadata, False, offline
entry = await self._find_cache_entry(file_path)
if entry is None:
return None, False, offline
# Nothing CivitAI-shaped was ever cached for this model (e.g. a plain
# HuggingFace download): reporting an empty partial payload would only
# raise an "incomplete metadata" notice for a model that never had any,
# so keep the 404 the modal already handles.
cached_civitai = entry.get("civitai")
if not isinstance(cached_civitai, dict) or not cached_civitai:
return None, False, offline
return self._cached_civitai_payload(entry), True, offline
def _is_model_path_offline(self, file_path: str) -> bool:
"""Return ``True`` when *file_path* is outside every reachable root.
In-memory check only: the live root list is what the scanner admitted,
so a switched-off drive answers without a ``stat()`` that can block for
seconds on a dead network share.
"""
check = getattr(self.scanner, "is_file_path_available", None)
if not callable(check):
return False
try:
return not check(file_path)
except Exception: # pragma: no cover - defensive guard
return False
async def _find_cache_entry(self, file_path: str) -> Optional[Dict[str, Any]]:
"""Return the cached list entry for *file_path*, if there is one."""
if not file_path:
return None
try:
cache = await self.scanner.get_cached_data()
except Exception: # pragma: no cover - defensive guard
return None
candidate = str(file_path).replace("\\", "/")
raw_data = getattr(cache, "raw_data", None) or []
for entry in raw_data:
if isinstance(entry, dict) and entry.get("file_path") == candidate:
return entry
# The cache stores forward-slashed business paths; fold spelling,
# separators and (on Windows) case before giving up.
target = normalize_root_identity(file_path)
if not target:
return None
for entry in raw_data:
if not isinstance(entry, dict):
continue
if normalize_root_identity(entry.get("file_path", "")) == target:
return entry
return None
def _cached_civitai_payload(self, entry: Dict[str, Any]) -> Dict[str, Any]:
"""Rebuild a CivitAI payload from the lightweight cache entry.
The cache keeps only the fields the grid needs, plus the license
permissions as a bitfield. Decoding that bitfield restores the
``civitai.model`` section the modal renders its license icons from;
everything the cache never stored (description, example images, creator
avatar) stays absent rather than being invented.
"""
civitai = entry.get("civitai")
payload: Dict[str, Any] = dict(civitai) if isinstance(civitai, dict) else {}
model_section = payload.get("model")
if isinstance(model_section, dict):
merged = dict(model_section)
merged.update(decode_license_flags(entry.get("license_flags")))
payload["model"] = merged
base_model = entry.get("base_model")
if base_model and not payload.get("baseModel"):
payload["baseModel"] = base_model
return self.filter_civitai_data(payload)
async def get_model_description(self, file_path: str) -> Optional[str]:
"""Return the stored modelDescription field for a model."""
metadata, should_skip = await MetadataManager.load_metadata(
+21
View File
@@ -2826,6 +2826,27 @@ class ModelScanner:
"""Get model root directories"""
raise NotImplementedError("Subclasses must implement get_model_roots")
def is_file_path_available(self, file_path: str) -> bool:
"""Return whether *file_path* sits under a currently reachable root.
Pure in-memory check: the live root list was filled when the library
was admitted, so a switched-off drive answers ``False`` without a
``stat()`` that can block for seconds on a dead network share. A drive
plugged in after startup is admitted by :meth:`refresh_model_roots`
(run by the refresh menu and by scans), so this follows the same
"reachable" definition as the refresh scope list.
"""
if not file_path:
return False
candidate = normalize_root_identity(file_path)
if not candidate:
return False
for root in self.get_model_roots():
prefix = _normalized_root_prefix(root)
if prefix and candidate.startswith(prefix):
return True
return False
async def get_all_folders(self) -> List[str]:
"""Return every known directory under the model roots.
+35
View File
@@ -339,6 +339,41 @@ def resolve_license_info(
return payload, build_license_flags(payload)
# Commercial permission bits, in the order ``_resolve_commercial_bits`` packs
# them after ``_COMMERCIAL_SHIFT``. The names match CivitAI's API values so the
# decoded payload renders through the exact same client-side code path.
_COMMERCIAL_FLAG_VALUES: tuple[tuple[int, str], ...] = (
(1, "Image"),
(2, "RentCivit"),
(3, "Rent"),
(4, "Sell"),
)
def decode_license_flags(flags: Any) -> Dict[str, Any]:
"""Decode a cached license bitfield back into a CivitAI license payload.
Inverse of :func:`build_license_flags`. Used when the authoritative
``.metadata.json`` is unreachable (offline drive) but the model modal still
has to render the license icons: the bitfield survives in the persistent
cache, the original ``civitai.model`` section does not.
"""
try:
value = int(flags)
except (TypeError, ValueError):
value = build_license_flags({})
return {
"allowNoCredit": bool(value & (1 << 0)),
"allowCommercialUse": [
name for bit, name in _COMMERCIAL_FLAG_VALUES if value & (1 << bit)
],
"allowDerivatives": bool(value & (1 << 5)),
"allowDifferentLicense": bool(value & (1 << 6)),
}
def _normalize_media_width(width: int | None) -> int | None:
if width is None:
return None
@@ -172,6 +172,37 @@
transform: translateY(-1px);
}
/* Metadata provenance notice (offline drive / unreadable sidecar) */
.modal-metadata-notice {
display: flex;
align-items: flex-start;
gap: var(--space-2);
margin-bottom: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid color-mix(in oklch, var(--color-info, #4dabf7) 35%, transparent);
border-left-width: 3px;
border-radius: var(--border-radius-sm);
background: color-mix(in oklch, var(--color-info, #4dabf7) 10%, transparent);
color: var(--text-color);
font-size: 0.85em;
line-height: 1.45;
}
.modal-metadata-notice i {
margin-top: 2px;
flex-shrink: 0;
color: var(--color-info, #4dabf7);
}
.modal-metadata-notice--offline {
border-color: color-mix(in oklch, var(--color-warning, #f59f00) 40%, transparent);
background: color-mix(in oklch, var(--color-warning, #f59f00) 12%, transparent);
}
.modal-metadata-notice--offline i {
color: var(--color-warning, #f59f00);
}
/* Info Grid */
.info-grid {
display: grid;
+19 -1
View File
@@ -2245,6 +2245,20 @@ export class BaseModelApiClient {
}
async fetchModelMetadata(filePath) {
const result = await this.fetchModelMetadataWithStatus(filePath);
return result.metadata;
}
/**
* Fetch the full CivitAI metadata together with its provenance.
*
* The backend degrades to the cached payload when `.metadata.json` cannot
* be read (offline drive, unmounted share, missing sidecar), so callers
* that render a full view need to know whether what they got is complete:
*
* @returns {Promise<{metadata: Object, partial: boolean, offline: boolean}>}
*/
async fetchModelMetadataWithStatus(filePath) {
try {
const params = new URLSearchParams({ file_path: filePath });
const response = await fetch(`${this.apiConfig.endpoints.metadata}?${params}`);
@@ -2256,7 +2270,11 @@ export class BaseModelApiClient {
const data = await response.json();
if (data.success) {
return data.metadata;
return {
metadata: data.metadata || {},
partial: Boolean(data.partial),
offline: Boolean(data.offline),
};
} else {
throw new Error(data.error || `No metadata found for ${this.apiConfig.config.singularName}`);
}
@@ -81,8 +81,19 @@ async function loadModelDescription() {
} catch (error) {
console.error('Error loading model description:', error);
const failedText = translate('modals.model.description.failedToLoad', {}, 'Failed to load model description');
descriptionContent.innerHTML = `<div class="no-description">${failedText}</div>`;
// The description lives in `.metadata.json`, next to the model file. If
// that disk is offline the generic failure text hides the real cause,
// so say what is going on (the modal already knows the provenance).
const modalElement = document.getElementById('modelModal');
const offline = modalElement?.dataset.metadataOffline === 'true';
const message = offline
? translate(
'modals.model.description.offline',
{},
'The description is stored with the model file, which is on an offline disk. Reconnect it to read the description.'
)
: translate('modals.model.description.failedToLoad', {}, 'Failed to load model description');
descriptionContent.innerHTML = `<div class="no-description">${message}</div>`;
} finally {
// Hide loading state
descriptionLoading?.classList.add('hidden');
+31 -3
View File
@@ -490,12 +490,19 @@ export async function showModelModal(model, modelType) {
cleanupNavigationShortcuts();
detachModalHandlers(modalId);
// Fetch complete civitai metadata
// Fetch complete civitai metadata. The backend falls back to the cached
// payload when the sidecar is unreadable (offline drive, missing file), so
// merge instead of replacing: a partial payload must never drop fields the
// list response already carried (creator, ids, trigger words).
let completeCivitaiData = model.civitai || {};
let metadataPartial = false;
let metadataOffline = false;
if (model.file_path) {
try {
const fullMetadata = await getModelApiClient().fetchModelMetadata(model.file_path);
completeCivitaiData = fullMetadata || model.civitai || {};
const result = await getModelApiClient().fetchModelMetadataWithStatus(model.file_path);
metadataPartial = Boolean(result?.partial);
metadataOffline = Boolean(result?.offline);
completeCivitaiData = { ...(model.civitai || {}), ...(result?.metadata || {}) };
} catch (error) {
console.warn('Failed to fetch complete metadata, using existing data:', error);
// Continue with existing data if fetch fails
@@ -507,6 +514,22 @@ export async function showModelModal(model, modelType) {
...model,
civitai: completeCivitaiData
};
// A model on a switched-off drive keeps its cached metadata, but nothing on
// disk can be read or written until it is reconnected. Say so instead of
// leaving the user wondering why the creator/description/examples are gone.
const offlineNotice = metadataOffline
? `
<div class="modal-metadata-notice modal-metadata-notice--offline" role="status">
<i class="fas fa-plug-circle-xmark" aria-hidden="true"></i>
<span>${translate('modals.model.notices.offline', {}, 'The disk holding this model is offline. Showing cached information — file actions and edits stay unavailable until it is reconnected.')}</span>
</div>`
: (metadataPartial
? `
<div class="modal-metadata-notice" role="status">
<i class="fas fa-circle-info" aria-hidden="true"></i>
<span>${translate('modals.model.notices.partial', {}, 'The model metadata file could not be read. Showing cached information — description, examples and creator avatar may be unavailable.')}</span>
</div>`
: '');
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
@@ -838,6 +861,7 @@ export async function showModelModal(model, modelType) {
</header>
<div class="modal-body">
${offlineNotice}
<div class="info-section">
<div class="info-grid">
<div class="info-item">
@@ -1013,6 +1037,10 @@ export async function showModelModal(model, modelType) {
const activeModalElement = document.getElementById(modalId);
if (activeModalElement) {
activeModalElement.dataset.filePath = modelWithFullData.file_path || '';
// Metadata provenance: the description tab reads this to explain why the
// text is unavailable instead of showing a generic "failed to load".
activeModalElement.dataset.metadataPartial = metadataPartial ? 'true' : 'false';
activeModalElement.dataset.metadataOffline = metadataOffline ? 'true' : 'false';
// Store usage_tips for LoRA models
if (modelType === 'loras' && modelWithFullData.usage_tips) {
activeModalElement.dataset.usageTips = modelWithFullData.usage_tips;
+3
View File
@@ -285,6 +285,9 @@ class MockModelService:
async def get_model_metadata(self, *_args, **_kwargs): # pragma: no cover
return {}
async def get_model_metadata_with_fallback(self, *_args, **_kwargs): # pragma: no cover
return {}, False, False
async def get_model_description(self, *_args, **_kwargs): # pragma: no cover
return ""
@@ -177,10 +177,10 @@ describe('Model metadata interactions keep file path in sync', () => {
it('uses the latest file path when saving notes', async () => {
const saveModelMetadata = vi.fn().mockResolvedValue({ success: true });
const fetchModelMetadata = vi.fn().mockResolvedValue(null);
const fetchModelMetadataWithStatus = vi.fn().mockResolvedValue({ metadata: {}, partial: false, offline: false });
getModelApiClient.mockReturnValue({
fetchModelMetadata,
fetchModelMetadataWithStatus,
saveModelMetadata,
});
@@ -209,10 +209,10 @@ describe('Model metadata interactions keep file path in sync', () => {
});
it('shows recipes tab for checkpoint modals and loads linked recipes by hash', async () => {
const fetchModelMetadata = vi.fn().mockResolvedValue(null);
const fetchModelMetadataWithStatus = vi.fn().mockResolvedValue({ metadata: {}, partial: false, offline: false });
getModelApiClient.mockReturnValue({
fetchModelMetadata,
fetchModelMetadataWithStatus,
saveModelMetadata: vi.fn(),
});
@@ -126,7 +126,7 @@ describe('Model modal hash rendering', () => {
copyToClipboard.mockReset();
calculateModelHash = vi.fn();
getModelApiClient.mockReturnValue({
fetchModelMetadata: vi.fn().mockResolvedValue(null),
fetchModelMetadataWithStatus: vi.fn().mockResolvedValue({ metadata: {}, partial: false, offline: false }),
saveModelMetadata: vi.fn(),
calculateModelHash,
});
@@ -112,9 +112,9 @@ describe('Model modal license rendering', () => {
});
it('handles aggregated commercial strings without extra restrictions (classic style)', async () => {
const fetchModelMetadata = vi.fn().mockResolvedValue(null);
const fetchModelMetadataWithStatus = vi.fn().mockResolvedValue({ metadata: {}, partial: false, offline: false });
getModelApiClient.mockReturnValue({
fetchModelMetadata,
fetchModelMetadataWithStatus,
saveModelMetadata: vi.fn(),
});
@@ -0,0 +1,208 @@
import { describe, it, beforeEach, 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,
} = 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,
}));
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(),
}));
function makeModel(overrides = {}) {
return {
model_name: 'Offline Model',
file_path: 'models/offline.safetensors',
file_name: 'offline.safetensors',
civitai: {
modelId: 7,
name: 'v1.0',
creator: { username: 'artist' },
},
...overrides,
};
}
describe('Model modal metadata fallback', () => {
let getModelApiClient;
let fetchModelMetadataWithStatus;
beforeEach(async () => {
document.body.innerHTML = '';
({ getModelApiClient } = await import(API_FACTORY));
getModelApiClient.mockReset();
fetchModelMetadataWithStatus = vi.fn();
getModelApiClient.mockReturnValue({
fetchModelMetadataWithStatus,
saveModelMetadata: vi.fn(),
});
});
async function renderModal(model) {
const { showModelModal } = await import(MODAL_MODULE);
await showModelModal(model, 'loras');
return document.getElementById('modelModal');
}
it('merges the cached payload into the list-level civitai data', async () => {
fetchModelMetadataWithStatus.mockResolvedValue({
metadata: {
name: 'v2.0',
creator: { username: 'artist' },
},
partial: true,
offline: true,
});
const modal = await renderModal(makeModel());
expect(modal.querySelector('.version-name-content').textContent).toBe('v2.0');
expect(modal.querySelector('.creator-username').textContent).toBe('artist');
// The list payload's model id must survive a partial response.
expect(modal.querySelector('.civitai-id-entry .model-hash-value').textContent).toBe('7');
});
it('shows the offline notice and flags the modal for the description tab', async () => {
fetchModelMetadataWithStatus.mockResolvedValue({
metadata: { name: 'v2.0' },
partial: true,
offline: true,
});
const modal = await renderModal(makeModel());
const notice = modal.querySelector('.modal-metadata-notice');
expect(notice).not.toBeNull();
expect(notice.classList.contains('modal-metadata-notice--offline')).toBe(true);
expect(notice.textContent).toContain('offline');
expect(modal.dataset.metadataOffline).toBe('true');
expect(modal.dataset.metadataPartial).toBe('true');
});
it('shows the partial notice when the sidecar is unreadable but the disk is online', async () => {
fetchModelMetadataWithStatus.mockResolvedValue({
metadata: {},
partial: true,
offline: false,
});
const modal = await renderModal(makeModel());
const notice = modal.querySelector('.modal-metadata-notice');
expect(notice).not.toBeNull();
expect(notice.classList.contains('modal-metadata-notice--offline')).toBe(false);
expect(notice.textContent).toContain('could not be read');
expect(modal.dataset.metadataOffline).toBe('false');
});
it('renders no notice for complete metadata', async () => {
fetchModelMetadataWithStatus.mockResolvedValue({
metadata: { name: 'v1.0', description: 'About' },
partial: false,
offline: false,
});
const modal = await renderModal(makeModel());
expect(modal.querySelector('.modal-metadata-notice')).toBeNull();
expect(modal.dataset.metadataOffline).toBe('false');
});
it('keeps the list data when the metadata request fails outright', async () => {
fetchModelMetadataWithStatus.mockRejectedValue(new Error('boom'));
const modal = await renderModal(makeModel());
expect(modal.querySelector('.creator-username').textContent).toBe('artist');
expect(modal.querySelector('.modal-metadata-notice')).toBeNull();
});
});
@@ -126,7 +126,7 @@ describe('Model modal sidecar location entry', () => {
getModelApiClient.mockReset();
showToast.mockReset();
getModelApiClient.mockReturnValue({
fetchModelMetadata: vi.fn().mockResolvedValue(null),
fetchModelMetadataWithStatus: vi.fn().mockResolvedValue({ metadata: {}, partial: false, offline: false }),
saveModelMetadata: vi.fn(),
});
originalMode = state.global.settings.sidecar_storage_mode;
@@ -116,7 +116,7 @@ describe('Model modal source links (#1094)', () => {
const { getModelApiClient } = await import(API_FACTORY);
getModelApiClient.mockReset();
getModelApiClient.mockReturnValue({
fetchModelMetadata: vi.fn().mockResolvedValue(null),
fetchModelMetadataWithStatus: vi.fn().mockResolvedValue({ metadata: {}, partial: false, offline: false }),
saveModelMetadata: vi.fn(),
});
});
@@ -0,0 +1,99 @@
"""Route contract for the degraded ``/metadata`` response.
The model modal relies on the cached payload the service returns when the
sidecar is unreadable, together with the ``partial`` / ``offline`` flags that
tell the UI why the payload is incomplete.
"""
import json
import logging
from py.routes.handlers.model_handlers import ModelQueryHandler
class DummyRequest:
def __init__(self, query=None):
self.query = query or {}
class StubService:
model_type = "lora"
def __init__(self, result=None, error=None):
self._result = result
self._error = error
self.calls = []
async def get_model_metadata_with_fallback(self, file_path):
self.calls.append(file_path)
if self._error is not None:
raise self._error
return self._result
def make_handler(service):
return ModelQueryHandler(service=service, logger=logging.getLogger("test"))
async def test_returns_partial_payload_with_provenance_flags():
service = StubService(
result=(
{
"id": 42,
"modelId": 7,
"name": "v2.0",
"creator": {"username": "artist"},
"model": {"type": "LORA", "allowNoCredit": False},
},
True,
True,
)
)
response = await make_handler(service).get_model_metadata(
DummyRequest({"file_path": "models/offline.safetensors"})
)
payload = json.loads(response.text)
assert response.status == 200
assert payload["success"] is True
assert payload["partial"] is True
assert payload["offline"] is True
assert payload["metadata"]["creator"] == {"username": "artist"}
assert service.calls == ["models/offline.safetensors"]
async def test_full_metadata_reports_no_degradation():
service = StubService(result=({"name": "v1.0"}, False, False))
response = await make_handler(service).get_model_metadata(
DummyRequest({"file_path": "models/online.safetensors"})
)
payload = json.loads(response.text)
assert response.status == 200
assert payload["partial"] is False
assert payload["offline"] is False
async def test_missing_everywhere_still_404s():
service = StubService(result=(None, False, True))
response = await make_handler(service).get_model_metadata(
DummyRequest({"file_path": "models/gone.safetensors"})
)
assert response.status == 404
async def test_missing_file_path_is_rejected():
service = StubService(result=({}, False, False))
response = await make_handler(service).get_model_metadata(DummyRequest({}))
assert response.status == 400
async def test_service_failure_maps_to_500():
service = StubService(error=RuntimeError("boom"))
response = await make_handler(service).get_model_metadata(
DummyRequest({"file_path": "models/offline.safetensors"})
)
assert response.status == 500
@@ -0,0 +1,243 @@
"""Cached-metadata fallback for the model modal.
When the model's disk is offline the authoritative ``.metadata.json`` cannot be
read, but the scanner still holds the cache entry. The modal must keep showing
what the cache retains (creator, version name, trigger words, license
permissions) instead of falling back to the stripped list payload.
"""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from py.services.lora_service import LoraService
from py.utils.civitai_utils import build_license_flags
from py.utils.models import LoraMetadata
def make_service(entries, *, path_available=True):
scanner = Mock()
scanner.get_cached_data = AsyncMock(
return_value=SimpleNamespace(raw_data=list(entries))
)
scanner.is_file_path_available = Mock(return_value=path_available)
return LoraService(scanner)
def make_entry(file_path):
return {
"file_path": file_path,
"file_name": "artist_style",
"model_name": "Artist Style",
"base_model": "Illustrious",
"license_flags": build_license_flags(
{
"allowNoCredit": False,
"allowCommercialUse": ["Image", "Rent"],
"allowDerivatives": False,
"allowDifferentLicense": True,
}
),
"civitai": {
"id": 42,
"modelId": 7,
"name": "v2.0",
"creator": {"username": "artist"},
"trainedWords": ["artist style"],
"model": {"type": "LORA"},
},
}
@pytest.fixture
def offline_model(tmp_path):
"""A cache entry whose model file and sidecar are on an unreachable root."""
file_path = str(tmp_path / "removable" / "artist_style.safetensors")
return file_path, make_entry(file_path)
async def test_metadata_falls_back_to_cache_entry(offline_model):
file_path, entry = offline_model
service = make_service([entry], path_available=False)
metadata, partial, offline = await service.get_model_metadata_with_fallback(file_path)
assert partial is True
assert offline is True
assert metadata["creator"] == {"username": "artist"}
assert metadata["name"] == "v2.0"
assert metadata["id"] == 42
assert metadata["modelId"] == 7
assert metadata["trainedWords"] == ["artist style"]
assert metadata["baseModel"] == "Illustrious"
async def test_fallback_decodes_license_permissions(offline_model):
file_path, entry = offline_model
service = make_service([entry], path_available=False)
metadata, _partial, _offline = await service.get_model_metadata_with_fallback(file_path)
assert metadata["model"] == {
"type": "LORA",
"allowNoCredit": False,
"allowCommercialUse": ["Image", "Rent"],
"allowDerivatives": False,
"allowDifferentLicense": True,
}
async def test_fallback_does_not_invent_license_data_without_civitai_model(tmp_path):
file_path = str(tmp_path / "huggingface" / "flux.safetensors")
entry = {
"file_path": file_path,
"file_name": "flux",
"model_name": "Flux",
"base_model": "Flux",
"license_flags": 127,
"civitai": {
"trainedWords": ["trigger"],
"creator": {"username": "someone"},
},
}
service = make_service([entry], path_available=False)
metadata, partial, _offline = await service.get_model_metadata_with_fallback(file_path)
assert partial is True
assert "model" not in metadata
assert metadata["creator"] == {"username": "someone"}
async def test_fallback_matches_forward_slashed_cache_path(offline_model):
file_path, entry = offline_model
service = make_service([entry], path_available=False)
# The API echoes the path as the card sent it (host separators on Windows).
windows_style = file_path.replace("/", "\\")
metadata, partial, _offline = await service.get_model_metadata_with_fallback(
windows_style
)
assert partial is True
assert metadata["name"] == "v2.0"
async def test_unknown_path_still_reports_missing_metadata(tmp_path):
service = make_service([], path_available=True)
metadata, partial, offline = await service.get_model_metadata_with_fallback(
str(tmp_path / "elsewhere" / "nope.safetensors")
)
assert metadata is None
assert partial is False
assert offline is False
async def test_entry_without_civitai_data_reports_missing_metadata(tmp_path):
file_path = str(tmp_path / "plain" / "hf-model.safetensors")
entry = {
"file_path": file_path,
"file_name": "hf-model",
"model_name": "HF Model",
"base_model": "Flux",
"civitai": None,
}
service = make_service([entry], path_available=False)
metadata, partial, offline = await service.get_model_metadata_with_fallback(file_path)
assert metadata is None
assert partial is False
assert offline is True
async def test_sidecar_payload_wins_when_readable(tmp_path, offline_model):
file_path, entry = offline_model
sidecar = tmp_path / "removable" / "artist_style.metadata.json"
sidecar.parent.mkdir(parents=True, exist_ok=True)
sidecar.write_text(
json.dumps(
{
"file_name": "artist_style",
"model_name": "Artist Style",
"file_path": file_path,
"size": 1234,
"modified": 111.0,
"sha256": "a" * 64,
"base_model": "Illustrious",
"preview_url": "",
"civitai": {"name": "v3.0", "creator": {"username": "artist"}},
}
),
encoding="utf-8",
)
scanner_service = make_service([entry], path_available=True)
scanner_service.scanner.sync_cache_from_metadata = AsyncMock(return_value=False)
metadata, partial, offline = await scanner_service.get_model_metadata_with_fallback(
file_path
)
assert partial is False
assert offline is False
assert metadata["name"] == "v3.0"
async def test_offline_path_skips_the_disk_read_in_alongside_mode(
offline_model, monkeypatch
):
"""A dead share must not be stat()ed just to open the modal."""
file_path, entry = offline_model
load_metadata = AsyncMock(return_value=(None, False))
monkeypatch.setattr(
"py.services.base_model_service.MetadataManager.load_metadata", load_metadata
)
monkeypatch.setattr(
"py.services.base_model_service.is_centralized", lambda: False
)
service = make_service([entry], path_available=False)
metadata, partial, _offline = await service.get_model_metadata_with_fallback(file_path)
load_metadata.assert_not_awaited()
assert partial is True
assert metadata["creator"] == {"username": "artist"}
async def test_centralized_storage_still_reads_the_sidecar_when_drive_is_offline(
offline_model, monkeypatch
):
"""Centralized sidecars sit on the app disk, so the real payload survives."""
file_path, entry = offline_model
sidecar_payload = LoraMetadata(
file_name="artist_style",
model_name="Artist Style",
file_path=file_path,
size=1234,
modified=111.0,
sha256="a" * 64,
base_model="Illustrious",
preview_url="",
civitai={"name": "v3.0", "creator": {"username": "artist"}},
)
monkeypatch.setattr(
"py.services.base_model_service.MetadataManager.load_metadata",
AsyncMock(return_value=(sidecar_payload, False)),
)
monkeypatch.setattr(
"py.services.base_model_service.is_centralized", lambda: True
)
service = make_service([entry], path_available=False)
service.scanner.sync_cache_from_metadata = AsyncMock(return_value=False)
metadata, partial, offline = await service.get_model_metadata_with_fallback(file_path)
assert partial is False
assert offline is True
assert metadata["name"] == "v3.0"
@@ -0,0 +1,86 @@
"""Decoding tests for the cached license bitfield.
The persistent cache keeps license permissions as a single integer. When the
on-disk metadata is unreachable (offline drive) the modal renders its license
icons from that integer, so the decode must reproduce the exact CivitAI payload
shape ``build_license_flags`` encoded.
"""
import pytest
from py.utils.civitai_utils import (
build_license_flags,
decode_license_flags,
resolve_license_payload,
)
PERMUTATIONS = [
{
"allowNoCredit": True,
"allowCommercialUse": ["Sell"],
"allowDerivatives": True,
"allowDifferentLicense": True,
},
{
"allowNoCredit": False,
"allowCommercialUse": ["Image", "RentCivit", "Rent", "Sell"],
"allowDerivatives": True,
"allowDifferentLicense": True,
},
{
"allowNoCredit": False,
"allowCommercialUse": [],
"allowDerivatives": False,
"allowDifferentLicense": False,
},
{
"allowNoCredit": True,
"allowCommercialUse": ["Image", "Rent"],
"allowDerivatives": False,
"allowDifferentLicense": True,
},
]
@pytest.mark.parametrize("payload", PERMUTATIONS)
def test_decode_round_trips_build_license_flags(payload):
assert decode_license_flags(build_license_flags(payload)) == payload
def test_decode_maps_each_commercial_bit_to_its_civitai_value():
base = {
"allowNoCredit": False,
"allowDerivatives": False,
"allowDifferentLicense": False,
}
for value in ("Image", "RentCivit", "Rent", "Sell"):
decoded = decode_license_flags(build_license_flags({**base, "allowCommercialUse": [value]}))
assert decoded["allowCommercialUse"] == [value]
def test_decode_keeps_full_permission_combination():
flags = build_license_flags(
{
"allowNoCredit": True,
"allowCommercialUse": ["Image", "RentCivit", "Rent", "Sell"],
"allowDerivatives": True,
"allowDifferentLicense": True,
}
)
assert flags == 127
assert decode_license_flags(flags) == {
"allowNoCredit": True,
"allowCommercialUse": ["Image", "RentCivit", "Rent", "Sell"],
"allowDerivatives": True,
"allowDifferentLicense": True,
}
def test_decode_falls_back_to_civitai_defaults_for_invalid_input():
expected = decode_license_flags(build_license_flags({}))
assert decode_license_flags(None) == expected
assert decode_license_flags("not-a-number") == expected
assert expected == resolve_license_payload({})