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
+108 -46
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.")); })),
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(); } } }),
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("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 }),
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("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; });
}
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);
});
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; });
}
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",
+122 -37
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,28 +2590,29 @@ 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}
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 "")
if _is_mlx(row) or not name or name in installed_names or name in seen_downloads:
continue
view = catalog_view(row, "catalog")
view["memory_fit"] = _known_ram_fit(view)
view["memory_warning"] = "Estimated runtime RAM exceeds detected host RAM" if view["memory_fit"] is False else ""
if name in catalog_names:
view["popularity_rank"] = rank
seen_downloads.add(name)
all_downloadable.append(view)
if view["memory_fit"]:
downloadable.append(dict(view))
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
]
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")
view["memory_fit"] = _known_ram_fit(view)
view["memory_warning"] = "Estimated runtime RAM exceeds detected host RAM" if view["memory_fit"] is False else ""
if name in catalog_names:
view["popularity_rank"] = rank
seen_downloads.add(name)
all_downloadable.append(view)
if view["memory_fit"]:
downloadable.append(dict(view))
popular = _popular_fit_models(
[row for row in catalog.get("models", []) if not _is_mlx(row)],
@@ -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")