feat: improve local hash matching and strengthen download selection metadata

- add POST /model_resolver/local-matches-by-hash backend endpoint
- expand local hash lookup sources to lora_manager_archive, popular, and model_list
- deduplicate backend hash matches by SHA256 value instead of source-hash tuple
- add frontend sync for remote hash matches and hash-label disambiguation
- propagate sha256, file_info, selected_file, and selected_version in model selection/download flows
- render local match status with consistent hash labels across search panel and resolvers
This commit is contained in:
Dariusz L
2026-07-03 16:37:10 +02:00
parent 4c777e5c50
commit 42b13bbb60
5 changed files with 386 additions and 56 deletions
+66 -21
View File
@@ -630,6 +630,60 @@ class ModelResolverExtension:
get_local_model_hash_metadata(path, model=model)
)
@routes.post("/model_resolver/local-matches-by-hash")
@json_api_endpoint("local-matches-by-hash")
async def local_matches_by_hash(request):
"""Search local model metadata sidecars for a remote SHA256."""
data = await request.json()
sha256 = normalize_sha256(
data.get("sha256")
or data.get("hash")
or data.get("SHA256")
or ""
)
if not sha256:
return web.json_response(
{"error": "sha256 is required"}, status=400
)
category = data.get("category", "")
source = str(
data.get("source")
or data.get("hash_lookup_source")
or "download_source"
).strip().lower().replace("-", "_")
filename = (
data.get("filename")
or data.get("path")
or data.get("model_name")
or ""
)
max_matches = to_int(data.get("max_matches"), 20)
force_rescan = to_bool(data.get("force_rescan"), False)
matches = search_local_matches_by_hash(
sha256,
category=category or None,
max_matches=max_matches,
force_rescan=force_rescan,
)
enriched_matches = [
{
**match,
"hash_lookup_source": source or "download_source",
"hash_lookup_filename": filename,
"hash_lookup_sha256": sha256,
}
for match in matches
]
return web.json_response(
{
"sha256": sha256,
"local_hash_matches": enriched_matches,
"matches": enriched_matches,
}
)
@routes.post("/model_resolver/open-containing-folder")
@json_api_endpoint("open-containing-folder")
async def open_containing_folder(request):
@@ -2635,37 +2689,27 @@ class ModelResolverExtension:
return normalized
return ""
def is_hash_lookup_candidate(result):
if not isinstance(result, dict):
return False
try:
confidence = float(result.get("confidence") or 0)
except (TypeError, ValueError):
confidence = 0.0
if confidence >= 100.0:
return True
match_type = str(result.get("match_type") or "").lower()
if match_type in {"exact", "model_title", "title"}:
return True
return confidence >= 95.0
def collect_local_hash_matches(payload):
matches = []
seen_match_paths = set()
seen_hash_sources = set()
for source_key in ("huggingface", "civitai", "civarchive"):
seen_hashes = set()
for source_key in (
"huggingface",
"civitai",
"civarchive",
"lora_manager_archive",
"popular",
"model_list",
):
for source_result in iter_result_items(payload.get(source_key)):
raise_if_search_cancelled(source_key)
if not is_hash_lookup_candidate(source_result):
continue
sha256 = get_result_sha256(source_result)
if not sha256:
continue
hash_source_key = (source_key, sha256)
if hash_source_key in seen_hash_sources:
if sha256 in seen_hashes:
continue
seen_hash_sources.add(hash_source_key)
seen_hashes.add(sha256)
self.search_tracker.update(
progress_id,
@@ -2704,6 +2748,7 @@ class ModelResolverExtension:
"hash_lookup_filename": source_result.get("filename")
or source_result.get("path")
or filename,
"hash_lookup_sha256": sha256,
}
matches.append(enriched)
+110 -13
View File
@@ -741,6 +741,88 @@ export const resolveDownloadMethods = {
return hashMatches;
},
getExistingLocalHashMatchesForSha(missing = {}, sha256 = '', state = null) {
const hash = this.normalizeSearchResultSha256?.(sha256)
|| String(sha256 || '').trim().toLowerCase();
if (!hash) return [];
const results = state?.results || {};
const matches = [
...(Array.isArray(results.local_hash_matches) ? results.local_hash_matches : []),
...(Array.isArray(missing.matches) ? missing.matches : [])
];
const seen = new Set();
const found = [];
matches.forEach(match => {
if (!match || typeof match !== 'object') return;
if (!(match.hash_match || match.match_type === 'hash' || match.hash_lookup_source)) return;
const matchHash = this.normalizeSearchResultSha256?.(this.getLocalMatchHash?.(match) || match.sha256 || match.hash)
|| String(this.getLocalMatchHash?.(match) || match.sha256 || match.hash || '').trim().toLowerCase();
if (matchHash !== hash) return;
const identity = this.getLocalMatchIdentity?.(match) || JSON.stringify(match);
if (seen.has(identity)) return;
seen.add(identity);
found.push(match);
});
return found;
},
async syncRemoteHashMatchesForResult(missing = {}, result = {}, options = {}) {
if (!missing || !result || typeof result !== 'object') return [];
const sha256 = this.getSearchResultSha256?.(result) || '';
if (!sha256) return [];
const workflowKey = options.workflowKey || this.getWorkflowScopedQueueKey?.();
const state = options.state
|| (workflowKey ? this.getSearchStateForWorkflow?.(workflowKey, missing) : null)
|| this.searchResultCache?.get?.(this.getMissingSearchKey?.(missing))
|| this.getSearchState?.(missing)
|| null;
const existingMatches = this.getExistingLocalHashMatchesForSha(missing, sha256, state);
if (existingMatches.length) {
this.refreshSearchUiForMissing?.(missing, state || null, { workflowKey });
return existingMatches;
}
const sourceKey = String(
options.sourceKey
|| result.sourceKey
|| result.source
|| result.details_source
|| 'download_source'
).trim().toLowerCase().replace(/-/g, '_');
const category = options.category
|| result.category
|| result.directory
|| missing.category
|| '';
const filename = result.filename
|| result.downloadFilename
|| result.path
|| missing.original_path
|| '';
try {
const data = await this.fetchJson('/model_resolver/local-matches-by-hash', {
method: 'POST',
silent: true,
body: JSON.stringify({
sha256,
category,
source: sourceKey,
filename,
max_matches: 20
})
}, 'Find local models by hash');
return this.applyLocalHashMatchesFromSearchResponse?.(missing, data, { workflowKey }) || [];
} catch (error) {
console.warn('Model Resolver: local hash match sync failed:', error);
return [];
}
},
restoreSearchLocalHashMatchesForMissing(missing) {
if (!missing) return missing;
@@ -2599,14 +2681,23 @@ export const resolveDownloadMethods = {
const civitaiResult = results.civitai ? (Array.isArray(results.civitai) ? results.civitai[0] : results.civitai) : null;
const civarchiveResult = results.civarchive ? (Array.isArray(results.civarchive) ? results.civarchive[0] : results.civarchive) : null;
const loraManagerArchiveResult = results.lora_manager_archive ? (Array.isArray(results.lora_manager_archive) ? results.lora_manager_archive[0] : results.lora_manager_archive) : null;
const knownDownloadRow = this.getDownloadSourceTableRow(missing, missing.download_source);
const localHashMatches = [
...(Array.isArray(results.local_hash_matches) ? results.local_hash_matches : []),
...(Array.isArray(missing.matches) ? missing.matches.filter(match => match?.hash_lookup_source) : [])
];
const hashLabelMap = this.getHashMatchLabelMap?.(missing, results) || null;
const getHashMatchIdentities = (sourceKey, result) => (
this.getLocalHashMatchIdentitiesForResult?.(localHashMatches, sourceKey, result) || []
);
const getHashMatchDisplay = (sourceKey, result, fallbackLabel = 'Match', fallbackClass = 'neutral') => {
const identities = getHashMatchIdentities(sourceKey, result);
const hashLabel = this.getHashMatchLabelForSearchResult?.(result, hashLabelMap, identities) || '';
return {
identities,
match: this.getSearchResultMatchDisplay(result, fallbackLabel, fallbackClass, hashLabel)
};
};
const knownDownloadRow = this.getDownloadSourceTableRow(missing, missing.download_source, hashLabelMap);
const hasResults = knownDownloadRow || popular || modelListResult || hfResult || civitaiResult || civarchiveResult || loraManagerArchiveResult;
const progressHtml = this.renderSearchProgress(state);
const hasActiveProgress = this.hasActiveSearchProgress(state);
@@ -2662,6 +2753,7 @@ export const resolveDownloadMethods = {
addRow(knownDownloadRow);
if (popular) {
const popularHash = getHashMatchDisplay('popular', popular, 'Known', 'strong');
const popularFilename = popular.filename || this.getFilenameFromPath(missing.original_path);
const missingCategory = this.getMissingDownloadCategory?.(missing, 'checkpoints') || missing.category || 'checkpoints';
const popularSize = popular.size
@@ -2678,18 +2770,19 @@ export const resolveDownloadMethods = {
model: popular.name || popularFilename,
filename: popularFilename,
secondary: popular.name && popular.name !== popularFilename ? popularFilename : '',
match: this.getSearchResultMatchDisplay(popular, 'Known', 'strong'),
match: popularHash.match,
size: this.formatSearchResultSize({ ...popular, size: popularSize }),
downloadUrl: popular.url,
downloadFilename: popularFilename,
category: popular.directory || missingCategory,
openUrl: getModelCardUrl(popular.url),
searchedAt: this.getSearchResultTimestamp(popular),
localHashMatchIdentities: getHashMatchIdentities('popular', popular)
localHashMatchIdentities: popularHash.identities
});
}
if (modelListResult && modelListResult.url) {
const modelListHash = getHashMatchDisplay('model_list', modelListResult);
const missingCategory = this.getMissingDownloadCategory?.(missing, 'checkpoints') || missing.category || 'checkpoints';
addRow({
sourceKey: 'model-list',
@@ -2697,18 +2790,19 @@ export const resolveDownloadMethods = {
model: modelListResult.name || modelListResult.filename,
filename: modelListResult.filename,
secondary: modelListResult.name && modelListResult.name !== modelListResult.filename ? modelListResult.filename : '',
match: this.getSearchResultMatchDisplay(modelListResult),
match: modelListHash.match,
size: this.formatSearchResultSize(modelListResult),
downloadUrl: modelListResult.url,
downloadFilename: modelListResult.filename,
category: modelListResult.directory || missingCategory,
openUrl: getModelCardUrl(modelListResult.url),
searchedAt: this.getSearchResultTimestamp(modelListResult),
localHashMatchIdentities: getHashMatchIdentities('model_list', modelListResult)
localHashMatchIdentities: modelListHash.identities
});
}
if (hfResult && hfResult.url) {
const hfHash = getHashMatchDisplay('huggingface', hfResult);
const hfRepo = hfResult.repo_id || hfResult.repo || '';
const hfModelUrl = hfRepo ? `https://huggingface.co/${hfRepo}` : getModelCardUrl(hfResult.url);
const missingCategory = this.getMissingDownloadCategory?.(missing, 'checkpoints') || missing.category || 'checkpoints';
@@ -2718,18 +2812,19 @@ export const resolveDownloadMethods = {
model: hfRepo || hfResult.filename,
filename: hfResult.filename,
secondary: hfResult.path && hfResult.path !== hfResult.filename ? hfResult.path : '',
match: this.getSearchResultMatchDisplay(hfResult),
match: hfHash.match,
size: this.formatSearchResultSize(hfResult),
downloadUrl: hfResult.url,
downloadFilename: hfResult.filename,
category: missingCategory,
openUrl: hfModelUrl,
searchedAt: this.getSearchResultTimestamp(hfResult),
localHashMatchIdentities: getHashMatchIdentities('huggingface', hfResult)
localHashMatchIdentities: hfHash.identities
});
}
if (civarchiveResult && civarchiveResult.download_url) {
const civarchiveHash = getHashMatchDisplay('civarchive', civarchiveResult);
const archiveFilename = civarchiveResult.filename || this.getFilenameFromPath(missing.original_path);
const archiveName = civarchiveResult.name || archiveFilename || 'Model';
const archiveCategory = this.getSourceResultDownloadCategory?.(
@@ -2749,14 +2844,14 @@ export const resolveDownloadMethods = {
version: civarchiveResult.version_name || '',
filename: archiveFilename,
secondary: archiveSecondary,
match: this.getSearchResultMatchDisplay(civarchiveResult),
match: civarchiveHash.match,
size: this.formatSearchResultSize(civarchiveResult),
downloadUrl: civarchiveResult.download_url,
downloadFilename: archiveFilename,
category: archiveCategory,
openUrl: civarchiveResult.url,
searchedAt: this.getSearchResultTimestamp(civarchiveResult),
localHashMatchIdentities: getHashMatchIdentities('civarchive', civarchiveResult),
localHashMatchIdentities: civarchiveHash.identities,
detailsContext: {
...civarchiveResult,
details_source: 'civarchive',
@@ -2767,6 +2862,7 @@ export const resolveDownloadMethods = {
}
if (loraManagerArchiveResult && loraManagerArchiveResult.download_url) {
const loraArchiveHash = getHashMatchDisplay('lora_manager_archive', loraManagerArchiveResult);
const archiveFilename = loraManagerArchiveResult.filename || this.getFilenameFromPath(missing.original_path);
const archiveName = loraManagerArchiveResult.name || archiveFilename;
const archiveCategory = this.getSourceResultDownloadCategory?.(
@@ -2781,14 +2877,14 @@ export const resolveDownloadMethods = {
version: loraManagerArchiveResult.version_name || '',
filename: archiveFilename,
secondary: archiveName && archiveName !== archiveFilename ? archiveFilename : '',
match: this.getSearchResultMatchDisplay(loraManagerArchiveResult),
match: loraArchiveHash.match,
size: this.formatSearchResultSize(loraManagerArchiveResult),
downloadUrl: loraManagerArchiveResult.download_url || '',
downloadFilename: archiveFilename,
category: archiveCategory,
openUrl: loraManagerArchiveResult.url || getModelCardUrl(loraManagerArchiveResult.download_url),
searchedAt: this.getSearchResultTimestamp(loraManagerArchiveResult),
localHashMatchIdentities: getHashMatchIdentities('lora_manager_archive', loraManagerArchiveResult),
localHashMatchIdentities: loraArchiveHash.identities,
detailsContext: {
...loraManagerArchiveResult,
source: 'lora_manager_archive',
@@ -2800,6 +2896,7 @@ export const resolveDownloadMethods = {
}
if (civitaiResult && civitaiResult.download_url) {
const civitaiHash = getHashMatchDisplay('civitai', civitaiResult);
const modelUrl = civitaiResult.url || getCivitaiModelUrl(civitaiResult.model_id, civitaiResult.version_id);
const downloadFilename = civitaiResult.filename || missing.civitai_info?.expected_filename || civitaiResult.name;
const modelName = civitaiResult.name || missing.civitai_info?.model_name || downloadFilename || 'Model';
@@ -2819,14 +2916,14 @@ export const resolveDownloadMethods = {
version: civitaiResult.version_name || missing.civitai_info?.version_name || '',
filename: downloadFilename,
secondary: civitaiSecondary,
match: this.getSearchResultMatchDisplay(civitaiResult),
match: civitaiHash.match,
size: this.formatSearchResultSize(civitaiResult),
downloadUrl: civitaiResult.download_url,
downloadFilename,
category: civitaiCategory,
openUrl: modelUrl,
searchedAt: this.getSearchResultTimestamp(civitaiResult),
localHashMatchIdentities: getHashMatchIdentities('civitai', civitaiResult),
localHashMatchIdentities: civitaiHash.identities,
detailsContext: {
...civitaiResult,
name: modelName,
@@ -1173,6 +1173,8 @@ export const downloadTargetMethods = {
if (merged.hashes && typeof merged.hashes === 'object') metadata.hashes = merged.hashes;
if (merged.file_info && typeof merged.file_info === 'object') metadata.file_info = merged.file_info;
if (merged.file && typeof merged.file === 'object') metadata.file = merged.file;
if (merged.selected_file && typeof merged.selected_file === 'object') metadata.selected_file = merged.selected_file;
if (merged.selectedFile && typeof merged.selectedFile === 'object') metadata.selected_file = merged.selectedFile;
if (Array.isArray(merged.files)) metadata.files = merged.files;
if (selectedVersion && typeof selectedVersion === 'object') metadata.selected_version = selectedVersion;
if (merged.civitai && typeof merged.civitai === 'object') metadata.civitai = merged.civitai;
+116 -20
View File
@@ -1647,10 +1647,11 @@ export const searchPanelMethods = {
return typeof result.size === 'number' ? this.formatBytes(result.size) : String(result.size);
},
getSearchResultMatchDisplay(result = {}, fallbackLabel = 'Match', fallbackClass = 'neutral') {
getSearchResultMatchDisplay(result = {}, fallbackLabel = 'Match', fallbackClass = 'neutral', hashLabel = '') {
const matchType = String(result.match_type || '').toLowerCase();
if (result.hash_verified || result.hash_verified_sha256 || matchType === 'hash') {
return { label: 'Hash', className: 'hash' };
const resolvedHashLabel = String(hashLabel || result.hash_match_label || result.hashLabel || '').trim();
if (resolvedHashLabel || result.hash_verified || result.hash_verified_sha256 || matchType === 'hash') {
return { label: resolvedHashLabel || 'Hash', className: 'hash' };
}
if (matchType === 'exact') {
@@ -1707,6 +1708,77 @@ export const searchPanelMethods = {
return '';
},
collectHashLabelMapHashes(missing = {}, results = null) {
const hashes = [];
const seen = new Set();
const addHash = (value) => {
const hash = this.normalizeSearchResultSha256?.(value)
|| String(value || '').trim().toLowerCase();
if (!hash || !/^[a-f0-9]{64}$/.test(hash) || seen.has(hash)) return;
seen.add(hash);
hashes.push(hash);
};
const addMatch = (match) => {
if (!match || typeof match !== 'object') return;
if (!(match.hash_match || match.match_type === 'hash' || match.hash_lookup_source)) return;
addHash(this.getLocalMatchHash?.(match) || match.sha256 || match.hash);
};
const addResult = (result) => {
if (Array.isArray(result)) {
result.forEach(addResult);
return;
}
if (!result || typeof result !== 'object') return;
const matchType = String(result.match_type || '').toLowerCase();
if (!(result.hash_verified || result.hash_verified_sha256 || matchType === 'hash')) return;
addHash(this.getSearchResultSha256?.(result) || result.sha256 || result.hash);
};
(Array.isArray(missing.matches) ? missing.matches : []).forEach(addMatch);
const stateResults = results
|| this.getSearchState?.(missing)?.results
|| this.searchResultCache?.get?.(this.getMissingSearchKey?.(missing))?.results
|| {};
(Array.isArray(stateResults.local_hash_matches) ? stateResults.local_hash_matches : []).forEach(addMatch);
[
missing.download_source,
stateResults.popular,
stateResults.model_list,
stateResults.huggingface,
stateResults.civitai,
stateResults.civarchive,
stateResults.lora_manager_archive
].forEach(addResult);
return hashes;
},
getHashMatchLabelMap(missing = {}, results = null) {
const hashes = this.collectHashLabelMapHashes?.(missing, results) || [];
const labelMap = new Map();
if (!hashes.length) return labelMap;
const useNumbers = hashes.length > 1;
hashes.forEach((hash, index) => {
labelMap.set(hash, useNumbers ? `Hash ${index + 1}` : 'Hash');
});
return labelMap;
},
getHashMatchLabelForSearchResult(result = {}, hashLabelMap = null, identities = []) {
const hash = this.getSearchResultSha256?.(result) || '';
const label = hash && hashLabelMap?.get?.(hash) ? hashLabelMap.get(hash) : '';
if (!label) return '';
const matchType = String(result.match_type || '').toLowerCase();
const hasLinkedLocalMatch = Array.isArray(identities) && identities.length > 0;
return (hasLinkedLocalMatch || result.hash_verified || result.hash_verified_sha256 || matchType === 'hash')
? label
: '';
},
normalizeHashLookupSourceKey(source = '') {
return String(source || '')
.trim()
@@ -1738,10 +1810,12 @@ export const searchPanelMethods = {
if (!match || typeof match !== 'object') return;
const matchSource = this.normalizeHashLookupSourceKey(match.hash_lookup_source || '');
if (!matchSource || matchSource !== normalizedSource) return;
const matchHash = this.normalizeSearchResultSha256(this.getLocalMatchHash(match));
if (sourceHash && matchHash !== sourceHash) return;
if (sourceHash) {
if (matchHash !== sourceHash) return;
} else if (!matchSource || matchSource !== normalizedSource) {
return;
}
addIdentity(this.getLocalMatchIdentity?.(match) || '');
});
@@ -1749,7 +1823,7 @@ export const searchPanelMethods = {
return identities;
},
getDownloadSourceTableRow(missing, downloadSource = {}) {
getDownloadSourceTableRow(missing, downloadSource = {}, hashLabelMap = null) {
if (!downloadSource?.url) return null;
const originalFilename = this.getFilenameFromPath(missing.original_path);
@@ -1818,6 +1892,17 @@ export const searchPanelMethods = {
const localHashMatches = Array.isArray(missing.matches)
? missing.matches.filter(match => match?.hash_lookup_source)
: [];
const localHashMatchIdentities = this.getLocalHashMatchIdentitiesForResult?.(
localHashMatches,
source,
downloadSource
) || [];
const resolvedHashLabelMap = hashLabelMap || this.getHashMatchLabelMap?.(missing) || null;
const hashMatchLabel = this.getHashMatchLabelForSearchResult?.(
downloadSource,
resolvedHashLabelMap,
localHashMatchIdentities
) || '';
return {
sourceKey,
@@ -1826,20 +1911,13 @@ export const searchPanelMethods = {
version: modelParts.version,
filename: downloadFilename,
secondary: secondaryParts.join(' / '),
match: isFromWorkflow
? { label: 'Provided', className: 'strong' }
: this.getSearchResultMatchDisplay(downloadSource, 'Known', 'strong'),
size: this.formatSearchResultSize(downloadSource),
downloadUrl: downloadSource.url,
downloadFilename,
category: rowCategory,
openUrl: modelUrl,
searchedAt: this.getSearchResultTimestamp(downloadSource),
localHashMatchIdentities: this.getLocalHashMatchIdentitiesForResult?.(
localHashMatches,
source,
downloadSource
) || [],
localHashMatchIdentities,
pathMetadata: rowPathMetadata,
downloadMetadata: this.getDownloadMetadata(missing, rowDetailsContext || {
...downloadSource,
@@ -1854,7 +1932,10 @@ export const searchPanelMethods = {
openUrl: modelUrl,
pathMetadata: rowPathMetadata
}),
detailsContext: rowDetailsContext
detailsContext: rowDetailsContext,
match: isFromWorkflow
? { label: 'Provided', className: 'strong' }
: this.getSearchResultMatchDisplay(downloadSource, 'Known', 'strong', hashMatchLabel)
};
},
@@ -2137,10 +2218,24 @@ export const searchPanelMethods = {
};
},
renderLocalMatchStatus(match = {}) {
renderLocalMatchStatus(match = {}, hashLabelMap = null) {
const confidence = Number(match.confidence || 0);
const isHashMatch = Boolean(match.hash_match || match.match_type === 'hash');
const label = isHashMatch ? 'Hash' : (confidence === 100 ? 'Exact' : 'Partial');
const model = match.model || {};
const hashes = model.hashes && typeof model.hashes === 'object' ? model.hashes : {};
const normalizedHash = String(
match.sha256
|| match.hash
|| model.sha256
|| model.hash
|| hashes.SHA256
|| hashes.sha256
|| ''
).trim().toLowerCase();
const hashLabel = normalizedHash && hashLabelMap?.get?.(normalizedHash)
? hashLabelMap.get(normalizedHash)
: '';
const label = isHashMatch ? (hashLabel || 'Hash') : (confidence === 100 ? 'Exact' : 'Partial');
const className = isHashMatch
? 'mr-match-status-hash'
: (confidence === 100 ? 'mr-match-status-exact' : 'mr-match-status-partial');
@@ -2178,6 +2273,7 @@ export const searchPanelMethods = {
const hasMatches = filteredMatches.length > 0;
const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
const hashLabelMap = this.getHashMatchLabelMap?.(missing) || null;
let html = '';
@@ -2211,7 +2307,7 @@ export const searchPanelMethods = {
html += `<div class="${rowClass}"${identityAttr}${this.getContextMenuAttrs(contextModel)}>`;
html += this.getConfidenceBadge(match.confidence);
html += `<span class="mr-match-filename" data-tooltip="${this.escapeHtml(matchPath)}">${this.escapeHtml(matchPath)}</span>`;
html += this.renderLocalMatchStatus(match);
html += this.renderLocalMatchStatus(match, hashLabelMap);
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>`;
@@ -2244,7 +2340,7 @@ export const searchPanelMethods = {
html += `<div class="mr-match-row"${identityAttr}${this.getContextMenuAttrs(contextModel)}>`;
html += this.getConfidenceBadge(match.confidence);
html += `<span class="mr-match-filename" data-tooltip="${this.escapeHtml(matchPath)}">${this.escapeHtml(matchPath)}</span>`;
html += this.renderLocalMatchStatus(match);
html += this.renderLocalMatchStatus(match, hashLabelMap);
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>`;
}
+92 -2
View File
@@ -2869,6 +2869,30 @@ export const modelInfoMethods = {
const preferredMirror = this.getSourceModelPreferredMirror(file, contextModel, mirrors);
const preferredDownloadUrl = preferredMirror?.url || file.download_url;
const preferredFilename = preferredMirror?.filename || file.name || contextModel.filename || data.name || 'model';
const fileHash = this.getSourceModelFileHash(file);
const fileHashes = file.hashes && typeof file.hashes === 'object'
? { ...file.hashes }
: (fileHash ? { SHA256: fileHash } : {});
const selectedFile = {
...file,
name: preferredFilename,
filename: preferredFilename,
download_url: preferredDownloadUrl
};
if (fileHash) {
selectedFile.sha256 = selectedFile.sha256 || fileHash;
selectedFile.hash = selectedFile.hash || fileHash;
}
if (Object.keys(fileHashes).length) {
selectedFile.hashes = { ...fileHashes };
}
const selectedVersion = {
id: version.id || data.version_id || contextModel.version_id,
name: version.name || '',
base_model: version.base_model || contextModel.base_model || '',
trained_words: version.trained_words || version.trainedWords || [],
files: [selectedFile]
};
const payload = {
source: data.source || contextModel.details_source || contextModel.source,
model_id: data.model_id || contextModel.model_id,
@@ -2880,6 +2904,13 @@ export const modelInfoMethods = {
download_url: preferredDownloadUrl,
url: version.url || data.version_url || data.url,
size: file.size,
sha256: fileHash,
hash: fileHash,
hashes: Object.keys(fileHashes).length ? fileHashes : undefined,
file_info: selectedFile,
file: selectedFile,
selected_file: selectedFile,
selected_version: selectedVersion,
base_model: version.base_model || contextModel.base_model,
tags: data.tags || [],
match_type: 'selected',
@@ -3042,12 +3073,49 @@ export const modelInfoMethods = {
</div>
<div class="mr-model-details-mirror-list">
${group.items.map((mirror) => {
const mirrorHash = this.getSourceModelFileHash(mirror) || basePayload.sha256 || basePayload.hash || '';
const mirrorHashes = mirror.hashes && typeof mirror.hashes === 'object'
? { ...mirror.hashes }
: (
basePayload.hashes && typeof basePayload.hashes === 'object'
? { ...basePayload.hashes }
: (mirrorHash ? { SHA256: mirrorHash } : {})
);
const mirrorFilename = mirror.filename || basePayload.filename || 'Download mirror';
const mirrorFileInfo = {
...(basePayload.file_info && typeof basePayload.file_info === 'object' ? basePayload.file_info : {}),
...mirror,
name: mirrorFilename,
filename: mirrorFilename,
download_url: mirror.url,
url: mirror.url
};
if (mirrorHash) {
mirrorFileInfo.sha256 = mirrorFileInfo.sha256 || mirrorHash;
mirrorFileInfo.hash = mirrorFileInfo.hash || mirrorHash;
}
if (Object.keys(mirrorHashes).length) {
mirrorFileInfo.hashes = { ...mirrorHashes };
}
const mirrorSelectedVersion = basePayload.selected_version && typeof basePayload.selected_version === 'object'
? {
...basePayload.selected_version,
files: [mirrorFileInfo]
}
: undefined;
const payload = {
...basePayload,
download_url: mirror.url,
filename: mirror.filename || basePayload.filename,
filename: mirrorFilename,
mirror_source: mirror.source || this.getSourceModelMirrorHost(mirror.url),
mirror_index: mirror._index
mirror_index: mirror._index,
sha256: mirrorHash,
hash: mirrorHash,
hashes: Object.keys(mirrorHashes).length ? mirrorHashes : undefined,
file_info: mirrorFileInfo,
file: mirrorFileInfo,
selected_file: mirrorFileInfo,
selected_version: mirrorSelectedVersion
};
const meta = this.getSourceModelMirrorMeta(mirror, mirror._index === 0);
const isDead = this.isSourceModelMirrorDead(mirror);
@@ -3321,6 +3389,13 @@ export const modelInfoMethods = {
const sourceKey = String(selection.source || contextModel.details_source || contextModel.source || '').toLowerCase();
const selectedBaseModel = selection.base_model || contextModel.base_model || '';
const selectionHashes = selection.hashes && typeof selection.hashes === 'object' ? selection.hashes : {};
const selectionFile = selection.file_info || selection.selected_file || selection.file || null;
const selectionFileHashes = selectionFile?.hashes && typeof selectionFile.hashes === 'object' ? selectionFile.hashes : {};
const selectionSha256 = this.getSourceModelFileHash?.(selection)
|| this.getSourceModelFileHash?.(selectionFile || {})
|| String(selection.sha256 || selection.hash || selectionHashes.SHA256 || selectionHashes.sha256 || selectionFileHashes.SHA256 || selectionFileHashes.sha256 || '').trim();
const selectionSelectedVersion = selection.selected_version || selection.selectedVersion || null;
const sourceResult = {
source: sourceKey,
model_id: selection.model_id,
@@ -3333,11 +3408,22 @@ export const modelInfoMethods = {
download_url: selection.download_url,
size: selection.size,
base_model: selectedBaseModel,
sha256: selectionSha256,
hash: selectionSha256,
tags: selection.tags || [],
match_type: selection.match_type || 'selected',
confidence: selection.confidence || 100,
searchedAt: new Date().toISOString()
};
if (Object.keys(selectionHashes).length) sourceResult.hashes = selectionHashes;
if (selectionFile && typeof selectionFile === 'object') {
sourceResult.file_info = selectionFile;
sourceResult.file = selectionFile;
sourceResult.selected_file = selectionFile;
}
if (selectionSelectedVersion && typeof selectionSelectedVersion === 'object') {
sourceResult.selected_version = selectionSelectedVersion;
}
missing.download_source = {
...sourceResult,
@@ -3370,6 +3456,10 @@ export const modelInfoMethods = {
this.refreshSearchBaseModelLabels?.();
this.updateBatchFooterButtons?.();
this.persistSearchStateForActiveWorkflow?.();
this.syncRemoteHashMatchesForResult?.(missing, sourceResult, { sourceKey })
?.catch?.(error => {
console.warn('Model Resolver: failed to sync selected model hash:', error);
});
this.showNotification?.(`Selected ${selection.filename || selection.name || 'model version'}.`, 'success');
},