feat: enhance CivArchive resilience and mirror display

backend:
- add 429 retry handling with backoff in CivArchive API requests
- add page-scraping fallback for CivArchive model payloads
- normalize CivArchive mirrors with dead/gated/paid/sha256 metadata
- include file metadata and mirror data in normalized archive files
- propagate additional dead/missing status field aliases
- include metadata in normalized CivitAI files

frontend:
- render mirror lists with source grouping, dead-link indicators, and skull icons
- add file badges (primary, type, format, size, fp, mirror count, id)
- improve secondary info in resolve/download methods to include base model
- enhance search panel secondary info with base model and filename
- on selecting a version with empty files, fetch fresh version details
- propagate base model context to CivitAI search state and display labels
This commit is contained in:
Dariusz L
2026-06-08 03:03:53 +02:00
parent 9bebb52395
commit 6e2f1b3101
7 changed files with 561 additions and 50 deletions
+163 -44
View File
@@ -8,6 +8,7 @@ import html
import json
import os
import re
import time
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qs, urljoin, urlparse
@@ -122,20 +123,34 @@ def _request_json(
timeout: int = 20,
) -> Optional[Dict[str, Any]]:
url = f"{CIVARCHIVE_API_URL}{path}"
try:
response = requests.get(
url,
params={k: v for k, v in (params or {}).items() if v is not None},
headers=REQUEST_HEADERS,
timeout=timeout,
)
except Exception as e:
log_warn(f"CivArchive API request failed: path={path}, error={e}")
return None
response = None
request_params = {k: v for k, v in (params or {}).items() if v is not None}
for attempt in range(2):
try:
response = requests.get(
url,
params=request_params,
headers=REQUEST_HEADERS,
timeout=timeout,
)
except Exception as e:
log_warn(f"CivArchive API request failed: path={path}, error={e}")
return None
if response.status_code != 200:
if response.status_code != 429 or attempt == 1:
break
retry_after = response.headers.get("Retry-After")
try:
delay = float(retry_after) if retry_after else 1.2
except (TypeError, ValueError):
delay = 1.2
time.sleep(max(0.5, min(delay, 3.0)))
if response is None or response.status_code != 200:
status = response.status_code if response is not None else "no-response"
log_debug(
f"CivArchive API returned {response.status_code}: path={path}, params={params}"
f"CivArchive API returned {status}: path={path}, params={params}"
)
return None
@@ -146,6 +161,50 @@ def _request_json(
return None
def _extract_model_payload_from_page(
model_id: int,
version_id: Optional[int] = None,
timeout: int = 20,
) -> Optional[Dict[str, Any]]:
params = {"modelVersionId": version_id} if version_id is not None else None
try:
response = requests.get(
f"{CIVARCHIVE_BASE_URL}/models/{model_id}",
params=params,
headers=REQUEST_HEADERS,
timeout=timeout,
)
except Exception as e:
log_warn(f"CivArchive model page request failed: model_id={model_id}, error={e}")
return None
if response.status_code != 200:
log_debug(
f"CivArchive model page returned {response.status_code}: model_id={model_id}, version_id={version_id}"
)
return None
next_data = _extract_next_data(response.text)
page_props = next_data.get("props", {}).get("pageProps", {})
if isinstance(page_props.get("data"), dict):
return page_props["data"]
if isinstance(page_props.get("model"), dict):
return page_props["model"]
return page_props if isinstance(page_props, dict) and page_props else None
def _request_model_payload(
model_id: int,
version_id: Optional[int] = None,
timeout: int = 20,
) -> Optional[Dict[str, Any]]:
params = {"modelVersionId": version_id} if version_id is not None else None
payload = _request_json(f"/models/{model_id}", params=params, timeout=timeout)
if payload:
return payload
return _extract_model_payload_from_page(model_id, version_id, timeout=timeout)
def _search_page(
query: str,
model_type: Optional[str] = None,
@@ -290,6 +349,11 @@ def _mirror_from_top_file(file_data: Dict[str, Any]) -> Optional[Dict[str, Any]]
"model_version_id": file_data.get("model_version_id")
or file_data.get("modelVersionId"),
"deletedAt": file_data.get("deletedAt") or file_data.get("deleted_at"),
"is_dead": file_data.get("is_dead")
or file_data.get("isDead")
or file_data.get("likelyDead")
or file_data.get("likely_dead")
or file_data.get("dead"),
"is_gated": file_data.get("is_gated"),
"is_paid": file_data.get("is_paid"),
}
@@ -454,6 +518,81 @@ def _collect_download_urls(file_info: Dict[str, Any]) -> List[str]:
return non_civitai_urls + civitai_urls
def _normalize_archive_mirrors(file_info: Dict[str, Any]) -> List[Dict[str, Any]]:
mirrors = file_info.get("mirrors") or []
if not isinstance(mirrors, list):
mirrors = [mirrors]
normalized: List[Dict[str, Any]] = []
seen_urls = set()
for mirror in mirrors:
if not isinstance(mirror, dict):
continue
url = _normalize_download_url(mirror.get("url"))
if not url or url in seen_urls:
continue
seen_urls.add(url)
sha256 = mirror.get("sha256") or mirror.get("hash")
deleted_at = mirror.get("deletedAt") or mirror.get("deleted_at")
is_dead = bool(
deleted_at
or mirror.get("is_dead")
or mirror.get("isDead")
or mirror.get("likelyDead")
or mirror.get("likely_dead")
or mirror.get("dead")
or str(mirror.get("status") or "").lower() in {"dead", "deleted", "unavailable", "missing"}
)
normalized.append(
{
"url": url,
"source": mirror.get("source") or mirror.get("platform"),
"filename": mirror.get("filename") or mirror.get("name") or file_info.get("name"),
"sha256": sha256,
"hash": sha256,
"deleted_at": deleted_at,
"is_dead": is_dead,
"is_gated": mirror.get("is_gated"),
"is_paid": mirror.get("is_paid"),
}
)
download_url = _normalize_download_url(file_info.get("downloadUrl"))
if download_url and download_url not in seen_urls:
normalized.append(
{
"url": download_url,
"source": file_info.get("source") or file_info.get("platform"),
"filename": file_info.get("name") or file_info.get("filename"),
"sha256": file_info.get("sha256"),
"hash": file_info.get("sha256"),
"deleted_at": file_info.get("deletedAt") or file_info.get("deleted_at"),
"is_dead": bool(
file_info.get("deletedAt")
or file_info.get("deleted_at")
or file_info.get("is_dead")
or file_info.get("isDead")
or file_info.get("likelyDead")
or file_info.get("likely_dead")
or file_info.get("dead")
or str(file_info.get("status") or "").lower() in {"dead", "deleted", "unavailable", "missing"}
),
"is_gated": file_info.get("is_gated"),
"is_paid": file_info.get("is_paid"),
}
)
non_civitai = [
mirror for mirror in normalized
if not str(mirror.get("url") or "").startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
]
civitai = [
mirror for mirror in normalized
if str(mirror.get("url") or "").startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
]
return non_civitai + civitai
def _normalize_archive_image(image: Dict[str, Any]) -> Dict[str, Any]:
meta = image.get("meta") if isinstance(image.get("meta"), dict) else {}
return {
@@ -476,6 +615,8 @@ def _normalize_archive_file(file_info: Dict[str, Any], model_id: Optional[int],
transformed = _transform_file_entry(file_info)
download_urls = _collect_download_urls(transformed)
hashes = transformed.get("hashes") if isinstance(transformed.get("hashes"), dict) else {}
metadata = transformed.get("metadata") if isinstance(transformed.get("metadata"), dict) else {}
mirrors = _normalize_archive_mirrors(transformed)
return {
"id": transformed.get("id"),
"name": transformed.get("name"),
@@ -486,6 +627,9 @@ def _normalize_archive_file(file_info: Dict[str, Any], model_id: Optional[int],
"primary": bool(transformed.get("primary")),
"sha256": transformed.get("sha256") or hashes.get("SHA256") or hashes.get("sha256"),
"hashes": hashes,
"metadata": metadata,
"mirrors": mirrors,
"mirror_count": len(mirrors),
"model_id": model_id or transformed.get("modelId"),
"version_id": version_id or transformed.get("modelVersionId"),
}
@@ -520,7 +664,7 @@ def _normalize_archive_version(
return {
"id": version_id,
"name": version.get("name") or "",
"base_model": version.get("baseModel"),
"base_model": version.get("baseModel") or version.get("base_model") or version.get("baseModelType"),
"published_at": version.get("publishedAt") or version.get("createdAt"),
"updated_at": version.get("updatedAt"),
"description": version.get("description") or "",
@@ -540,8 +684,7 @@ def get_civarchive_model_details(
if not model_id:
return None
params = {"modelVersionId": version_id} if version_id is not None else None
payload = _request_json(f"/models/{model_id}", params=params, timeout=25)
payload = _request_model_payload(model_id, version_id, timeout=25)
if not payload and version_id:
resolved = resolve_civarchive_model_version(model_id, version_id)
if not resolved:
@@ -615,40 +758,16 @@ def get_civarchive_model_details(
active_version = model_block.get("version") if isinstance(model_block.get("version"), dict) else {}
active_version_id = _coerce_int(active_version.get("id"))
hydrated_versions: List[Dict[str, Any]] = []
hydration_cache: Dict[int, Dict[str, Any]] = {}
for version_summary in raw_versions:
if not isinstance(version_summary, dict):
continue
current_id = _coerce_int(version_summary.get("id"))
full_version = None
if current_id and active_version_id and current_id == active_version_id:
full_version = {**version_summary, **active_version}
elif current_id:
cached = hydration_cache.get(current_id)
if cached is None:
version_payload = _request_json(
f"/models/{model_id}",
params={"modelVersionId": current_id},
timeout=25,
)
version_data = _normalize_payload(version_payload or {})
version_model = (
version_data.get("model")
if isinstance(version_data.get("model"), dict)
else version_data
)
cached = (
version_model.get("version")
if isinstance(version_model.get("version"), dict)
else {}
)
hydration_cache[current_id] = cached
if cached:
full_version = {**version_summary, **cached}
hydrated_versions.append(full_version or version_summary)
hydrated_versions.append({**version_summary, **active_version})
else:
hydrated_versions.append(version_summary)
versions = [
_normalize_archive_version(version, context, top_files)
@@ -838,7 +957,7 @@ def _build_result_from_payload(
"download_url": download_urls[0],
"download_urls": download_urls,
"size": _size_kb_to_bytes(selected_file.get("sizeKB")),
"base_model": version.get("baseModel"),
"base_model": version.get("baseModel") or version.get("base_model") or version.get("baseModelType"),
"tags": tags,
"trained_words": trained_words,
"images": version.get("images", []),
@@ -885,7 +1004,7 @@ def resolve_civarchive_model_version(
return None
params = {"modelVersionId": version_id} if version_id is not None else None
payload = _request_json(f"/models/{model_id}", params=params)
payload = _request_model_payload(model_id, version_id)
if not payload:
return None
+1
View File
@@ -982,6 +982,7 @@ def _normalize_civitai_file(
"primary": bool(file_info.get("primary", False)),
"sha256": file_info.get("sha256") or hashes.get("SHA256") or hashes.get("sha256"),
"hashes": hashes,
"metadata": file_info.get("metadata") or {},
"model_id": model_id,
"version_id": version_id,
}
+147
View File
@@ -3975,6 +3975,9 @@
border-radius: 7px;
background: rgba(255,255,255,0.04);
}
.mr-model-details-file > .mr-model-details-mirrors {
grid-column: 1 / -1;
}
.mr-model-details-file.is-primary {
border-color: rgba(114,211,138,0.28);
background: rgba(114,211,138,0.08);
@@ -3997,12 +4000,150 @@
color: rgba(255,255,255,0.58);
font-size: 11px;
}
.mr-model-details-file-badges {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 7px;
}
.mr-model-details-file-badges span {
display: inline-flex;
align-items: center;
min-height: 20px;
max-width: 100%;
padding: 2px 6px;
border-radius: 5px;
background: rgba(255,255,255,0.075);
color: rgba(255,255,255,0.78);
font-size: 10px;
font-weight: 800;
line-height: 1;
}
.mr-model-details-mirrors {
display: grid;
gap: 10px;
margin-top: 2px;
padding-top: 9px;
border-top: 1px solid rgba(255,255,255,0.08);
}
.mr-model-details-mirrors-title {
color: rgba(255,255,255,0.62);
font-size: 10px;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
.mr-model-details-mirror-group {
display: grid;
gap: 7px;
}
.mr-model-details-mirror-group-title {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 0;
color: rgba(255,255,255,0.56);
font-size: 11px;
font-weight: 800;
}
.mr-model-details-mirror-platform-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex: 0 0 18px;
}
.mr-model-details-mirror-platform-icon svg {
width: 18px;
height: 18px;
}
.mr-model-details-mirror-list {
display: grid;
gap: 6px;
padding-left: 25px;
}
.mr-model-details-mirror {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
min-width: 0;
width: 100%;
min-height: 48px;
padding: 9px 10px;
border: 1px solid rgba(255,255,255,0.07);
border-radius: 6px;
background: rgba(0,0,0,0.15);
color: inherit;
text-align: left;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
transition: background 120ms ease, border-color 120ms ease;
}
.mr-model-details-mirror:hover {
border-color: rgba(255,255,255,0.13);
background: rgba(255,255,255,0.07);
}
.mr-model-details-mirror.is-dead {
border-color: rgba(185,28,28,0.58);
background: rgba(127,29,29,0.36);
}
.mr-model-details-mirror.is-dead:hover {
border-color: rgba(248,113,113,0.74);
background: rgba(153,27,27,0.54);
}
.mr-model-details-mirror-info {
min-width: 0;
}
.mr-model-details-mirror-info strong,
.mr-model-details-mirror-info small {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mr-model-details-mirror-info strong {
color: #f0f0f2;
font-size: 11px;
font-weight: 800;
}
.mr-model-details-mirror-info small {
margin-top: 3px;
color: rgba(255,255,255,0.42);
font-size: 10px;
}
.mr-model-details-mirror.is-dead .mr-model-details-mirror-info small {
color: rgba(254,202,202,0.72);
}
.mr-model-details-mirror-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
color: rgba(255,255,255,0.56);
}
.mr-model-details-mirror-action svg {
width: 17px;
height: 17px;
}
.mr-model-details-mirror.is-dead .mr-model-details-mirror-action {
color: #f87171;
}
.mr-model-details-use {
min-height: 31px;
padding: 0 10px;
border-radius: 6px;
font-size: 11px;
}
.mr-model-details-mirror.mr-model-details-use {
min-height: 48px;
padding: 9px 10px;
font-size: inherit;
}
.mr-model-details-loading {
display: flex;
align-items: center;
@@ -4051,6 +4192,12 @@
.mr-model-details-file {
grid-template-columns: 1fr;
}
.mr-model-details-mirror {
grid-template-columns: 1fr;
}
.mr-model-details-mirror-list {
padding-left: 0;
}
}
.mr-options-wrap {
@@ -1488,13 +1488,18 @@ export const resolveDownloadMethods = {
if (civarchiveResult && civarchiveResult.download_url) {
const archiveFilename = civarchiveResult.filename || missing.original_path?.split('/').pop()?.split('\\').pop() || '';
const archiveName = civarchiveResult.name || archiveFilename || 'Model';
const archiveSecondary = [
archiveName && archiveName !== archiveFilename ? archiveFilename : '',
civarchiveResult.platform || '',
civarchiveResult.base_model || ''
].filter(Boolean).join(' / ');
addRow({
sourceKey: 'civarchive',
sourceLabel: 'CivArchive',
model: archiveName,
version: civarchiveResult.version_name || '',
filename: archiveFilename,
secondary: archiveName && archiveName !== archiveFilename ? archiveFilename : (civarchiveResult.platform || civarchiveResult.base_model || ''),
secondary: archiveSecondary,
match: this.getSearchResultMatchDisplay(civarchiveResult),
size: this.formatSearchResultSize(civarchiveResult),
downloadUrl: civarchiveResult.download_url,
@@ -1535,13 +1540,17 @@ export const resolveDownloadMethods = {
const modelUrl = civitaiResult.url || (civitaiResult.model_id ? `https://civitai.com/models/${civitaiResult.model_id}${civitaiResult.version_id ? `?modelVersionId=${civitaiResult.version_id}` : ''}` : '');
const downloadFilename = missing.civitai_info?.expected_filename || civitaiResult.filename || civitaiResult.name;
const modelName = missing.civitai_info?.model_name || civitaiResult.name || downloadFilename || 'Model';
const civitaiSecondary = [
civitaiResult.type || '',
civitaiResult.base_model || missing.civitai_info?.base_model || ''
].filter(Boolean).join(' / ');
addRow({
sourceKey: 'civitai',
sourceLabel: 'CivitAI',
model: modelName,
version: missing.civitai_info?.version_name || civitaiResult.version_name || '',
filename: downloadFilename,
secondary: civitaiResult.type || civitaiResult.base_model || '',
secondary: civitaiSecondary,
match: this.getSearchResultMatchDisplay(civitaiResult),
size: this.formatSearchResultSize(civitaiResult),
downloadUrl: civitaiResult.download_url,
+7 -1
View File
@@ -1114,6 +1114,12 @@ export const searchPanelMethods = {
);
const modelName = modelParts.name || downloadFilename;
const fullModelName = this.getVersionedModelName(modelName, modelParts.version);
const baseModel = downloadSource.base_model || missing.civitai_info?.base_model || '';
const secondaryParts = [
sourceSecondary,
fullModelName && fullModelName !== downloadFilename ? downloadFilename : '',
baseModel
].filter(Boolean);
return {
sourceKey,
@@ -1121,7 +1127,7 @@ export const searchPanelMethods = {
model: modelName,
version: modelParts.version,
filename: downloadFilename,
secondary: sourceSecondary || (fullModelName && fullModelName !== downloadFilename ? downloadFilename : ''),
secondary: secondaryParts.join(' / '),
match: isFromWorkflow
? { label: 'Provided', className: 'strong' }
: this.getSearchResultMatchDisplay(downloadSource, 'Known', 'strong'),
+231 -3
View File
@@ -1186,6 +1186,47 @@ export const modelInfoMethods = {
`;
},
async fetchSourceModelDetailsVersion(details, versionId) {
if (!details?._detailsData || !versionId) return;
try {
const data = details._detailsData;
const tokens = this.getStoredTokens?.() || {};
const response = await api.fetchApi('/model_resolver/model-details', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: data.source || details._sourceModel?.details_source || details._sourceModel?.source,
model_id: data.model_id || details._sourceModel?.model_id || details._sourceModel?.modelId,
version_id: versionId,
civitai_key: tokens.civitai_key || ''
})
});
if (!response.ok) {
throw new Error(`Version details request failed: ${response.status}`);
}
const fresh = await response.json();
const freshVersion = fresh.selected_version;
if (!freshVersion?.id) return;
data.versions = (data.versions || []).map(version =>
String(version.id) === String(freshVersion.id)
? { ...version, ...freshVersion }
: version
);
data.selected_version = data.versions.find(version => String(version.id) === String(freshVersion.id)) || freshVersion;
if (String(details._selectedVersionId || '') !== String(freshVersion.id)) return;
details.innerHTML = this.renderSourceModelDetails(data, details._sourceModel || {});
this.bindTooltips(details);
} catch (error) {
console.error('Model Resolver: failed to fetch source model version details:', error);
this.showNotification?.('Failed to load this model version.', 'error');
}
},
renderSourceModelDetails(data = {}, contextModel = {}) {
const versions = Array.isArray(data.versions) ? data.versions : [];
const selectedVersionId = String(data.selected_version?.id || data.version_id || versions[0]?.id || '');
@@ -1283,6 +1324,8 @@ export const modelInfoMethods = {
}
return files.map((file, index) => {
const fileMeta = this.getSourceModelFileMeta(file);
const mirrors = this.getSourceModelMirrors(file);
const payload = {
source: data.source || contextModel.details_source || contextModel.source,
model_id: data.model_id || contextModel.model_id,
@@ -1299,20 +1342,183 @@ export const modelInfoMethods = {
match_type: 'selected',
confidence: 100
};
const mirrorList = mirrors.length > 1
? this.renderSourceModelMirrors(mirrors, payload)
: '';
return `
<div class="mr-model-details-file ${file.primary ? 'is-primary' : ''}">
<div>
<strong>${this.escapeHtml(file.name || `File ${index + 1}`)}</strong>
<span>${this.escapeHtml([file.type, this.formatSearchResultSize(file), file.sha256 ? `SHA256 ${String(file.sha256).slice(0, 10)}` : ''].filter(Boolean).join(' / '))}</span>
<span>${this.escapeHtml(fileMeta.summary)}</span>
${fileMeta.badges.length ? `<div class="mr-model-details-file-badges">${fileMeta.badges.map(badge => `<span>${this.escapeHtml(badge)}</span>`).join('')}</div>` : ''}
</div>
<button type="button" class="mr-btn mr-btn-primary mr-model-details-use" data-selection="${this.escapeHtml(encodeURIComponent(JSON.stringify(payload)))}">
Use this version
</button>
${mirrorList}
</div>
`;
}).join('');
},
getSourceModelMirrors(file = {}) {
const mirrors = Array.isArray(file.mirrors)
? file.mirrors.filter(mirror => mirror?.url)
: [];
const downloadUrls = Array.isArray(file.download_urls) ? file.download_urls.filter(Boolean) : [];
const seen = new Set();
const normalized = [];
for (const mirror of mirrors) {
const url = String(mirror.url || '');
if (!url || seen.has(url)) continue;
seen.add(url);
normalized.push({
...mirror,
url,
filename: mirror.filename || file.name,
is_dead: Boolean(mirror.is_dead || mirror.isDead || mirror.deleted_at || mirror.deletedAt)
});
}
for (const url of downloadUrls) {
const value = String(url || '');
if (!value || seen.has(value)) continue;
seen.add(value);
normalized.push({
url: value,
filename: file.name,
source: this.getSourceModelMirrorHost(value),
is_dead: false
});
}
return normalized;
},
renderSourceModelMirrors(mirrors = [], basePayload = {}) {
if (!mirrors.length) return '';
const groups = this.groupSourceModelMirrors(mirrors);
return `
<div class="mr-model-details-mirrors">
<div class="mr-model-details-mirrors-title">Mirrors</div>
${groups.map(group => `
<div class="mr-model-details-mirror-group">
<div class="mr-model-details-mirror-group-title">
<span class="mr-model-details-mirror-platform-icon">${this.getSourceModelMirrorIcon(group.label)}</span>
<span>${this.escapeHtml(group.label)} (${group.items.length} ${group.items.length === 1 ? 'mirror' : 'mirrors'})</span>
</div>
<div class="mr-model-details-mirror-list">
${group.items.map((mirror) => {
const payload = {
...basePayload,
download_url: mirror.url,
filename: mirror.filename || basePayload.filename,
mirror_source: mirror.source || this.getSourceModelMirrorHost(mirror.url),
mirror_index: mirror._index
};
const meta = this.getSourceModelMirrorMeta(mirror, mirror._index === 0);
const isDead = this.isSourceModelMirrorDead(mirror);
const title = isDead ? 'Likely dead link' : 'Download file';
const actionIcon = getSvgIcon(isDead ? 'skull' : 'download');
const rowClass = isDead ? ' is-dead' : '';
const payloadData = this.escapeHtml(encodeURIComponent(JSON.stringify(payload)));
return `
<button type="button"
class="mr-model-details-mirror${rowClass} mr-model-details-use"
title="${this.escapeHtml(title)}"
data-tooltip="${this.escapeHtml(title)}"
data-selection="${payloadData}">
<div class="mr-model-details-mirror-info">
<strong>${this.escapeHtml(mirror.filename || basePayload.filename || 'Download mirror')}</strong>
${meta ? `<small>${this.escapeHtml(meta)}</small>` : ''}
</div>
<span class="mr-model-details-mirror-action" aria-hidden="true">${actionIcon}</span>
</button>
`;
}).join('')}
</div>
</div>
`).join('')}
</div>
`;
},
groupSourceModelMirrors(mirrors = []) {
const groups = new Map();
mirrors.forEach((mirror, index) => {
const label = this.getSourceModelMirrorSourceLabel(mirror, index);
if (!groups.has(label)) {
groups.set(label, { label, items: [] });
}
groups.get(label).items.push({ ...mirror, _index: index });
});
return Array.from(groups.values());
},
getSourceModelMirrorIcon(label = '') {
const normalized = String(label || '').toLowerCase();
if (normalized.includes('huggingface')) return getSvgIcon('huggingface');
if (normalized.includes('civitai')) return getSvgIcon('civitai');
return getSvgIcon('globe');
},
isSourceModelMirrorDead(mirror = {}) {
return Boolean(mirror.is_dead || mirror.isDead || mirror.deleted_at || mirror.deletedAt);
},
getSourceModelMirrorHost(url = '') {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch (error) {
return '';
}
},
getSourceModelMirrorSourceLabel(mirror = {}, index = 0) {
const source = String(mirror.source || '').trim();
if (source) return source;
const host = this.getSourceModelMirrorHost(mirror.url || '');
if (host.includes('huggingface.co')) return 'HuggingFace';
if (host.includes('civitai.com')) return 'CivitAI';
return host || `Mirror ${index + 1}`;
},
getSourceModelMirrorMeta(mirror = {}, isDefault = false) {
const host = this.getSourceModelMirrorHost(mirror.url || '');
const meta = [
isDefault ? 'Default' : '',
this.isSourceModelMirrorDead(mirror) ? 'Likely dead' : '',
host,
mirror.is_gated ? 'Gated' : '',
mirror.is_paid ? 'Paid' : '',
mirror.sha256 ? `SHA256 ${String(mirror.sha256).slice(0, 10)}` : ''
].filter(Boolean);
return meta.join(' / ');
},
getSourceModelFileMeta(file = {}) {
const metadata = file.metadata && typeof file.metadata === 'object' ? file.metadata : {};
const mirrorCount = Array.isArray(file.mirrors) && file.mirrors.length
? file.mirrors.length
: file.mirror_count;
const badges = [
file.primary ? 'Primary' : '',
file.type || '',
metadata.format || '',
metadata.size || '',
metadata.fp || '',
mirrorCount ? `${mirrorCount} mirrors` : '',
file.id ? `ID ${file.id}` : ''
].filter(Boolean);
const summary = [
this.formatSearchResultSize(file),
file.sha256 ? `SHA256 ${String(file.sha256).slice(0, 10)}` : ''
].filter(Boolean).join(' / ') || 'Downloadable file';
return { badges, summary };
},
renderSourceModelImageMeta(image = {}) {
const stats = image.stats || {};
const metaItems = [
@@ -1374,9 +1580,14 @@ export const modelInfoMethods = {
details._selectedVersionId = String(versionTab.dataset.versionId || '');
const data = details._detailsData;
if (data) {
data.selected_version = (data.versions || []).find(version => String(version.id) === details._selectedVersionId) || data.selected_version;
const selected = (data.versions || []).find(version => String(version.id) === details._selectedVersionId) || data.selected_version;
data.selected_version = selected;
details.innerHTML = this.renderSourceModelDetails(data, details._sourceModel || {});
this.bindTooltips(details);
if (selected?.id && (!Array.isArray(selected.files) || !selected.files.length)) {
this.fetchSourceModelDetailsVersion(details, selected.id);
}
}
return;
}
@@ -1462,6 +1673,7 @@ export const modelInfoMethods = {
}
const sourceKey = String(selection.source || contextModel.details_source || contextModel.source || '').toLowerCase();
const selectedBaseModel = selection.base_model || contextModel.base_model || '';
const sourceResult = {
source: sourceKey,
model_id: selection.model_id,
@@ -1473,7 +1685,7 @@ export const modelInfoMethods = {
url: selection.url,
download_url: selection.download_url,
size: selection.size,
base_model: selection.base_model,
base_model: selectedBaseModel,
tags: selection.tags || [],
match_type: selection.match_type || 'selected',
confidence: selection.confidence || 100,
@@ -1491,8 +1703,24 @@ export const modelInfoMethods = {
if (state?.results && sourceKey) {
state.results[sourceKey] = sourceResult;
}
missing.civitai_info = {
...(missing.civitai_info || {}),
model_name: selection.name || missing.civitai_info?.model_name,
version_name: selection.version_name || missing.civitai_info?.version_name,
expected_filename: selection.filename || missing.civitai_info?.expected_filename,
base_model: selectedBaseModel || missing.civitai_info?.base_model,
tags: selection.tags || missing.civitai_info?.tags || []
};
if (selectedBaseModel) {
const canonicalBaseModel = this.resolveBaseModelAlias?.(selectedBaseModel) || selectedBaseModel;
if (state) {
state.selectedBaseModel = canonicalBaseModel;
state.lastAttemptBaseModelContext = canonicalBaseModel;
}
}
this.refreshSearchUiForMissing?.(missing, state || null);
this.refreshSearchBaseModelLabels?.();
this.updateBatchFooterButtons?.();
this.persistSearchStateForActiveWorkflow?.();
this.showNotification?.(`Selected ${selection.filename || selection.name || 'model version'}.`, 'success');
+1
View File
@@ -6,6 +6,7 @@ const SVG_ICONS = {
refreshCw: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"></path><path d="M21 3v6h-6"></path></svg>`,
locate: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12h3"></path><path d="M19 12h3"></path><path d="M12 2v3"></path><path d="M12 19v3"></path><circle cx="12" cy="12" r="7"></circle><circle cx="12" cy="12" r="2.5"></circle></svg>`,
download: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"></path><path d="m7 10 5 5 5-5"></path><path d="M5 21h14"></path></svg>`,
skull: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12.5 17-.5-1-.5 1h1z"></path><path d="M15 22a1 1 0 0 0 1-1v-1a2 2 0 0 0 1.56-3.25 8 8 0 1 0-11.12 0A2 2 0 0 0 8 20v1a1 1 0 0 0 1 1z"></path><circle cx="15" cy="12" r="1"></circle><circle cx="9" cy="12" r="1"></circle></svg>`,
copy: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`,
externalLink: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8.5 12h10.5"></path><path d="m15.25 15.75 3.75-3.75-3.75-3.75"></path><path d="M19 7.75V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-.75"></path></svg>`,
moreCircle: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="{color}" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"></path></svg>`,