feat: download Hugging Face GGUF models

This commit is contained in:
Hermes Agent
2026-08-28 14:51:53 +10:00
parent 1a54b41c33
commit c13ecf0af6
7 changed files with 239 additions and 18 deletions
+4 -2
View File
@@ -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. 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. 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 ## 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/<owner>/<repository>/` 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 ## Security limits
+22 -7
View File
@@ -126,7 +126,7 @@
} }
function ModelCard(props) { 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 openState = React.useState(false), open = openState[0], setOpen = openState[1];
var badges = []; var badges = [];
if (model.source === "huggingface") badges.push(h(Badge, { key: "huggingface", tone: "popular" }, "Hugging Face")); 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, 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, 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" && 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") !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 busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1]; var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1];
var targetDialogState = React.useState(null), targetDialog = targetDialogState[0], setTargetDialog = targetDialogState[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 loadingState = React.useState(true), loading = loadingState[0], setLoading = loadingState[1];
var externalSearchState = React.useState([]), externalSearch = externalSearchState[0], setExternalSearch = externalSearchState[1]; var externalSearchState = React.useState([]), externalSearch = externalSearchState[0], setExternalSearch = externalSearchState[1];
var externalSearchBusyState = React.useState(false), externalSearchBusy = externalSearchBusyState[0], setExternalSearchBusy = externalSearchBusyState[1]; var externalSearchBusyState = React.useState(false), externalSearchBusy = externalSearchBusyState[0], setExternalSearchBusy = externalSearchBusyState[1];
@@ -523,15 +526,15 @@
React.useEffect(function () { React.useEffect(function () {
var term = query.trim(); var term = query.trim();
var sequence = ++searchSequence.current; var sequence = ++searchSequence.current;
if (term.length < 2) { setExternalSearch([]); setExternalSearchBusy(false); return; } if (term.length < 2 || !includeHuggingFace) { setExternalSearch([]); setExternalSearchBusy(false); return; }
setExternalSearchBusy(true); setExternalSearchBusy(true);
var timer = setTimeout(function () { var timer = setTimeout(function () {
fetchJSON(API + "/catalog/search?q=" + encodeURIComponent(term) + "&limit=30").then(function (value) { fetchJSON(API + "/catalog/search?q=" + encodeURIComponent(term) + "&limit=30&include_huggingface=true").then(function (value) {
if (sequence === searchSequence.current) setExternalSearch(value.results || []); if (sequence === searchSequence.current) setExternalSearch(value.huggingface_results || []);
}).catch(function () { if (sequence === searchSequence.current) setExternalSearch([]); }).finally(function () { if (sequence === searchSequence.current) setExternalSearchBusy(false); }); }).catch(function () { if (sequence === searchSequence.current) setExternalSearch([]); }).finally(function () { if (sequence === searchSequence.current) setExternalSearchBusy(false); });
}, 250); }, 250);
return function () { clearTimeout(timer); }; return function () { clearTimeout(timer); };
}, [query]); }, [query, includeHuggingFace]);
function action(kind, name, selectedTarget) { function action(kind, name, selectedTarget) {
if (kind === "delete" && !window.confirm("Remove " + name + " from Ollama?")) return; if (kind === "delete" && !window.confirm("Remove " + name + " from Ollama?")) return;
if ((kind === "pull" || kind === "redownload") && !selectedTarget) { 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(""); }); 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 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 baseModels = data ? (tab === "installed" ? data.models || [] : tab === "popular" ? data.popular || [] : tab === "catalog" ? (showOversized ? data.catalog_all || data.catalog || [] : data.catalog || []) : []) : [];
var models = baseModels; var models = baseModels;
if (tab === "catalog") { 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")) 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" }, 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…"), externalSearchBusy && h("small", { className: "ollama-search-status" }, "Searching Hugging Face…"),
catalogControls 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 })), 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"))), 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), 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), 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("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 === "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 === "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); registry.register("ollama-manager", Page);
+1 -1
View File
@@ -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} @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%}} @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} .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: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} @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}
+1 -1
View File
@@ -3,7 +3,7 @@
"label": "Ollama Models", "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.", "description": "Inspect, manage, and chat with local Ollama models, including shared persistent conversations, performance metrics, images, PDFs, URLs, and live memory telemetry.",
"icon": "Cpu", "icon": "Cpu",
"version": "1.7.3", "version": "1.7.4",
"tab": {"path": "/ollama-manager", "position": "after:models"}, "tab": {"path": "/ollama-manager", "position": "after:models"},
"entry": "dist/index.js", "entry": "dist/index.js",
"css": "dist/style.css", "css": "dist/style.css",
+173 -5
View File
@@ -71,6 +71,8 @@ def _discover_ollama_endpoint() -> None:
LOCAL_OLLAMA = _ollama_base_url() LOCAL_OLLAMA = _ollama_base_url()
REMOTE_OLLAMA = "https://ollama.com" REMOTE_OLLAMA = "https://ollama.com"
HUGGINGFACE_API = "https://huggingface.co/api" 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" CATALOG_FILE = "catalog.json"
MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,190}$") MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,190}$")
MELBOURNE = ZoneInfo("Australia/Melbourne") 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]]: 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: with urlopen(request, timeout=timeout) as response:
raw = response.read() raw = response.read()
value = json.loads(raw.decode("utf-8")) if raw else [] 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"))] 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]]: def _search_ollama_catalog(query: str, limit: int = 50) -> list[dict[str, Any]]:
needle = str(query or "").strip().lower() needle = str(query or "").strip().lower()
if len(needle) < 2: if len(needle) < 2:
@@ -1740,6 +1883,11 @@ class ModelRequest(BaseModel):
placement: str = "gpu_ram" placement: str = "gpu_ram"
class HuggingFaceDownloadRequest(BaseModel):
repo_id: str
filename: str
class ConnectionRequest(BaseModel): class ConnectionRequest(BaseModel):
url: str url: str
role: str = "local" role: str = "local"
@@ -2781,22 +2929,42 @@ def catalog_refresh() -> dict[str, Any]:
@router.get("/catalog/search") @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] query = str(q or "").strip()[:120]
if len(query) < 2: 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) 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 combined = ollama + huggingface
return { return {
"query": query, "query": query,
"results": combined, "results": combined,
"ollama_results": ollama, "ollama_results": ollama,
"huggingface_results": huggingface, "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") @router.post("/pull")
def pull_model(body: ModelRequest) -> dict[str, Any]: def pull_model(body: ModelRequest) -> dict[str, Any]:
name = _valid_name(body.name) name = _valid_name(body.name)
+1 -1
View File
@@ -1,5 +1,5 @@
name: ollama-manager 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. 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 auto_install_dependencies: true
python_dependencies: python_dependencies:
+37 -1
View File
@@ -121,12 +121,48 @@ class ValidationHarnessTests(unittest.TestCase):
with patch.object(api, "_search_ollama_catalog", return_value=ollama) as ollama_search, patch.object( with patch.object(api, "_search_ollama_catalog", return_value=ollama) as ollama_search, patch.object(
api, "_search_huggingface", return_value=huggingface api, "_search_huggingface", return_value=huggingface
) as hf_search: ) 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["results"], ollama + huggingface)
self.assertEqual(response["sources"], ["ollama", "huggingface"]) self.assertEqual(response["sources"], ["ollama", "huggingface"])
ollama_search.assert_called_once_with("Qwen3.8", limit=10) ollama_search.assert_called_once_with("Qwen3.8", limit=10)
hf_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): def test_primary_draft_validators_and_primary_compilation_produce_one_answer(self):
body = api.ChatRequest( body = api.ChatRequest(
primary_model="primary", primary_model="primary",