diff --git a/app.py b/app.py
index 07ea779..363d7ca 100644
--- a/app.py
+++ b/app.py
@@ -49,6 +49,23 @@ async def list_models() -> list[str]:
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")
async def translate(req: TranslateRequest) -> StreamingResponse:
text = req.text.strip()
diff --git a/static/index.html b/static/index.html
index f916644..44a0820 100644
--- a/static/index.html
+++ b/static/index.html
@@ -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;
+ }
@@ -154,6 +177,11 @@
+
+
+ No model loaded
+
+
@@ -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 = `${e.message || e}`;
@@ -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);