Make Ollama chat the primary workflow

This commit is contained in:
Hermes Agent
2026-08-29 08:50:03 +10:00
parent 309947d353
commit e1f0589c3c
7 changed files with 316 additions and 87 deletions
+4 -1
View File
@@ -13,6 +13,9 @@ Native-like Hermes dashboard plugin for local Ollama model management and chat.
- View Ollama's loaded-model memory split: total, GPU VRAM, and normal RAM/offload
- View NVIDIA GPU telemetry when `nvidia-smi` is available
- Chat is the default view when the Ollama Models plugin opens
- Chat uses a conversation rail, central message timeline, and dedicated model/runtime controls
- Messages support safe Markdown-style emphasis, fenced code blocks, copy, and retry actions
- The catalog is loaded separately from live status with server-side search, filters, and pagination
- Natural composer behavior: Enter sends; Shift+Enter creates a new line
- Paste images directly into the composer and drag/drop images, PDFs, and text files
- Streamed Ollama responses with a real Stop action that cancels the active request
@@ -26,7 +29,7 @@ The chat supports two modes. With one selected model, it sends a normal direct r
## Chat storage and durability
Chat jobs are persisted before model execution. The browser observes job status through `/chat/status/{request_id}` and discovers active jobs through `/chat/jobs`, but does not own generation. Closing the browser no longer cancels a queued or running job, and a fresh browser session automatically reconnects to the latest active job. The server worker supervisor continuously re-queues jobs whose worker disappeared and persists a heartbeat while Ollama is thinking, including during idle streaming periods. Stop requests remain responsive because the Ollama stream is checked on a short read interval. The dashboard service is configured with `Restart=always`, and queued/running jobs are recovered after a service restart. Each conversation records immutable message versions, job attempts, stage timing, model metrics, and append-only operational events.
Chat jobs are persisted before model execution. The browser observes job status through `/chat/status/{request_id}` and discovers active jobs through `/chat/jobs`, but does not own generation. The browser sends only the new turn; the worker reconstructs prior turns from the server-owned conversation. Closing the browser no longer cancels a queued or running job, and a fresh browser session automatically reconnects to the latest active job. The server worker supervisor continuously re-queues jobs whose worker disappeared and persists a heartbeat while Ollama is thinking, including during idle streaming periods. Stop requests remain responsive because the Ollama stream is checked on a short read interval. The dashboard service is configured with `Restart=always`, and queued/running jobs are recovered after a service restart. Each conversation records immutable message versions, job attempts, stage timing, model metrics, and append-only operational events.
SQLite remains the default and requires no service installation. The Chat storage panel can detect native PostgreSQL, install it only after explicit confirmation, and link it as the active backend. PostgreSQL storage uses the local service environment and remains disabled until the user explicitly selects **Link PostgreSQL**.
+105 -43
View File
@@ -65,6 +65,55 @@
}
function Empty(props) { return h("div", { className: "ollama-empty" }, props.children); }
function renderInline(text, keyPrefix) {
var tokens = String(text || "").split(/(`[^`]*`|\*\*[^*]+\*\*|\*[^*]+\*)/g).filter(function (token) { return token !== ""; });
return tokens.map(function (token, index) {
var key = keyPrefix + "-" + index;
if (token.charAt(0) === "`" && token.charAt(token.length - 1) === "`") return h("code", { key: key, className: "ollama-inline-code" }, token.slice(1, -1));
if (token.indexOf("**") === 0 && token.lastIndexOf("**") === token.length - 2) return h("strong", { key: key }, token.slice(2, -2));
if (token.charAt(0) === "*" && token.charAt(token.length - 1) === "*") return h("em", { key: key }, token.slice(1, -1));
return token;
});
}
function RichText(props) {
var lines = String(props.content || "").split("\\n"), nodes = [], code = [], language = "", inCode = false;
lines.forEach(function (line, index) {
var fence = line.match(/^```(.*)$/);
if (fence) {
if (inCode) {
nodes.push(h("pre", { key: "code-" + index, className: "ollama-code-block" }, h("code", { className: language ? "language-" + language : "" }, code.join("\\n"))));
code = []; language = ""; inCode = false;
} else {
language = String(fence[1] || "").trim().replace(/[^A-Za-z0-9_-]/g, ""); inCode = true;
}
return;
}
if (inCode) { code.push(line); return; }
if (!line.trim()) { nodes.push(h("br", { key: "break-" + index })); return; }
nodes.push(h("div", { key: "line-" + index }, renderInline(line, "line-" + index)));
});
if (inCode) nodes.push(h("pre", { key: "code-final", className: "ollama-code-block" }, h("code", null, code.join("\\n"))));
return h("div", { className: "ollama-rich-text" }, nodes);
}
function copyText(value, onCopied) {
if (!navigator.clipboard || !navigator.clipboard.writeText) return;
navigator.clipboard.writeText(String(value || "")).then(function () { if (onCopied) onCopied(); }).catch(function () {});
}
function MessageBubble(props) {
var item = props.item || {}, assistant = item.role === "assistant";
return h("div", { className: "ollama-message " + item.role, key: props.messageKey },
h("div", { className: "ollama-message-meta" }, h("small", null, assistant ? (item.model || "Ollama") : "You"), item.created_at && h("small", null, fmtDate(item.created_at))),
h(RichText, { content: item.content }),
h("div", { className: "ollama-message-actions" },
h("button", { type: "button", onClick: function () { copyText(item.content, props.onCopied); } }, "Copy"),
assistant && props.onRetry && h("button", { type: "button", onClick: props.onRetry }, "Retry")
)
);
}
function ConnectionPanel(props) {
var data = props.data || {}, ollama = data.ollama || {}, connectionState = React.useState(ollama.endpoint || data.active_url || ""), url = connectionState[0], setUrl = connectionState[1];
var roleState = React.useState("local"), role = roleState[0], setRole = roleState[1];
@@ -292,7 +341,7 @@
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 }; }));
setHistory((value.messages || []).filter(function (message) { return message.role === "user" || message.role === "assistant"; }).map(function (message) { return { id: message.id, request_id: message.request_id, role: message.role, content: message.content, created_at: message.created_at, model: message.model, attachments: message.attachments || [] }; }));
setMetrics(value.metrics || []);
if (item.model) setModel(item.model);
if (Array.isArray(item.models) && item.models.length) setSelectedModels(item.models);
@@ -351,7 +400,7 @@
if (!thinkingId) return;
function pollThinking() { fetchJSON(API + "/chat/status/" + encodeURIComponent(thinkingId)).then(setThinkingDetails).catch(function () {}); }
pollThinking();
var timer = setInterval(pollThinking, 500);
var timer = setInterval(pollThinking, 1000);
return function () { clearInterval(timer); };
}, [thinkingId]);
function clearChat() {
@@ -363,7 +412,7 @@
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); }; }, []);
React.useEffect(function () { pollRuntime(); var timer = setInterval(pollRuntime, 5000); return function () { clearInterval(timer); }; }, []);
React.useEffect(function () { savePlacements(placements); }, [placements]);
function toggleIn(setter, name) { setter(function (old) { return old.indexOf(name) >= 0 ? old.filter(function (item) { return item !== name; }) : old.concat([name]); }); }
@@ -460,37 +509,48 @@
return pollChatJob(active.request_id, current).then(applyChatResult).catch(function (err) { if (!current.stopped) setNotice({ error: err.message || String(err) }); }).finally(function () { if (!current.stopped) { setThinking(null); setActiveRequest(null); } setBusy(""); });
}).catch(function () { return null; });
}
function send() {
if (busy === "send" || busy === "stop" || !selectedModels.length || (!message.trim() && !attachments.length)) return;
function retryMessage(index) {
if (busy === "send" || busy === "stop") return;
var prior = history.slice(0, index).reverse().find(function (item) { return item.role === "user"; });
if (prior && prior.content) send(prior.content);
}
function send(messageOverride) {
var outgoingMessage = messageOverride == null ? message : String(messageOverride);
if (busy === "send" || busy === "stop" || !selectedModels.length || (!outgoingMessage.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: selectedModels[0], models: selectedModels, primary_model: selectedModels[0], validator_models: selectedModels.slice(1), harness: selectedModels.length > 1, placements: placements, message: message, history: history, attachments: attachments, request_id: requestId, conversation_id: conversationId };
var outgoing = { role: "user", content: outgoingMessage.trim() || "[Attachments]" }, body = { model: selectedModels[0], models: selectedModels, primary_model: selectedModels[0], validator_models: selectedModels.slice(1), harness: selectedModels.length > 1, placements: placements, message: outgoingMessage, 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) { return result.done ? result : pollChatJob(requestId, current); }).then(applyChatResult).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 a validated model harness"), h("p", null, "Choose one primary model and at least one loaded validator model. The primary produces one final answer after reviewing the independent validation reports.")), 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(StoragePanel, { storage: storage, onConfigure: configureStorage, onInstall: installPostgres, onRefresh: refreshStorage }),
h(ModelPoolPanel, { models: models, loadedModels: loadedModels, selectedModels: selectedModels, primaryModel: selectedModels[0] || "", validatorModels: selectedModels.slice(1), poolSelection: poolSelection, placements: placements, busy: busy, onTogglePool: togglePoolModel, onPlacementChange: setPlacement, onToggleChat: toggleChatModel, onPrimaryChange: setPrimaryModel, onLoad: loadModel, onUnload: unloadModels }),
h("div", { className: "ollama-chat-header" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "LOCAL OLLAMA CHAT"), h("h2", null, "Chat with Ollama"), h("p", null, "Direct chat is the default. Enable quality review in the controls when you want independent validator checks.")), h(Button, { className: "secondary", disabled: !history.length || busy === "send", onClick: clearChat }, "Clear chat")),
notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : notice.warning ? "warning" : "ok") }, notice.error || notice.warning || notice.ok),
validationReports && validationReports.length > 0 && h("details", { className: "ollama-validation-evidence" }, h("summary", null, "Validation evidence · ", validationReports.length, " independent reports"), validationReports.map(function (item) { return h("div", { className: "ollama-validation-report", key: item.model }, h("strong", null, item.model), h("p", null, item.report || "No report text returned.")); })),
h("div", { className: "ollama-chat-shell" },
h("aside", { className: "ollama-conversation-rail" },
h("div", { className: "ollama-rail-heading" }, h("div", null, h("h3", null, "Conversations"), h("small", null, "Shared on this Hermes server")), h(Button, { className: "secondary", onClick: newConversation }, "New")),
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); } }, h("span", null, (item.title || "New conversation").slice(0, 52)), h("small", null, item.message_count || 0, " messages")); }) : h("small", null, "No saved conversations yet.")),
aggregate && h("div", { className: "ollama-metrics-summary" }, h("strong", null, aggregate.sample_count || 0, " samples"), h("span", null, "TTFT ", 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, "Errors ", aggregate.error_count || 0))
),
h("div", { className: "ollama-chat-main" },
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: 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(); } } }),
validationReports && validationReports.length > 0 && h("details", { className: "ollama-validation-evidence" }, h("summary", null, "Validation evidence · ", validationReports.length, " independent reports"), validationReports.map(function (item) { return h("div", { className: "ollama-validation-report", key: item.model }, h("strong", null, item.model), h("p", null, item.report || "No report text returned.")); })),
h("div", { className: "ollama-conversation" }, history.length ? history.map(function (item, index) { return h(MessageBubble, { item: item, messageKey: item.id || item.request_id || index, key: item.id || item.request_id || index, onCopied: function () { setNotice({ ok: "Message copied." }); }, onRetry: item.role === "assistant" ? function () { retryMessage(index); } : null }); }) : h(Empty, null, "Start a conversation. Select a loaded model in the controls, then send a 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: 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 in Chat controls…", 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."))
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.")
)
),
h("aside", { className: "ollama-chat-controls" },
h("div", { className: "ollama-controls-heading" }, h("h3", null, "Chat controls"), h("small", null, "Model, quality, and runtime")),
h(ModelPoolPanel, { models: models, loadedModels: loadedModels, selectedModels: selectedModels, primaryModel: selectedModels[0] || "", validatorModels: selectedModels.slice(1), poolSelection: poolSelection, placements: placements, busy: busy, onTogglePool: togglePoolModel, onPlacementChange: setPlacement, onToggleChat: toggleChatModel, onPrimaryChange: setPrimaryModel, onLoad: loadModel, onUnload: unloadModels }),
h("details", { className: "ollama-advanced-control" }, h("summary", null, "Chat storage"), h(StoragePanel, { storage: storage, onConfigure: configureStorage, onInstall: installPostgres, onRefresh: refreshStorage })),
h("details", { className: "ollama-advanced-control" }, h("summary", null, "Runtime telemetry"), h(RuntimePanel, { runtime: runtime, samples: samples }))
)
)
);
}
@@ -508,12 +568,27 @@
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1];
var targetDialogState = React.useState(null), targetDialog = targetDialogState[0], setTargetDialog = targetDialogState[1];
var loadingState = React.useState(true), loading = loadingState[0], setLoading = loadingState[1];
var catalogState = React.useState({ rows: [], total: 0, page: 1, has_more: false, capabilities: [] }), catalogData = catalogState[0], setCatalogData = catalogState[1];
var catalogLoadingState = React.useState(false), catalogLoading = catalogLoadingState[0], setCatalogLoading = catalogLoadingState[1];
var loadSequence = React.useRef(0);
var catalogSequence = React.useRef(0);
function load() {
var sequence = ++loadSequence.current;
return fetchJSON(API + "/status").then(function (value) { if (sequence !== loadSequence.current) return value; setData(value); setLoading(false); return value; }).catch(function (err) { if (sequence === loadSequence.current) { setNotice({ error: err.message || String(err) }); setLoading(false); } });
}
React.useEffect(function () { load(); var timer = setInterval(load, 5000); return function () { clearInterval(timer); }; }, []);
function loadCatalog(nextPage) {
var page = nextPage || 1;
var sequence = ++catalogSequence.current;
var params = new URLSearchParams({ q: query, page: String(page), page_size: "60", model_type: catalogType, capability: catalogCapability, sort: catalogSort, recent_only: recentOnly ? "true" : "false", show_oversized: showOversized ? "true" : "false" });
setCatalogLoading(true);
return fetchJSON(API + "/catalog?" + params.toString()).then(function (value) {
if (sequence !== catalogSequence.current) return value;
setCatalogData(function (old) { return page > 1 ? Object.assign({}, value, { rows: (old.rows || []).concat(value.catalog || []), capabilities: old.capabilities || ((value.catalog_filter_options || {}).capabilities || []) }) : Object.assign({}, value, { rows: value.catalog || [], capabilities: (value.catalog_filter_options || {}).capabilities || [] }); });
return value;
}).catch(function (err) { if (sequence === catalogSequence.current) setNotice({ error: err.message || String(err) }); }).finally(function () { if (sequence === catalogSequence.current) setCatalogLoading(false); });
}
React.useEffect(function () { load(); var timer = setInterval(load, 10000); return function () { clearInterval(timer); }; }, []);
React.useEffect(function () { if (tab !== "catalog") return; var timer = setTimeout(function () { loadCatalog(1); }, 250); return function () { clearTimeout(timer); }; }, [tab, query, catalogType, catalogCapability, catalogSort, recentOnly, showOversized]);
function action(kind, name, selectedTarget) {
if (kind === "delete" && !window.confirm("Remove " + name + " from Ollama?")) return;
if ((kind === "pull" || kind === "redownload") && !selectedTarget) {
@@ -526,33 +601,20 @@
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(""); }); }
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" ? catalogData.rows || [] : []) : [];
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; });
if (recentOnly) {
var recentCutoff = Date.now() - 365 * 24 * 60 * 60 * 1000;
models = models.filter(function (model) { var timestamp = Date.parse(model.modified_at || ""); return !model.modified_at || !Number.isFinite(timestamp) || timestamp >= recentCutoff; });
if (tab !== "catalog") {
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; });
}
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 catalogCapabilities = catalogData.capabilities && catalogData.capabilities.length ? catalogData.capabilities : (data && data.catalog_filter_options ? data.catalog_filter_options.capabilities || [] : []);
var jobs = data && data.jobs ? data.jobs.filter(function (job) { return job.state === "running"; }) : [];
var disk = data && data.disk ? data.disk : {};
var navTabs = h("nav", { className: "ollama-tabs", "aria-label": "Ollama views" },
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 (" + (tab === "catalog" ? models.length : ((data && data.catalog) || []).length) + ")")
h(Button, { className: tab === "catalog" ? "selected" : "", onClick: function () { setTab("catalog"); } }, "Available downloads (" + (tab === "catalog" ? (catalogData.total || 0) : ((data && data.catalog_count) || 0)) + ")")
);
var catalogControls = 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); } },
@@ -575,10 +637,10 @@
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"))),
notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : notice.warning ? "warning" : "ok") }, notice.error || notice.warning || notice.ok),
h("section", { className: "ollama-toolbar" }, h("div", { className: "ollama-nav-row" }, navTabs, h("div", { className: "ollama-toolbar-disk" }, h("span", null, "Disk"), h("strong", null, disk.used_percent == null ? "n/a" : Number(disk.used_percent).toFixed(1) + "%"), h("small", null, disk.available ? fmtBytes(disk.free_bytes) + " free" : "Unavailable"))), browseToolbar),
tab !== "chat" && h("div", { className: "ollama-info-strip" }, h("span", null, data && data.models ? data.models.filter(function (m) { return m.loaded; }).length + " currently loaded" : "Loading runtime state…"), h("span", null, "Catalog checked " + (data && data.catalog_updated_at ? fmtDate(data.catalog_updated_at) : "not yet")), h("span", null, "Next daily check " + (data && data.next_catalog_refresh ? fmtDate(data.next_catalog_refresh) : "01:00 Melbourne time") + " (1:00 AM Melbourne time)")),
tab !== "chat" && h("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, tab === "catalog" ? (catalogData.total || 0) + " matching downloads · showing " + models.length : "Next daily check " + (data && data.next_catalog_refresh ? fmtDate(data.next_catalog_refresh) : "01:00 Melbourne time") + " (1:00 AM Melbourne time)")),
tab === "chat" && h(ChatPanel, { models: data && data.models ? data.models : [], refresh: load }),
tab === "popular" && h("p", { className: "ollama-popular-note" }, "Popular is limited to models with known size and RAM estimates at or below the detected system RAM (" + (data && data.popular_filter && data.popular_filter.max_expected_ram_gib ? data.popular_filter.max_expected_ram_gib + " GiB" : "detecting…") + "). Oversized families are represented by a smaller fitting variant when available."), jobs.length > 0 && h("section", { className: "ollama-jobs" }, jobs.map(function (job) { return h("div", { key: job.id }, h("strong", null, job.action + " · " + job.name + " · " + (job.target || "local") + (job.endpoint ? " · " + job.endpoint : "")), h("span", null, job.percent == null ? job.status : job.percent + "%")); })),
tab !== "chat" && loading && h(Empty, null, "Loading local Ollama inventory…"), tab !== "chat" && !loading && !models.length && h(Empty, null, tab === "installed" ? "No local models found." : tab === "popular" ? "No popular catalog entries available." : "No catalog entries available. Try Refresh catalog."), tab !== "chat" && h("section", { className: "ollama-grid" }, models.map(function (model) { return h(ModelCard, { key: model.name, model: model, installed: tab === "installed" || !!model.installed, busy: busy, action: action }); }))
tab !== "chat" && loading && h(Empty, null, "Loading local Ollama inventory…"), tab === "catalog" && catalogLoading && !models.length && h(Empty, null, "Searching the Ollama catalog…"), tab !== "chat" && !loading && !catalogLoading && !models.length && h(Empty, null, tab === "installed" ? "No local models found." : tab === "popular" ? "No popular catalog entries available." : "No catalog entries match these filters."), tab !== "chat" && h("section", { className: "ollama-grid" }, models.map(function (model) { return h(ModelCard, { key: model.name, model: model, installed: tab === "installed" || !!model.installed, busy: busy, action: action }); })), tab === "catalog" && catalogData.has_more && h("div", { className: "ollama-catalog-more" }, h(Button, { className: "secondary", disabled: catalogLoading, onClick: function () { loadCatalog((catalogData.page || 1) + 1); } }, catalogLoading ? "Loading more…" : "Load more models"))
);
}
registry.register("ollama-manager", Page);
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,7 +3,7 @@
"label": "Ollama Models",
"description": "Inspect, manage, and chat with local Ollama models, including shared persistent conversations, performance metrics, images, PDFs, URLs, and live memory telemetry.",
"icon": "Cpu",
"version": "1.7.5",
"version": "1.7.6",
"tab": {"path": "/ollama-manager", "position": "after:models"},
"entry": "dist/index.js",
"css": "dist/style.css",
+102 -17
View File
@@ -440,6 +440,27 @@ def _metric_values(state: dict[str, Any], status: str | None = None, error: str
}
def _conversation_history(conversation_id: str, exclude_request_id: str = "") -> list[dict[str, str]]:
"""Read canonical prior turns from storage, excluding the current request."""
db = _chat_db()
try:
rows = db.execute(
"SELECT role,content,request_id FROM messages WHERE conversation_id=? ORDER BY id",
(conversation_id,),
).fetchall()
history: list[dict[str, str]] = []
for row in rows:
if exclude_request_id and str(row["request_id"]) == exclude_request_id:
continue
role = str(row["role"] or "")
content = str(row["content"] or "").strip()
if role in {"user", "assistant"} and content:
history.append({"role": role, "content": content})
return history
finally:
db.close()
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()
@@ -774,15 +795,6 @@ def _local_ps() -> list[dict[str, Any]]:
return []
def _local_ps() -> list[dict[str, Any]]:
try:
payload = _json_request(LOCAL_OLLAMA + "/api/ps", timeout=10)
models = payload.get("models", [])
return [item for item in models if isinstance(item, dict)]
except (HTTPError, URLError, OSError, ValueError):
return []
def _model_load_update(name: str, **values: Any) -> None:
with _model_loads_lock:
current = _model_loads.setdefault(name, {"name": name, "state": "queued", "stage": "Queued for Ollama", "started_at": time.time()})
@@ -2064,8 +2076,9 @@ def _run_chat_job(request_id: str) -> None:
_update_chat_job(request_id, status="canceled", finished_at=time.time())
_persist_chat_event(request_id, job.get("conversation_id"), "job-canceled-before-start", level="warning")
return
body = _chat_request_from_job(job)
conversation_id = str(job["conversation_id"])
body = _chat_request_from_job(job)
body.history = _conversation_history(conversation_id, exclude_request_id=request_id)
primary = str(job["primary_model"])
validators = json.loads(job.get("validator_models_json") or "[]")
mode = str(job.get("mode") or "direct")
@@ -2488,7 +2501,8 @@ def chat(body: ChatRequest) -> dict[str, Any]:
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]", primary, attachment_meta)
_chat_state(request_id, state="queued", stage="Queued for server-side execution", models=selected, primary_model=primary, validator_models=validators, harness=harness, conversation_id=conversation_id)
_create_chat_job(request_id, conversation_id, body, primary, validators, harness)
job_body = body.model_copy(update={"history": []})
_create_chat_job(request_id, conversation_id, job_body, primary, validators, harness)
_submit_chat_job(request_id)
job = _get_chat_job(request_id)
return _job_status_response(job or {"request_id": request_id, "conversation_id": conversation_id, "status": "queued", "mode": "harness" if harness else "direct", "primary_model": primary, "validator_models_json": json.dumps(validators), "result_json": "{}", "error": "", "updated_at": time.time()})
@@ -2549,7 +2563,7 @@ def connections_delete(role: str) -> dict[str, Any]:
@router.get("/status")
def status() -> dict[str, Any]:
def status(include_catalog: bool = False) -> dict[str, Any]:
tags = _local_tags()
ps_rows = _local_ps()
loaded = {str(row.get("name") or row.get("model")): row for row in ps_rows}
@@ -2576,14 +2590,15 @@ def status() -> dict[str, Any]:
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}
downloadable: list[dict[str, Any]] = []
all_downloadable: list[dict[str, Any]] = []
if include_catalog:
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 = []
all_downloadable = []
seen_downloads: set[str] = set()
for rank, row in enumerate(candidate_rows, 1):
name = str(row.get("name") or row.get("model") or "")
@@ -2638,6 +2653,7 @@ def status() -> dict[str, Any]:
"disk": _disk_snapshot(),
"models": local,
"popular": popular,
"catalog_count": len(catalog_rows),
"popular_filter": {
"max_expected_ram_gib": _host_ram_gib(),
"basis": "detected MemTotal from the running host",
@@ -2645,8 +2661,6 @@ def status() -> dict[str, Any]:
"requires_known_ram": True,
"smaller_fit_variants_substituted": True,
},
"catalog": downloadable,
"catalog_all": all_downloadable,
"catalog_filter": {
"max_expected_ram_gib": _host_ram_gib(),
"basis": "detected MemTotal from the running host",
@@ -2655,7 +2669,7 @@ def status() -> dict[str, Any]:
},
"catalog_filter_options": {
"types": ["all", "moe", "dense"],
"capabilities": sorted({cap for row in downloadable for cap in row.get("capabilities", [])}),
"capabilities": sorted({cap for row in (downloadable if include_catalog else catalog_rows) for cap in row.get("capabilities", [])}),
},
"catalog_source": catalog.get("source"),
"catalog_updated_at": catalog.get("fetched_at"),
@@ -2663,9 +2677,80 @@ def status() -> dict[str, Any]:
"next_catalog_refresh": _next_refresh(),
"jobs": _job_snapshot(),
"generated_at": time.time(),
**({"catalog": downloadable, "catalog_all": all_downloadable} if include_catalog else {}),
}
@router.get("/catalog")
def catalog(
q: str = "",
page: int = 1,
page_size: int = 50,
model_type: str = "all",
capability: str = "all",
sort: str = "popularity",
recent_only: bool = True,
show_oversized: bool = False,
) -> dict[str, Any]:
snapshot = status(include_catalog=True)
rows = list(snapshot.get("catalog_all", [])) if show_oversized else list(snapshot.get("catalog", []))
needle = str(q or "").strip().lower()
if needle:
rows = [
row for row in rows
if needle in " ".join([
str(row.get("name") or ""), str(row.get("family") or ""),
" ".join(str(value) for value in row.get("strengths", [])),
" ".join(str(value) for value in row.get("capabilities", [])),
]).lower()
]
if model_type in {"moe", "dense"}:
rows = [row for row in rows if bool(row.get("is_moe")) == (model_type == "moe")]
if capability != "all":
rows = [row for row in rows if capability in row.get("capabilities", [])]
if recent_only:
cutoff = time.time() - 365 * 24 * 60 * 60
recent_rows: list[dict[str, Any]] = []
for row in rows:
modified = row.get("modified_at")
timestamp = _catalog_timestamp(modified)
if not modified or timestamp is None or timestamp >= cutoff:
recent_rows.append(row)
rows = recent_rows
if sort == "size_asc":
rows.sort(key=lambda row: int(row.get("size_bytes") or 0))
elif sort == "size_desc":
rows.sort(key=lambda row: int(row.get("size_bytes") or 0), reverse=True)
elif sort == "newest":
rows.sort(key=lambda row: _catalog_timestamp(row.get("modified_at")) or 0, reverse=True)
elif sort == "name":
rows.sort(key=lambda row: str(row.get("name") or "").lower())
page_size = max(1, min(int(page_size), 100))
page = max(1, int(page))
start = (page - 1) * page_size
return {
"catalog": rows[start:start + page_size],
"page": page,
"page_size": page_size,
"total": len(rows),
"has_more": start + page_size < len(rows),
"catalog_count": snapshot.get("catalog_count", 0),
"catalog_filter_options": snapshot.get("catalog_filter_options", {}),
"catalog_source": snapshot.get("catalog_source"),
"catalog_updated_at": snapshot.get("catalog_updated_at"),
"catalog_error": snapshot.get("catalog_error"),
}
def _catalog_timestamp(value: Any) -> float | None:
if not value:
return None
try:
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
except (TypeError, ValueError, OverflowError):
return None
def _ollama_version() -> str | None:
try:
return str(_json_request(LOCAL_OLLAMA + "/api/version", timeout=5).get("version") or "unknown")
+1 -1
View File
@@ -1,5 +1,5 @@
name: ollama-manager
version: 1.7.5
version: 1.7.6
description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, shared persistent conversations, performance metrics, and live runtime telemetry.
auto_install_dependencies: true
python_dependencies:
+79
View File
@@ -139,6 +139,85 @@ class ValidationHarnessTests(unittest.TestCase):
self.assertIn("VALIDATOR 1", compiler_prompt)
self.assertIn("VALIDATOR 2", compiler_prompt)
self.assertNotIn("validator-a found no material issue\n\nvalidator-b found no material issue", final)
def test_status_omits_full_catalog_by_default(self):
with patch.object(api, "_local_tags", return_value=[]), patch.object(api, "_local_ps", return_value=[]), patch.object(
api, "_ensure_catalog", return_value={"models": [], "families": {}, "source": "test"}
), patch.object(api, "_ollama_version", return_value="test"), patch.object(
api, "_connection_snapshot", return_value=[]
), patch.object(api, "_disk_snapshot", return_value={}), patch.object(api, "_running_in_container", return_value=False), patch.object(
api, "_next_refresh", return_value=None
):
response = api.status()
self.assertNotIn("catalog", response)
self.assertNotIn("catalog_all", response)
self.assertEqual(response["catalog_count"], 0)
def test_conversation_history_excludes_current_request(self):
class Cursor:
def fetchall(self):
return [
{"role": "user", "content": "prior question", "request_id": "prior"},
{"role": "assistant", "content": "prior answer", "request_id": "prior"},
{"role": "user", "content": "current question", "request_id": "current"},
]
class Database:
def execute(self, statement, parameters=()):
self.parameters = parameters
return Cursor()
def close(self):
pass
database = Database()
with patch.object(api, "_chat_db", return_value=database):
history = api._conversation_history("conversation", exclude_request_id="current")
self.assertEqual(history, [{"role": "user", "content": "prior question"}, {"role": "assistant", "content": "prior answer"}])
def test_catalog_endpoint_returns_filtered_page(self):
catalog_rows = [
{"name": "qwen3:8b", "family": "qwen3", "capabilities": ["completion"], "is_moe": False},
{"name": "llama3:8b", "family": "llama3", "capabilities": ["completion"], "is_moe": False},
]
snapshot = {
"catalog": catalog_rows,
"catalog_all": catalog_rows,
"catalog_count": 2,
"catalog_filter_options": {"capabilities": ["completion"]},
"catalog_source": "test",
"catalog_updated_at": None,
"catalog_error": None,
}
with patch.object(api, "status", return_value=snapshot):
response = api.catalog(q="qwen", page=1, page_size=1, recent_only=False)
self.assertEqual(response["total"], 1)
self.assertEqual(response["catalog"][0]["name"], "qwen3:8b")
self.assertFalse(response["has_more"])
def test_chat_route_persists_empty_browser_history_in_job(self):
body = api.ChatRequest(primary_model="primary", message="Canonical only", history=[{"role": "user", "content": "stale"}])
fake_job = {
"request_id": "request",
"conversation_id": "conversation",
"status": "queued",
"mode": "direct",
"primary_model": "primary",
"validator_models_json": "[]",
"result_json": "{}",
"error": "",
"updated_at": 1.0,
}
created = []
with patch.object(api, "_harness_models", return_value=("primary", [], False)), patch.object(
api, "_get_chat_job", side_effect=[None, fake_job]
), patch.object(api, "_ensure_conversation"), patch.object(api, "_persist_message"), patch.object(
api, "_chat_state"
), patch.object(api, "_create_chat_job", side_effect=lambda request_id, conversation_id, value, primary, validators, harness: created.append(value)), patch.object(
api, "_submit_chat_job"
):
api.chat(body)
self.assertEqual(len(created), 1)
self.assertEqual(created[0].history, [])
if __name__ == "__main__":