feat: add HuggingFace model details, variant table, and download context

This commit is contained in:
Dariusz L
2026-07-28 17:20:36 +02:00
parent 9aa7077a33
commit f8a6e8b626
10 changed files with 1675 additions and 917 deletions
+38 -20
View File
@@ -496,6 +496,7 @@ class ModelResolverExtension:
check_huggingface_token,
get_author_fallback_index_status,
get_huggingface_download_url,
get_huggingface_model_details,
parse_huggingface_url,
refresh_author_fallback_index,
search_huggingface_for_file,
@@ -3332,37 +3333,46 @@ class ModelResolverExtension:
model_id = data.get("model_id")
version_id = data.get("version_id")
civitai_key = data.get("civitai_key", "")
hf_token = data.get("hf_token", "")
file_path = data.get("file_path", "")
branch = data.get("branch", "")
if not download_available:
return web.json_response(
{"error": "Download providers are not available"}, status=503
)
try:
model_id = (
int(model_id)
if model_id is not None and str(model_id).strip()
else None
)
except (TypeError, ValueError):
model_id = None
try:
version_id = (
int(version_id)
if version_id is not None and str(version_id).strip()
else None
)
except (TypeError, ValueError):
version_id = None
if source == "lora_manager_archive":
source = "civitai"
if source not in {"civitai", "civarchive"}:
if source not in {"civitai", "civarchive", "huggingface"}:
return web.json_response(
{"error": "Unsupported model details source"}, status=400
)
if source == "huggingface":
model_id = str(model_id or "").strip()
branch = str(branch or version_id or "main").strip() or "main"
version_id = branch
else:
try:
model_id = (
int(model_id)
if model_id is not None and str(model_id).strip()
else None
)
except (TypeError, ValueError):
model_id = None
try:
version_id = (
int(version_id)
if version_id is not None and str(version_id).strip()
else None
)
except (TypeError, ValueError):
version_id = None
if not model_id:
return web.json_response(
{"error": "model_id is required"}, status=400
@@ -3375,12 +3385,20 @@ class ModelResolverExtension:
version_id,
civitai_key or None,
)
else:
elif source == "civarchive":
details = await asyncio.to_thread(
get_civarchive_model_details,
model_id,
version_id,
)
else:
details = await asyncio.to_thread(
get_huggingface_model_details,
model_id,
file_path,
branch,
hf_token or None,
)
if not details:
return web.json_response(
+211 -1
View File
@@ -5,6 +5,7 @@ Search and download models from HuggingFace Hub.
"""
import os
import posixpath
import re
import threading
import time
@@ -331,7 +332,214 @@ def parse_huggingface_url(url: str) -> Optional[Dict[str, str]]:
def get_huggingface_download_url(repo: str, filename: str, branch: str = "main") -> str:
"""Generate a direct download URL for a HuggingFace file."""
return f"https://huggingface.co/{repo}/resolve/{branch}/{quote(filename)}"
return (
f"https://huggingface.co/{repo}/resolve/"
f"{quote(branch, safe='')}/{quote(filename)}"
)
def _normalize_huggingface_path(value: Any) -> str:
return str(value or "").replace("\\", "/").strip("/")
def _get_huggingface_variant_label(filename: str) -> str:
"""Return a concise precision/quantization label inferred from a filename."""
stem = os.path.splitext(get_filename_from_path(filename))[0].lower()
patterns = (
("nvfp4_mixed", "NVFP4 mixed"),
("fp8_scaled", "FP8 scaled"),
("int8_convrot", "INT8 convrot"),
("nvfp4", "NVFP4"),
("fp8", "FP8"),
("bf16", "BF16"),
("fp16", "FP16"),
("int8", "INT8"),
("int4", "INT4"),
)
for token, label in patterns:
if re.search(rf"(?:^|[_.-]){re.escape(token)}(?:$|[_.-])", stem):
return label
return ""
def _normalize_huggingface_details_file(
repo_id: str,
branch: str,
file_info: Dict[str, Any],
target_path: str,
) -> Optional[Dict[str, Any]]:
file_path = _normalize_huggingface_path(file_info.get("path"))
if not file_path:
return None
download_url = get_huggingface_download_url(repo_id, file_path, branch)
if not looks_like_model_file(download_url):
return None
filename = get_filename_from_path(file_path)
sha256 = _extract_huggingface_file_sha256(file_info)
extension = os.path.splitext(filename)[1].lstrip(".")
metadata = {
"format": extension.upper() if extension else "",
"fp": _get_huggingface_variant_label(filename),
}
metadata = {key: value for key, value in metadata.items() if value}
page_url = (
f"https://huggingface.co/{repo_id}/blob/"
f"{quote(branch, safe='')}/{quote(file_path, safe='/')}"
)
return {
"name": filename,
"filename": filename,
"path": file_path,
"size": extract_file_size(file_info),
"sha256": sha256,
"hash": sha256,
"hashes": {"SHA256": sha256} if sha256 else {},
"download_url": download_url,
"url": page_url,
"primary": bool(target_path and file_path == target_path),
"metadata": metadata,
}
def get_huggingface_model_details(
repo_id: str,
file_path: str = "",
branch: str = "main",
token: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Fetch model-file variants from the folder containing a matched HF file."""
normalized_repo_id = str(repo_id or "").strip().strip("/")
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", normalized_repo_id):
return None
normalized_branch = str(branch or "main").strip() or "main"
if len(normalized_branch) > 200 or any(
character in normalized_branch for character in "\r\n?#"
):
return None
target_path = _normalize_huggingface_path(file_path)
target_directory = posixpath.dirname(target_path)
headers = {"Authorization": f"Bearer {token.strip()}"} if token and token.strip() else {}
repo_tree = _get_repo_tree(
normalized_repo_id,
headers=headers,
branch=normalized_branch,
)
if not repo_tree:
return None
normalized_files = []
for file_info in repo_tree:
candidate_path = _normalize_huggingface_path(file_info.get("path"))
if not candidate_path:
continue
if target_path and posixpath.dirname(candidate_path) != target_directory:
continue
normalized_file = _normalize_huggingface_details_file(
normalized_repo_id,
normalized_branch,
file_info,
target_path,
)
if normalized_file:
normalized_files.append(normalized_file)
if not normalized_files:
return None
normalized_files.sort(
key=lambda item: (
not item.get("primary", False),
str(item.get("name") or "").lower(),
)
)
model_data: Dict[str, Any] = {}
try:
response_data = execute_provider_json_request(
"HuggingFace model details",
f"{HF_API_URL}/models/{normalized_repo_id}",
headers=headers,
timeout=15,
)
if isinstance(response_data, dict):
model_data = response_data
except Exception as e:
log.debug(
f"Error getting HuggingFace model metadata for {normalized_repo_id}: {e}"
)
card_data = (
model_data.get("cardData")
if isinstance(model_data.get("cardData"), dict)
else {}
)
tags = model_data.get("tags") if isinstance(model_data.get("tags"), list) else []
repo_url = f"https://huggingface.co/{normalized_repo_id}"
tree_url = (
f"{repo_url}/tree/{quote(normalized_branch, safe='')}"
+ (
f"/{quote(target_directory, safe='/')}"
if target_directory
else ""
)
)
version = {
"id": normalized_branch,
"name": normalized_branch,
"published_at": model_data.get("createdAt") or "",
"updated_at": model_data.get("lastModified") or "",
"description": "",
"trained_words": [],
"stats": {
"downloads": model_data.get("downloads", 0),
"thumbsUpCount": model_data.get("likes", 0),
},
"files": normalized_files,
"images": [],
"url": tree_url,
}
owner = normalized_repo_id.split("/", 1)[0]
description = (
model_data.get("description")
or card_data.get("description")
or (
f"Select a model file from `{target_directory}` in this Hugging Face "
"repository."
if target_directory
else "Select a model file from this Hugging Face repository."
)
)
return {
"source": "huggingface",
"details_source": "huggingface",
"model_id": normalized_repo_id,
"version_id": normalized_branch,
"name": model_data.get("modelId") or normalized_repo_id,
"type": model_data.get("pipeline_tag")
or model_data.get("library_name")
or "Model repository",
"description": description,
"creator": {"username": model_data.get("author") or owner},
"stats": {
"downloads": model_data.get("downloads", 0),
"thumbsUpCount": model_data.get("likes", 0),
},
"tags": tags,
"url": repo_url,
"version_url": tree_url,
"folder": target_directory,
"branch": normalized_branch,
"versions": [version],
"selected_version": version,
"images": [],
}
@@ -381,6 +589,7 @@ def _build_huggingface_result(
sha256=sha256,
repo_id=repo_id,
path=file_path,
branch="main",
)
@@ -1077,6 +1286,7 @@ def build_huggingface_custom_result(
repo_id=repo_id,
repo=repo_id,
path=file_path,
branch=branch,
page_url=page_url,
version_url=page_url,
custom_url=True,
File diff suppressed because it is too large Load Diff
+76
View File
@@ -9,6 +9,7 @@ from core.sources.huggingface import (
_is_author_index_fresh,
_read_persistent_author_indexes,
build_huggingface_custom_result,
get_huggingface_model_details,
parse_huggingface_url,
get_huggingface_download_url,
_normalize_huggingface_size_probe_url,
@@ -192,3 +193,78 @@ class HuggingFaceSourceTests(unittest.TestCase):
queries = build_filename_search_queries("some_model_bf16.safetensors")
self.assertIn("some_model", queries)
self.assertIn("some_model_bf16", queries)
def test_model_details_lists_only_variants_from_matched_folder(self):
sha256 = "b" * 64
tree = [
{
"path": "diffusion_models/model_fp8_scaled.safetensors",
"size": 100,
"lfs": {"oid": f"sha256:{sha256}", "size": 100},
},
{
"path": "diffusion_models/model_int8_convrot.safetensors",
"size": 80,
},
{
"path": "text_encoders/encoder_fp8.safetensors",
"size": 50,
},
{"path": "diffusion_models/README.md", "size": 10},
]
with patch("core.sources.huggingface._get_repo_tree", return_value=tree):
with patch(
"core.sources.huggingface.execute_provider_json_request",
return_value={
"modelId": "Comfy-Org/example",
"author": "Comfy-Org",
"downloads": 123,
"likes": 4,
"tags": ["comfyui"],
},
):
details = get_huggingface_model_details(
"Comfy-Org/example",
"diffusion_models/model_fp8_scaled.safetensors",
)
self.assertIsNotNone(details)
self.assertEqual("huggingface", details["source"])
self.assertEqual("diffusion_models", details["folder"])
files = details["selected_version"]["files"]
self.assertEqual(
[
"model_fp8_scaled.safetensors",
"model_int8_convrot.safetensors",
],
[file_info["name"] for file_info in files],
)
self.assertTrue(files[0]["primary"])
self.assertEqual("FP8 scaled", files[0]["metadata"]["fp"])
self.assertEqual(sha256, files[0]["sha256"])
self.assertEqual("INT8 convrot", files[1]["metadata"]["fp"])
def test_model_details_passes_huggingface_token_to_tree_and_metadata(self):
tree = [{"path": "model.safetensors", "size": 100}]
with patch(
"core.sources.huggingface._get_repo_tree",
return_value=tree,
) as mock_tree:
with patch(
"core.sources.huggingface.execute_provider_json_request",
return_value={},
) as mock_request:
details = get_huggingface_model_details(
"private/repo",
"model.safetensors",
token="hf_secret",
)
self.assertIsNotNone(details)
expected_headers = {"Authorization": "Bearer hf_secret"}
mock_tree.assert_called_once_with(
"private/repo",
headers=expected_headers,
branch="main",
)
self.assertEqual(expected_headers, mock_request.call_args.kwargs["headers"])
+127
View File
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
getModelCardUrl,
parseHuggingFaceFileUrl,
} from '../web/resolver/utils/url_utils.js';
const searchPanelSource = await readFile(
new URL('../web/resolver/search/search_panel.js', import.meta.url),
'utf8'
);
const modelInfoSource = await readFile(
new URL('../web/resolver/views/model_info_methods.js', import.meta.url),
'utf8'
);
const resolveDownloadSource = await readFile(
new URL('../web/resolver/actions/resolve_download_methods.js', import.meta.url),
'utf8'
);
const resolverCssSource = await readFile(
new URL('../web/css/resolver-main.css', import.meta.url),
'utf8'
);
test('HuggingFace download rows expose the Show More action', () => {
assert.match(
searchPanelSource,
/\['civitai', 'civarchive', 'huggingface'\]\.includes\(normalizedSource\)/
);
assert.match(
modelInfoSource,
/\['civitai', 'civarchive', 'huggingface', 'lora_manager_archive'\]\.includes\(source\)/
);
assert.match(
resolveDownloadSource,
/if \(hfResult && hfResult\.url\)[\s\S]*?detailsContext:\s*\{[\s\S]*?\.\.\.hfResult,[\s\S]*?details_source:\s*'huggingface'/
);
});
test('HuggingFace details requests keep folder, branch, and token context', () => {
assert.match(modelInfoSource, /file_path: model\.path/);
assert.match(modelInfoSource, /branch: model\.branch \|\| ''/);
assert.match(modelInfoSource, /hf_token: tokens\.hf_token \|\| ''/);
});
test('a selected HuggingFace variant keeps its repository path', () => {
assert.match(
modelInfoSource,
/path: selection\.path \|\| selectionFile\?\.path \|\| ''/
);
assert.match(
modelInfoSource,
/branch: selection\.branch \|\| contextModel\.branch \|\| ''/
);
});
test('HuggingFace blob URLs expose repository, branch, and containing path', () => {
const url = (
'https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/blob/main/'
+ 'split_files/text_encoders/llava_llama3_fp8_scaled.safetensors'
);
assert.deepEqual(parseHuggingFaceFileUrl(url), {
repo: 'Comfy-Org/HunyuanVideo_repackaged',
revision: 'main',
path: 'split_files/text_encoders/llava_llama3_fp8_scaled.safetensors',
filename: 'llava_llama3_fp8_scaled.safetensors',
});
assert.equal(getModelCardUrl(url), url);
});
test('Local Database adds HuggingFace details only for direct HF file URLs', () => {
assert.match(
resolveDownloadSource,
/const huggingFaceFile = parseHuggingFaceFileUrl\(modelListResult\.url\)/
);
assert.match(
resolveDownloadSource,
/const modelListDetailsContext = huggingFaceFile[\s\S]*?details_source:\s*'huggingface'[\s\S]*?model_id:\s*huggingFaceFile\.repo[\s\S]*?branch:\s*huggingFaceFile\.revision[\s\S]*?path:\s*huggingFaceFile\.path/
);
assert.match(
resolveDownloadSource,
/detailsContext:\s*modelListDetailsContext/
);
});
test('Workflow URL derives HuggingFace details from a direct file URL', () => {
assert.match(
searchPanelSource,
/const huggingFaceFile = normalizedSource === 'huggingface'[\s\S]*?parseHuggingFaceFileUrl\([\s\S]*?downloadSource\.download_url \|\| downloadSource\.url \|\| rawModelUrl[\s\S]*?\)/
);
assert.match(
searchPanelSource,
/model_id: downloadSource\.model_id \|\| huggingFaceFile\?\.repo/
);
assert.match(
searchPanelSource,
/branch: downloadSource\.branch \|\| huggingFaceFile\?\.revision \|\| ''/
);
assert.match(
searchPanelSource,
/path: downloadSource\.path \|\| huggingFaceFile\?\.path \|\| ''/
);
});
test('HuggingFace details use a full-width compact variant table', () => {
assert.match(
modelInfoSource,
/mr-model-details-main \$\{isHuggingFace \? 'is-huggingface' : ''\}/
);
assert.match(modelInfoSource, /mr-model-details-file-table-header/);
assert.match(modelInfoSource, /mr-model-details-file-table-row/);
assert.match(modelInfoSource, /mr-model-details-file-table-use/);
assert.match(
resolverCssSource,
/\.mr-model-details-main\.is-huggingface\s*\{[^}]*display:\s*block/s
);
assert.match(
resolverCssSource,
/\.mr-model-details-main\.is-huggingface \.mr-model-details-content\s*\{[^}]*display:\s*none/s
);
assert.match(
resolverCssSource,
/\.mr-model-details-file-table-row\s*\{[^}]*min-height:\s*52px/s
);
});
+198
View File
@@ -5393,6 +5393,51 @@ a.mr-image-preview-resource-name:hover {
border-left: 1px solid rgba(255, 255, 255, 0.08);
}
.mr-model-details-main.is-huggingface {
display: block;
overflow: hidden;
}
.mr-model-details-main.is-huggingface .mr-model-details-content {
display: none;
}
.mr-model-details-side.is-huggingface {
width: 100%;
height: 100%;
padding: 12px;
border-left: 0;
box-sizing: border-box;
}
.mr-model-details-side.is-huggingface .mr-model-details-summary-panel {
padding: 10px 12px;
}
.mr-model-details-side.is-huggingface .mr-model-details-kv {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 6px;
background: rgba(255, 255, 255, 0.07);
}
.mr-model-details-side.is-huggingface .mr-model-details-kv div {
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
padding: 7px 9px;
background: #25262b;
}
.mr-model-details-side.is-huggingface .mr-model-details-tags {
margin-top: 9px;
}
.mr-model-details-side.is-huggingface .mr-model-details-variants-panel {
padding: 10px 12px 12px;
}
.mr-model-details-version-tabs {
display: flex;
gap: 7px;
@@ -5651,6 +5696,126 @@ a.mr-image-preview-resource-name:hover {
gap: 9px;
}
.mr-model-details-files.is-table {
gap: 0;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 7px;
}
.mr-model-details-file-table-header,
.mr-model-details-file-table-row {
display: grid;
grid-template-columns:
minmax(220px, 1.45fr)
minmax(240px, 1.35fr)
minmax(180px, 0.9fr)
minmax(132px, 0.55fr);
gap: 14px;
align-items: center;
min-width: 0;
}
.mr-model-details-file-table-header {
min-height: 30px;
padding: 0 11px;
background: rgba(255, 255, 255, 0.055);
color: rgba(255, 255, 255, 0.48);
font-size: 10px;
font-weight: 850;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.mr-model-details-file-table-row {
min-height: 52px;
padding: 7px 11px;
border-top: 1px solid rgba(255, 255, 255, 0.07);
background: rgba(255, 255, 255, 0.025);
}
.mr-model-details-file-table-row:nth-child(odd) {
background: rgba(255, 255, 255, 0.04);
}
.mr-model-details-file-table-row.is-primary {
background: rgba(114, 211, 138, 0.055);
}
.mr-model-details-file-table-row.is-target {
background: rgba(114, 211, 138, 0.09);
box-shadow: inset 3px 0 0 rgba(114, 211, 138, 0.78);
}
.mr-model-details-file-table-name,
.mr-model-details-file-table-summary {
min-width: 0;
}
.mr-model-details-file-table-name strong {
display: block;
overflow: hidden;
color: #f3f3f5;
font-size: 12px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.mr-model-details-file-table-summary {
overflow: hidden;
color: rgba(255, 255, 255, 0.6);
font-size: 11px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.mr-model-details-file-table-badges {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
overflow: hidden;
}
.mr-model-details-file-table-badges span {
display: inline-flex;
align-items: center;
flex: 0 1 auto;
min-width: 0;
min-height: 20px;
padding: 2px 6px;
overflow: hidden;
border-radius: 5px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.78);
font-size: 10px;
font-weight: 800;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.mr-model-details-file-table-row.is-target .mr-model-details-file-table-badges span:first-child {
background: rgba(114, 211, 138, 0.18);
color: #9cf2b2;
}
.mr-model-details-file-table-badges .is-empty {
color: rgba(255, 255, 255, 0.42);
}
.mr-model-details-file-table-use {
width: 100%;
min-width: 0;
white-space: nowrap;
}
.mr-model-details-file-table-mirrors {
grid-column: 1 / -1;
}
.mr-model-details-file {
display: block;
min-width: 0;
@@ -6008,6 +6173,16 @@ a.mr-image-preview-resource-name:hover {
border-left: 0;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.mr-model-details-file-table-header,
.mr-model-details-file-table-row {
grid-template-columns:
minmax(190px, 1.2fr)
minmax(210px, 1.2fr)
minmax(150px, 0.8fr)
minmax(120px, 0.55fr);
gap: 10px;
}
}
@media (max-width: 720px) {
@@ -6050,6 +6225,29 @@ a.mr-image-preview-resource-name:hover {
padding-left: 10px;
}
.mr-model-details-file-table-header {
display: none;
}
.mr-model-details-file-table-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 5px 10px;
padding: 9px 10px;
}
.mr-model-details-file-table-name,
.mr-model-details-file-table-summary,
.mr-model-details-file-table-badges {
grid-column: 1;
}
.mr-model-details-file-table-use {
grid-column: 2;
grid-row: 1 / span 3;
width: auto;
min-width: 112px;
}
.mr-model-details-mirror {
grid-template-columns: 1fr;
}
@@ -3,7 +3,7 @@ import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { createModuleLogger } from "../../log_system/log_funcs.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
import { getModelCardUrl } from "../utils/url_utils.js";
import { getModelCardUrl, parseHuggingFaceFileUrl } from "../utils/url_utils.js";
import { getCivitaiModelUrl } from "../globals.js";
import { normalizePathIdentity } from "../utils/html_utils.js";
const log = createModuleLogger('resolve_download_methods');
@@ -3060,6 +3060,22 @@ export const resolveDownloadMethods = {
if (modelListResult && modelListResult.url) {
const modelListHash = getHashMatchDisplay('model_list', modelListResult);
const missingCategory = this.getMissingDownloadCategory?.(missing, 'checkpoints') || missing.category || 'checkpoints';
const huggingFaceFile = parseHuggingFaceFileUrl(modelListResult.url);
const modelListDetailsContext = huggingFaceFile
? {
...modelListResult,
source: modelListResult.source || 'model_list',
details_source: 'huggingface',
model_id: huggingFaceFile.repo,
repo_id: huggingFaceFile.repo,
version_id: huggingFaceFile.revision,
branch: huggingFaceFile.revision,
path: huggingFaceFile.path,
filename: modelListResult.filename || huggingFaceFile.filename,
missing_key: this.getMissingModelKey(missing),
category: modelListResult.directory || missingCategory
}
: null;
addRow({
sourceKey: 'model-list',
sourceLabel: 'Local Database',
@@ -3073,7 +3089,8 @@ export const resolveDownloadMethods = {
category: modelListResult.directory || missingCategory,
openUrl: getModelCardUrl(modelListResult.url),
searchedAt: this.getSearchResultTimestamp(modelListResult),
localHashMatchIdentities: modelListHash.identities
localHashMatchIdentities: modelListHash.identities,
detailsContext: modelListDetailsContext
});
}
@@ -3095,7 +3112,15 @@ export const resolveDownloadMethods = {
category: missingCategory,
openUrl: hfModelUrl,
searchedAt: this.getSearchResultTimestamp(hfResult),
localHashMatchIdentities: hfHash.identities
localHashMatchIdentities: hfHash.identities,
detailsContext: {
...hfResult,
name: hfRepo || hfResult.filename,
filename: hfResult.filename,
details_source: 'huggingface',
missing_key: this.getMissingModelKey(missing),
category: missingCategory
}
});
}
+14 -5
View File
@@ -2,7 +2,7 @@ import { app } from "../../../../../scripts/app.js";
import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
import { getModelCardUrl } from "../utils/url_utils.js";
import { getModelCardUrl, parseHuggingFaceFileUrl } from "../utils/url_utils.js";
import { getCivitaiModelUrl } from "../globals.js";
import { safeStorage, normalizePathIdentity } from "../utils/html_utils.js";
const localStorage = safeStorage;
@@ -2045,6 +2045,12 @@ export const searchPanelMethods = {
|| downloadSource.workflow_model_url
|| downloadSource.url;
const modelUrl = getModelCardUrl(rawModelUrl) || rawModelUrl;
const normalizedSource = String(source).toLowerCase();
const huggingFaceFile = normalizedSource === 'huggingface'
? parseHuggingFaceFileUrl(
downloadSource.download_url || downloadSource.url || rawModelUrl
)
: null;
const versionName = downloadSource.version_name || missing.civitai_info?.version_name || '';
const modelParts = this.getModelVersionParts(
downloadSource.name || missing.civitai_info?.model_name || '',
@@ -2070,13 +2076,16 @@ export const searchPanelMethods = {
version: modelParts.version,
category: rowCategory
});
const rowDetailsContext = ['civitai', 'civarchive'].includes(String(source).toLowerCase())
const rowDetailsContext = ['civitai', 'civarchive', 'huggingface'].includes(normalizedSource)
? {
...downloadSource,
source,
details_source: String(source).toLowerCase(),
model_id: downloadSource.model_id,
version_id: downloadSource.version_id,
details_source: normalizedSource,
model_id: downloadSource.model_id || huggingFaceFile?.repo,
repo_id: downloadSource.repo_id || downloadSource.repo || huggingFaceFile?.repo,
version_id: downloadSource.version_id || huggingFaceFile?.revision,
branch: downloadSource.branch || huggingFaceFile?.revision || '',
path: downloadSource.path || huggingFaceFile?.path || '',
name: modelName,
filename: downloadFilename,
missing_key: this.getMissingModelKey(missing),
+43 -6
View File
@@ -2,6 +2,41 @@
* URL Utilities for Model Resolver
*/
/**
* Parse a Hugging Face blob/resolve file URL into repository coordinates.
* @param {string} value
* @returns {{repo: string, revision: string, path: string, filename: string}|null}
*/
export function parseHuggingFaceFileUrl(value) {
if (!value) return null;
try {
const url = new URL(String(value));
const host = url.hostname.toLowerCase().replace(/^www\./, '');
if (host !== 'huggingface.co') return null;
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length < 5 || !['resolve', 'blob'].includes(parts[2])) {
return null;
}
const decodePart = part => decodeURIComponent(part);
const repo = `${decodePart(parts[0])}/${decodePart(parts[1])}`;
const revision = decodePart(parts[3]);
const path = parts.slice(4).map(decodePart).join('/');
if (!repo || !revision || !path) return null;
return {
repo,
revision,
path,
filename: path.split('/').pop() || ''
};
} catch {
return null;
}
}
/**
* Parses the download URL and returns the model card URL.
* Supports HuggingFace and CivitAI platforms.
@@ -14,12 +49,14 @@ export function getModelCardUrl(downloadUrl) {
try {
// HuggingFace URLs
if (downloadUrl.includes('huggingface.co')) {
const fileMatch = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)\/(?:resolve|blob)\/([^\/]+)\/(.+)$/);
if (fileMatch) {
const repo = fileMatch[1];
const revision = fileMatch[2];
const filePath = fileMatch[3].split(/[?#]/)[0];
return `https://huggingface.co/${repo}/blob/${revision}/${filePath}`;
const file = parseHuggingFaceFileUrl(downloadUrl);
if (file) {
const encodedRevision = encodeURIComponent(file.revision);
const encodedPath = file.path
.split('/')
.map(part => encodeURIComponent(part))
.join('/');
return `https://huggingface.co/${file.repo}/blob/${encodedRevision}/${encodedPath}`;
}
const match = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)/);
+69 -11
View File
@@ -563,7 +563,7 @@ export const modelInfoMethods = {
canShowSourceDetails(model = {}) {
const source = String(model.details_source || model.source || '').toLowerCase();
return model?.context_scope === 'download_table'
&& ['civitai', 'civarchive', 'lora_manager_archive'].includes(source)
&& ['civitai', 'civarchive', 'huggingface', 'lora_manager_archive'].includes(source)
&& Boolean(model.model_id || model.modelId);
},
@@ -2766,7 +2766,15 @@ export const modelInfoMethods = {
source: model.details_source || model.source,
model_id: model.model_id || model.modelId,
version_id: model.version_id || model.versionId,
civitai_key: tokens.civitai_key || ''
file_path: model.path
|| model.file_path
|| model.selected_file?.path
|| model.file_info?.path
|| model.file?.path
|| '',
branch: model.branch || '',
civitai_key: tokens.civitai_key || '',
hf_token: tokens.hf_token || ''
})
}, 'Fetch model details');
details._detailsData = data;
@@ -2830,7 +2838,15 @@ export const modelInfoMethods = {
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 || ''
file_path: details._sourceModel?.path
|| details._sourceModel?.file_path
|| details._sourceModel?.selected_file?.path
|| details._sourceModel?.file_info?.path
|| details._sourceModel?.file?.path
|| '',
branch: data.branch || details._sourceModel?.branch || '',
civitai_key: tokens.civitai_key || '',
hf_token: tokens.hf_token || ''
})
}, 'Fetch model version details');
const freshVersion = fresh.selected_version;
@@ -2864,6 +2880,7 @@ export const modelInfoMethods = {
const description = data.description || selectedVersion.description || '';
const sanitizedDescription = description ? this.sanitizeDescriptionHtml(description) : '';
const source = data.source || contextModel.details_source || contextModel.source || '';
const isHuggingFace = String(source).toLowerCase() === 'huggingface';
const creator = data.creator || {};
const creatorName = creator.username || creator.name || '';
const pageUrl = data.version_url || data.url || '';
@@ -2878,7 +2895,7 @@ export const modelInfoMethods = {
].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '');
return `
<div class="mr-model-details-shell">
<div class="mr-model-details-shell ${isHuggingFace ? 'is-huggingface' : ''}">
<div class="mr-model-details-topbar">
<button type="button" class="mr-model-details-icon-btn" data-action="close" aria-label="Close">${getSvgIcon('x')}</button>
<div class="mr-model-details-title">
@@ -2890,7 +2907,7 @@ export const modelInfoMethods = {
${pageUrl ? `<a class="mr-model-details-action" href="${this.escapeHtml(pageUrl)}" target="_blank" rel="noopener noreferrer">${getSvgIcon('externalLink')} Open page</a>` : ''}
</div>
</div>
<div class="mr-model-details-main">
<div class="mr-model-details-main ${isHuggingFace ? 'is-huggingface' : ''}">
<section class="mr-model-details-content">
<div class="mr-model-details-version-tabs">
${versions.map(version => `
@@ -2917,8 +2934,8 @@ export const modelInfoMethods = {
${sanitizedDescription ? sanitizedDescription : '<p>No description available.</p>'}
</div>
</section>
<aside class="mr-model-details-side">
<section class="mr-model-details-panel">
<aside class="mr-model-details-side ${isHuggingFace ? 'is-huggingface' : ''}">
<section class="mr-model-details-panel mr-model-details-summary-panel">
<h3>Details</h3>
<div class="mr-model-details-kv">
<div><span>Type</span><strong>${this.escapeHtml(data.type || '-')}</strong></div>
@@ -2934,9 +2951,17 @@ export const modelInfoMethods = {
<div class="mr-model-details-tags">${trainedWords.map(word => `<span>${this.escapeHtml(word)}</span>`).join('')}</div>
</section>
` : ''}
<section class="mr-model-details-panel">
<section class="mr-model-details-panel mr-model-details-variants-panel">
<h3>Download Variants</h3>
<div class="mr-model-details-files">
<div class="mr-model-details-files ${isHuggingFace ? 'is-table' : ''}">
${isHuggingFace ? `
<div class="mr-model-details-file-table-header" aria-hidden="true">
<span>File</span>
<span>Size / Hash</span>
<span>Variant</span>
<span>Action</span>
</div>
` : ''}
${this.renderSourceModelDetailsFiles(selectedVersion, data, contextModel)}
</div>
</section>
@@ -2988,6 +3013,8 @@ export const modelInfoMethods = {
renderSourceModelDetailsFiles(version = {}, data = {}, contextModel = {}) {
const files = Array.isArray(version.files) ? version.files.filter(file => file?.download_url) : [];
const source = String(data.source || contextModel.details_source || contextModel.source || '').toLowerCase();
const isHuggingFace = source === 'huggingface';
if (!files.length) {
return '<div class="mr-model-details-empty">No downloadable files available for this version.</div>';
}
@@ -3048,6 +3075,8 @@ export const modelInfoMethods = {
selected_version: selectedVersion,
base_model: version.base_model || contextModel.base_model,
tags: data.tags || [],
path: file.path || '',
branch: data.branch || contextModel.branch || '',
match_type: 'selected',
confidence: 100
};
@@ -3059,6 +3088,33 @@ export const modelInfoMethods = {
file.primary ? 'Primary' : '',
mirrors.length ? `${mirrors.length} mirrors` : ''
].filter(Boolean);
const compactBadges = Array.from(new Set([
...summaryBadges,
...fileMeta.badges
]));
const selectionData = this.escapeHtml(encodeURIComponent(JSON.stringify(payload)));
const actionLabel = isTarget ? 'Use matched file' : 'Use this file';
if (isHuggingFace) {
return `
<div class="mr-model-details-file-table-row ${file.primary ? 'is-primary' : ''} ${isTarget ? 'is-target' : ''}">
<div class="mr-model-details-file-table-name">
<strong>${this.escapeHtml(file.name || `File ${index + 1}`)}</strong>
</div>
<div class="mr-model-details-file-table-summary" data-tooltip="${this.escapeHtml(fileMeta.summary)}">
${this.escapeHtml(fileMeta.summary)}
</div>
<div class="mr-model-details-file-table-badges">
${compactBadges.length
? compactBadges.map(badge => `<span>${this.escapeHtml(badge)}</span>`).join('')
: '<span class="is-empty">Model file</span>'}
</div>
<button type="button" class="mr-btn mr-btn-primary mr-model-details-use mr-model-details-file-table-use" data-selection="${selectionData}">
${actionLabel}
</button>
${mirrorList ? `<div class="mr-model-details-file-table-mirrors">${mirrorList}</div>` : ''}
</div>
`;
}
const openAttr = index === 0 ? ' open' : '';
return `
<details class="mr-model-details-file ${file.primary ? 'is-primary' : ''} ${isTarget ? 'is-target' : ''}"${openAttr}>
@@ -3072,8 +3128,8 @@ export const modelInfoMethods = {
</summary>
<div class="mr-model-details-file-body">
${fileMeta.badges.length ? `<div class="mr-model-details-file-badges">${fileMeta.badges.map(badge => `<span>${this.escapeHtml(badge)}</span>`).join('')}</div>` : ''}
<button type="button" class="mr-btn mr-btn-primary mr-model-details-use" data-selection="${this.escapeHtml(encodeURIComponent(JSON.stringify(payload)))}">
${isTarget ? 'Use matched file' : 'Use this file'}
<button type="button" class="mr-btn mr-btn-primary mr-model-details-use" data-selection="${selectionData}">
${actionLabel}
</button>
${mirrorList}
</div>
@@ -3546,6 +3602,8 @@ export const modelInfoMethods = {
sha256: selectionSha256,
hash: selectionSha256,
tags: selection.tags || [],
path: selection.path || selectionFile?.path || '',
branch: selection.branch || contextModel.branch || '',
match_type: selection.match_type || 'selected',
confidence: selection.confidence || 100,
searchedAt: new Date().toISOString()