feat: add local model hash comparison functionality

Implement end-to-end hash comparison for local models and local matches.

Backend:
- Add /model_resolver/local-model-hashes API endpoint
- Add get_local_model_hash_metadata() to read sidecar SHA256 metadata

Frontend:
- Add Compare Hashes context menu item and CSS
- Pass missing_key/missing_search_key through resolveDownloadMethods
- Enhance local match rendering with hash status and context data
- Add hash normalization, formatting, and candidate collection helpers
- Wire compareLocalModelHashesWithCurrentFinding action

Also update HuggingFace author index metadata.
This commit is contained in:
Dariusz L
2026-07-02 00:27:59 +02:00
parent 1c71b609f3
commit 1128234bbd
8 changed files with 413 additions and 15 deletions
+25
View File
@@ -93,6 +93,7 @@ class ModelResolverExtension:
from .core.resolver import (
analyze_and_find_matches,
apply_resolution,
get_local_model_hash_metadata,
normalize_sha256,
search_local_matches_by_hash,
search_local_matches,
@@ -522,6 +523,30 @@ class ModelResolverExtension:
)
return web.json_response({"matches": matches})
@routes.post("/model_resolver/local-model-hashes")
@json_api_endpoint("local-model-hashes")
async def local_model_hashes(request):
"""Return SHA256 hashes already stored in local sidecar metadata."""
data = await request.json()
model = data.get("model") if isinstance(data.get("model"), dict) else {}
path = (
data.get("path")
or data.get("file_path")
or data.get("resolved_path")
or model.get("path")
or model.get("resolved_path")
or ""
)
if not path:
return web.json_response(
{"error": "path is required"}, status=400
)
return web.json_response(
get_local_model_hash_metadata(path, model=model)
)
@routes.post("/model_resolver/open-containing-folder")
@json_api_endpoint("open-containing-folder")
async def open_containing_folder(request):
+62
View File
@@ -479,6 +479,68 @@ def search_local_matches_by_hash(
return matches
def get_local_model_hash_metadata(
model_path: str,
model: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Return SHA256 hashes already stored in sidecar metadata for a local model.
This intentionally does not hash model files. It only reads metadata sidecars
next to the selected local model, so callers can do quick hash comparisons.
"""
raw_path = str(model_path or "").strip()
if not raw_path:
return {"exists": False, "metadata_path": "", "hashes": [], "sha256": ""}
normalized_path = os.path.abspath(os.path.normpath(raw_path))
exists = os.path.exists(normalized_path)
model_info: Dict[str, Any] = {
**(model if isinstance(model, dict) else {}),
"path": normalized_path,
}
model_info.setdefault("filename", os.path.basename(normalized_path))
model_info.setdefault("relative_path", model_info.get("filename", ""))
first_metadata_path = ""
last_hash_status = ""
for metadata_path in _metadata_sidecar_candidates(normalized_path):
if not os.path.exists(metadata_path):
continue
if not first_metadata_path:
first_metadata_path = metadata_path
try:
with open(metadata_path, "r", encoding="utf-8") as handle:
metadata = json.load(handle)
except Exception as exc:
log.debug(f"Could not read metadata sidecar for local hash lookup: {metadata_path} ({exc})")
continue
if not isinstance(metadata, dict):
continue
last_hash_status = str(metadata.get("hash_status") or "").strip()
hashes = _extract_model_sha256_from_metadata(metadata, model_info)
if hashes:
return {
"exists": exists,
"metadata_path": metadata_path,
"hash_status": last_hash_status,
"hashes": hashes,
"sha256": hashes[0],
}
return {
"exists": exists,
"metadata_path": first_metadata_path,
"hash_status": last_hash_status,
"hashes": [],
"sha256": "",
}
def extract_workflow_urls(workflow_json: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
"""
Extract model URLs from workflow JSON.
File diff suppressed because one or more lines are too long
+13
View File
@@ -2637,6 +2637,19 @@
color: #72d38a;
}
.mr-search-match-hash {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 20px;
padding: 2px 7px;
border: 1px solid rgba(125, 211, 252, 0.22);
border-radius: var(--mr-missing-control-radius, var(--mr-ui-radius-sm));
line-height: 1;
color: #7dd3fc;
background: rgba(125, 211, 252, 0.12);
}
.mr-search-match-medium {
color: #f1cf64;
}
@@ -457,6 +457,10 @@ export const resolveDownloadMethods = {
const targetPath = directory || filePath;
if (!targetPath) return null;
const missing = info?.missing || progress?.missing || null;
const missingKey = missing ? this.getMissingModelKey?.(missing) : (info?.missing_key || progress?.missing_key || '');
const missingSearchKey = missing ? this.getMissingSearchKey?.(missing) : (info?.missing_search_key || progress?.missing_search_key || '');
return {
context_scope: 'download_folder',
name: progress?.filename || info?.filename || 'Download',
@@ -466,7 +470,9 @@ export const resolveDownloadMethods = {
folder_path: directory || targetPath,
download_directory: directory || '',
download_path: filePath || '',
category: info?.category || progress?.category || ''
category: info?.category || progress?.category || '',
missing_key: missingKey || '',
missing_search_key: missingSearchKey || ''
};
},
+9
View File
@@ -165,6 +165,15 @@ export class ResolverManagerDialog extends ComfyDialog {
$el("div.mr-context-menu-divider.mr-context-menu-divider-folder", {
"data-menu-divider": "folder"
}),
$el("div.mr-context-menu-item.mr-context-menu-action-compare-hashes", {
"data-menu-action": "compareHashes",
onclick: () => this.handleContextMenuAction('compareHashes')
}, [
$el("span.mr-context-menu-item-icon", {
innerHTML: getSvgIcon('hash', 'currentColor', 'mr-context-menu-item-svg')
}),
$el("span", { textContent: "Compare Hashes" })
]),
$el("div.mr-context-menu-item.mr-context-menu-action-open-folder", {
"data-menu-action": "openFolder",
onclick: () => this.handleContextMenuAction('openFolder')
+67 -12
View File
@@ -1653,6 +1653,10 @@ export const searchPanelMethods = {
getSearchResultMatchDisplay(result = {}, fallbackLabel = 'Match', fallbackClass = 'neutral') {
const matchType = String(result.match_type || '').toLowerCase();
if (result.hash_verified || result.hash_verified_sha256 || matchType === 'hash') {
return { label: 'Hash', className: 'hash' };
}
if (matchType === 'exact') {
return { label: 'Exact', className: 'strong' };
}
@@ -1998,16 +2002,59 @@ export const searchPanelMethods = {
`;
},
buildContextMenuModelData(model = {}, fallbackName = '') {
const resolvedPath = model.path || model.resolved_path || '';
const filename = model.filename || fallbackName || resolvedPath.split(/[\/\\]/).pop() || '';
return {
buildContextMenuModelData(model = {}, fallbackName = '', extra = {}) {
const merged = {
...model,
name: model.name || filename,
original_path: model.original_path || filename,
resolved_path: resolvedPath,
category: model.category || ''
...(extra && typeof extra === 'object' ? extra : {})
};
const resolvedPath = merged.path || merged.resolved_path || '';
const filename = merged.filename || fallbackName || resolvedPath.split(/[\/\\]/).pop() || '';
return {
...merged,
name: merged.name || filename,
original_path: merged.original_path || filename,
resolved_path: resolvedPath,
category: merged.category || ''
};
},
getLocalMatchHash(match = {}) {
const model = match.model || {};
const hashes = model.hashes && typeof model.hashes === 'object' ? model.hashes : {};
return String(
match.sha256
|| match.hash
|| model.sha256
|| model.hash
|| hashes.SHA256
|| hashes.sha256
|| ''
).trim();
},
getLocalMatchContextData(missing = {}, match = {}) {
const hash = this.getLocalMatchHash(match);
const metadataPath = match.metadata_path || match.model?.metadata_path || '';
return {
context_scope: 'local_match',
missing_key: this.getMissingModelKey?.(missing) || '',
missing_search_key: this.getMissingSearchKey?.(missing) || '',
local_match_confidence: match.confidence || 0,
local_match_sha256: hash,
sha256: hash || match.model?.sha256 || '',
hash: hash || match.model?.hash || '',
hash_match: Boolean(match.hash_match || match.match_type === 'hash'),
metadata_path: metadataPath
};
},
renderLocalMatchStatus(match = {}) {
const confidence = Number(match.confidence || 0);
const label = confidence === 100 ? 'Exact' : 'Partial';
const className = confidence === 100
? 'mr-match-status-exact'
: 'mr-match-status-partial';
return `<span class="mr-match-status ${className}">${label}</span>`;
},
areLocalMatchAlternativesCollapsed() {
@@ -2059,12 +2106,16 @@ export const searchPanelMethods = {
const buttonId = `resolve-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`;
const matchPath = match.model?.relative_path || match.model?.path || match.path || match.filename || '';
const isBestMatch = matchIndex === 0 && match.confidence >= 95;
const contextModel = this.buildContextMenuModelData(match.model || {}, match.filename || '');
const contextModel = this.buildContextMenuModelData(
match.model || {},
match.filename || '',
this.getLocalMatchContextData(missing, match)
);
html += `<div class="mr-match-row ${isBestMatch ? 'mr-best-match' : ''}"${this.getContextMenuAttrs(contextModel)}>`;
html += this.getConfidenceBadge(match.confidence);
html += `<span class="mr-match-filename" data-tooltip="${this.escapeHtml(matchPath)}">${this.escapeHtml(matchPath)}</span>`;
html += `<span class="mr-match-status ${match.confidence === 100 ? 'mr-match-status-exact' : 'mr-match-status-partial'}">${match.confidence === 100 ? 'Exact' : 'Partial'}</span>`;
html += this.renderLocalMatchStatus(match);
html += `<button id="${buttonId}" class="mr-btn mr-btn-secondary mr-btn-sm mr-btn-icon-only mr-local-link-btn" data-tooltip="Link this local match" aria-label="Link this local match">`;
html += getSvgIcon('link');
html += `</button>`;
@@ -2084,12 +2135,16 @@ export const searchPanelMethods = {
for (let mIdx = 0; mIdx < otherMatches.length; mIdx++) {
const match = otherMatches[mIdx];
const altBtnId = `resolve-alt-${missingIndex}-${missing.node_id}-${missing.widget_index}-${mIdx}`;
const contextModel = this.buildContextMenuModelData(match.model || {}, match.filename || '');
const contextModel = this.buildContextMenuModelData(
match.model || {},
match.filename || '',
this.getLocalMatchContextData(missing, match)
);
const matchPath = match.model?.relative_path || match.model?.path || match.path || match.filename || '';
html += `<div class="mr-match-row"${this.getContextMenuAttrs(contextModel)}>`;
html += this.getConfidenceBadge(match.confidence);
html += `<span class="mr-match-filename" data-tooltip="${this.escapeHtml(matchPath)}">${this.escapeHtml(matchPath)}</span>`;
html += `<span class="mr-match-status mr-match-status-partial">Partial</span>`;
html += this.renderLocalMatchStatus(match);
html += `<button id="${altBtnId}" class="mr-btn mr-btn-secondary mr-btn-sm mr-btn-icon-only mr-local-link-btn" data-tooltip="Link this local match" aria-label="Link this local match">${getSvgIcon('link')}</button>`;
html += `</div>`;
}
+229 -1
View File
@@ -29,19 +29,24 @@ export const modelInfoMethods = {
const isDownloadRootContext = model?.context_scope === 'download_root';
const isDownloadQueueContext = model?.context_scope === 'download_queue';
const isDownloadHistoryContext = model?.context_scope === 'download_history';
const isLocalModelContext = model?.context_scope === 'local_model' || model?.context_scope === 'local_match';
const isFolderOnlyContext = isDownloadFolderContext || isDownloadRootContext;
const isSourceModelContext = !isDownloadTableContext && !isFolderOnlyContext && !isDownloadQueueContext && !isDownloadHistoryContext;
const hasLocalPath = Boolean(model?.open_path || model?.folder_path || model?.download_directory || model?.directory || model?.path || model?.resolved_path);
const showOpenFolder = !isDownloadTableContext && hasLocalPath;
const showCompareHashes = (isLocalModelContext || isDownloadFolderContext)
&& hasLocalPath
&& Boolean(model?.missing_key || model?.missing_search_key);
const showSwitchWorkflow = (isDownloadQueueContext || isDownloadHistoryContext) && Boolean(this.canSwitchToDownloadWorkflow?.(model));
this.setContextMenuItemVisible('showInfo', isSourceModelContext);
this.setContextMenuItemVisible('showMore', canShowMore);
this.setContextMenuItemVisible('civitai', isSourceModelContext);
this.setContextMenuItemVisible('switchWorkflow', showSwitchWorkflow);
this.setContextMenuItemVisible('compareHashes', showCompareHashes);
this.setContextMenuItemVisible('openFolder', showOpenFolder);
this.setContextMenuDividerVisible('source', isSourceModelContext || canShowMore);
this.setContextMenuDividerVisible('workflow', showSwitchWorkflow && (isSourceModelContext || canShowMore));
this.setContextMenuDividerVisible('folder', showOpenFolder && (isSourceModelContext || canShowMore || showSwitchWorkflow));
this.setContextMenuDividerVisible('folder', (showCompareHashes || showOpenFolder) && (isSourceModelContext || canShowMore || showSwitchWorkflow));
const openFolderLabel = this.contextMenu.querySelector('.mr-context-menu-action-open-folder span:last-child');
if (openFolderLabel) {
@@ -86,6 +91,8 @@ export const modelInfoMethods = {
this.openInCivitAI(model);
} else if (action === 'openFolder') {
this.openContainingFolder(model);
} else if (action === 'compareHashes') {
this.compareLocalModelHashesWithCurrentFinding(model);
} else if (action === 'switchWorkflow') {
this.switchToDownloadWorkflow(model);
} else if (action === 'showInfo') {
@@ -134,6 +141,227 @@ export const modelInfoMethods = {
return (this.missingModels || []).find(missing => this.getMissingModelKey?.(missing) === key) || null;
},
getMissingForHashCompareContext(model = {}) {
const missingKey = String(model?.missing_key || '').trim();
if (missingKey) {
const byMissingKey = this.getMissingByKey(missingKey);
if (byMissingKey) return byMissingKey;
}
const searchKey = String(model?.missing_search_key || '').trim();
if (searchKey && typeof this.getMissingSearchKey === 'function') {
return (this.missingModels || []).find(missing => this.getMissingSearchKey(missing) === searchKey) || null;
}
return null;
},
normalizeSha256ForCompare(value = '') {
let text = String(value || '').trim();
text = text.replace(/^sha256[:=]/i, '').trim().toLowerCase();
return /^[a-f0-9]{64}$/.test(text) ? text : '';
},
formatSha256Short(value = '') {
const hash = this.normalizeSha256ForCompare(value) || String(value || '').trim();
if (hash.length <= 18) return hash;
return `${hash.slice(0, 8)}...${hash.slice(-8)}`;
},
collectHashCandidatesForCompare(value, source = 'metadata', seen = new Set(), depth = 0) {
const candidates = [];
if (value === undefined || value === null || depth > 5) return candidates;
const addHash = (rawValue, hashSource = source) => {
const hash = this.normalizeSha256ForCompare(rawValue);
if (!hash || seen.has(hash)) return;
seen.add(hash);
candidates.push({ hash, source: hashSource || source || 'metadata' });
};
if (typeof value === 'string' || typeof value === 'number') {
addHash(value);
return candidates;
}
if (Array.isArray(value)) {
value.forEach(item => {
candidates.push(...this.collectHashCandidatesForCompare(item, source, seen, depth + 1));
});
return candidates;
}
if (typeof value !== 'object') return candidates;
[
['local_match_sha256', 'local match'],
['sha256', source],
['hash', source],
['SHA256', source],
['file_hash', source],
['fileHash', source]
].forEach(([key, label]) => addHash(value[key], label));
const hashes = value.hashes;
if (Array.isArray(hashes)) {
candidates.push(...this.collectHashCandidatesForCompare(hashes, source, seen, depth + 1));
} else if (hashes && typeof hashes === 'object') {
['SHA256', 'sha256', 'hash'].forEach(key => addHash(hashes[key], source));
}
[
['file_info', value.file_info],
['file', value.file],
['selected_file', value.selected_file],
['path_metadata', value.path_metadata],
['download_metadata', value.download_metadata],
['metadata', value.metadata],
['selected_version', value.selected_version || value.selectedVersion],
['version', value.version],
['civitai', value.civitai]
].forEach(([key, nestedValue]) => {
if (nestedValue !== undefined && nestedValue !== null) {
candidates.push(...this.collectHashCandidatesForCompare(nestedValue, `${source} ${key}`, seen, depth + 1));
}
});
['files', 'mirrors', 'download_files', 'downloadFiles', 'modelVersions', 'versions'].forEach(key => {
if (Array.isArray(value[key])) {
candidates.push(...this.collectHashCandidatesForCompare(value[key], `${source} ${key}`, seen, depth + 1));
}
});
return candidates;
},
getLocalHashCandidatesForCompare(model = {}) {
const seen = new Set();
return this.collectHashCandidatesForCompare(model, 'local model metadata', seen);
},
getLocalHashComparePath(model = {}) {
return model?.open_path
|| model?.resolved_path
|| model?.path
|| model?.file_path
|| '';
},
async fetchLocalHashCandidatesForCompare(model = {}) {
const path = this.getLocalHashComparePath(model);
if (!path) {
return { candidates: [], data: null };
}
const data = await this.fetchJson('/model_resolver/local-model-hashes', {
method: 'POST',
silent: true,
body: JSON.stringify({ path, model })
}, 'Read local model hash metadata');
return {
data,
candidates: this.collectHashCandidatesForCompare(data, 'local metadata', new Set())
};
},
markSearchResultHashBadgesForMissing(missing = {}, sha256 = '') {
const hash = this.normalizeSha256ForCompare(sha256);
if (!missing || !hash) return false;
const markIfMatches = (result, sourceLabel = 'result') => {
if (!result || typeof result !== 'object') return false;
const hashes = this.collectHashCandidatesForCompare(result, sourceLabel, new Set());
if (!hashes.some(candidate => candidate.hash === hash)) return false;
result.hash_verified = true;
result.hash_verified_sha256 = hash;
return true;
};
const state = this.getSearchState?.(missing)
|| this.searchResultCache?.get(this.getMissingSearchKey?.(missing));
const results = state?.results || {};
const sources = [
['download_source', missing.download_source],
['popular', results.popular],
['model_list', results.model_list],
['huggingface', results.huggingface],
['civitai', results.civitai],
['civarchive', results.civarchive],
['lora_manager_archive', results.lora_manager_archive]
];
let changed = false;
sources.forEach(([sourceKey, value]) => {
if (Array.isArray(value)) {
value.forEach(item => {
changed = markIfMatches(item, sourceKey) || changed;
});
return;
}
changed = markIfMatches(value, sourceKey) || changed;
});
if (changed && state) {
const workflowKey = this.getWorkflowScopedQueueKey?.();
this.persistSearchStateForWorkflow?.(workflowKey, missing, state);
this.refreshSearchUiForMissing?.(missing, state, { workflowKey });
}
return changed;
},
async compareLocalModelHashesWithCurrentFinding(model = {}) {
const missing = this.getMissingForHashCompareContext(model);
if (!missing) {
this.showNotification?.('Cannot find the search results for this model.', 'warning');
return;
}
let localHashes = this.getLocalHashCandidatesForCompare(model);
let metadataResult = null;
if (!localHashes.length) {
try {
const lookup = await this.fetchLocalHashCandidatesForCompare(model);
metadataResult = lookup.data;
localHashes = lookup.candidates;
} catch (error) {
this.showNotification?.(`Local hash metadata lookup failed: ${error?.message || error}`, 'error');
return;
}
}
const filename = model.filename
|| model.name
|| String(this.getLocalHashComparePath(model) || '').split(/[\/\\]/).pop()
|| 'Selected local model';
if (!localHashes.length) {
const metadataPath = metadataResult?.metadata_path || model.metadata_path || '';
this.showNotification?.(
metadataPath
? 'Local metadata exists, but no completed SHA256 hash was found.'
: 'No local SHA256 metadata found for this model.',
'warning'
);
return;
}
const marked = localHashes.some(candidate => (
this.markSearchResultHashBadgesForMissing(missing, candidate.hash)
));
if (marked) {
return;
}
const localPreview = localHashes
.map(candidate => this.formatSha256Short(candidate.hash))
.join(', ');
this.showNotification?.(
`No search result hash matches ${filename} (${localPreview}).`,
'warning'
);
},
async openContainingFolder(model) {
const path = model?.open_path || model?.folder_path || model?.download_directory || model?.directory || model?.path || model?.resolved_path || '';
if (!path) {