feature/loaded-model-indicator #2
17
app.py
17
app.py
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user