feat: include Hugging Face model search

This commit is contained in:
Hermes Agent
2026-08-28 11:33:41 +10:00
parent 2612e90dbf
commit 1a54b41c33
6 changed files with 187 additions and 6 deletions
+27 -4
View File
@@ -129,6 +129,7 @@
var model = props.model, installed = props.installed, busy = props.busy, action = props.action;
var openState = React.useState(false), open = openState[0], setOpen = openState[1];
var badges = [];
if (model.source === "huggingface") badges.push(h(Badge, { key: "huggingface", tone: "popular" }, "Hugging Face"));
if (installed && model.loaded) badges.push(h(Badge, { key: "loaded", tone: "live" }, "loaded"));
if (installed) badges.push(h(Badge, { key: "installed", tone: "installed" }, "installed"));
if (!installed) badges.push(h(Badge, { key: "available", tone: "download" }, "available"));
@@ -141,7 +142,8 @@
h("div", { className: "ollama-card-actions" },
installed && h(Button, { disabled: !!busy, onClick: function () { action("redownload", model.name); } }, busy === model.name + ":redownload" ? "Updating…" : "Update / re-download"),
installed && h(Button, { disabled: !!busy, className: "ollama-button danger", onClick: function () { action("delete", model.name); } }, busy === model.name + ":delete" ? "Removing…" : "Remove"),
!installed && h(Button, { disabled: !!busy, onClick: function () { action("pull", model.name); } }, busy === model.name + ":pull" ? "Downloading…" : "Download")
!installed && model.source === "huggingface" && model.hf_url && h("a", { className: "ollama-button secondary", href: model.hf_url, target: "_blank", rel: "noreferrer" }, "Open on Hugging Face"),
!installed && model.source !== "huggingface" && h(Button, { disabled: !!busy, onClick: function () { action("pull", model.name); } }, busy === model.name + ":pull" ? "Downloading…" : "Download")
)
),
h("div", { className: "ollama-model-summary" },
@@ -150,7 +152,7 @@
h("div", null, h("small", null, "Type"), h("strong", null, model.architecture || "Unknown")),
h("div", null, h("small", null, "Parameters"), h("strong", null, model.parameter_size || "Unknown"), model.activated_parameter_size && h("small", { className: "ollama-activated-parameters" }, model.activated_parameter_size, " activated"))
),
h("div", { className: "ollama-card-meta" }, h("span", null, (model.quantization || "Unknown") + " · " + (model.format || "Unknown")), model.context_length && h("span", null, "Context " + Number(model.context_length).toLocaleString()), model.modified_at ? h("span", null, "Last updated " + fmtDate(model.modified_at)) : h("span", { className: "ollama-date-unavailable" }, "Last updated unavailable")),
h("div", { className: "ollama-card-meta" }, h("span", null, (model.quantization || "Unknown") + " · " + (model.format || "Unknown")), model.hf_downloads != null && h("span", null, Number(model.hf_downloads).toLocaleString() + " downloads"), model.hf_likes != null && h("span", null, Number(model.hf_likes).toLocaleString() + " likes"), model.context_length && h("span", null, "Context " + Number(model.context_length).toLocaleString()), model.modified_at ? h("span", null, "Last updated " + fmtDate(model.modified_at)) : h("span", { className: "ollama-date-unavailable" }, "Last updated unavailable")),
h("div", { className: "ollama-strengths" }, h("strong", null, "Excels at: "), (model.strengths || []).join(" · ")),
installed && h(VariantTable, { model: model, action: action, busy: busy }),
h(Button, { className: "ollama-details-toggle", onClick: function () { setOpen(!open); } }, open ? "Hide capability breakdown" : "Show capability breakdown"),
@@ -159,6 +161,7 @@
h("h4", null, "Runtime estimate"),
h("p", null, model.expected_ram_basis || "No estimate basis available.", " Actual memory varies with context length, KV cache, GPU offload, and concurrent requests."),
h("div", { className: "ollama-detail-grid" },
h("span", null, "Source: ", h("strong", null, model.source_label || model.source || "Ollama")),
h("span", null, "Family: ", h("strong", null, model.family || "unknown")),
h("span", null, "Digest: ", h("strong", null, model.digest ? model.digest.slice(0, 16) + "…" : "unknown")),
h("span", null, "Embedding: ", h("strong", null, model.embedding_length || "unknown")),
@@ -508,12 +511,27 @@
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1];
var targetDialogState = React.useState(null), targetDialog = targetDialogState[0], setTargetDialog = targetDialogState[1];
var loadingState = React.useState(true), loading = loadingState[0], setLoading = loadingState[1];
var externalSearchState = React.useState([]), externalSearch = externalSearchState[0], setExternalSearch = externalSearchState[1];
var externalSearchBusyState = React.useState(false), externalSearchBusy = externalSearchBusyState[0], setExternalSearchBusy = externalSearchBusyState[1];
var loadSequence = React.useRef(0);
var searchSequence = React.useRef(0);
function load() {
var sequence = ++loadSequence.current;
return fetchJSON(API + "/status").then(function (value) { if (sequence !== loadSequence.current) return value; setData(value); setLoading(false); return value; }).catch(function (err) { if (sequence === loadSequence.current) { setNotice({ error: err.message || String(err) }); setLoading(false); } });
}
React.useEffect(function () { load(); var timer = setInterval(load, 5000); return function () { clearInterval(timer); }; }, []);
React.useEffect(function () {
var term = query.trim();
var sequence = ++searchSequence.current;
if (term.length < 2) { setExternalSearch([]); setExternalSearchBusy(false); return; }
setExternalSearchBusy(true);
var timer = setTimeout(function () {
fetchJSON(API + "/catalog/search?q=" + encodeURIComponent(term) + "&limit=30").then(function (value) {
if (sequence === searchSequence.current) setExternalSearch(value.results || []);
}).catch(function () { if (sequence === searchSequence.current) setExternalSearch([]); }).finally(function () { if (sequence === searchSequence.current) setExternalSearchBusy(false); });
}, 250);
return function () { clearTimeout(timer); };
}, [query]);
function action(kind, name, selectedTarget) {
if (kind === "delete" && !window.confirm("Remove " + name + " from Ollama?")) return;
if ((kind === "pull" || kind === "redownload") && !selectedTarget) {
@@ -543,6 +561,10 @@
if (catalogSort === "name") return String(a.name).localeCompare(String(b.name));
return (Number(a.popularity_rank) || 999999) - (Number(b.popularity_rank) || 999999);
});
if (query.trim().length >= 2 && externalSearch.length) {
var existingNames = new Set(models.map(function (model) { return model.name; }));
models = models.concat(externalSearch.filter(function (model) { return !existingNames.has(model.name); }));
}
}
var needle = query.toLowerCase().trim(); if (needle) models = models.filter(function (model) { return (model.name + " " + model.family + " " + (model.strengths || []).join(" ") + " " + (model.capabilities || []).join(" ")).toLowerCase().indexOf(needle) >= 0; });
var catalogCapabilities = data && data.catalog_filter_options ? data.catalog_filter_options.capabilities || [] : [];
@@ -568,7 +590,8 @@
h("label", { className: "ollama-catalog-checkbox ollama-catalog-memory-bypass", title: "This only bypasses the catalog display filter; loading remains protected by the 95% RAM safety guard." }, h("input", { type: "checkbox", checked: showOversized, onChange: function (event) { setShowOversized(event.target.checked); } }), h("span", null, "Show models above estimated RAM"))
);
var browseToolbar = tab !== "chat" && h("div", { className: "ollama-browse-row" },
h("input", { className: "ollama-search", value: query, placeholder: "Search models, capabilities, or strengths…", onChange: function (event) { setQuery(event.target.value); } }),
h("input", { className: "ollama-search", value: query, placeholder: "Search Ollama + Hugging Face models…", onChange: function (event) { setQuery(event.target.value); } }),
externalSearchBusy && h("small", { className: "ollama-search-status" }, "Searching Hugging Face…"),
catalogControls
);
return h("main", { className: "ollama-page" }, h("header", { className: "ollama-hero" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "LOCAL MODEL OPERATIONS"), h("h1", null, "Ollama Models"), h("p", null, "Inspect, chat with, download, update, and remove models from the local Ollama runtime.")), h("div", { className: "ollama-health" }, h(Badge, { tone: data && data.ollama && data.ollama.available ? "live" : "danger" }, data && data.ollama && data.ollama.available ? "Ollama online" : "Ollama unavailable"), data && data.ollama && h("span", null, "v" + (data.ollama.version || "unknown")), h(Button, { disabled: busy === "catalog", onClick: refreshCatalog }, busy === "catalog" ? "Refreshing…" : "Refresh catalog")), h(ConnectionPanel, { data: data, reload: load })),
@@ -578,7 +601,7 @@
tab !== "chat" && h("div", { className: "ollama-info-strip" }, h("span", null, data && data.models ? data.models.filter(function (m) { return m.loaded; }).length + " currently loaded" : "Loading runtime state…"), h("span", null, "Catalog checked " + (data && data.catalog_updated_at ? fmtDate(data.catalog_updated_at) : "not yet")), h("span", null, "Next daily check " + (data && data.next_catalog_refresh ? fmtDate(data.next_catalog_refresh) : "01:00 Melbourne time") + " (1:00 AM Melbourne time)")),
tab === "chat" && h(ChatPanel, { models: data && data.models ? data.models : [], refresh: load }),
tab === "popular" && h("p", { className: "ollama-popular-note" }, "Popular is limited to models with known size and RAM estimates at or below the detected system RAM (" + (data && data.popular_filter && data.popular_filter.max_expected_ram_gib ? data.popular_filter.max_expected_ram_gib + " GiB" : "detecting…") + "). Oversized families are represented by a smaller fitting variant when available."), jobs.length > 0 && h("section", { className: "ollama-jobs" }, jobs.map(function (job) { return h("div", { key: job.id }, h("strong", null, job.action + " · " + job.name + " · " + (job.target || "local") + (job.endpoint ? " · " + job.endpoint : "")), h("span", null, job.percent == null ? job.status : job.percent + "%")); })),
tab !== "chat" && loading && h(Empty, null, "Loading local Ollama inventory…"), tab !== "chat" && !loading && !models.length && h(Empty, null, tab === "installed" ? "No local models found." : tab === "popular" ? "No popular catalog entries available." : "No catalog entries available. Try Refresh catalog."), tab !== "chat" && h("section", { className: "ollama-grid" }, models.map(function (model) { return h(ModelCard, { key: model.name, model: model, installed: tab === "installed" || !!model.installed, busy: busy, action: action }); }))
tab !== "chat" && loading && h(Empty, null, "Loading local Ollama inventory…"), tab !== "chat" && !loading && !models.length && h(Empty, null, tab === "installed" ? "No local models found." : tab === "popular" ? "No popular catalog entries available." : query.trim().length >= 2 ? "No Ollama or Hugging Face models matched this search." : "No catalog entries available. Try Refresh catalog."), tab !== "chat" && h("section", { className: "ollama-grid" }, models.map(function (model) { return h(ModelCard, { key: model.source + ":" + model.name, model: model, installed: tab === "installed" || !!model.installed, busy: busy, action: action }); }))
);
}
registry.register("ollama-manager", Page);
+1 -1
View File
@@ -3,7 +3,7 @@
"label": "Ollama Models",
"description": "Inspect, manage, and chat with local Ollama models, including shared persistent conversations, performance metrics, images, PDFs, URLs, and live memory telemetry.",
"icon": "Cpu",
"version": "1.7.2",
"version": "1.7.3",
"tab": {"path": "/ollama-manager", "position": "after:models"},
"entry": "dist/index.js",
"css": "dist/style.css",
+118
View File
@@ -70,6 +70,7 @@ def _discover_ollama_endpoint() -> None:
LOCAL_OLLAMA = _ollama_base_url()
REMOTE_OLLAMA = "https://ollama.com"
HUGGINGFACE_API = "https://huggingface.co/api"
CATALOG_FILE = "catalog.json"
MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,190}$")
MELBOURNE = ZoneInfo("Australia/Melbourne")
@@ -747,6 +748,14 @@ def _json_request(url: str, method: str = "GET", payload: Any = None, timeout: i
return value if isinstance(value, dict) else {}
def _json_list_request(url: str, timeout: int = 30) -> list[dict[str, Any]]:
request = Request(url, headers={"Accept": "application/json", "User-Agent": "Hermes-Ollama-Models/1.7.3"})
with urlopen(request, timeout=timeout) as response:
raw = response.read()
value = json.loads(raw.decode("utf-8")) if raw else []
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
def _valid_name(name: str) -> str:
name = str(name or "").strip()
if not MODEL_RE.fullmatch(name):
@@ -1361,6 +1370,98 @@ def _model_view(raw: dict[str, Any], loaded: dict[str, Any] | None = None, sourc
}
def _huggingface_model_view(raw: dict[str, Any]) -> dict[str, Any]:
repo_id = str(raw.get("id") or raw.get("modelId") or "").strip()
raw_tags = raw.get("tags")
tags = [str(tag).strip() for tag in raw_tags if str(tag).strip()][:32] if isinstance(raw_tags, list) else []
pipeline = str(raw.get("pipeline_tag") or "").strip()
library = str(raw.get("library_name") or "").strip()
searchable = " ".join([repo_id, pipeline, library, *tags])
capabilities = _infer_capabilities(repo_id, library, {}, ["completion"] if pipeline in {"text-generation", "text2text-generation", "image-text-to-text"} else [])
if pipeline == "image-text-to-text" and "vision" not in capabilities:
capabilities.append("vision")
if pipeline in {"text-to-image", "image-to-image", "image-classification"} and "vision" not in capabilities:
capabilities.append("vision")
is_moe = bool(re.search(r"(?:moe|mixture.of.experts|a\d+b)", searchable, re.I))
return {
"name": repo_id,
"source": "huggingface",
"source_label": "Hugging Face",
"downloadable": False,
"installed": False,
"loaded": False,
"size_bytes": 0,
"size_gb": None,
"size_label": "Hub repository",
"loaded_bytes": 0,
"loaded_vram_bytes": 0,
"digest": str(raw.get("sha") or ""),
"modified_at": raw.get("lastModified"),
"family": library or "Hugging Face model",
"architecture": pipeline or "Unknown",
"is_moe": is_moe,
"parameter_size": "unknown",
"activated_parameter_size": None,
"parameter_summary": "unknown",
"description": f"Hugging Face model · {pipeline or 'pipeline unavailable'}" + (f" · {library}" if library else ""),
"quantization": "see repository files",
"format": library or "Hub format",
"context_length": None,
"input_modalities": ["Text", "Image"] if "vision" in capabilities else ["Text"],
"embedding_length": None,
"capabilities": list(dict.fromkeys(capabilities)),
"capability_breakdown": {cap: CAPABILITY_INFO[cap] for cap in capabilities if cap in CAPABILITY_INFO},
"strengths": [pipeline or "model repository", "Hugging Face Hub metadata"],
"expected_ram_gb": None,
"expected_ram_label": "Unknown · inspect repository requirements",
"expected_ram_basis": "Hugging Face does not provide a reliable universal runtime RAM estimate in search results.",
"hf_url": f"https://huggingface.co/{repo_id}",
"hf_downloads": int(raw.get("downloads") or 0),
"hf_likes": int(raw.get("likes") or 0),
"hf_pipeline_tag": pipeline,
"hf_library": library,
"hf_tags": tags,
}
def _search_huggingface(query: str, limit: int = 30) -> list[dict[str, Any]]:
query = str(query or "").strip()
if len(query) < 2:
return []
limit = max(1, min(int(limit), 50))
url = f"{HUGGINGFACE_API}/models?{urlencode({'search': query[:120], 'limit': limit, 'sort': 'downloads', 'direction': '-1', 'full': 'false'})}"
try:
rows = _json_list_request(url, timeout=20)
except (HTTPError, URLError, OSError, ValueError):
return []
return [_huggingface_model_view(row) for row in rows if (row.get("id") or row.get("modelId"))]
def _search_ollama_catalog(query: str, limit: int = 50) -> list[dict[str, Any]]:
needle = str(query or "").strip().lower()
if len(needle) < 2:
return []
catalog = _ensure_catalog()
rows = list(catalog.get("models", []))
family_map: dict[str, Any] = {}
raw_families = catalog.get("families")
if isinstance(raw_families, dict):
family_map = raw_families
rows.extend(variant for variants in family_map.values() if isinstance(variants, list) for variant in variants if isinstance(variant, dict))
matches: list[dict[str, Any]] = []
seen: set[str] = set()
for raw in rows:
name = str(raw.get("name") or raw.get("model") or "")
if not name or name in seen:
continue
view = _model_view(raw, source="catalog")
haystack = " ".join([name, view.get("family", ""), view.get("description", ""), *view.get("capabilities", []), *view.get("strengths", [])]).lower()
if needle in haystack:
seen.add(name)
matches.append(view)
return matches[:max(1, min(int(limit), 100))]
class _VariantPageParser(HTMLParser):
"""Extract the public Ollama tag rows without depending on third-party HTML packages."""
@@ -2679,6 +2780,23 @@ def catalog_refresh() -> dict[str, Any]:
return {"ok": bool(catalog.get("models")), "updated_at": catalog.get("fetched_at"), "count": len(catalog.get("models", [])), "error": catalog.get("last_error")}
@router.get("/catalog/search")
def catalog_search(q: str = "", limit: int = 30) -> dict[str, Any]:
query = str(q or "").strip()[:120]
if len(query) < 2:
return {"query": query, "results": [], "sources": ["ollama", "huggingface"]}
ollama = _search_ollama_catalog(query, limit=limit)
huggingface = _search_huggingface(query, limit=limit)
combined = ollama + huggingface
return {
"query": query,
"results": combined,
"ollama_results": ollama,
"huggingface_results": huggingface,
"sources": ["ollama", "huggingface"],
}
@router.post("/pull")
def pull_model(body: ModelRequest) -> dict[str, Any]:
name = _valid_name(body.name)