Make Ollama chat the primary workflow
This commit is contained in:
@@ -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**.
|
||||
|
||||
|
||||
Vendored
+108
-46
@@ -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);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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
@@ -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")
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user