diff --git a/README.md b/README.md index 18c0b5b..c218e51 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ The full validation scan covered 7,230 raw public variants: 604 were classified Available downloads now combine Ollama's popular API response with the public `https://ollama.com/library` index and each public family tag page. This means models that are not currently popular and are not installed locally—such as `ornith-1.5:9b` and `ornith-1.5:35b`—are discoverable. The catalog refresh found 235 public family slugs and 7,230 raw variants during validation. The existing MLX exclusion and host-RAM fit filter still apply, so very large variants such as `ornith-1.5:397b` remain hidden when they cannot fit the detected host RAM. +Model search also queries the public Hugging Face Hub API when a search contains at least two characters. Hugging Face results are labeled **Hugging Face**, show repository metadata such as pipeline, library, downloads, and likes, and include an **Open on Hugging Face** link. They are intentionally not sent to Ollama's `/api/pull`: repository formats and runtime requirements vary, so a Transformers/safetensors/FP8/GGUF repository must be inspected before installation. For example, searching `Qwen3.8-Flash-Next` returns `Qwen/Qwen3.8-Flash-Next` and compatible community repositories when Hugging Face has indexed them. + The Live runtime panel now shows overall CPU usage, logical CPU count, load averages, overall GPU utilization, and per-GPU VRAM usage. When multiple logical CPUs are detected, it expands into a scrollable responsive per-core grid. When multiple GPUs are detected, it expands into a responsive per-GPU grid showing utilization, VRAM used/free, temperature, and power when the driver reports them. The grids use auto-fit sizing and bounded scrolling so the panel scales to larger CPU and GPU counts without overflowing the dashboard. diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js index 0b4014f..0fd5dcb 100644 --- a/dashboard/dist/index.js +++ b/dashboard/dist/index.js @@ -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); diff --git a/dashboard/manifest.json b/dashboard/manifest.json index cf2a8a8..d8e5085 100644 --- a/dashboard/manifest.json +++ b/dashboard/manifest.json @@ -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", diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py index 459d59e..55e48f6 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -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) diff --git a/plugin.yaml b/plugin.yaml index 27552b1..03c3882 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: ollama-manager -version: 1.7.2 +version: 1.7.3 description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, shared persistent conversations, performance metrics, and live runtime telemetry. auto_install_dependencies: true python_dependencies: diff --git a/tests/test_validation_harness.py b/tests/test_validation_harness.py index 5c3580f..2d696bb 100644 --- a/tests/test_validation_harness.py +++ b/tests/test_validation_harness.py @@ -89,6 +89,44 @@ class ValidationHarnessTests(unittest.TestCase): self.assertEqual(response, {"jobs": active}) listed.assert_called_once_with(active_only=True, conversation_id="conversation", limit=20) + def test_huggingface_search_normalizes_public_model_metadata(self): + with patch.object( + api, + "_json_list_request", + return_value=[ + { + "id": "Qwen/Qwen3.8-Flash-Next", + "downloads": 4810, + "likes": 3966, + "pipeline_tag": "image-text-to-text", + "library_name": "transformers", + "lastModified": "2026-08-27T00:00:00.000Z", + "sha": "abc123", + "tags": ["qwen", "conversational"], + } + ], + ): + results = api._search_huggingface("Qwen3.8-Flash-Next", limit=10) + self.assertEqual(len(results), 1) + model = results[0] + self.assertEqual(model["name"], "Qwen/Qwen3.8-Flash-Next") + self.assertEqual(model["source"], "huggingface") + self.assertEqual(model["hf_url"], "https://huggingface.co/Qwen/Qwen3.8-Flash-Next") + self.assertIn("vision", model["capabilities"]) + self.assertFalse(model["downloadable"]) + + def test_catalog_search_combines_ollama_and_huggingface_sources(self): + ollama = [{"name": "qwen3.8:latest", "source": "catalog"}] + huggingface = [{"name": "Qwen/Qwen3.8-Flash-Next", "source": "huggingface"}] + with patch.object(api, "_search_ollama_catalog", return_value=ollama) as ollama_search, patch.object( + api, "_search_huggingface", return_value=huggingface + ) as hf_search: + response = api.catalog_search("Qwen3.8", limit=10) + self.assertEqual(response["results"], ollama + huggingface) + self.assertEqual(response["sources"], ["ollama", "huggingface"]) + ollama_search.assert_called_once_with("Qwen3.8", limit=10) + hf_search.assert_called_once_with("Qwen3.8", limit=10) + def test_primary_draft_validators_and_primary_compilation_produce_one_answer(self): body = api.ChatRequest( primary_model="primary",