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