feat: add adaptive Ollama catalog filtering

This commit is contained in:
Hermes Agent
2026-08-19 00:54:19 +10:00
parent 07078465da
commit 8989ecd7af
6 changed files with 582 additions and 70 deletions
+19 -1
View File
@@ -18,7 +18,25 @@ Native-like Hermes dashboard plugin for local Ollama model management and chat.
- Streamed Ollama responses with a real Stop action that cancels the active request
- Minimized-by-default expandable thinking/progress details with live stage, elapsed time, event, and character counters
The chat transcript and selected model are persisted in this browser, so navigating away from the plugin or reloading the dashboard does not clear the conversation. Use **Clear chat** to remove the saved transcript. The UI also shows a prominent live processing status while Ollama is working, including an animated indicator, elapsed time, request preparation, and response-generation stages. This is operational progress only; private model chain-of-thought is not exposed. Uploaded files remain temporary and are not stored in browser persistence.
The chat transcript and selected model are persisted in the Hermes server's SQLite database at `~/.hermes/ollama-manager/chat.sqlite3`, so conversations can be listed and resumed from another browser or after a dashboard restart. Use **New conversation** to start a separate thread and **Clear chat** to delete the selected shared conversation. Uploaded files remain temporary; attachment names/types/URLs are retained as metadata, not raw file contents.
## Performance metrics
Each completed request records Ollama-provided counters and timing when available:
- Time to first token (TTFT)
- Total request latency
- Prompt/input token count and prompt tokens/sec
- Output token count and output tokens/sec
- Total tokens
- Ollama total/load/prompt-evaluation/generation durations
- Model, request status, request ID, and error details
The dashboard shows per-conversation metrics and rolling aggregates across the latest 100 requests. Values are shown as unavailable when Ollama does not provide them; the plugin does not estimate token counts.
## Catalog and download filters
The Popular and Available downloads views only show models with known size and expected RAM estimates that fit the running host's installed RAM, detected from `/proc/meminfo` and reported in the API filter metadata. Available downloads also includes known fitting variants from installed model families. The dashboard can filter downloads by Dense/MoE type or ability (completion, thinking, tools, vision, audio, or video), and organize them by popularity, newest, size, or name.
## Security limits
+103 -24
View File
@@ -12,19 +12,20 @@
function readSavedChat() {
try {
var raw = window.localStorage.getItem(CHAT_STORAGE_KEY);
if (!raw) return { model: "", history: [] };
if (!raw) return { model: "", models: [], history: [] };
var value = JSON.parse(raw);
return {
model: typeof value.model === "string" ? value.model : "",
models: Array.isArray(value.models) ? value.models.filter(function (item) { return typeof item === "string"; }).slice(0, 12) : [],
history: Array.isArray(value.history) ? value.history.filter(function (item) { return item && (item.role === "user" || item.role === "assistant") && typeof item.content === "string"; }).slice(-100) : []
};
} catch (_) {
return { model: "", history: [] };
return { model: "", models: [], history: [] };
}
}
function saveChat(model, history) {
function saveChat(model, models, history) {
try {
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify({ model: model || "", history: history.slice(-100) }));
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify({ model: model || "", models: (models || []).slice(0, 12), history: history.slice(-100) }));
} catch (_) {}
}
@@ -132,7 +133,7 @@
h("div", { className: "ollama-runtime-stat" }, h("small", null, "GPU telemetry"), h("strong", null, gpu.telemetry_available ? (gpu.gpus || []).map(function (item) { return item.name + " · " + fmtBytes(item.used_bytes) + " / " + fmtBytes(item.total_bytes); }).join("; ") : "Unavailable"), h("small", null, gpu.detected ? "Ollama VRAM split is still shown below." : "No supported GPU was detected."))
),
h("div", { className: "ollama-memory-chart" }, (props.samples || []).map(function (sample, index) { var height = sample.total ? Math.max(3, Math.min(100, sample.used * 100 / sample.total)) : 3; return h("span", { key: index, title: fmtBytes(sample.used) + " used", style: { height: height + "%" } }); })),
h("div", { className: "ollama-loaded-memory" }, h("h4", null, "Loaded model placement"), models.length ? models.map(function (model) { return h("div", { className: "ollama-loaded-row", key: model.name }, h("strong", null, model.name), h("span", null, "Total ", fmtBytes(model.total_bytes)), h("span", null, "GPU VRAM ", fmtBytes(model.gpu_bytes)), h("span", null, "Normal RAM ", fmtBytes(model.ram_bytes)), h("span", null, model.gpu_offload_percent + "% GPU offload")); }) : h("p", null, "No model is currently loaded. Select a model and press Load model, or send a message."))
h("div", { className: "ollama-loaded-memory" }, h("h4", null, "Loaded models and capabilities"), models.length ? models.map(function (model) { return h("div", { className: "ollama-loaded-row", key: model.name }, h("strong", null, model.name), h("span", null, "Total ", fmtBytes(model.total_bytes)), h("span", null, "GPU VRAM ", fmtBytes(model.gpu_bytes)), h("span", null, "Normal RAM ", fmtBytes(model.ram_bytes)), h("span", null, model.gpu_offload_percent + "% GPU offload"), h("span", { className: "ollama-loaded-capabilities" }, "Capabilities: ", (model.capabilities || []).join(", ") || "Unknown", " · Input: ", (model.input_modalities || []).join(", ") || "Text", " · ", model.parameter_size || "unknown", " · ", model.quantization || "unknown", " · Context ", model.context_length || "unknown"), h("span", { className: "ollama-permanent-label" }, model.permanent ? "Permanent keep-alive" : "Runtime-loaded")); }) : h("p", null, "No model is currently loaded. Use the model pool below to load one or more permanently."))
);
}
@@ -171,14 +172,31 @@
);
}
function ModelPoolPanel(props) {
var models = props.models || [], loaded = models.filter(function (item) { return item.loaded; });
return h("section", { className: "ollama-model-pool" },
h("div", { className: "ollama-pool-heading" }, h("div", null, h("h3", null, "Model pool"), h("p", null, "Choose installed models to keep permanently loaded. Loaded models remain available to Hermes Agent through Local Ollama.")), h(Badge, { tone: loaded.length ? "live" : "muted" }, loaded.length + " loaded")),
h("div", { className: "ollama-pool-grid" }, models.map(function (item) { return h("label", { className: "ollama-pool-item", key: item.name }, h("input", { type: "checkbox", checked: props.poolSelection.indexOf(item.name) >= 0, onChange: function () { props.onTogglePool(item.name); } }), h("span", null, h("strong", null, item.name), h("small", null, item.loaded ? "Loaded permanently" : "Installed · not loaded", " · ", (item.capabilities || []).join(", ") || "capabilities unknown"))); })),
h("div", { className: "ollama-pool-actions" }, h(Button, { disabled: !props.poolSelection.length || !!props.busy, onClick: props.onLoad }, props.busy === "/models/load" ? "Loading…" : "Load selected permanently"), h(Button, { className: "secondary", disabled: !props.poolSelection.length || !!props.busy, onClick: props.onUnload }, props.busy === "/models/unload" ? "Unloading…" : "Unload selected")),
h("div", { className: "ollama-chat-model-selection" }, h("strong", null, "Models for this answer"), h("small", null, "Select two or more loaded models for parallel perspectives."), loaded.length ? loaded.map(function (item) { return h("label", { key: item.name }, h("input", { type: "checkbox", checked: props.selectedModels.indexOf(item.name) >= 0, onChange: function () { props.onToggleChat(item.name); } }), item.name, " · ", (item.capabilities || []).join(", ")); }) : h("span", null, "Load one or more models above first."))
);
}
function ChatPanel(props) {
var models = props.models || [];
var loadedModels = models.filter(function (item) { return item.loaded; });
var savedChatState = React.useState(function () { return readSavedChat(); })[0];
var modelState = React.useState(savedChatState.model || (models[0] ? models[0].name : "")), model = modelState[0], setModel = modelState[1];
var modelState = React.useState(savedChatState.model || (loadedModels[0] ? loadedModels[0].name : (models[0] ? models[0].name : ""))), model = modelState[0], setModel = modelState[1];
var selectedModelsState = React.useState(savedChatState.models && savedChatState.models.length ? savedChatState.models : (loadedModels[0] ? [loadedModels[0].name] : [])), selectedModels = selectedModelsState[0], setSelectedModels = selectedModelsState[1];
var poolState = React.useState(loadedModels.map(function (item) { return item.name; })), poolSelection = poolState[0], setPoolSelection = poolState[1];
var messageState = React.useState(""), message = messageState[0], setMessage = messageState[1];
var urlState = React.useState(""), url = urlState[0], setUrl = urlState[1];
var attachState = React.useState([]), attachments = attachState[0], setAttachments = attachState[1];
var historyState = React.useState(savedChatState.history), history = historyState[0], setHistory = historyState[1];
var conversationIdState = React.useState(""), conversationId = conversationIdState[0], setConversationId = conversationIdState[1];
var conversationsState = React.useState([]), conversations = conversationsState[0], setConversations = conversationsState[1];
var metricsState = React.useState(null), metrics = metricsState[0], setMetrics = metricsState[1];
var aggregateState = React.useState(null), aggregate = aggregateState[0], setAggregate = aggregateState[1];
var runtimeState = React.useState(null), runtime = runtimeState[0], setRuntime = runtimeState[1];
var samplesState = React.useState([]), samples = samplesState[0], setSamples = samplesState[1];
var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
@@ -191,8 +209,37 @@
var activeRequestState = React.useState(null), activeRequest = activeRequestState[0], setActiveRequest = activeRequestState[1];
var thinkingId = thinking ? thinking.request_id : "";
React.useEffect(function () { if (!model && models[0]) setModel(models[0].name); }, [models, model]);
React.useEffect(function () { saveChat(model, history); }, [model, history]);
function openConversation(id) {
if (!id) return;
fetchJSON(API + "/conversations/" + encodeURIComponent(id)).then(function (value) {
var item = value.conversation || {};
setConversationId(item.id || id);
setHistory((value.messages || []).filter(function (message) { return message.role === "user" || message.role === "assistant"; }).map(function (message) { return { role: message.role, content: message.content, created_at: message.created_at, model: message.model }; }));
setMetrics(value.metrics || []);
if (item.model) setModel(item.model);
if (Array.isArray(item.models) && item.models.length) setSelectedModels(item.models);
}).catch(function (err) { setNotice({ error: err.message || String(err) }); });
}
function refreshConversations() {
fetchJSON(API + "/metrics?limit=100").then(function (value) { setAggregate(value.aggregate || null); }).catch(function () {});
return fetchJSON(API + "/conversations").then(function (value) {
var rows = value.conversations || [];
setConversations(rows);
if (!conversationId && rows.length) openConversation(rows[0].id);
return rows;
}).catch(function () { return []; });
}
function newConversation() { setConversationId(""); setHistory([]); setMetrics([]); setNotice({ ok: "New conversation ready." }); }
React.useEffect(function () { refreshConversations(); }, []);
React.useEffect(function () { if (conversationId) saveChat(model, selectedModels, history); }, [model, selectedModels, history, conversationId]);
React.useEffect(function () { if (!model && (loadedModels[0] || models[0])) setModel((loadedModels[0] || models[0]).name); }, [models, loadedModels, model]);
React.useEffect(function () {
var validLoaded = selectedModels.filter(function (name) { return loadedModels.some(function (item) { return item.name === name; }); });
if (validLoaded.length !== selectedModels.length || !validLoaded.length) setSelectedModels(validLoaded.length ? validLoaded : (loadedModels[0] ? [loadedModels[0].name] : []));
var validPool = poolSelection.filter(function (name) { return models.some(function (item) { return item.name === name; }); });
if (validPool.length !== poolSelection.length) setPoolSelection(validPool);
}, [models]);
React.useEffect(function () { saveChat(model, selectedModels, history); }, [model, selectedModels, history]);
React.useEffect(function () {
if (!thinking) { setThinkingElapsed(0); return; }
function tick() { setThinkingElapsed(Math.floor((Date.now() - thinking.startedAt) / 1000)); }
@@ -208,18 +255,26 @@
return function () { clearInterval(timer); };
}, [thinkingId]);
function clearChat() {
setHistory([]);
var id = conversationId;
setHistory([]); setMetrics([]); setConversationId("");
if (id) fetchJSON(API + "/conversations/" + encodeURIComponent(id), { method: "DELETE" }).catch(function () {});
try { window.localStorage.removeItem(CHAT_STORAGE_KEY); } catch (_) {}
setNotice({ ok: "Chat history cleared from this browser." });
refreshConversations();
setNotice({ ok: "Conversation deleted from shared storage." });
}
function pollRuntime() { fetchJSON(API + "/runtime").then(function (value) { setRuntime(value); setSamples(function (old) { return old.concat([{ used: Number(value.memory_used_bytes || 0), total: Number(value.memory_total_bytes || 0) }]).slice(-60); }); }).catch(function () {}); }
React.useEffect(function () { pollRuntime(); var timer = setInterval(pollRuntime, 1000); return function () { clearInterval(timer); }; }, []);
function loadModel() {
if (!model) return;
setBusy("load"); setNotice(null);
fetchJSON(API + "/chat/load", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: model }) }).then(function () { setNotice({ ok: model + " loaded or refreshed in Ollama memory." }); pollRuntime(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); });
function toggleIn(setter, name) { setter(function (old) { return old.indexOf(name) >= 0 ? old.filter(function (item) { return item !== name; }) : old.concat([name]); }); }
function manageModels(endpoint, label) {
if (!poolSelection.length) { setNotice({ error: "Select one or more installed models first." }); return; }
setBusy(endpoint); setNotice(null);
fetchJSON(API + endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ names: poolSelection }) }).then(function (result) { setNotice({ ok: label + ": " + poolSelection.join(", ") }); pollRuntime(); if (props.refresh) props.refresh(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); });
}
function loadModel() { manageModels("/models/load", "Permanently loaded"); }
function unloadModels() { manageModels("/models/unload", "Unloaded"); }
function toggleChatModel(name) { if (!loadedModels.some(function (item) { return item.name === name; })) return; toggleIn(setSelectedModels, name); }
function togglePoolModel(name) { toggleIn(setPoolSelection, name); }
function addUrl() { if (!url.trim()) return; setAttachments(function (old) { return old.concat([{ name: url.trim(), url: url.trim(), mime_type: "" }]); }); setUrl(""); }
function addFiles(files) {
var selected = Array.prototype.slice.call(files || []).filter(function (file) { return file && file.size <= 20 * 1024 * 1024; }).slice(0, 12);
@@ -246,25 +301,32 @@
});
}
function send() {
if (busy === "send" || busy === "stop" || !model || (!message.trim() && !attachments.length)) return;
if (busy === "send" || busy === "stop" || !selectedModels.length || (!message.trim() && !attachments.length)) return;
var requestId = makeRequestId();
var controller = typeof AbortController === "function" ? new AbortController() : null;
var current = { id: requestId, controller: controller, stopped: false };
var outgoing = { role: "user", content: message.trim() || "[Attachments]" }, body = { model: model, message: message, history: history, attachments: attachments, request_id: requestId };
var outgoing = { role: "user", content: message.trim() || "[Attachments]" }, body = { model: selectedModels[0], models: selectedModels, message: message, history: history, attachments: attachments, request_id: requestId, conversation_id: conversationId };
setHistory(function (old) { return old.concat([outgoing]); }); setMessage(""); setBusy("send"); setActiveRequest(current); setThinking({ request_id: requestId, startedAt: Date.now(), stage: attachments.length ? "Preparing attachments and sending request to Ollama" : "Sending request to Ollama" }); setThinkingDetails(null); setThinkingOpen(false); setNotice(null);
var requestOptions = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
if (controller) requestOptions.signal = controller.signal;
fetchJSON(API + "/chat", requestOptions).then(function (result) { var answer = result.message && result.message.content ? result.message.content : "(No response text returned.)"; setHistory(function (old) { return old.concat([{ role: "assistant", content: answer }]); }); setAttachments([]); setRuntime(result.runtime || runtime); setNotice({ ok: "Response complete. Live placement is shown below." }); pollRuntime(); }).catch(function (err) { if (!current.stopped) setNotice({ error: err.message || String(err) }); }).finally(function () { if (!current.stopped) { setThinking(null); setActiveRequest(null); } setBusy(""); });
fetchJSON(API + "/chat", requestOptions).then(function (result) { var answer = result.message && result.message.content ? result.message.content : "(No response text returned.)"; setConversationId(result.conversation_id || conversationId); setHistory(function (old) { return old.concat([{ role: "assistant", content: answer }]); }); setMetrics(result.metrics || []); setAttachments([]); setRuntime(result.runtime || runtime); setNotice({ ok: "Response complete. Shared conversation and performance metrics saved." }); pollRuntime(); refreshConversations(); }).catch(function (err) { if (!current.stopped) setNotice({ error: err.message || String(err) }); }).finally(function () { if (!current.stopped) { setThinking(null); setActiveRequest(null); } setBusy(""); });
}
return h("section", { className: "ollama-chat" },
h("div", { className: "ollama-chat-header" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "LOCAL OLLAMA CHAT"), h("h2", null, "Chat with your selected model"), h("p", null, "Images are sent as Ollama vision inputs; PDFs and web pages are extracted as untrusted document text.")), h("div", { className: "ollama-chat-model" }, h("label", null, "Model", h("select", { value: model, onChange: function (event) { setModel(event.target.value); } }, models.map(function (item) { return h("option", { key: item.name, value: item.name }, item.name + (item.loaded ? " · loaded" : "")); }))), h(Button, { disabled: !model || busy === "load", onClick: loadModel }, busy === "load" ? "Loading…" : "Load model"), h(Button, { className: "secondary", disabled: !history.length || busy === "send", onClick: clearChat }, "Clear chat"))),
h("div", { className: "ollama-chat-header" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "LOCAL OLLAMA CHAT"), h("h2", null, "Chat with selected loaded models"), h("p", null, "Select one or more permanently loaded models below. Multiple models answer in parallel and their labelled perspectives are combined.")), h(Button, { className: "secondary", disabled: !history.length || busy === "send", onClick: clearChat }, "Clear chat")),
h("section", { className: "ollama-persistence-panel" },
h("div", { className: "ollama-persistence-heading" }, h("div", null, h("h3", null, "Shared conversations"), h("p", null, "Saved on this Hermes server; any browser can resume them.")), h(Button, { className: "secondary", onClick: newConversation }, "New conversation")),
h("div", { className: "ollama-conversation-list" }, conversations.length ? conversations.map(function (item) { return h(Button, { key: item.id, className: item.id === conversationId ? "selected" : "", onClick: function () { openConversation(item.id); } }, (item.title || "New conversation").slice(0, 70), " · ", item.message_count || 0, " messages"); }) : h("small", null, "No saved conversations yet.")),
aggregate && h("div", { className: "ollama-metrics-summary" }, h("strong", null, "Model performance · ", aggregate.sample_count || 0, " samples"), h("span", null, "TTFT avg: ", aggregate.avg_time_to_first_token_ms == null ? "n/a" : aggregate.avg_time_to_first_token_ms + " ms"), h("span", null, "Output: ", aggregate.avg_eval_tokens_per_second == null ? "n/a" : aggregate.avg_eval_tokens_per_second + " tok/s"), h("span", null, "Latency: ", aggregate.avg_total_latency_ms == null ? "n/a" : aggregate.avg_total_latency_ms + " ms"), h("span", null, "Errors: ", aggregate.error_count || 0)),
metrics && metrics.length > 0 && h("div", { className: "ollama-metrics-detail" }, (metrics.slice(-3)).map(function (item, index) { return h("span", { key: index }, item.model || "model", " · TTFT ", item.time_to_first_token_ms == null ? "n/a" : item.time_to_first_token_ms + " ms", " · ", item.eval_count == null ? "n/a" : item.eval_count + " output tokens", " · ", item.eval_tokens_per_second == null ? "n/a" : item.eval_tokens_per_second + " tok/s"); }))
),
h(ModelPoolPanel, { models: models, loadedModels: loadedModels, selectedModels: selectedModels, poolSelection: poolSelection, busy: busy, onTogglePool: togglePoolModel, onToggleChat: toggleChatModel, onLoad: loadModel, onUnload: unloadModels }),
notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : "ok") }, notice.error || notice.ok),
thinking && h(ThinkingStatus, { stage: thinkingDetails && thinkingDetails.stage ? thinkingDetails.stage : (thinkingElapsed < 1 ? thinking.stage : "Ollama is generating the response"), elapsed: thinkingDetails && thinkingDetails.elapsed != null ? thinkingDetails.elapsed : thinkingElapsed, details: thinkingDetails, expanded: thinkingOpen, onToggle: function () { setThinkingOpen(!thinkingOpen); }, onStop: stop }),
h(RuntimePanel, { runtime: runtime, samples: samples }),
h("div", { className: "ollama-chat-layout" },
h("div", { className: "ollama-conversation" }, history.length ? history.map(function (item, index) { return h("div", { className: "ollama-message " + item.role, key: index }, h("small", null, item.role === "assistant" ? "Ollama" : "You"), h("div", null, item.content)); }) : h(Empty, null, "Start a conversation. The selected model will be loaded into Ollama memory when you load it or send the first message.")),
h("div", { className: "ollama-composer" + (dragging ? " drop-active" : ""), onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop }, dragging && h("div", { className: "ollama-drop-hint" }, "Drop files here to attach"), h("textarea", { value: message, placeholder: "Ask the selected local model… Press Enter to send; Shift+Enter for a new line.", onPaste: onPaste, onChange: function (event) { setMessage(event.target.value); }, onKeyDown: function (event) { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); send(); } } }),
h("div", { className: "ollama-attachment-actions" }, h("label", { className: "ollama-file-button" }, "Attach image / PDF / file", h("input", { type: "file", multiple: true, accept: "image/*,application/pdf,text/*,.txt,.md,.csv,.json,.log,.xml,.yaml,.yml", onChange: onFiles })), h("input", { className: "ollama-url-input", value: url, placeholder: "https://example.com/document", onChange: function (event) { setUrl(event.target.value); }, onKeyDown: function (event) { if (event.key === "Enter") addUrl(); } }), h(Button, { onClick: addUrl, disabled: !url.trim() }, "Add URL"), h(Button, { onClick: send, disabled: busy === "send" || busy === "stop" || !model || (!message.trim() && !attachments.length) }, busy === "send" ? "Sending…" : "Send (Enter)")),
h("div", { className: "ollama-composer" + (dragging ? " drop-active" : ""), onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop }, dragging && h("div", { className: "ollama-drop-hint" }, "Drop files here to attach"), h("textarea", { value: message, placeholder: selectedModels.length ? "Ask " + selectedModels.length + " loaded model" + (selectedModels.length === 1 ? "" : "s") + "… Press Enter to send; Shift+Enter for a new line." : "Load and select at least one model above…", onPaste: onPaste, onChange: function (event) { setMessage(event.target.value); }, onKeyDown: function (event) { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); send(); } } }),
h("div", { className: "ollama-attachment-actions" }, h("label", { className: "ollama-file-button" }, "Attach image / PDF / file", h("input", { type: "file", multiple: true, accept: "image/*,application/pdf,text/*,.txt,.md,.csv,.json,.log,.xml,.yaml,.yml", onChange: onFiles })), h("input", { className: "ollama-url-input", value: url, placeholder: "https://example.com/document", onChange: function (event) { setUrl(event.target.value); }, onKeyDown: function (event) { if (event.key === "Enter") addUrl(); } }), h(Button, { onClick: addUrl, disabled: !url.trim() }, "Add URL"), h(Button, { onClick: send, disabled: busy === "send" || busy === "stop" || !selectedModels.length || (!message.trim() && !attachments.length) }, busy === "send" ? "Sending…" : "Send (Enter)")),
attachments.length > 0 && h("div", { className: "ollama-attachments" }, attachments.map(function (item, index) { return h("span", { className: "ollama-attachment", key: index }, item.name || item.url, h("button", { type: "button", onClick: function () { setAttachments(function (old) { return old.filter(function (_, i) { return i !== index; }); }); } }, "×")); })),
h("p", { className: "ollama-chat-footnote" }, "Limits: 20 MiB per uploaded file, 15 MiB per fetched URL. Private/local URL targets are blocked. Remote content is treated as untrusted text."))
)
@@ -275,6 +337,9 @@
var dataState = React.useState(null), data = dataState[0], setData = dataState[1];
var tabState = React.useState("chat"), tab = tabState[0], setTab = tabState[1];
var queryState = React.useState(""), query = queryState[0], setQuery = queryState[1];
var catalogTypeState = React.useState("all"), catalogType = catalogTypeState[0], setCatalogType = catalogTypeState[1];
var catalogCapabilityState = React.useState("all"), catalogCapability = catalogCapabilityState[0], setCatalogCapability = catalogCapabilityState[1];
var catalogSortState = React.useState("popularity"), catalogSort = catalogSortState[0], setCatalogSort = catalogSortState[1];
var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1];
var loadingState = React.useState(true), loading = loadingState[0], setLoading = loadingState[1];
@@ -282,15 +347,29 @@
React.useEffect(function () { load(); var timer = setInterval(load, 5000); return function () { clearInterval(timer); }; }, []);
function action(kind, name) { if (kind === "delete" && !window.confirm("Remove " + name + " from Ollama?")) return; var key = name + ":" + kind; setBusy(key); setNotice(null); fetchJSON(API + (kind === "delete" ? "/model" : "/" + kind), { method: kind === "delete" ? "DELETE" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name }) }).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(""); }); }
var models = data ? (tab === "installed" ? data.models || [] : tab === "popular" ? data.popular || [] : tab === "catalog" ? data.catalog || [] : []) : [];
var baseModels = data ? (tab === "installed" ? data.models || [] : tab === "popular" ? data.popular || [] : tab === "catalog" ? data.catalog || [] : []) : [];
var models = baseModels;
if (tab === "catalog") {
if (catalogType === "moe") models = models.filter(function (model) { return model.is_moe; });
if (catalogType === "dense") models = models.filter(function (model) { return !model.is_moe; });
if (catalogCapability !== "all") models = models.filter(function (model) { return (model.capabilities || []).indexOf(catalogCapability) >= 0; });
models = models.slice().sort(function (a, b) {
if (catalogSort === "size_asc") return (Number(a.size_bytes) || 0) - (Number(b.size_bytes) || 0);
if (catalogSort === "size_desc") return (Number(b.size_bytes) || 0) - (Number(a.size_bytes) || 0);
if (catalogSort === "newest") return (Date.parse(b.modified_at || "") || 0) - (Date.parse(a.modified_at || "") || 0);
if (catalogSort === "name") return String(a.name).localeCompare(String(b.name));
return (Number(a.popularity_rank) || 999999) - (Number(b.popularity_rank) || 999999);
});
}
var needle = query.toLowerCase().trim(); if (needle) models = models.filter(function (model) { return (model.name + " " + model.family + " " + (model.strengths || []).join(" ") + " " + (model.capabilities || []).join(" ")).toLowerCase().indexOf(needle) >= 0; });
var catalogCapabilities = data && data.catalog_filter_options ? data.catalog_filter_options.capabilities || [] : [];
var jobs = data && data.jobs ? data.jobs.filter(function (job) { return job.state === "running"; }) : [];
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"))),
notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : "ok") }, notice.error || notice.ok),
h("section", { className: "ollama-toolbar" }, h("div", { className: "ollama-tabs" }, h(Button, { className: tab === "chat" ? "selected" : "", onClick: function () { setTab("chat"); } }, "Ollama Chat"), h(Button, { className: tab === "installed" ? "selected" : "", onClick: function () { setTab("installed"); } }, "Installed (" + ((data && data.models) || []).length + ")"), h(Button, { className: tab === "popular" ? "selected" : "", onClick: function () { setTab("popular"); } }, "Top 20 popular (" + ((data && data.popular) || []).length + ")"), h(Button, { className: tab === "catalog" ? "selected" : "", onClick: function () { setTab("catalog"); } }, "Available downloads (" + ((data && data.catalog) || []).length + ")")), tab !== "chat" && h("input", { className: "ollama-search", value: query, placeholder: "Search models, capabilities, or strengths…", onChange: function (event) { setQuery(event.target.value); } })),
h("section", { className: "ollama-toolbar" }, h("div", { className: "ollama-tabs" }, h(Button, { className: tab === "chat" ? "selected" : "", onClick: function () { setTab("chat"); } }, "Ollama Chat"), h(Button, { className: tab === "installed" ? "selected" : "", onClick: function () { setTab("installed"); } }, "Installed (" + ((data && data.models) || []).length + ")"), h(Button, { className: tab === "popular" ? "selected" : "", onClick: function () { setTab("popular"); } }, "Top 20 popular (" + ((data && data.popular) || []).length + ")"), h(Button, { className: tab === "catalog" ? "selected" : "", onClick: function () { setTab("catalog"); } }, "Available downloads (" + ((data && data.catalog) || []).length + ")")), tab !== "chat" && h("input", { className: "ollama-search", value: query, placeholder: "Search models, capabilities, or strengths…", onChange: function (event) { setQuery(event.target.value); } }), tab === "catalog" && h("div", { className: "ollama-catalog-controls" }, h("label", null, "Type", h("select", { className: "ollama-catalog-select", value: catalogType, onChange: function (event) { setCatalogType(event.target.value); } }, h("option", { value: "all" }, "All types"), h("option", { value: "moe" }, "MoE only"), h("option", { value: "dense" }, "Dense only"))), h("label", null, "Ability", h("select", { className: "ollama-catalog-select", value: catalogCapability, onChange: function (event) { setCatalogCapability(event.target.value); } }, h("option", { value: "all" }, "All abilities"), catalogCapabilities.map(function (capability) { return h("option", { key: capability, value: capability }, capability); }))), h("label", null, "Organize", h("select", { className: "ollama-catalog-select", value: catalogSort, onChange: function (event) { setCatalogSort(event.target.value); } }, h("option", { value: "popularity" }, "Popularity"), h("option", { value: "newest" }, "Newest"), h("option", { value: "size_asc" }, "Size: smallest first"), h("option", { value: "size_desc" }, "Size: largest first"), h("option", { value: "name" }, "Name"))))),
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 : [] }),
tab === "popular" && h("p", { className: "ollama-popular-note" }, "Popular is limited to models with known size and RAM estimates at or below 30 GiB. 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), h("span", null, job.percent == null ? job.status : job.percent + "%")); })),
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), h("span", null, job.percent == null ? job.status : job.percent + "%")); })),
tab !== "chat" && loading && h(Empty, null, "Loading local Ollama inventory…"), tab !== "chat" && !loading && !models.length && h(Empty, null, tab === "installed" ? "No local models found." : tab === "popular" ? "No popular catalog entries available." : "No catalog entries available. Try Refresh catalog."), tab !== "chat" && h("section", { className: "ollama-grid" }, models.map(function (model) { return h(ModelCard, { key: model.name, model: model, installed: tab === "installed" || !!model.installed, busy: busy, action: action }); }))
);
}
+3
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,9 +1,9 @@
{
"name": "ollama-manager",
"label": "Ollama Models",
"description": "Inspect, manage, and chat with local Ollama models, including 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",
"version": "1.4.0",
"version": "1.5.0",
"tab": {"path": "/ollama-manager", "position": "after:models"},
"entry": "dist/index.js",
"css": "dist/style.css",
+453 -41
View File
@@ -10,10 +10,12 @@ import mimetypes
import os
import re
import socket
import sqlite3
import subprocess
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta, timezone
from html import unescape
from html.parser import HTMLParser
@@ -34,17 +36,180 @@ REMOTE_OLLAMA = "https://ollama.com"
CATALOG_FILE = "catalog.json"
MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,190}$")
MELBOURNE = ZoneInfo("Australia/Melbourne")
POPULAR_RAM_LIMIT_GIB = 30.0
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
MAX_ATTACHMENT_TEXT = 80_000
MAX_URL_BYTES = 15 * 1024 * 1024
CHAT_KEEP_ALIVE = "10m"
CHAT_KEEP_ALIVE = -1
_jobs: dict[str, dict[str, Any]] = {}
_jobs_lock = threading.Lock()
_chat_requests: dict[str, dict[str, Any]] = {}
_chat_requests_lock = threading.Lock()
_catalog_lock = threading.Lock()
_chat_db_init_lock = threading.Lock()
_chat_db_ready = False
def _chat_db_path() -> Path:
return _home() / "chat.sqlite3"
def _chat_db() -> sqlite3.Connection:
global _chat_db_ready
path = _chat_db_path()
path.parent.mkdir(parents=True, exist_ok=True)
with _chat_db_init_lock:
connection = sqlite3.connect(path, timeout=30)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA foreign_keys=ON")
if not _chat_db_ready:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT 'New conversation',
model TEXT NOT NULL DEFAULT '',
models_json TEXT NOT NULL DEFAULT '[]',
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
request_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
attachments_json TEXT NOT NULL DEFAULT '[]',
created_at REAL NOT NULL,
UNIQUE(request_id, role, model)
);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, id);
CREATE TABLE IF NOT EXISTS chat_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
request_id TEXT NOT NULL,
model TEXT NOT NULL,
status TEXT NOT NULL,
started_at REAL,
first_token_at REAL,
finished_at REAL,
prompt_eval_count INTEGER,
eval_count INTEGER,
total_duration_ns INTEGER,
load_duration_ns INTEGER,
prompt_eval_duration_ns INTEGER,
eval_duration_ns INTEGER,
error TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL,
UNIQUE(request_id, model)
);
CREATE INDEX IF NOT EXISTS idx_metrics_conversation ON chat_metrics(conversation_id, id);
"""
)
connection.commit()
try:
path.chmod(0o600)
except OSError:
pass
_chat_db_ready = True
return connection
def _conversation_id(value: str | None = None) -> str:
value = str(value or uuid.uuid4().hex).strip()
if not re.fullmatch(r"[A-Za-z0-9._-]{1,80}", value):
raise HTTPException(400, "Invalid conversation id")
return value
def _ensure_conversation(conversation_id: str, model: str, models: list[str], title: str = "") -> None:
now = time.time()
title = re.sub(r"\\s+", " ", title.strip())[:100] or "New conversation"
db = _chat_db()
try:
db.execute(
"INSERT INTO conversations(id,title,model,models_json,created_at,updated_at) VALUES(?,?,?,?,?,?) "
"ON CONFLICT(id) DO UPDATE SET model=excluded.model, models_json=excluded.models_json, updated_at=excluded.updated_at",
(conversation_id, title, model, json.dumps(models[:12]), now, now),
)
db.commit()
finally:
db.close()
def _persist_message(conversation_id: str, request_id: str, role: str, content: str, model: str, attachments: list[dict[str, Any]] | None = None) -> None:
db = _chat_db()
try:
db.execute(
"INSERT OR IGNORE INTO messages(conversation_id,request_id,role,content,model,attachments_json,created_at) VALUES(?,?,?,?,?,?,?)",
(conversation_id, request_id, role, content, model, json.dumps(attachments or [])[:10000], time.time()),
)
db.execute("UPDATE conversations SET updated_at=? WHERE id=?", (time.time(), conversation_id))
db.commit()
finally:
db.close()
def _metric_values(state: dict[str, Any], status: str | None = None, error: str = "") -> dict[str, Any]:
started = float(state.get("started_at") or time.time())
finished = float(state.get("finished_at") or time.time())
first = state.get("first_token_at")
prompt_count = state.get("prompt_eval_count")
eval_count = state.get("eval_count")
prompt_duration = state.get("prompt_eval_duration")
eval_duration = state.get("eval_duration")
total_duration = state.get("total_duration")
load_duration = state.get("load_duration")
return {
"status": status or str(state.get("state") or "unknown"),
"started_at": started,
"first_token_at": first,
"finished_at": finished,
"time_to_first_token_ms": round((float(first) - started) * 1000, 2) if first else None,
"total_latency_ms": round((finished - started) * 1000, 2),
"prompt_eval_count": prompt_count,
"eval_count": eval_count,
"total_tokens": (int(prompt_count) + int(eval_count)) if prompt_count is not None and eval_count is not None else None,
"prompt_eval_duration_ns": prompt_duration,
"eval_duration_ns": eval_duration,
"total_duration_ns": total_duration,
"load_duration_ns": load_duration,
"prompt_tokens_per_second": round(int(prompt_count) / (int(prompt_duration) / 1e9), 2) if prompt_count and prompt_duration else None,
"eval_tokens_per_second": round(int(eval_count) / (int(eval_duration) / 1e9), 2) if eval_count and eval_duration else None,
"error": error,
}
def _persist_metric(conversation_id: str, request_id: str, model: str, state: dict[str, Any], status: str | None = None, error: str = "") -> dict[str, Any]:
values = _metric_values(state, status=status, error=error)
db = _chat_db()
try:
db.execute(
"INSERT INTO chat_metrics(conversation_id,request_id,model,status,started_at,first_token_at,finished_at,prompt_eval_count,eval_count,total_duration_ns,load_duration_ns,prompt_eval_duration_ns,eval_duration_ns,error,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(request_id,model) DO UPDATE SET status=excluded.status, started_at=excluded.started_at, first_token_at=excluded.first_token_at, finished_at=excluded.finished_at, prompt_eval_count=excluded.prompt_eval_count, eval_count=excluded.eval_count, total_duration_ns=excluded.total_duration_ns, load_duration_ns=excluded.load_duration_ns, prompt_eval_duration_ns=excluded.prompt_eval_duration_ns, eval_duration_ns=excluded.eval_duration_ns, error=excluded.error",
(conversation_id, request_id, model, values["status"], values["started_at"], values["first_token_at"], values["finished_at"], values["prompt_eval_count"], values["eval_count"], values["total_duration_ns"], values["load_duration_ns"], values["prompt_eval_duration_ns"], values["eval_duration_ns"], values["error"], time.time()),
)
db.commit()
finally:
db.close()
return values
def _row_metric(row: sqlite3.Row) -> dict[str, Any]:
value = dict(row)
started = value.get("started_at")
first = value.get("first_token_at")
finished = value.get("finished_at")
value["time_to_first_token_ms"] = round((first - started) * 1000, 2) if first and started else None
value["total_latency_ms"] = round((finished - started) * 1000, 2) if finished and started else None
prompt = value.get("prompt_eval_count")
output = value.get("eval_count")
value["total_tokens"] = (prompt + output) if prompt is not None and output is not None else None
value["prompt_tokens_per_second"] = round(prompt / (value["prompt_eval_duration_ns"] / 1e9), 2) if prompt and value.get("prompt_eval_duration_ns") else None
value["eval_tokens_per_second"] = round(output / (value["eval_duration_ns"] / 1e9), 2) if output and value.get("eval_duration_ns") else None
return value
CAPABILITY_INFO = {
"completion": "Text generation and chat completion.",
@@ -135,6 +300,12 @@ def _read_meminfo() -> dict[str, int]:
return values
def _host_ram_gib() -> float | None:
"""Return installed system RAM as GiB, detected from the running host."""
total = _read_meminfo().get("MemTotal", 0)
return round(total / (1024 ** 3), 1) if total else None
def _gpu_snapshot() -> dict[str, Any]:
"""Return NVIDIA GPU telemetry when available, without requiring CUDA."""
query = "name,memory.total,memory.used,memory.free"
@@ -177,17 +348,27 @@ def _runtime_snapshot() -> dict[str, Any]:
swap_total = mem.get("SwapTotal", 0)
swap_free = mem.get("SwapFree", 0)
ps_rows = _local_ps()
tag_rows = {str(row.get("name") or row.get("model")): row for row in _local_tags()}
model_memory = []
for row in ps_rows:
name = str(row.get("name") or row.get("model") or "")
total_bytes = int(row.get("size") or 0)
gpu_bytes = int(row.get("size_vram") or 0)
capability_view = _model_view(tag_rows.get(name, {"name": name}), row)
model_memory.append({
"name": name,
"total_bytes": total_bytes,
"gpu_bytes": gpu_bytes,
"ram_bytes": max(0, total_bytes - gpu_bytes),
"gpu_offload_percent": round(gpu_bytes * 100 / total_bytes, 1) if total_bytes else 0,
"capabilities": capability_view["capabilities"],
"capability_breakdown": capability_view["capability_breakdown"],
"input_modalities": capability_view["input_modalities"],
"family": capability_view["family"],
"context_length": capability_view["context_length"],
"parameter_size": capability_view["parameter_size"],
"quantization": capability_view["quantization"],
"permanent": True,
})
return {
"captured_at": time.time(),
@@ -325,13 +506,15 @@ def _family_key(name: str) -> str:
def _known_ram_fit(model: dict[str, Any]) -> bool:
"""Return True only when both size and RAM are known and fit this host."""
"""Return True when known size/RAM estimates fit installed host RAM."""
size_gb = model.get("size_gb")
ram_gb = model.get("expected_ram_gb")
host_ram_gib = _host_ram_gib()
return (
isinstance(size_gb, (int, float))
and isinstance(ram_gb, (int, float))
and float(ram_gb) <= POPULAR_RAM_LIMIT_GIB
and host_ram_gib is not None
and float(ram_gb) <= host_ram_gib
)
@@ -735,6 +918,10 @@ class ModelRequest(BaseModel):
name: str
class ModelsRequest(BaseModel):
names: list[str] = Field(default_factory=list)
class ChatAttachment(BaseModel):
name: str = ""
mime_type: str = ""
@@ -743,11 +930,13 @@ class ChatAttachment(BaseModel):
class ChatRequest(BaseModel):
model: str
model: str = ""
models: list[str] = Field(default_factory=list)
message: str = ""
history: list[dict[str, Any]] = Field(default_factory=list)
attachments: list[ChatAttachment] = Field(default_factory=list)
request_id: str = ""
conversation_id: str = ""
class ChatStopRequest(BaseModel):
@@ -792,8 +981,8 @@ def _load_model(name: str) -> dict[str, Any]:
return {"ok": True, "model": name, "response": result.get("response", ""), "runtime": _runtime_snapshot()}
def _chat_payload(body: ChatRequest) -> dict[str, Any]:
model = _require_installed_model(body.model)
def _chat_payload(body: ChatRequest, model_name: str | None = None) -> dict[str, Any]:
model = _require_installed_model(model_name or body.model)
messages: list[dict[str, Any]] = []
for item in body.history[-24:]:
role = str(item.get("role") or "")
@@ -830,12 +1019,18 @@ def _valid_chat_request_id(value: str) -> str:
def _chat_state(request_id: str, **values: Any) -> dict[str, Any]:
with _chat_requests_lock:
state = _chat_requests.setdefault(request_id, {"request_id": request_id, "cancel": threading.Event(), "state": "starting", "stage": "Preparing request", "started_at": time.time(), "chunks": 0, "thinking_chars": 0, "response_chars": 0})
state = _chat_requests.setdefault(request_id, {"request_id": request_id, "cancel": threading.Event(), "state": "starting", "stage": "Preparing request", "started_at": time.time(), "chunks": 0, "thinking_chars": 0, "response_chars": 0, "first_token_at": None, "prompt_eval_count": None, "eval_count": None, "total_duration": None, "load_duration": None, "prompt_eval_duration": None, "eval_duration": None})
state.update(values, updated_at=time.time())
return {key: value for key, value in state.items() if key != "cancel"}
return {key: value for key, value in state.items() if key not in {"cancel", "response"}}
def _stream_chat_request(payload: dict[str, Any], request_id: str) -> dict[str, Any]:
def _stream_chat_request(payload: dict[str, Any], request_id: str, cancel_event: threading.Event | None = None, parent_id: str | None = None) -> dict[str, Any]:
if cancel_event is not None or parent_id is not None:
with _chat_requests_lock:
state = _chat_requests.get(request_id)
if state:
if cancel_event is not None: state["cancel"] = cancel_event
if parent_id is not None: state["parent_id"] = parent_id
data = json.dumps({**payload, "stream": True}).encode("utf-8")
request = Request(LOCAL_OLLAMA + "/api/chat", data=data, headers={"Accept": "application/x-ndjson", "Content-Type": "application/json"}, method="POST")
response_text: list[str] = []
@@ -843,10 +1038,7 @@ def _stream_chat_request(payload: dict[str, Any], request_id: str) -> dict[str,
_chat_state(request_id, state="connecting", stage="Connecting to Ollama")
try:
with urlopen(request, timeout=1800) as response:
sock = getattr(getattr(getattr(response, "fp", None), "raw", None), "_sock", None)
if sock is not None:
sock.settimeout(1.0)
_chat_state(request_id, state="generating", stage="Ollama is generating")
_chat_state(request_id, response=response, state="generating", stage="Ollama is generating")
while True:
with _chat_requests_lock:
cancelled = bool(_chat_requests.get(request_id, {}).get("cancel", threading.Event()).is_set())
@@ -855,8 +1047,12 @@ def _stream_chat_request(payload: dict[str, Any], request_id: str) -> dict[str,
raise _ChatStopped()
try:
raw_line = response.readline()
except (socket.timeout, TimeoutError):
continue
except (socket.timeout, TimeoutError, OSError, ValueError) as exc:
with _chat_requests_lock:
cancelled = bool(_chat_requests.get(request_id, {}).get("cancel", threading.Event()).is_set())
if cancelled:
raise _ChatStopped() from exc
raise
if not raw_line:
break
try:
@@ -870,6 +1066,8 @@ def _stream_chat_request(payload: dict[str, Any], request_id: str) -> dict[str,
response_text.append(chunk)
if thinking_chunk:
thinking_text.append(thinking_chunk)
if chunk and not _chat_requests.get(request_id, {}).get("first_token_at"):
_chat_state(request_id, first_token_at=time.time())
_chat_state(
request_id,
state="generating",
@@ -877,7 +1075,12 @@ def _stream_chat_request(payload: dict[str, Any], request_id: str) -> dict[str,
chunks=int(_chat_requests.get(request_id, {}).get("chunks", 0)) + 1,
thinking_chars=sum(map(len, thinking_text)),
response_chars=sum(map(len, response_text)),
eval_count=event.get("eval_count"),
prompt_eval_count=event.get("prompt_eval_count", _chat_requests.get(request_id, {}).get("prompt_eval_count")),
eval_count=event.get("eval_count", _chat_requests.get(request_id, {}).get("eval_count")),
total_duration=event.get("total_duration", _chat_requests.get(request_id, {}).get("total_duration")),
load_duration=event.get("load_duration", _chat_requests.get(request_id, {}).get("load_duration")),
prompt_eval_duration=event.get("prompt_eval_duration", _chat_requests.get(request_id, {}).get("prompt_eval_duration")),
eval_duration=event.get("eval_duration", _chat_requests.get(request_id, {}).get("eval_duration")),
)
if event.get("done"):
break
@@ -888,7 +1091,9 @@ def _stream_chat_request(payload: dict[str, Any], request_id: str) -> dict[str,
_chat_state(request_id, state="failed", stage="Ollama request failed", finished_at=time.time())
raise
_chat_state(request_id, state="completed", stage="Response complete", finished_at=time.time())
return {"message": {"role": "assistant", "content": "".join(response_text)}, "done": True}
with _chat_requests_lock:
final_state = dict(_chat_requests.get(request_id, {}))
return {"message": {"role": "assistant", "content": "".join(response_text)}, "done": True, "metrics": _metric_values(final_state, status="completed")}
@router.get("/chat/status/{request_id}")
@@ -898,7 +1103,7 @@ def chat_status(request_id: str) -> dict[str, Any]:
state = _chat_requests.get(request_id)
if not state:
raise HTTPException(404, "Chat request not found")
result = {key: value for key, value in state.items() if key not in {"cancel"}}
result = {key: value for key, value in state.items() if key not in {"cancel", "response"}}
result["elapsed"] = round(max(0.0, time.time() - float(state.get("started_at") or time.time())), 1)
return result
@@ -911,12 +1116,98 @@ def chat_stop(body: ChatStopRequest) -> dict[str, Any]:
if not state:
return {"ok": True, "request_id": request_id, "state": "not_found"}
state["cancel"].set()
responses = []
for child_id, child in _chat_requests.items():
if child_id == request_id or child.get("parent_id") == request_id:
child["cancel"].set()
response = child.get("response")
if response is not None: responses.append(response)
child["state"] = "stopping"
child["stage"] = "Stopping Ollama request"
child["updated_at"] = time.time()
state["state"] = "stopping"
state["stage"] = "Stopping Ollama request"
state["updated_at"] = time.time()
for response in responses:
try: response.close()
except Exception: pass
return {"ok": True, "request_id": request_id, "state": "stopping"}
@router.get("/conversations")
def conversations() -> dict[str, Any]:
db = _chat_db()
try:
rows = db.execute("SELECT id,title,model,models_json,created_at,updated_at,(SELECT COUNT(*) FROM messages m WHERE m.conversation_id=c.id) AS message_count FROM conversations c ORDER BY updated_at DESC LIMIT 100").fetchall()
result = []
for row in rows:
item = dict(row)
item["models"] = json.loads(item.pop("models_json") or "[]")
result.append(item)
return {"conversations": result}
finally:
db.close()
@router.get("/conversations/{conversation_id}")
def conversation(conversation_id: str) -> dict[str, Any]:
conversation_id = _conversation_id(conversation_id)
db = _chat_db()
try:
row = db.execute("SELECT id,title,model,models_json,created_at,updated_at FROM conversations WHERE id=?", (conversation_id,)).fetchone()
if not row:
raise HTTPException(404, "Conversation not found")
item = dict(row)
item["models"] = json.loads(item.pop("models_json") or "[]")
messages = []
for message in db.execute("SELECT id,request_id,role,content,model,attachments_json,created_at FROM messages WHERE conversation_id=? ORDER BY id", (conversation_id,)).fetchall():
value = dict(message)
value["attachments"] = json.loads(value.pop("attachments_json") or "[]")
messages.append(value)
metrics = [_row_metric(metric) for metric in db.execute("SELECT * FROM chat_metrics WHERE conversation_id=? ORDER BY id", (conversation_id,)).fetchall()]
return {"conversation": item, "messages": messages, "metrics": metrics}
finally:
db.close()
@router.delete("/conversations/{conversation_id}")
def delete_conversation(conversation_id: str) -> dict[str, Any]:
conversation_id = _conversation_id(conversation_id)
db = _chat_db()
try:
db.execute("DELETE FROM conversations WHERE id=?", (conversation_id,))
db.commit()
return {"ok": True, "conversation_id": conversation_id}
finally:
db.close()
@router.get("/metrics")
def metrics(limit: int = 100) -> dict[str, Any]:
limit = max(1, min(int(limit), 500))
db = _chat_db()
try:
rows = [_row_metric(row) for row in db.execute("SELECT * FROM chat_metrics ORDER BY id DESC LIMIT ?", (limit,)).fetchall()]
completed = [row for row in rows if row["status"] == "completed"]
def average(key: str) -> float | None:
values = [float(row[key]) for row in completed if row.get(key) is not None]
return round(sum(values) / len(values), 2) if values else None
aggregate = {
"sample_count": len(rows),
"completed_count": len(completed),
"error_count": sum(row["status"] == "failed" for row in rows),
"stopped_count": sum(row["status"] == "stopped" for row in rows),
"avg_time_to_first_token_ms": average("time_to_first_token_ms"),
"avg_total_latency_ms": average("total_latency_ms"),
"avg_eval_tokens_per_second": average("eval_tokens_per_second"),
"avg_prompt_tokens_per_second": average("prompt_tokens_per_second"),
"total_output_tokens": sum(int(row["eval_count"]) for row in completed if row.get("eval_count") is not None),
}
return {"metrics": rows, "aggregate": aggregate}
finally:
db.close()
@router.get("/runtime")
def runtime() -> dict[str, Any]:
return _runtime_snapshot()
@@ -927,26 +1218,120 @@ def chat_load(body: ModelRequest) -> dict[str, Any]:
return _load_model(body.name)
@router.post("/models/load")
def models_load(body: ModelsRequest) -> dict[str, Any]:
names = list(dict.fromkeys(_valid_name(name) for name in body.names if str(name).strip()))[:12]
if not names:
raise HTTPException(400, "Select at least one model to load")
results = []
for name in names:
try:
results.append({"name": name, "ok": True, "result": _load_model(name)})
except Exception as exc:
results.append({"name": name, "ok": False, "error": str(exc)})
return {"ok": all(item["ok"] for item in results), "results": results, "runtime": _runtime_snapshot(), "keep_alive": "permanent"}
@router.post("/models/unload")
def models_unload(body: ModelsRequest) -> dict[str, Any]:
names = list(dict.fromkeys(_valid_name(name) for name in body.names if str(name).strip()))[:12]
if not names:
raise HTTPException(400, "Select at least one model to unload")
results = []
for name in names:
try:
_require_installed_model(name)
_json_request(LOCAL_OLLAMA + "/api/generate", method="POST", payload={"model": name, "prompt": "", "stream": False, "keep_alive": 0}, timeout=120)
results.append({"name": name, "ok": True})
except Exception as exc:
results.append({"name": name, "ok": False, "error": str(exc)})
return {"ok": all(item["ok"] for item in results), "results": results, "runtime": _runtime_snapshot()}
@router.post("/chat")
def chat(body: ChatRequest) -> dict[str, Any]:
request_id = _valid_chat_request_id(body.request_id or uuid.uuid4().hex)
_chat_state(request_id, state="preparing", stage="Preparing attachments")
payload = _chat_payload(body)
conversation_id = _conversation_id(body.conversation_id)
selected = list(dict.fromkeys(_valid_name(name) for name in (body.models or ([body.model] if body.model else [])) if str(name).strip()))[:12]
if not selected:
raise HTTPException(400, "Select at least one loaded model")
_ensure_conversation(conversation_id, selected[0], selected, body.message or "New conversation")
attachment_meta = [{"name": item.name, "mime_type": item.mime_type, "url": item.url} for item in body.attachments[:12]]
_persist_message(conversation_id, request_id, "user", body.message.strip() or "[Attachments]", selected[0], attachment_meta)
_chat_state(request_id, state="preparing", stage="Preparing attachments", models=selected, conversation_id=conversation_id)
with _chat_requests_lock:
cancel_event = _chat_requests[request_id]["cancel"]
if len(selected) == 1:
payload = _chat_payload(body, selected[0])
try:
result = _stream_chat_request(payload, request_id, cancel_event=cancel_event)
except _ChatStopped as exc:
with _chat_requests_lock:
state = dict(_chat_requests.get(request_id, {}))
_persist_metric(conversation_id, request_id, selected[0], state, status="stopped")
raise HTTPException(499, "Chat stopped by user") from exc
except HTTPError as exc:
with _chat_requests_lock:
state = dict(_chat_requests.get(request_id, {}))
_persist_metric(conversation_id, request_id, selected[0], state, status="failed", error=str(exc))
raise _ollama_error(exc) from exc
except Exception as exc:
with _chat_requests_lock:
state = dict(_chat_requests.get(request_id, {}))
_persist_metric(conversation_id, request_id, selected[0], state, status="failed", error=str(exc))
raise
message = result.get("message") if isinstance(result.get("message"), dict) else {}
content = str(message.get("content") or "")
_persist_message(conversation_id, request_id, "assistant", content, selected[0])
with _chat_requests_lock:
state = dict(_chat_requests.get(request_id, {}))
persisted_metrics = _persist_metric(conversation_id, request_id, selected[0], state, status="completed")
return {"ok": True, "request_id": request_id, "conversation_id": conversation_id, "model": selected[0], "models": selected, "message": {"role": "assistant", "content": content}, "done": True, "metrics": persisted_metrics, "runtime": _runtime_snapshot()}
_chat_state(request_id, state="generating", stage=f"Querying {len(selected)} models in parallel")
results: dict[str, dict[str, Any]] = {}
errors: dict[str, str] = {}
def run_model(index: int, name: str):
child_id = f"{request_id}-{index}"
_chat_state(child_id, state="preparing", stage=f"Preparing {name}", model=name, parent_id=request_id, conversation_id=conversation_id)
payload = _chat_payload(body, name)
return name, _stream_chat_request(payload, child_id, cancel_event=cancel_event, parent_id=request_id)
try:
result = _stream_chat_request(payload, request_id)
with ThreadPoolExecutor(max_workers=len(selected), thread_name_prefix="ollama-chat") as pool:
futures = [pool.submit(run_model, index, name) for index, name in enumerate(selected)]
for future in as_completed(futures):
try:
name, result = future.result()
results[name] = result
child_id = f"{request_id}-{selected.index(name)}"
with _chat_requests_lock:
child_state = dict(_chat_requests.get(child_id, {}))
_persist_metric(conversation_id, child_id, name, child_state, status="completed")
_chat_state(request_id, stage=f"Received response from {len(results)} of {len(selected)} models", response_chars=sum(len(str((r.get("message") or {}).get("content") or "")) for r in results.values()))
except _ChatStopped:
raise
except HTTPError as exc:
errors[str(exc)] = str(exc)
except Exception as exc:
errors[type(exc).__name__] = str(exc)
except _ChatStopped as exc:
raise HTTPException(499, "Chat stopped by user") from exc
except HTTPError as exc:
raise _ollama_error(exc) from exc
message = result.get("message") if isinstance(result.get("message"), dict) else {}
return {
"ok": True,
"request_id": request_id,
"model": payload["model"],
"message": {"role": "assistant", "content": str(message.get("content") or "")},
"done": True,
"runtime": _runtime_snapshot(),
}
if not results and errors:
raise HTTPException(502, "All selected Ollama models failed: " + "; ".join(errors.values()))
sections = []
for name in selected:
if name in results:
message = results[name].get("message") if isinstance(results[name].get("message"), dict) else {}
sections.append(f"[{name}]\n{str(message.get('content') or '').strip()}")
else:
sections.append(f"[{name}]\nModel failed: {errors.get(name, 'No response received')}")
combined = "\n\n".join(sections)
_chat_state(request_id, state="completed", stage="Combined model responses", finished_at=time.time(), response_chars=len(combined))
_persist_message(conversation_id, request_id, "assistant", combined, selected[0])
return {"ok": True, "request_id": request_id, "conversation_id": conversation_id, "model": selected[0], "models": selected, "message": {"role": "assistant", "content": combined}, "model_responses": {name: str((results.get(name, {}).get("message") or {}).get("content") or "") for name in selected if name in results}, "metrics": [results[name].get("metrics") for name in selected if name in results], "errors": errors, "done": True, "runtime": _runtime_snapshot()}
@router.get("/status")
@@ -969,13 +1354,29 @@ def status() -> dict[str, Any]:
view["loaded"] = name in loaded
return view
downloadable = [
catalog_view(row, "catalog")
for row in catalog.get("models", [])
if not _is_mlx(row) and str(row.get("name") or row.get("model")) not in installed_names
]
family_rows = catalog.get("families") if isinstance(catalog.get("families"), dict) else {}
catalog_rows = list(catalog.get("models", []))
catalog_names = {str(row.get("name") or row.get("model") or "") for row in catalog_rows}
candidate_rows = catalog_rows + [
variant
for rows in family_rows.values()
for variant in rows
if str(variant.get("name") or variant.get("model") or "") not in catalog_names
]
downloadable = []
seen_downloads: set[str] = set()
for rank, row in enumerate(candidate_rows, 1):
name = str(row.get("name") or row.get("model") or "")
if _is_mlx(row) or not name or name in installed_names or name in seen_downloads:
continue
view = catalog_view(row, "catalog")
if not _known_ram_fit(view):
continue
if name in catalog_names:
view["popularity_rank"] = rank
seen_downloads.add(name)
downloadable.append(view)
popular = _popular_fit_models(
[row for row in catalog.get("models", []) if not _is_mlx(row)],
family_rows,
@@ -1000,14 +1401,25 @@ def status() -> dict[str, Any]:
"models": local,
"popular": popular,
"popular_filter": {
"max_expected_ram_gib": POPULAR_RAM_LIMIT_GIB,
"max_expected_ram_gib": _host_ram_gib(),
"basis": "detected MemTotal from the running host",
"requires_known_size": True,
"requires_known_ram": True,
"smaller_fit_variants_substituted": True,
},
"catalog": downloadable,
"catalog_updated_at": catalog.get("fetched_at"),
"catalog_filter": {
"max_expected_ram_gib": _host_ram_gib(),
"basis": "detected MemTotal from the running host",
"requires_known_size": True,
"requires_known_ram": True,
},
"catalog_filter_options": {
"types": ["all", "moe", "dense"],
"capabilities": sorted({cap for row in downloadable for cap in row.get("capabilities", [])}),
},
"catalog_source": catalog.get("source"),
"catalog_updated_at": catalog.get("fetched_at"),
"catalog_error": catalog.get("last_error"),
"next_catalog_refresh": _next_refresh(),
"jobs": _job_snapshot(),
+2 -2
View File
@@ -1,5 +1,5 @@
name: ollama-manager
version: 1.4.0
description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, and live runtime telemetry.
version: 1.5.0
description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, shared persistent conversations, performance metrics, and live runtime telemetry.
python_dependencies:
- "pypdf>=6.0,<7.0"