feat: implement local-only model info fetching and refactor model info dialog

- Add local_only endpoint mode: returns basic file info (filename, size, path,
  location, sha256) without querying CivitAI or computing file hashes
- Validate provided sha256/hash inputs (must be 64-char hex) and fall back to
  empty string when invalid
- Refactor monolithic info response builder into build_info_response() helper
  used across all result paths (local, CivitAI success, CivitAI fallback,
  not-found), reducing duplication
- Add get_model_info_for_file(local_only=True) early return in CivitAI source
  that builds a minimal local-only result without hash lookup
- Split frontend fetchModelInfoForDialog into:
  - fetchLocalModelInfoForDialog: opens dialog with local data immediately,
    marks civitai_checked=false
  - fetchCivitaiModelInfoForDialog: async CivitAI lookup triggered by user
- Add 'Fetch with CivitAI' button in info dialog when local file exists but
  CivitAI has not been checked yet
- Infer model_type from category in both backend and frontend to reduce
  reliance on upstream data shape
- Fix sidecar metadata condition (was skipping save when path already existed)
- Add cursor/disabled styles for info dialog CivitAI link button
This commit is contained in:
Dariusz L
2026-06-26 22:16:44 +02:00
parent 4e576cbd56
commit 3cd302d4ee
4 changed files with 346 additions and 60 deletions
+137 -39
View File
@@ -1112,6 +1112,19 @@ class ModelResolverExtension:
filename = data.get("filename", "")
category = data.get("category", "")
resolved_path = data.get("resolved_path", "")
local_only = to_bool(data.get("local_only"), False)
provided_hash = (
data.get("sha256")
or data.get("hash")
or data.get("file_hash")
or ""
)
provided_hash = str(provided_hash or "").strip().lower()
if not (
len(provided_hash) == 64
and all(ch in "0123456789abcdef" for ch in provided_hash)
):
provided_hash = ""
if not filename:
return web.json_response(
@@ -1155,17 +1168,125 @@ class ModelResolverExtension:
except Exception:
pass
# Search CivitAI for the model using hash
if download_available and file_path and _os.path.exists(file_path):
if file_path and _os.path.exists(file_path):
file_location = _os.path.dirname(file_path).replace("\\", "/")
if file_location and not file_location.endswith("/"):
file_location += "/"
def infer_model_type_from_category(value):
if not str(value or "").strip():
return ""
normalized = normalize_download_category(value or "")
type_map = {
"checkpoints": "checkpoint",
"loras": "lora",
"vae": "vae",
"text_encoders": "text_encoder",
"clip": "clip",
"clip_vision": "clip_vision",
"controlnet": "controlnet",
"upscale_models": "upscale",
"diffusion_models": "diffusion_model",
}
return type_map.get(normalized, normalized.rstrip("s"))
def build_info_response(
result=None,
*,
metadata_path="",
metadata_saved=False,
civitai_checked=False,
local_payload=False,
):
result = result or {}
size_value = result.get("size")
if not size_value and file_path and _os.path.exists(file_path):
try:
size_value = _os.path.getsize(file_path)
except Exception:
size_value = None
model_type = (
result.get("model_type")
or result.get("type")
or infer_model_type_from_category(category)
)
return {
"filename": result.get("filename") or filename,
"category": category,
"file_path": result.get("file_path") or file_path or "",
"resolved_path": result.get("resolved_path") or file_path or "",
"metadata_path": metadata_path
or result.get("metadata_path")
or "",
"metadata_saved": bool(metadata_saved),
"location": result.get("location") or file_location,
"url": result.get("url"),
"version_url": result.get("version_url"),
"model_id": result.get("model_id"),
"model_name": result.get("model_name") or clean_name,
"model_type": model_type,
"version_id": result.get("version_id"),
"version_name": result.get("version_name", ""),
"sha256": result.get("sha256") or provided_hash,
"size": size_value,
"base_model": result.get("base_model"),
"tags": result.get("tags", []),
"trained_words": result.get("trained_words", []),
"images": result.get("images", []),
"clip_skip": result.get("clip_skip"),
"description": result.get("description", ""),
"model_description": result.get("model_description", ""),
"from_metadata": bool(result.get("from_metadata")),
"local_only": bool(local_payload),
"civitai_checked": bool(civitai_checked),
}
if local_only:
result = None
if download_available and file_path and _os.path.exists(file_path):
try:
from .core.sources.civitai import (
get_model_info_for_file,
)
result = get_model_info_for_file(
file_path,
local_only=True,
)
except Exception as e:
self.logger.warning(f"Local model info error: {e}")
return web.json_response(
build_info_response(
result,
local_payload=True,
civitai_checked=False,
)
)
# Search CivitAI for the model using hash
if download_available and file_path and _os.path.exists(file_path):
try:
from .core.sources.civitai import (
get_model_info_by_hash,
get_model_info_for_file,
)
result = get_model_info_for_file(file_path)
if provided_hash:
result = get_model_info_by_hash(provided_hash)
if result:
result["file_path"] = file_path
result["resolved_path"] = file_path
result["location"] = file_location
if not result.get("size"):
try:
result["size"] = _os.path.getsize(file_path)
except Exception:
pass
else:
result = get_model_info_for_file(file_path)
if result and (
result.get("url")
or result.get("version_url")
@@ -1177,7 +1298,7 @@ class ModelResolverExtension:
if not result.get("from_metadata"):
try:
sidecar_path = get_metadata_sidecar_path(file_path)
if sidecar_path and not _os.path.exists(sidecar_path):
if sidecar_path:
metadata_payload = {
"source": "civitai",
"details_source": "civitai",
@@ -1190,7 +1311,7 @@ class ModelResolverExtension:
"model_id": result.get("model_id"),
"version_id": result.get("version_id"),
"version_name": result.get("version_name", ""),
"sha256": result.get("sha256"),
"sha256": result.get("sha256") or provided_hash,
"size": result.get("size"),
"base_model": result.get("base_model"),
"tags": result.get("tags", []),
@@ -1219,45 +1340,18 @@ class ModelResolverExtension:
or "",
) or ""
metadata_saved = bool(metadata_path)
elif sidecar_path:
metadata_path = sidecar_path
except Exception as metadata_error:
self.logger.warning(
f"CivitAI metadata sidecar save failed: {metadata_error}"
)
return web.json_response(
{
"filename": filename,
"file_path": file_path,
"resolved_path": file_path,
"metadata_path": metadata_path,
"metadata_saved": metadata_saved,
"location": result.get("location")
or file_location,
"url": result.get("url"),
"version_url": result.get("version_url"),
"model_id": result.get("model_id"),
"model_name": result.get(
"model_name", clean_name
),
"model_type": result.get("model_type", ""),
"version_id": result.get("version_id"),
"version_name": result.get("version_name", ""),
"sha256": result.get("sha256"),
"size": result.get("size"),
"base_model": result.get("base_model"),
"tags": result.get("tags", []),
"trained_words": result.get(
"trained_words", []
),
"images": result.get("images", []),
"clip_skip": result.get("clip_skip"),
"description": result.get("description", ""),
"model_description": result.get(
"model_description", ""
),
}
build_info_response(
result,
metadata_path=metadata_path,
metadata_saved=metadata_saved,
civitai_checked=True,
)
)
except Exception as e:
self.logger.warning(f"CivitAI search error: {e}")
@@ -1275,6 +1369,7 @@ class ModelResolverExtension:
if result and result.get("url"):
return web.json_response(
{
"filename": filename,
"url": result["url"],
"file_path": file_path,
"resolved_path": file_path,
@@ -1283,13 +1378,16 @@ class ModelResolverExtension:
"version_id": result.get("version_id"),
"size": result.get("size"),
"tags": result.get("tags", []),
"civitai_checked": True,
}
)
except Exception as e:
self.logger.warning(f"CivitAI fallback search error: {e}")
# No result found
return web.json_response({"url": None})
response = build_info_response(None, civitai_checked=True)
response["url"] = None
return web.json_response(response)
@routes.post("/model_resolver/model-details")
@json_api_endpoint("model-details")
+23 -1
View File
@@ -1783,7 +1783,7 @@ def _metadata_to_model_info(metadata: Dict[str, Any]) -> Dict[str, Any]:
def get_model_info_for_file(
file_path: str, api_key: Optional[str] = None
file_path: str, api_key: Optional[str] = None, local_only: bool = False
) -> Optional[Dict[str, Any]]:
"""
Get model info from CivitAI by computing file hash and looking up by hash.
@@ -1792,6 +1792,8 @@ def get_model_info_for_file(
Args:
file_path: Full path to the model file
api_key: Optional CivitAI API key
local_only: If true, never calculate a hash or query CivitAI when
metadata is missing. Return only local file information.
Returns:
Dict with model info from CivitAI, or None if not found
@@ -1826,6 +1828,26 @@ def get_model_info_for_file(
]
log_info(f"Files in {directory}: {metadata_files}")
if local_only:
filename = os.path.basename(file_path)
stem = os.path.splitext(filename)[0]
result = {
"source": "local",
"filename": filename,
"file_path": file_path,
"resolved_path": file_path,
"model_name": stem,
"model_type": "",
"location": _format_model_location(file_path),
"from_metadata": False,
"local_only": True,
}
try:
result["size"] = os.path.getsize(file_path)
except Exception:
pass
return result
# If no metadata file, compute hash and look up on CivitAI
file_hash = _get_sha256_hash(file_path)
if not file_hash:
+10
View File
@@ -3834,6 +3834,7 @@
font-weight: 700;
white-space: nowrap;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.08) inset;
cursor: pointer;
}
.mr-info-link:hover {
@@ -3842,6 +3843,15 @@
color: #ffffff;
}
button.mr-info-link {
font: inherit;
}
button.mr-info-link:disabled {
cursor: wait;
opacity: 0.72;
}
.mr-info-external-link-icon {
width: 14px;
height: 14px;
+176 -20
View File
@@ -264,8 +264,8 @@ export const modelInfoMethods = {
});
}
// Fetch CivitAI info
this.fetchModelInfoForDialog(loraName, modelData, dialog);
dialog._infoDialogLookup = { loraName, modelData };
this.fetchLocalModelInfoForDialog(loraName, modelData, dialog);
},
/**
@@ -793,14 +793,14 @@ export const modelInfoMethods = {
async calculateInfoDialogHash(dialog, button) {
if (button?.dataset.hashCalculating === 'true') {
await this.cancelInfoDialogHashCalculation(dialog, button);
return;
return null;
}
const data = dialog?._infoDialogData || {};
const filePath = this.getInfoDialogModelFilePath(data);
if (!filePath) {
this.showNotification?.('No local file path available', 'error');
return;
return null;
}
dialog._hashCalculation = { progressId: '', cancelRequested: false };
@@ -842,7 +842,7 @@ export const modelInfoMethods = {
await new Promise(resolve => setTimeout(resolve, 250));
if (!dialog.isConnected) {
await this.cancelInfoDialogHashCalculation(dialog, button);
return;
return null;
}
const progress = await this.fetchJson(
`/model_resolver/calculate-file-hash/progress/${encodeURIComponent(progressId)}`,
@@ -869,7 +869,7 @@ export const modelInfoMethods = {
}
dialog._hashCalculation = null;
this.showNotification?.('Hash calculation stopped.', 'info');
return;
return null;
}
if (progress?.status === 'error') {
throw new Error(progress.error || progress.message || 'Hash calculation failed');
@@ -895,6 +895,7 @@ export const modelInfoMethods = {
result.metadata_updated ? 'Hash calculated and saved to metadata.' : 'Hash calculated.',
'success'
);
return updatedData;
} catch (error) {
dialog._hashCalculation = null;
if (button) {
@@ -905,6 +906,7 @@ export const modelInfoMethods = {
}
this.hideInfoDialogHashProgress(dialog);
this.showNotification?.(`Hash calculation failed: ${error?.message || error}`, 'error');
return null;
}
},
@@ -913,6 +915,12 @@ export const modelInfoMethods = {
dialog.dataset.mlInfoBound = 'true';
dialog.addEventListener('click', async (event) => {
const fetchCivitaiButton = event.target.closest('.mr-info-fetch-civitai');
if (fetchCivitaiButton && dialog.contains(fetchCivitaiButton)) {
await this.fetchCivitaiModelInfoForDialog(dialog, fetchCivitaiButton);
return;
}
const hashButton = event.target.closest('.mr-info-hash-calculate');
if (hashButton && dialog.contains(hashButton)) {
await this.calculateInfoDialogHash(dialog, hashButton);
@@ -1047,28 +1055,167 @@ export const modelInfoMethods = {
dialog._infoDialogResizeSaveTimer = resizeSaveTimer;
},
getModelInfoResolvedPath(modelData = {}) {
return modelData?.resolved_path
|| modelData?.path
|| modelData?.full_path
|| modelData?.folder_path
|| modelData?.download_directory
|| '';
},
buildLocalInfoDialogData(loraName, modelData = {}) {
const filename = loraName || modelData?.filename || modelData?.name || '';
const modelName = String(filename || '').replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
const resolvedPath = this.getModelInfoResolvedPath(modelData);
const categoryTypeMap = {
checkpoints: 'checkpoint',
loras: 'lora',
vae: 'vae',
text_encoders: 'text_encoder',
clip: 'clip',
clip_vision: 'clip_vision',
controlnet: 'controlnet',
upscale_models: 'upscale',
diffusion_models: 'diffusion_model'
};
const rawType = modelData?.model_type || modelData?.modelType || modelData?.type || modelData?.category || '';
const normalizedType = String(rawType || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
return {
filename,
category: modelData?.category || '',
file_path: resolvedPath,
resolved_path: resolvedPath,
location: resolvedPath ? this.formatInfoDialogLocation({ file_path: resolvedPath }) : '',
model_name: modelData?.model_name || modelData?.modelName || modelName,
model_type: categoryTypeMap[normalizedType] || rawType,
sha256: modelData?.sha256 || modelData?.hash || modelData?.hashes?.SHA256 || '',
size: modelData?.size || modelData?.file_size || modelData?.fileSize || '',
base_model: modelData?.base_model || modelData?.baseModel || '',
tags: modelData?.tags || [],
trained_words: modelData?.trained_words || modelData?.trainedWords || [],
local_only: true,
civitai_checked: false
};
},
/**
* Fetch model info and update the dialog
* Fetch local model info and update the dialog without querying CivitAI.
*/
async fetchModelInfoForDialog(loraName, modelData, dialog) {
async fetchLocalModelInfoForDialog(loraName, modelData, dialog) {
const requestId = `${Date.now()}_${Math.random()}`;
dialog._localInfoRequestId = requestId;
const fallbackData = this.buildLocalInfoDialogData(loraName, modelData);
this.updateInfoDialogWithData(dialog, fallbackData);
try {
const resolvedPath = modelData?.resolved_path
|| modelData?.path
|| modelData?.full_path
|| modelData?.folder_path
|| modelData?.download_directory
|| '';
const data = await this.fetchJson('/model_resolver/civitai-search', {
method: 'POST',
body: JSON.stringify({
filename: loraName,
category: modelData?.category || '',
resolved_path: resolvedPath
resolved_path: this.getModelInfoResolvedPath(modelData),
local_only: true
})
}, 'Fetch local model info');
if (
dialog._localInfoRequestId !== requestId
|| dialog._infoDialogCivitaiFetchStarted
|| dialog._infoDialogData?.civitai_checked
|| dialog._infoDialogData?.version_url
|| dialog._infoDialogData?.url
) {
return;
}
this.updateInfoDialogWithData(dialog, { ...fallbackData, ...data, civitai_checked: false });
} catch (e) {
if (dialog._localInfoRequestId !== requestId || dialog._infoDialogCivitaiFetchStarted) {
return;
}
this.updateInfoDialogWithData(dialog, fallbackData);
}
},
async fetchCivitaiModelInfoForDialog(dialog, button = null) {
if (!dialog) return;
if (button?.dataset.fetchingCivitai === 'true') return;
const lookup = dialog._infoDialogLookup || {};
let data = dialog._infoDialogData || {};
const filename = data.filename || lookup.loraName || '';
const category = data.category || lookup.modelData?.category || '';
const resolvedPath = this.getInfoDialogModelFilePath(data)
|| this.getModelInfoResolvedPath(lookup.modelData || {});
if (!filename) {
this.showNotification?.('No model filename available', 'error');
return;
}
dialog._infoDialogCivitaiFetchStarted = true;
const setFetchingState = (isFetching, label = 'Fetch with CivitAI') => {
if (!button) return;
button.dataset.fetchingCivitai = isFetching ? 'true' : 'false';
button.disabled = isFetching;
button.innerHTML = isFetching
? `${getSvgIcon('civitai', 'currentColor', 'mr-info-external-link-icon')} Fetching...`
: `${getSvgIcon('civitai', 'currentColor', 'mr-info-external-link-icon')} ${this.escapeHtml(label)}`;
};
setFetchingState(true);
try {
let hash = this.getInfoDialogHash(data);
if (!hash) {
const hashButton = dialog.querySelector('.mr-info-hash-calculate');
const hashResult = await this.calculateInfoDialogHash(dialog, hashButton);
if (!hashResult) {
setFetchingState(false);
return;
}
data = dialog._infoDialogData || data;
hash = this.getInfoDialogHash(data);
}
if (!hash) {
throw new Error('No SHA256 hash available');
}
const result = await this.fetchJson('/model_resolver/civitai-search', {
method: 'POST',
body: JSON.stringify({
filename,
category,
resolved_path: resolvedPath,
sha256: hash
})
}, 'Fetch CivitAI model info');
this.updateInfoDialogWithData(dialog, data);
} catch (e) {
this.updateInfoDialogError(dialog, 'Error fetching info from CivitAI');
const merged = {
...data,
...result,
filename,
category,
file_path: result.file_path || data.file_path || resolvedPath,
resolved_path: result.resolved_path || data.resolved_path || resolvedPath,
sha256: result.sha256 || hash,
civitai_checked: true
};
dialog._infoDialogData = merged;
this.updateInfoDialogWithData(dialog, merged);
if (merged.url || merged.version_url) {
this.showNotification?.('CivitAI info loaded.', 'success');
} else {
this.showNotification?.('No exact CivitAI match found.', 'info');
}
} catch (error) {
this.showNotification?.(`CivitAI fetch failed: ${error?.message || error}`, 'error');
this.updateInfoDialogWithData(dialog, {
...(dialog._infoDialogData || data),
civitai_checked: true
});
} finally {
setFetchingState(false);
}
},
@@ -1092,7 +1239,7 @@ export const modelInfoMethods = {
// Update title
const titleEl = dialog.querySelector('.mr-info-dialog-title');
if (titleEl) {
const modelName = data.model_name || data.modelName || 'Unknown Model';
const modelName = data.model_name || data.modelName || data.name || data.filename || 'Unknown Model';
const versionName = data.version_name || data.versionName || '';
titleEl.innerHTML = this.renderVersionedModelNameHtml(modelName, versionName) || this.escapeHtml(modelName);
}
@@ -1178,7 +1325,7 @@ export const modelInfoMethods = {
${getSvgIcon('externalLink', 'currentColor', 'mr-info-external-link-icon')}
</a>
`;
} else {
} else if (data.civitai_checked || data.civitaiChecked) {
const searchName = data.model_name || data.modelName || 'Unknown';
civitaiLinkEl.innerHTML = `
<span class="mr-info-not-found">Model not found</span>
@@ -1187,6 +1334,15 @@ export const modelInfoMethods = {
${getSvgIcon('externalLink', 'currentColor', 'mr-info-external-link-icon')}
</a>
`;
} else if (this.getInfoDialogModelFilePath(data)) {
civitaiLinkEl.innerHTML = `
<button type="button" class="mr-info-link mr-info-fetch-civitai">
${getSvgIcon('civitai', 'currentColor', 'mr-info-external-link-icon')}
Fetch with CivitAI
</button>
`;
} else {
civitaiLinkEl.innerHTML = '';
}
}