feat: show which model is currently loaded in Ollama
- /api/loaded backend endpoint proxies Ollama /api/ps and returns
{models: [{name, expires_at}]}
- Status line under the controls shows "Hot: gemma2:9b — unloads in 4m"
with a glowing orange dot, or "No model loaded" in muted style
- Loaded models in the dropdown get a "● " prefix
- Refreshes on page load, after every translation, and 500ms after the
user pauses typing in the input box
This commit is contained in:
@@ -132,6 +132,29 @@
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1.15); }
|
||||
}
|
||||
|
||||
.hot-status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin: 0.25rem 0 0.75rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
.hot-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #e07a3e;
|
||||
box-shadow: 0 0 6px #e07a3e;
|
||||
}
|
||||
.hot-status.idle { opacity: 0.55; }
|
||||
.hot-status.idle .hot-dot {
|
||||
background: var(--border);
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -154,6 +177,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hot-status" class="hot-status idle">
|
||||
<span class="hot-dot"></span>
|
||||
<span id="hot-status-text">No model loaded</span>
|
||||
</div>
|
||||
|
||||
<div class="panes">
|
||||
<div>
|
||||
<label for="input">Input</label>
|
||||
@@ -186,6 +214,8 @@
|
||||
const output = document.getElementById("output");
|
||||
const goBtn = document.getElementById("go");
|
||||
const meta = document.getElementById("meta");
|
||||
const hotStatus = document.getElementById("hot-status");
|
||||
const hotStatusText = document.getElementById("hot-status-text");
|
||||
|
||||
function fillLangs() {
|
||||
const auto = document.createElement("option");
|
||||
@@ -313,6 +343,7 @@
|
||||
output.textContent = acc;
|
||||
clearInterval(ticker);
|
||||
setMeta({ phase: "done", model, seconds: dt, tokens });
|
||||
refreshLoaded();
|
||||
} catch (e) {
|
||||
clearInterval(ticker);
|
||||
output.innerHTML = `<span class="error">${e.message || e}</span>`;
|
||||
@@ -324,13 +355,73 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatRemaining(expiresAt) {
|
||||
if (!expiresAt) return null;
|
||||
const ms = new Date(expiresAt).getTime() - Date.now();
|
||||
if (!isFinite(ms) || ms <= 0) return null;
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
const mins = Math.floor(totalSec / 60);
|
||||
const secs = totalSec % 60;
|
||||
if (mins >= 60) {
|
||||
const hrs = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
return `${hrs}h${m ? m + "m" : ""}`;
|
||||
}
|
||||
if (mins > 0) return `${mins}m${secs ? secs + "s" : ""}`;
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
async function refreshLoaded() {
|
||||
let models = [];
|
||||
try {
|
||||
const r = await fetch("/api/loaded");
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
models = j.models || [];
|
||||
}
|
||||
} catch {
|
||||
/* leave previous state if Ollama is briefly unreachable */
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedMap = new Map(models.map(m => [m.name, m.expires_at]));
|
||||
|
||||
// Update dropdown options. Use textContent (option doesn't render HTML).
|
||||
for (const opt of modelSel.options) {
|
||||
if (loadedMap.has(opt.value)) {
|
||||
opt.textContent = `● ${opt.value}`;
|
||||
} else {
|
||||
opt.textContent = opt.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Update status line.
|
||||
if (models.length === 0) {
|
||||
hotStatus.classList.add("idle");
|
||||
hotStatusText.textContent = "No model loaded";
|
||||
return;
|
||||
}
|
||||
hotStatus.classList.remove("idle");
|
||||
const parts = models.map(m => {
|
||||
const remaining = formatRemaining(m.expires_at);
|
||||
return remaining ? `${m.name} — unloads in ${remaining}` : m.name;
|
||||
});
|
||||
hotStatusText.textContent = `Hot: ${parts.join(", ")}`;
|
||||
}
|
||||
|
||||
let typingTimer;
|
||||
input.addEventListener("input", () => {
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(refreshLoaded, 500);
|
||||
});
|
||||
|
||||
goBtn.addEventListener("click", translate);
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") translate();
|
||||
});
|
||||
|
||||
fillLangs();
|
||||
loadModels();
|
||||
loadModels().then(refreshLoaded);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user