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:
+163
-44
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
Reference in New Issue
Block a user