From 732982e66ceb174187faf91851f4c72e8478623d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 15:51:57 +0200 Subject: [PATCH 1/2] feat: show which model is currently loaded in Ollama MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- app.py | 17 +++++++++ static/index.html | 93 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 1 deletion(-) 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); -- 2.49.1 From f9acdef3fe0159533b89c8eb21d3e62625b6dbcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 15:55:09 +0200 Subject: [PATCH 2/2] chore: introduce semver + CHANGELOG.md, cut v0.1.0 - CHANGELOG.md following Keep a Changelog; 0.1.0 entry covers all features shipped through the loaded-model indicator - build.sh now also tags the image with the version read from pyproject.toml, e.g. rg.nl-ams.scw.cloud/valinor/finrod:0.1.0 - README documents the release workflow (edit CHANGELOG, bump pyproject version, merge, tag vX.Y.Z, build, bump valinor) --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ README.md | 27 +++++++++++++++++++++------ build.sh | 23 +++++++++++++++-------- 3 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b27a040 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to finrod are documented here. + +Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-06-09 + +First release. Stateless translator UI that proxies streaming requests to +Ollama on radagast. + +### Added +- FastAPI backend with three endpoints: + - `GET /api/models` — filtered (`gemma,aya` by default) list from Ollama's `/api/tags` + - `POST /api/translate` — streams Ollama's NDJSON `/api/generate` response back to the browser + - `GET /api/loaded` — proxies Ollama's `/api/ps` for the currently-loaded model + - `GET /healthz` — for Kubernetes probes +- Single-page UI: + - Source / target / model dropdowns (30+ languages, "Auto-detect" source) + - Token-by-token streaming into the output pane with a blinking caret + - Live status line: spinner while waiting for first token, pulsing dot during streaming, elapsed time and token count + - Glowing border on the output pane during streaming + - Loaded-model indicator (`● ` prefix in dropdown, "Hot: … — unloads in Xm" status line); refreshes on page load, on translation completion, and 500 ms after typing pauses + - ⌘/Ctrl-Enter to translate from the input + - Dark mode, inline SVG favicon (Star of Fëanor) +- Dockerfile: two-stage `uv` build, slim Python 3.13 runtime +- `build.sh`: multi-arch (amd64 + arm64) manual push to Scaleway, tags `:latest`, `:`, and `:` +- Cluster deploy via the `valinor` repo at `apps/finrod/` (bjw-s app-template, internal-only ingress at `finrod.jpnadas.xyz`) + +### Fixed +- Lock against public PyPI explicitly — the workstation's global `uv` config had pointed `uv.lock` sources at the dexterenergy private artifact registry, which 401'd inside Docker diff --git a/README.md b/README.md index 064a342..569db11 100644 --- a/README.md +++ b/README.md @@ -47,14 +47,29 @@ Then on every change: ./build.sh ``` -That builds `linux/amd64,linux/arm64` and pushes `rg.nl-ams.scw.cloud/valinor/finrod:{latest, }`. Take note of the short SHA it prints — that's what you pin in valinor. +That builds `linux/amd64,linux/arm64` and pushes three tags: +- `:latest` — moving pointer +- `:` — immutable, traceable +- `:` (e.g. `:0.1.0`) — semver ## Deploy -Cluster manifests live in `valinor/apps/finrod/` (bjw-s app-template, internal-only ingress at `finrod.jpnadas.xyz`). To roll out a new build: +Cluster manifests live in `valinor/apps/finrod/` (bjw-s app-template, internal-only ingress at `finrod.jpnadas.xyz`). To roll out a new build, bump `tag:` in `valinor/apps/finrod/values.yaml` to the new semver on a branch → PR → merge → ArgoCD syncs. -1. `./build.sh` here — get the SHA -2. Bump `tag:` in `valinor/apps/finrod/values.yaml` to that SHA on a branch -3. Merge → ArgoCD syncs the new image +## Release workflow -Using `:latest` works for the first deploy (the pull policy is `Always`) but pinning to a SHA on each rollout gives you trivial rollback via git revert. +This project uses [Semantic Versioning](https://semver.org/) and keeps a human-edited [CHANGELOG.md](./CHANGELOG.md). + +To cut a release: + +1. On a branch, edit `CHANGELOG.md`: move accumulated `[Unreleased]` notes into a new `## [X.Y.Z] - YYYY-MM-DD` section. +2. Bump `version` in `pyproject.toml` to `X.Y.Z`. +3. PR → merge to `main`. +4. Tag the merge commit: + ```sh + git checkout main && git pull + git tag -a vX.Y.Z -m "vX.Y.Z" + git push origin vX.Y.Z + ``` +5. `./build.sh` — the image now also gets `:X.Y.Z` (read from `pyproject.toml`). +6. In `valinor`, bump `apps/finrod/values.yaml` `tag:` from the previous version to `X.Y.Z` on a branch → PR → ArgoCD picks it up. diff --git a/build.sh b/build.sh index f6bd87f..86cdbd6 100755 --- a/build.sh +++ b/build.sh @@ -9,20 +9,24 @@ # docker login rg.nl-ams.scw.cloud -u nologin -p # # Usage: -# ./build.sh # tags :latest and : -# TAG=v0.2.0 ./build.sh # additional explicit tag +# ./build.sh +# +# Always tags: +# :latest — moving pointer +# : — immutable, traceable to a commit +# : — semver from pyproject.toml (e.g. :0.1.0) set -euo pipefail +cd "$(dirname "$0")" + REGISTRY="${REGISTRY:-rg.nl-ams.scw.cloud/valinor}" IMAGE="${REGISTRY}/finrod" SHA="$(git rev-parse --short HEAD)" -TAGS=(-t "${IMAGE}:latest" -t "${IMAGE}:${SHA}") -if [[ -n "${TAG:-}" ]]; then - TAGS+=(-t "${IMAGE}:${TAG}") -fi +VERSION="$(awk -F\" '/^version =/ {print $2; exit}' pyproject.toml)" +: "${VERSION:?Could not read version from pyproject.toml}" -cd "$(dirname "$0")" +TAGS=(-t "${IMAGE}:latest" -t "${IMAGE}:${SHA}" -t "${IMAGE}:${VERSION}") docker buildx build \ --platform linux/amd64,linux/arm64 \ @@ -31,4 +35,7 @@ docker buildx build \ . echo -echo "Pushed: ${IMAGE}:{latest,${SHA}${TAG:+,${TAG}}}" +echo "Pushed:" +echo " ${IMAGE}:latest" +echo " ${IMAGE}:${SHA}" +echo " ${IMAGE}:${VERSION}" -- 2.49.1