feature/loaded-model-indicator #2

Merged
jpnadas merged 2 commits from feature/loaded-model-indicator into main 2026-06-09 13:57:02 +00:00
2 changed files with 109 additions and 1 deletions
Showing only changes of commit 732982e66c - Show all commits

17
app.py
View File

@@ -49,6 +49,23 @@ async def list_models() -> list[str]:
return sorted(names) return sorted(names)
@app.get("/api/loaded")
async def loaded_models() -> dict[str, list[dict[str, str | None]]]:
"""Models currently resident in Ollama's memory (from /api/ps)."""
async with httpx.AsyncClient(timeout=5) as client:
try:
r = await client.get(f"{OLLAMA_URL}/api/ps")
r.raise_for_status()
except httpx.HTTPError as e:
raise HTTPException(502, f"Ollama unreachable: {e}") from e
return {
"models": [
{"name": m["name"], "expires_at": m.get("expires_at")}
for m in r.json().get("models", [])
]
}
@app.post("/api/translate") @app.post("/api/translate")
async def translate(req: TranslateRequest) -> StreamingResponse: async def translate(req: TranslateRequest) -> StreamingResponse:
text = req.text.strip() text = req.text.strip()

View File

@@ -132,6 +132,29 @@
0%, 100% { opacity: 0.3; transform: scale(0.85); } 0%, 100% { opacity: 0.3; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); } 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> </style>
</head> </head>
<body> <body>
@@ -154,6 +177,11 @@
</div> </div>
</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 class="panes">
<div> <div>
<label for="input">Input</label> <label for="input">Input</label>
@@ -186,6 +214,8 @@
const output = document.getElementById("output"); const output = document.getElementById("output");
const goBtn = document.getElementById("go"); const goBtn = document.getElementById("go");
const meta = document.getElementById("meta"); const meta = document.getElementById("meta");
const hotStatus = document.getElementById("hot-status");
const hotStatusText = document.getElementById("hot-status-text");
function fillLangs() { function fillLangs() {
const auto = document.createElement("option"); const auto = document.createElement("option");
@@ -313,6 +343,7 @@
output.textContent = acc; output.textContent = acc;
clearInterval(ticker); clearInterval(ticker);
setMeta({ phase: "done", model, seconds: dt, tokens }); setMeta({ phase: "done", model, seconds: dt, tokens });
refreshLoaded();
} catch (e) { } catch (e) {
clearInterval(ticker); clearInterval(ticker);
output.innerHTML = `<span class="error">${e.message || e}</span>`; 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); goBtn.addEventListener("click", translate);
input.addEventListener("keydown", (e) => { input.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") translate(); if ((e.metaKey || e.ctrlKey) && e.key === "Enter") translate();
}); });
fillLangs(); fillLangs();
loadModels(); loadModels().then(refreshLoaded);
</script> </script>
</body> </body>
</html> </html>