From c13ecf0af62ca497886f462a6e2a43174484524e Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 28 Aug 2026 14:51:53 +1000 Subject: [PATCH] feat: download Hugging Face GGUF models --- README.md | 6 +- dashboard/dist/index.js | 29 +++-- dashboard/dist/style.css | 2 +- dashboard/manifest.json | 2 +- dashboard/plugin_api.py | 178 ++++++++++++++++++++++++++++++- plugin.yaml | 2 +- tests/test_validation_harness.py | 38 ++++++- 7 files changed, 239 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c218e51..7fee46d 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ 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. +Model search can optionally query the public Hugging Face Hub API when **Search Hugging Face** is checked and the query 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. Repositories with GGUF files also show **Download GGUF**. The picker groups split GGUF shards into one complete selectable set, downloads all required files, checks available disk space, and imports the first shard into local Ollama with `ollama create` when Ollama shares the dashboard filesystem. Transformers/safetensors/FP8-only repositories remain viewable but are not falsely offered as Ollama downloads. For example, `Qwen/Qwen3.8-Flash-Next` is searchable, while `unsloth/Qwen3.8-Flash-Next-GGUF` exposes complete GGUF sets. 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. @@ -158,7 +158,9 @@ The dashboard plugin API is mounted when the dashboard starts. Restart Hermes af ## Download storage -The plugin does not store model blobs in Hermes. It sends Ollama's native `POST /api/pull` request to the selected endpoint. Therefore a download goes to the Ollama instance shown in the job message, and the Ollama service owns the model storage location. The exact path is controlled by Ollama's `OLLAMA_MODELS` setting; common Linux service/user locations are `/usr/share/ollama/.ollama/models` and `~/.ollama/models`. Check the Ollama service environment on the target host to determine the authoritative path. +The plugin does not store normal Ollama model blobs in Hermes. It sends Ollama's native `POST /api/pull` request to the selected endpoint. Therefore a normal Ollama download goes to the Ollama instance shown in the job message, and the Ollama service owns the model storage location. The exact path is controlled by Ollama's `OLLAMA_MODELS` setting; common Linux service/user locations are `/usr/share/ollama/.ollama/models` and `~/.ollama/models`. Check the Ollama service environment on the target host to determine the authoritative path. + +Hugging Face downloads are separate: the checkbox enables Hub search, and **Download GGUF** downloads a selected complete GGUF file or shard set under the Hermes home `huggingface///` directory. The plugin revalidates the repository metadata and filename, checks free disk space when the Hub publishes sizes, and reports progress in the jobs panel. On a non-containerized host where Ollama is on the same filesystem, it writes a temporary Modelfile and runs `ollama create` to import the GGUF. If Ollama is remote or containerized with a different filesystem, the file is downloaded but automatic import is not attempted; the UI says so explicitly. Transformers, safetensors, and FP8-only repositories remain browseable through their Hugging Face link but are not treated as Ollama-compatible downloads. ## Security limits diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js index 0fd5dcb..c59660c 100644 --- a/dashboard/dist/index.js +++ b/dashboard/dist/index.js @@ -126,7 +126,7 @@ } function ModelCard(props) { - var model = props.model, installed = props.installed, busy = props.busy, action = props.action; + var model = props.model, installed = props.installed, busy = props.busy, action = props.action, onHuggingFace = props.onHuggingFace; 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")); @@ -143,6 +143,7 @@ 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 && 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" && onHuggingFace && h(Button, { disabled: !!busy, onClick: function () { onHuggingFace(model); } }, "Download GGUF"), !installed && model.source !== "huggingface" && h(Button, { disabled: !!busy, onClick: function () { action("pull", model.name); } }, busy === model.name + ":pull" ? "Downloading…" : "Download") ) ), @@ -510,6 +511,8 @@ var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1]; var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1]; var targetDialogState = React.useState(null), targetDialog = targetDialogState[0], setTargetDialog = targetDialogState[1]; + var hfFileDialogState = React.useState(null), hfFileDialog = hfFileDialogState[0], setHfFileDialog = hfFileDialogState[1]; + var hfSearchState = React.useState(false), includeHuggingFace = hfSearchState[0], setIncludeHuggingFace = hfSearchState[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]; @@ -523,15 +526,15 @@ React.useEffect(function () { var term = query.trim(); var sequence = ++searchSequence.current; - if (term.length < 2) { setExternalSearch([]); setExternalSearchBusy(false); return; } + if (term.length < 2 || !includeHuggingFace) { 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 || []); + fetchJSON(API + "/catalog/search?q=" + encodeURIComponent(term) + "&limit=30&include_huggingface=true").then(function (value) { + if (sequence === searchSequence.current) setExternalSearch(value.huggingface_results || []); }).catch(function () { if (sequence === searchSequence.current) setExternalSearch([]); }).finally(function () { if (sequence === searchSequence.current) setExternalSearchBusy(false); }); }, 250); return function () { clearTimeout(timer); }; - }, [query]); + }, [query, includeHuggingFace]); function action(kind, name, selectedTarget) { if (kind === "delete" && !window.confirm("Remove " + name + " from Ollama?")) return; if ((kind === "pull" || kind === "redownload") && !selectedTarget) { @@ -544,6 +547,16 @@ fetchJSON(API + (kind === "delete" ? "/model" : "/" + kind), { method: kind === "delete" ? "DELETE" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name, target: selectedTarget || "local" }) }).then(function (result) { setNotice({ ok: result.message || "Action started." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); } function refreshCatalog() { setBusy("catalog"); setNotice(null); fetchJSON(API + "/catalog/refresh", { method: "POST" }).then(function (result) { setNotice({ ok: "Catalog refreshed: " + result.count + " models." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); } + function openHuggingFaceFiles(model) { + if (!model || !model.name) return; + setBusy("hf-files"); setNotice(null); setHfFileDialog({ model: model, files: null, import_supported: false }); + fetchJSON(API + "/huggingface/files?repo_id=" + encodeURIComponent(model.name)).then(function (value) { setHfFileDialog({ model: model, files: value.files || [], import_supported: !!value.import_supported }); }).catch(function (err) { setHfFileDialog(null); setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); + } + function downloadHuggingFaceFile(model, file) { + if (!model || !file || !file.filename) return; + setBusy("hf-download"); setNotice(null); + fetchJSON(API + "/huggingface/download", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ repo_id: model.name, filename: file.filename }) }).then(function (result) { setHfFileDialog(null); setNotice({ ok: result.message || "Hugging Face GGUF download started." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); + } var baseModels = data ? (tab === "installed" ? data.models || [] : tab === "popular" ? data.popular || [] : tab === "catalog" ? (showOversized ? data.catalog_all || data.catalog || [] : data.catalog || []) : []) : []; var models = baseModels; if (tab === "catalog") { @@ -590,18 +603,20 @@ 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 Ollama + Hugging Face models…", onChange: function (event) { setQuery(event.target.value); } }), + h("input", { className: "ollama-search", value: query, placeholder: includeHuggingFace ? "Search Ollama + Hugging Face models…" : "Search Ollama models…", onChange: function (event) { setQuery(event.target.value); } }), + tab === "catalog" && h("label", { className: "ollama-catalog-checkbox ollama-huggingface-checkbox", title: "Search the public Hugging Face Hub in addition to Ollama." }, h("input", { type: "checkbox", checked: includeHuggingFace, onChange: function (event) { setIncludeHuggingFace(event.target.checked); } }), h("span", null, "Search Hugging Face")), 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 })), targetDialog && h("div", { className: "ollama-target-modal" }, h("div", { className: "ollama-target-card" }, h("h3", null, "Where should " + targetDialog.name + " be downloaded?"), h("p", null, "Both local and remote Ollama instances are online. Choose the destination for this model."), targetDialog.targets.map(function (item) { return h(Button, { key: item.kind, onClick: function () { var chosen = targetDialog; setTargetDialog(null); action(chosen.kind, chosen.name, item.kind); } }, (item.kind || "local").toUpperCase(), " · ", item.url, " · v", item.version, " · ", item.models, " models"); }), h(Button, { className: "secondary", onClick: function () { setTargetDialog(null); } }, "Cancel"))), + hfFileDialog && h("div", { className: "ollama-target-modal" }, h("div", { className: "ollama-target-card ollama-huggingface-file-card" }, h("h3", null, "Download GGUF from ", hfFileDialog.model.name), h("p", null, hfFileDialog.import_supported ? "Choose a GGUF file. It will be downloaded and imported into the local Ollama runtime." : "Choose a GGUF file. It will be downloaded to Hermes storage; automatic Ollama import is unavailable in this deployment."), hfFileDialog.files === null ? h("p", null, "Loading repository files…") : hfFileDialog.files.length ? hfFileDialog.files.map(function (file) { return h("div", { className: "ollama-huggingface-file-row", key: file.filename }, h("span", null, h("strong", null, file.filename), h("small", null, (file.size_label || "Unknown size") + (file.split ? " · complete " + file.file_count + "-shard set" : ""))), h(Button, { disabled: !!busy, onClick: function () { downloadHuggingFaceFile(hfFileDialog.model, file); } }, busy === "hf-download" ? "Starting…" : (file.split ? "Download set" : "Download"))); }) : h("p", null, "This repository has no compatible GGUF files. Open the repository to inspect Transformers, safetensors, or other formats."), h("div", { className: "ollama-huggingface-file-actions" }, h("a", { className: "ollama-button secondary", href: hfFileDialog.model.hf_url, target: "_blank", rel: "noreferrer" }, "Open repository"), h(Button, { className: "secondary", onClick: function () { setHfFileDialog(null); } }, "Cancel")))), notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : notice.warning ? "warning" : "ok") }, notice.error || notice.warning || notice.ok), h("section", { className: "ollama-toolbar" }, h("div", { className: "ollama-nav-row" }, navTabs, h("div", { className: "ollama-toolbar-disk" }, h("span", null, "Disk"), h("strong", null, disk.used_percent == null ? "n/a" : Number(disk.used_percent).toFixed(1) + "%"), h("small", null, disk.available ? fmtBytes(disk.free_bytes) + " free" : "Unavailable"))), browseToolbar), 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." : 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 }); })) + 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, onHuggingFace: openHuggingFaceFiles }); })) ); } registry.register("ollama-manager", Page); diff --git a/dashboard/dist/style.css b/dashboard/dist/style.css index d145de8..3e56361 100644 --- a/dashboard/dist/style.css +++ b/dashboard/dist/style.css @@ -8,7 +8,7 @@ @media(max-width:600px){.ollama-runtime-grid{grid-template-columns:1fr}.ollama-chat-model{display:block}.ollama-chat-model select{width:100%;margin-bottom:8px}.ollama-message.user{margin-left:0}.ollama-message.assistant{margin-right:0}}.ollama-connection-panel{min-width:0;max-width:620px;width:100%;box-sizing:border-box;container-type:inline-size;margin-top:14px;padding:12px;border:1px solid rgba(164,211,199,.2);border-radius:10px;background:rgba(10,31,28,.72);box-shadow:0 8px 24px rgba(0,0,0,.12)}.ollama-connection-heading{display:flex;justify-content:space-between;gap:10px;color:#d7ebe5}.ollama-connection-heading strong{font-size:12px}.ollama-connection-heading small{display:block;margin-top:3px;color:#8fa9a4;font-size:10px}.ollama-connection-form{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;gap:6px;align-items:stretch;margin-top:9px}.ollama-connection-role,.ollama-connection-input{box-sizing:border-box;min-width:0;width:100%;height:36px;border:1px solid rgba(155,205,194,.28);border-radius:6px;background:#102d29;color:#e8f2ef;padding:7px;font:inherit;font-size:11px}.ollama-connection-input{overflow:hidden;text-overflow:ellipsis}.ollama-connection-form .ollama-button{height:36px;min-width:0;padding:7px 9px;white-space:nowrap}.ollama-connection-result{margin-top:7px;font-size:10px}.ollama-connection-result.ok{color:#9af1c7}.ollama-connection-result.error{color:#ffb1b1}.ollama-connection-list{display:grid;gap:5px;margin-top:8px}.ollama-connection-row{display:flex;align-items:center;gap:7px;width:100%;border:0;border-top:1px solid rgba(164,211,199,.1);padding:7px 0;background:none;color:#c5ddd7;text-align:left;cursor:pointer;font:inherit}.ollama-connection-row span:nth-child(2){display:flex;flex-direction:column;gap:2px;min-width:0}.ollama-connection-row strong{font-size:10px}.ollama-connection-row small{color:#8fa9a4;font-size:9px;overflow-wrap:anywhere}.ollama-connection-dot{width:7px;height:7px;border-radius:50%;background:#b36d6d;flex:0 0 auto}.ollama-connection-dot.online{background:#75d2b7;box-shadow:0 0 8px rgba(117,210,183,.55)}.ollama-connection-row-main{display:flex;align-items:center;gap:7px;flex:1;min-width:0;border:0;padding:0;background:none;color:inherit;text-align:left;cursor:pointer;font:inherit}.ollama-connection-remove{flex:0 0 auto;padding:5px 7px;font-size:9px}.ollama-connection-row-main>span:nth-child(2){display:flex;flex-direction:column;gap:2px;min-width:0}.ollama-connection-row-main strong{font-size:10px}.ollama-connection-row-main small{color:#8fa9a4;font-size:9px;overflow-wrap:anywhere} @container (max-width: 460px){.ollama-connection-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.ollama-connection-role,.ollama-connection-input{grid-column:span 1}.ollama-connection-form .ollama-button{width:100%}} -.ollama-target-modal{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(4,15,14,.72)}.ollama-target-card{display:grid;gap:10px;max-width:560px;width:100%;padding:20px;border:1px solid rgba(141,210,193,.38);border-radius:12px;background:#102d29;box-shadow:0 14px 50px rgba(0,0,0,.35)}.ollama-target-card h3{margin:0;color:#effcf8}.ollama-target-card p{margin:0;color:#a5bfba;font-size:12px}.ollama-target-card .ollama-button{text-align:left}@media(max-width:900px){.ollama-connection-panel{min-width:0;max-width:none}.ollama-connection-form{flex-wrap:wrap}.ollama-connection-input{min-width:160px}} +.ollama-target-modal{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(4,15,14,.72)}.ollama-target-card{display:grid;gap:10px;max-width:560px;width:100%;padding:20px;border:1px solid rgba(141,210,193,.38);border-radius:12px;background:#102d29;box-shadow:0 14px 50px rgba(0,0,0,.35)}.ollama-target-card h3{margin:0;color:#effcf8}.ollama-target-card p{margin:0;color:#a5bfba;font-size:12px}.ollama-target-card .ollama-button{text-align:left}.ollama-huggingface-file-card{max-height:min(720px,90vh);overflow:auto}.ollama-huggingface-file-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 0;border-top:1px solid rgba(164,211,199,.14)}.ollama-huggingface-file-row span{display:grid;gap:3px;min-width:0}.ollama-huggingface-file-row strong{overflow-wrap:anywhere;font-size:12px}.ollama-huggingface-file-row small,.ollama-search-status{color:#8fa9a4;font-size:11px}.ollama-huggingface-file-actions{display:flex;gap:8px;flex-wrap:wrap}@media(max-width:900px){.ollama-connection-panel{min-width:0;max-width:none}.ollama-connection-form{flex-wrap:wrap}.ollama-connection-input{min-width:160px}} .ollama-catalog-controls{display:grid;grid-template-columns:repeat(3,minmax(130px,1fr));gap:8px;align-items:end;margin-top:0;padding:10px;border:1px solid rgba(164,211,199,.16);border-radius:10px;background:rgba(10,31,28,.55)}.ollama-catalog-controls label{display:flex;flex-direction:column;gap:5px;color:#a5bfba;font-size:10px;text-transform:uppercase;letter-spacing:.06em}.ollama-catalog-checkbox{display:flex!important;flex-direction:row!important;align-items:center;gap:8px;grid-column:1 / -1;padding:8px 4px;color:#b8ead9!important;text-transform:none!important;letter-spacing:normal!important;cursor:pointer}.ollama-catalog-checkbox input{width:15px;height:15px;margin:0;accent-color:#75d2b7}.ollama-catalog-checkbox span{font-size:11px}.ollama-catalog-memory-bypass{color:#ffd89a!important;background:rgba(142,90,25,.12);border-radius:7px}.ollama-catalog-select{min-width:145px;border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:8px;font:inherit;font-size:11px;text-transform:none;letter-spacing:normal} @media(max-width:1000px){.ollama-nav-row{display:grid;grid-template-columns:1fr}.ollama-toolbar-disk{justify-self:end}.ollama-browse-row{grid-template-columns:1fr}.ollama-catalog-controls{margin-top:0}} @media(max-width:760px){.ollama-tabs{grid-template-columns:repeat(2,minmax(0,1fr))}.ollama-nav-row{gap:8px}.ollama-toolbar-disk{justify-self:stretch;grid-template-columns:auto auto;min-width:0}.ollama-browse-row{gap:8px}.ollama-catalog-controls{grid-template-columns:1fr;align-items:stretch}.ollama-catalog-select{width:100%}}.ollama-harness-primary,.ollama-harness-validators{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.ollama-harness-primary{min-width:260px}.ollama-harness-primary label{display:flex;align-items:center;gap:8px;color:#a5bfba;font-size:11px}.ollama-harness-primary select{border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:8px;font:inherit;font-size:11px;max-width:260px}.ollama-harness-validators{flex-basis:100%;padding-top:8px;border-top:1px solid rgba(164,211,199,.14)}.ollama-harness-validators>strong{color:#d5e8e2;font-size:11px}.ollama-harness-ready,.ollama-harness-warning{flex-basis:100%;font-size:10px}.ollama-harness-ready{color:#9af1c7}.ollama-harness-warning{color:#ffd89a}.ollama-validation-evidence{margin-top:10px;padding:10px 12px;border:1px solid rgba(141,210,193,.22);border-radius:8px;background:rgba(10,31,28,.5);color:#a5bfba;font-size:11px}.ollama-validation-evidence summary{cursor:pointer;color:#b8ead9;font-weight:700}.ollama-validation-report{margin-top:10px;padding-top:8px;border-top:1px solid rgba(164,211,199,.12)}.ollama-validation-report strong{color:#effcf8;font-size:11px}.ollama-validation-report p{margin:4px 0 0;white-space:pre-wrap;line-height:1.45}.ollama-storage-panel{margin-top:14px;padding:14px 16px;border:1px solid rgba(141,210,193,.22);border-radius:10px;background:rgba(10,31,28,.5)}.ollama-storage-copy{display:flex;flex-direction:column;gap:4px;margin-top:10px}.ollama-storage-copy strong{color:#effcf8;font-size:12px}.ollama-storage-copy small,.ollama-storage-note{color:#a5bfba;font-size:10px}.ollama-storage-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}.ollama-storage-note{display:block;margin-top:10px} diff --git a/dashboard/manifest.json b/dashboard/manifest.json index d8e5085..dbcee03 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.3", + "version": "1.7.4", "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 55e48f6..8b2bea2 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -71,6 +71,8 @@ def _discover_ollama_endpoint() -> None: LOCAL_OLLAMA = _ollama_base_url() REMOTE_OLLAMA = "https://ollama.com" HUGGINGFACE_API = "https://huggingface.co/api" +HUGGINGFACE_DOWNLOAD_ROOT = "huggingface" +HF_REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}/[A-Za-z0-9][A-Za-z0-9._-]{0,95}$") CATALOG_FILE = "catalog.json" MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,190}$") MELBOURNE = ZoneInfo("Australia/Melbourne") @@ -749,7 +751,7 @@ def _json_request(url: str, method: str = "GET", payload: Any = None, timeout: i 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"}) + request = Request(url, headers={"Accept": "application/json", "User-Agent": "Hermes-Ollama-Models/1.7.4"}) with urlopen(request, timeout=timeout) as response: raw = response.read() value = json.loads(raw.decode("utf-8")) if raw else [] @@ -1437,6 +1439,147 @@ def _search_huggingface(query: str, limit: int = 30) -> list[dict[str, Any]]: return [_huggingface_model_view(row) for row in rows if (row.get("id") or row.get("modelId"))] +def _valid_huggingface_repo(repo_id: str) -> str: + repo_id = str(repo_id or "").strip() + if not HF_REPO_RE.fullmatch(repo_id): + raise HTTPException(400, "Invalid Hugging Face repository id") + return repo_id + + +def _valid_huggingface_filename(filename: str) -> str: + filename = unquote(str(filename or "")).strip().replace("\\", "/") + path = Path(filename) + if not filename or path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts) or not filename.lower().endswith(".gguf"): + raise HTTPException(400, "Only safe Hugging Face GGUF filenames are supported") + return filename + + +def _format_bytes(value: int) -> str: + amount = float(max(0, int(value or 0))) + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if amount < 1024 or unit == "TiB": + return f"{amount:.1f} {unit}" if unit != "B" else f"{int(amount)} B" + amount /= 1024 + return "Unknown" + + +def _huggingface_repo_files(repo_id: str) -> list[dict[str, Any]]: + repo_id = _valid_huggingface_repo(repo_id) + url = f"{HUGGINGFACE_API}/models/{repo_id}?full=true" + try: + metadata = _json_request(url, timeout=30) + except (HTTPError, URLError, OSError, ValueError) as exc: + raise HTTPException(502, "Hugging Face repository metadata is unavailable") from exc + raw_files: list[dict[str, Any]] = [] + siblings = metadata.get("siblings", []) if isinstance(metadata, dict) else [] + for item in siblings if isinstance(siblings, list) else []: + if not isinstance(item, dict): + continue + filename = str(item.get("rfilename") or item.get("path") or "").strip() + if not filename.lower().endswith(".gguf"): + continue + try: + filename = _valid_huggingface_filename(filename) + except HTTPException: + continue + lfs: dict[str, Any] = {} + raw_lfs = item.get("lfs") + if isinstance(raw_lfs, dict): + lfs = raw_lfs + size = int(lfs.get("size") or item.get("size") or 0) + raw_files.append({"filename": filename, "size": size, "download_url": f"https://huggingface.co/{repo_id}/resolve/main/{filename}?download=true"}) + groups: dict[str, list[dict[str, Any]]] = {} + split_pattern = re.compile(r"^(.*)-\d{5}-of-\d{5}(\.gguf)$", re.I) + for item in raw_files: + match = split_pattern.match(item["filename"]) + group_key = f"{match.group(1)}{match.group(2)}" if match else item["filename"] + groups.setdefault(group_key, []).append(item) + files: list[dict[str, Any]] = [] + for group_key, group in groups.items(): + group.sort(key=lambda item: item["filename"]) + size = sum(int(item.get("size") or 0) for item in group) + files.append({ + "filename": group[0]["filename"], + "filenames": [item["filename"] for item in group], + "size": size, + "size_label": _format_bytes(size) if size else (f"{len(group)} shards · size unavailable" if len(group) > 1 else "Unknown"), + "file_count": len(group), + "split": len(group) > 1, + "download_url": group[0]["download_url"], + }) + return sorted(files, key=lambda item: (item.get("size") or 0, item["filename"])) + + +def _hf_ollama_import_available() -> bool: + parsed = urlparse(LOCAL_OLLAMA) + return parsed.hostname in {"localhost", "127.0.0.1", "::1"} and not _running_in_container() and bool(shutil.which("ollama")) + + +def _ollama_model_name_for_hf(repo_id: str, filename: str) -> str: + owner, repo = repo_id.split("/", 1) + stem = re.sub(r"-\d{5}-of-\d{5}$", "", Path(filename).stem.lower()) + value = re.sub(r"[^a-z0-9._-]+", "-", f"hf-{owner}-{repo}-{stem}").strip("-._") + return value[:190] or "hf-imported-model" + + +def _run_huggingface_download(job_id: str, repo_id: str, filename: str) -> None: + temporary: Path | None = None + try: + repo_id = _valid_huggingface_repo(repo_id) + filename = _valid_huggingface_filename(filename) + file_info = next((item for item in _huggingface_repo_files(repo_id) if filename in item.get("filenames", [item["filename"]])), None) + if not file_info: + raise RuntimeError("Requested GGUF file was not found in the public Hugging Face repository") + expected = int(file_info.get("size") or 0) + free_bytes = shutil.disk_usage(_home()).free + if expected and free_bytes < expected + 1024 ** 3: + raise RuntimeError("Insufficient free disk space for the Hugging Face GGUF download") + destination_root = _home() / HUGGINGFACE_DOWNLOAD_ROOT / repo_id + destination_root.mkdir(parents=True, exist_ok=True) + download_files = list(file_info.get("filenames") or [filename]) + completed = 0 + total = expected + for remote_filename in download_files: + destination = destination_root / remote_filename + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name(f".{destination.name}.{job_id}.part") + download_url = f"https://huggingface.co/{repo_id}/resolve/main/{remote_filename}?download=true" + request = Request(download_url, headers={"Accept": "application/octet-stream", "User-Agent": "Hermes-Ollama-Models/1.7.4"}) + with urlopen(request, timeout=60) as response, temporary.open("wb") as output: + total = max(total, completed + int(response.headers.get("Content-Length") or 0)) + for chunk in iter(lambda: response.read(8 * 1024 * 1024), b""): + output.write(chunk) + completed += len(chunk) + _set_job(job_id, status="downloading", completed=completed, total=total, percent=round(completed * 100 / total, 1) if total else None) + os.replace(temporary, destination) + destination = destination_root / download_files[0] + model_name = _ollama_model_name_for_hf(repo_id, filename) + imported = False + if _hf_ollama_import_available(): + modelfile = destination.with_name(f".{destination.name}.Modelfile") + modelfile.write_text(f"FROM {destination}\n", encoding="utf-8") + try: + env = dict(os.environ) + env["OLLAMA_HOST"] = LOCAL_OLLAMA + result = subprocess.run(["ollama", "create", model_name, "-f", str(modelfile)], capture_output=True, text=True, timeout=3600, check=False, env=env) + if result.returncode != 0: + raise RuntimeError((result.stderr or result.stdout or "ollama create failed")[-1000:]) + imported = True + finally: + try: + modelfile.unlink() + except FileNotFoundError: + pass + _set_job(job_id, state="completed", status="success", percent=100, path=str(destination), model_name=model_name, imported=imported, message="Downloaded and imported into local Ollama" if imported else "Downloaded GGUF; Ollama import was not available on this filesystem") + except Exception as exc: + if temporary is not None: + try: + temporary.unlink() + except FileNotFoundError: + pass + _set_job(job_id, state="failed", status="error", error=str(exc)) + + def _search_ollama_catalog(query: str, limit: int = 50) -> list[dict[str, Any]]: needle = str(query or "").strip().lower() if len(needle) < 2: @@ -1740,6 +1883,11 @@ class ModelRequest(BaseModel): placement: str = "gpu_ram" +class HuggingFaceDownloadRequest(BaseModel): + repo_id: str + filename: str + + class ConnectionRequest(BaseModel): url: str role: str = "local" @@ -2781,22 +2929,42 @@ def catalog_refresh() -> dict[str, Any]: @router.get("/catalog/search") -def catalog_search(q: str = "", limit: int = 30) -> dict[str, Any]: +def catalog_search(q: str = "", limit: int = 30, include_huggingface: bool = False) -> dict[str, Any]: query = str(q or "").strip()[:120] if len(query) < 2: - return {"query": query, "results": [], "sources": ["ollama", "huggingface"]} + return {"query": query, "results": [], "sources": ["ollama", "huggingface"] if include_huggingface else ["ollama"]} ollama = _search_ollama_catalog(query, limit=limit) - huggingface = _search_huggingface(query, limit=limit) + huggingface = _search_huggingface(query, limit=limit) if include_huggingface else [] combined = ollama + huggingface return { "query": query, "results": combined, "ollama_results": ollama, "huggingface_results": huggingface, - "sources": ["ollama", "huggingface"], + "sources": ["ollama", "huggingface"] if include_huggingface else ["ollama"], } +@router.get("/huggingface/files") +def huggingface_files(repo_id: str) -> dict[str, Any]: + repo_id = _valid_huggingface_repo(repo_id) + files = _huggingface_repo_files(repo_id) + return {"repo_id": repo_id, "files": [{key: value for key, value in item.items() if key != "download_url"} for item in files], "import_supported": _hf_ollama_import_available()} + + +@router.post("/huggingface/download") +def huggingface_download(body: HuggingFaceDownloadRequest) -> dict[str, Any]: + repo_id = _valid_huggingface_repo(body.repo_id) + filename = _valid_huggingface_filename(body.filename) + if not any(item["filename"] == filename for item in _huggingface_repo_files(repo_id)): + raise HTTPException(400, "Requested GGUF file was not found in the public Hugging Face repository") + job_id = uuid.uuid4().hex + with _jobs_lock: + _jobs[job_id] = {"id": job_id, "name": f"{repo_id}/{filename}", "action": "huggingface-download", "target": "local", "state": "running", "status": "starting", "percent": 0, "created_at": time.time(), "updated_at": time.time()} + threading.Thread(target=_run_huggingface_download, args=(job_id, repo_id, filename), daemon=True, name=f"huggingface-download-{job_id[:8]}").start() + return {"ok": True, "job_id": job_id, "repo_id": repo_id, "filename": filename, "message": "Hugging Face GGUF download started"} + + @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 03c3882..fd28c0c 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: ollama-manager -version: 1.7.3 +version: 1.7.4 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 2d696bb..e7c021a 100644 --- a/tests/test_validation_harness.py +++ b/tests/test_validation_harness.py @@ -121,12 +121,48 @@ class ValidationHarnessTests(unittest.TestCase): 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) + response = api.catalog_search("Qwen3.8", limit=10, include_huggingface=True) 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_huggingface_groups_split_gguf_files_into_complete_sets(self): + metadata = { + "siblings": [ + {"rfilename": "Q4/Qwen-00001-of-00002.gguf"}, + {"rfilename": "Q4/Qwen-00002-of-00002.gguf"}, + {"rfilename": "Q8/Qwen.gguf"}, + {"rfilename": "Q8/README.md"}, + ] + } + with patch.object(api, "_json_request", return_value=metadata): + files = api._huggingface_repo_files("owner/repository") + self.assertEqual(len(files), 2) + split = next(item for item in files if item["split"]) + self.assertEqual(split["file_count"], 2) + self.assertEqual(len(split["filenames"]), 2) + single = next(item for item in files if not item["split"]) + self.assertEqual(single["filename"], "Q8/Qwen.gguf") + + def test_huggingface_download_rejects_non_gguf_paths(self): + with self.assertRaises(api.HTTPException): + api._valid_huggingface_filename("../model.safetensors") + with self.assertRaises(api.HTTPException): + api._valid_huggingface_filename("model.bin") + self.assertEqual(api._valid_huggingface_filename("Q4_K_M/model.gguf"), "Q4_K_M/model.gguf") + + def test_huggingface_download_queues_validated_file(self): + body = api.HuggingFaceDownloadRequest(repo_id="unsloth/Qwen3.8-Flash-Next-GGUF", filename="Q4_K_M/model.gguf") + fake_thread = type("Thread", (), {"start": lambda self: None}) + with patch.object(api, "_huggingface_repo_files", return_value=[{"filename": body.filename, "size": 123}]), patch.object( + api.threading, "Thread", return_value=fake_thread() + ) as thread: + response = api.huggingface_download(body) + self.assertTrue(response["ok"]) + self.assertEqual(response["filename"], body.filename) + thread.assert_called_once() + def test_primary_draft_validators_and_primary_compilation_produce_one_answer(self): body = api.ChatRequest( primary_model="primary",