Compare commits

28 Commits

Author SHA1 Message Date
fd8f0b74aa Merge pull request 'v0.3.0: OCR via camera, FAB UX, side drawer' (#4) from feature/ocr-camera into main 2026-06-09 18:38:33 +00:00
João Pedro Battistella Nadas
713e3f1993 release: v0.3.0 2026-06-09 20:38:13 +02:00
João Pedro Battistella Nadas
b4990e1729 ux: drop page header, move Finrod wordmark into the drawer
The h1 + tagline at the top of the main view were just taking
vertical space. Strip them. The "Finrod" wordmark now lives only
inside the side drawer, set in Cinzel (classical-inscription
serif, loaded from Google Fonts) alongside the Star of Fëanor
mark — matches the favicon and the loremaster vibe.

Body padding-top adjusted to 3.5 rem so the controls clear the
fixed hamburger button at the top-left.
2026-06-09 20:09:36 +02:00
João Pedro Battistella Nadas
1fffe8b7e1 ux: hamburger + slide-out side drawer (not a kebab popover)
Replace the <details>-based kebab popover with a proper Material-
style navigation drawer:

- Hamburger button (menu icon) at top-left.
- <aside> drawer slides in from the left via transform: translateX().
  width: min(85vw, 320px). Backdrop overlay behind it.
- Close paths: X button in the drawer header, backdrop click, Esc.
- Padding honours Android safe-area insets on all four sides.
- Model selector + label live inside the drawer; future per-app
  settings can move here too.
2026-06-09 20:02:11 +02:00
João Pedro Battistella Nadas
bde042a459 ux: 2-col controls, model in a kebab menu, Dutch default, drop iOS code
- Controls go 3 cols → 2 cols (From / To). Model selector moves
  into a kebab (⋮ more_vert) menu in the top-left corner, using
  a native <details> element so toggle and a11y come for free.
- Default source language: Dutch (was Auto-detect). Target stays
  English. Quickest path to a translation since the typical use
  case is reading Dutch text.
- Drop the VisualViewport listener and --kb-inset CSS var —
  Android Chrome already handles keyboard with the
  interactive-widget=resizes-content meta directive; iOS Safari
  isn't in scope. FAB bottom calc reverts to just safe-area-inset.
2026-06-09 19:58:43 +02:00
João Pedro Battistella Nadas
86620f9f5c ux(fab): float above the on-screen keyboard
Two-pronged fix so the Translate + Scan FABs stay reachable when
the mobile soft keyboard is open:

- Add `interactive-widget=resizes-content` to the viewport meta —
  Chrome on Android then shrinks the layout viewport when the
  keyboard opens, so `position: fixed; bottom: ...` is naturally
  above it. iOS Safari ignores this directive, hence:
- VisualViewport listener computes
    inset = innerHeight - vv.height - vv.offsetTop
  and writes it to a `--kb-inset` CSS custom property. The FAB
  bottom calc uses `max(var(--kb-inset), safe-area-inset-bottom)`
  so the keyboard inset wins when it's open and the home-indicator
  inset wins when it isn't.

Net: tap the editor on iOS → keyboard slides up → Translate /
Scan FABs rise with it, no manual minimise required.
2026-06-09 19:53:16 +02:00
João Pedro Battistella Nadas
f160c0510b feat(ux): translate becomes a primary FAB stacked under camera
Promotes Translate from an inline button under the input pane to
the primary 56x56 FAB at the bottom-right corner. Camera FAB
becomes the secondary in the stack, just above. Both share a
single .fab base class; positioning via .fab-primary /
.fab-secondary.

- Translate icon: Material Symbols `translate` (28 px).
- Pulsing-ring `busy` animation while a translation runs, since
  the icon-only button can't show "Translating…" text anymore.
- body padding-bottom bumped to ~9 rem so the output pane
  scrolls clear of the stacked FABs.
- ⌘/Ctrl-Enter still triggers translate via the existing
  CodeMirror keymap; tooltip text updated.
2026-06-09 19:49:05 +02:00
João Pedro Battistella Nadas
ffa133261d ux(ocr): one more shot at the mobile modal layout
Previous attempt was actually-broken — typo notwithstanding, the
mix of 100vh, no explicit flex-shrink, and inconsistent body
padding was easy for the body to grow and shove the footer
buttons offscreen.

This pass:
- 100dvh instead of 100vh (mobile chrome / address bar collapses
  don't cause overflow / under-shoot)
- flex-shrink: 0 on .modal-header and .modal-footer so the body
  can never squeeze them out
- explicit background on the footer so it's not transparent
  against whatever's behind
- camera <video> fills modal-body with object-fit: contain
- body padding kept just enough (~16 px) for crop handle safety
2026-06-09 19:42:36 +02:00
João Pedro Battistella Nadas
29226500e8 ux(ocr): full-bleed modal on mobile, buttons hug screen edges
@media (max-width: 640px):
- backdrop loses its 1 rem inset; modal goes 100vw × 100vh with
  no border-radius
- crop modal body keeps ~20 px horizontal padding so cropper
  corner handles stay safely outside Android's edge-swipe
  back-gesture zone (the corner DRAG is what triggers it; a tap
  on a button at the edge doesn't)
- camera viewfinder body has zero padding — video fills the
  whole space
- footer has minimal 0.5 rem padding so the rotate / invert /
  cancel / check buttons hug the screen edges
- iOS notch / home-indicator insets honoured via
  env(safe-area-inset-*)

Doesn't change the camera-confirm behaviour — that's the OS-camera
fallback path that fires when getUserMedia is denied for not being
a secure context (i.e. http://lan-ip:8000 from the phone, not
HTTPS or localhost).
2026-06-09 19:38:53 +02:00
João Pedro Battistella Nadas
a63b11e467 feat(ocr): live camera viewfinder + invert-colours toggle
Four wins:

1. Replace <input type=file capture> with a getUserMedia
   viewfinder modal on mobile. Tapping the shutter goes straight
   to the crop modal — skips the OS "retake/use" confirm step.
   Falls back to file picker if camera permission is denied or
   the API is unavailable. "Pick from file" icon
   (photo_library) inside the camera modal opens the gallery
   on demand.

2. Drop the Reset button — with no default crop box, "reset" is
   just "no box again," which is the starting state anyway.

3. The check (Use this) button is now larger and more prominent
   — wider padding, 24px icon, accent fill.

4. Invert-colours toggle (invert_colors icon) in the crop modal.
   White-on-dark text (signs, menus, screenshots) breaks
   Tesseract because the binariser was made for ink on paper.
   Toggling the button flips the grayscale buffer before
   adaptive thresholding so the binary output ends up
   black-on-white. State plumbed cleanly: openCropper now
   resolves with {blob, invert}; preprocessForOCR takes an
   {invert} option; binarizeAdaptive applies the flip.

Also: drop `capture=environment` from the file <input>. Without
it the file path correctly opens the gallery/file dialog instead
of re-opening the OS camera when the user picks "Pick from file".
2026-06-09 19:27:34 +02:00
João Pedro Battistella Nadas
cc249fb444 ux(ocr): Material Symbols icons, no default crop box, more padding
- Material Symbols Outlined webfont (Google Fonts) — used for the
  FAB camera (photo_camera), and the rotate_left / rotate_right /
  restart_alt / check buttons in the crop modal. Replaces the
  platform-rendered 📷 emoji that was off-centre, and replaces
  text labels on rotate/use buttons. Cancel stays text.
- Cropper: autoCrop=false — no default selection. User drags on
  the image to draw the crop region; if they "Use this" without
  drawing, falls back to the full (possibly rotated) image.
- Modal-body padding bumped from 1.25 rem to 2 rem so the
  bottom-right corner handle sits ~48 px from the screen edge,
  outside Android's edge-swipe back-gesture zone.
- Header copy updated to "Drag on the image to select the text
  region" since there's no longer a default box to convey intent.
2026-06-09 19:18:52 +02:00
João Pedro Battistella Nadas
a4064d0d72 ux(ocr): camera FAB + modal padding for Android edge gestures
- Scan button is now a 📷 floating action button at bottom-right
  of the viewport (camera emoji per explicit request). Replaces
  the small "Scan image" button in the input header.
- Crop modal: inner padding on .modal-body so cropperjs's
  bottom-right corner handle isn't on top of Android's edge-swipe
  back-gesture zone. Backdrop also respects iOS safe-area insets
  (env(safe-area-inset-{right,left,bottom})).
2026-06-09 19:10:22 +02:00
João Pedro Battistella Nadas
74b8877550 feat(ocr): crop + rotate modal before recognition
After picking an image, a modal opens with cropperjs's drag-handle
crop UI and Rotate left / Rotate right / Reset buttons. Cropping
to just the text region is the single biggest quality win for
OCR on phone photos — strips out logos, lighting transitions, and
background junk that bleed past the confidence filter.

- Lazy-loads cropperjs + its CSS from esm.sh on first use (no
  bundling; same pattern as Tesseract.js and CodeMirror).
- Honours EXIF orientation from phone shots (checkOrientation).
- Keyboard: Esc cancels, Enter accepts. Clicking the backdrop
  also cancels.
- Output goes through the existing preprocessForOCR pipeline
  (downscale to 1600px + Bradley adaptive threshold) before
  Tesseract sees it.
2026-06-09 19:02:51 +02:00
João Pedro Battistella Nadas
4d4e351860 feat(ocr): adaptive binarisation + per-word confidence filter
Two layers to stop tesseract hallucinating text on phone photos:

1. Bradley adaptive threshold (O(N) via integral images) turns the
   downscaled grayscale image into a binary B/W mask. Cuts out
   gradients, shadows, and surface texture that tesseract
   misreads as ink.
2. After recognise(), filter data.words by confidence >= 60 and
   re-assemble lines from kept words. Status line surfaces how
   many were dropped so the cost is visible.

Output of preprocess stage is PNG (not JPEG) so the sharp B/W
edges don't get re-blurred.
2026-06-09 18:54:27 +02:00
João Pedro Battistella Nadas
dcac01e51b perf(ocr): downscale photos to 1600px long edge before recognize
Phone cameras emit 12+ MP images (~4000 px long edge). Tesseract
on a full-resolution shot was painfully slow — minutes, not
seconds. Downscaling to 1600 px on the long edge before
worker.recognize() makes OCR 4-8× faster with no real accuracy
loss for printed text.

Implementation: createImageBitmap -> draw to a sized HTMLCanvas
-> canvas.toBlob() at JPEG 0.85. Smaller-than-1600 px inputs are
passed through unchanged. Status line briefly shows the
downscale ratio so the timing is interpretable.
2026-06-09 18:48:40 +02:00
João Pedro Battistella Nadas
6d6b52e10e fix: don't show "unloads in" when keep_alive=-1
With the radagast server flipped to OLLAMA_KEEP_ALIVE=-1, /api/ps
returns expires_at as year 4001 (effectively forever), which the
old code rendered as "Hot: gemma2:9b — unloads in 17500000h". Add
a 7-day threshold to formatRemaining(); anything beyond that just
shows the model name with no expiry suffix.
2026-06-09 18:47:52 +02:00
João Pedro Battistella Nadas
123ce59595 feat: OCR a camera shot / image into the input via Tesseract.js
New "Scan image" button in the input pane header:

- Opens the phone's rear camera (capture=environment) or a desktop
  file picker via a hidden <input type="file">.
- On image selected, lazy-imports tesseract.js from esm.sh — no
  bundling, no Docker image growth.
- Language follows the "From" dropdown (ISO 639-2 mapping for the
  30 currently-supported languages). "Auto-detect" loads the
  eng+por+deu+nld pack and lets tesseract pick.
- Progress status renders below the input (engine load → OCR
  percentage → "done in Xs").
- Result replaces the editor contents; existing vim bindings,
  Shift-H/L nav, Ctrl-Enter translate all keep working.

First scan triggers a ~5 MB Tesseract WASM download plus the chosen
language pack (~3-10 MB). Cached by the browser after.
2026-06-09 18:42:19 +02:00
d4a033e500 Merge pull request 'v0.2.0: vim bindings, Shift-H/L focus nav, hot-model default' (#3) from feature/vim-bindings into main 2026-06-09 15:22:41 +00:00
João Pedro Battistella Nadas
163f8dda57 release: v0.2.0 2026-06-09 17:22:26 +02:00
João Pedro Battistella Nadas
2d067226b9 revert: drop clipboard yank/paste and modal dropdowns
The vim clipboard integration was unreliable across browsers
(library "+" register broken on Firefox; custom navigator.clipboard
actions worked on Chrome but hit chip prompts and Firefox config
edge cases). The modal dropdown layer added more state than it
saved keystrokes. Pulling both back out.

What remains of the vim work:
  - CodeMirror + @replit/codemirror-vim in the input buffer
    (i/Esc, hjkl, word/line motions, yy/p via vim's internal
    register, undo, /, :)
  - Shift-H / Shift-L focus navigation across controls, including
    the H/L override inside the editor so it works from any focus
2026-06-09 17:07:01 +02:00
João Pedro Battistella Nadas
73aa54d7bd fix: Firefox clipboard, flip dropdown default to normal mode
- Replace Vim.map("...", "\"+...", ...) with custom Vim.defineAction
  handlers calling navigator.clipboard directly. The library's "+"
  register implementation doesn't reliably write on Firefox.
  Limitation: only yy/Y/visual-y reach system clipboard; bare
  y<motion> still uses vim's internal register.
- Flip dropdown modal: default NORMAL (j/k arrows), `/` or `i`
  enters search. Listener moved to document capture phase so closed
  dropdowns receive keydown on Firefox.
2026-06-09 16:53:11 +02:00
João Pedro Battistella Nadas
b092047ca6 feat: y/Y system clipboard, modal vim dropdowns
- y/Y/yy and visual y now route through vim's "+" register, which
  copies to the system clipboard via the synchronous document
  .execCommand("copy") path — no permission prompt.
- p/P also remapped to "+ register for symmetry; replaces my custom
  navigator.clipboard.readText() action. Browser may still show a
  permission chip on first paste; granting once makes it silent.
- Source / target / model dropdowns get modal vim-style nav:
  default is native typeahead, Esc → normal mode (accent border),
  j/k move selectedIndex, i and / return to search, blur resets.
  Disabled on coarse-pointer devices.
2026-06-09 16:47:33 +02:00
João Pedro Battistella Nadas
a058665b5b feat: vim p/P paste from system clipboard
Vim's default p/P pastes from its internal unnamed register, which
is useless when the text to translate lives in another app's
clipboard. Override both in normal mode to read navigator.clipboard
.readText() and insert at (after) or before the cursor.

Caveats: requires browser clipboard permission (prompted on first
use), HTTPS or localhost. Both already satisfied — finrod is served
over the wildcard TLS cert.
2026-06-09 16:41:52 +02:00
João Pedro Battistella Nadas
876014a7b7 fix: Shift-H/L also exit the editor in vim normal mode
The previous version only handled Shift-H/L when focus was outside
the editor; inside vim normal mode they were viewport top/bottom
and required Esc+Tab to escape. Now Vim.mapCommand overrides H/L
in normal-mode context to call the same moveFocus(±1) helper, so
the shortcut works uniformly regardless of focus position.
2026-06-09 16:39:05 +02:00
João Pedro Battistella Nadas
9564900909 feat: editor font-size match, hot model default, Shift-H/L nav
- .cm-editor pinned to font-size 1rem so the input matches the
  output pane (CodeMirror 6 defaults smaller)
- refreshLoaded(isInitial=true) on first load picks the hot model
  as the dropdown default — avoids the 5-15s disk reload on the
  first translation. Subsequent refreshes don't override the user's
  manual selection.
- document-level Shift-H / Shift-L cycle focus across controls
  (source → target → model → editor → button), suppressed when
  focus is inside CodeMirror so vim's viewport H/L still work.
2026-06-09 16:22:20 +02:00
João Pedro Battistella Nadas
782c7182f2 feat: vim bindings in the input via CodeMirror 6
- Replace the plain <textarea> with a CodeMirror 6 EditorView that
  has @replit/codemirror-vim attached. Loaded from esm.sh, so no
  build step — finrod stays no-bundler.
- vim() extension is conditionally omitted on coarse-pointer devices
  (phones, tablets) where modal editing without a hardware Esc key
  is unusable and IME composition fights the keymap.
- Ctrl-/Cmd-Enter shortcut and the typing-debounce that refreshes
  the "Hot:" indicator both migrate to CodeMirror's keymap and
  updateListener APIs respectively.
- Editor styled to match the existing .output pane.

CHANGELOG: note under [Unreleased].
2026-06-09 16:15:36 +02:00
João Pedro Battistella Nadas
f9acdef3fe 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)
2026-06-09 15:55:09 +02:00
João Pedro Battistella Nadas
732982e66c 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
2026-06-09 15:51:57 +02:00
6 changed files with 1106 additions and 43 deletions

75
CHANGELOG.md Normal file
View File

@@ -0,0 +1,75 @@
# 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.3.0] - 2026-06-09
### Added
- "Scan image" button next to the input pane: opens the phone's rear camera (or a file picker on desktop), runs OCR locally in the browser via Tesseract.js (loaded from esm.sh on first use), and replaces the editor contents with the recognised text. Language follows the "From" dropdown via an ISO 639-2 mapping; "Auto-detect" loads a common European set (`eng+por+deu+nld`). Progress status shows beneath the input ("Loading OCR engine…" → "OCR 40%" → "OCR done in 3.2s"). Zero image-size cost — same CDN pattern as CodeMirror.
- Images are downscaled to 1600 px on the long edge before OCR. A 12 MP phone photo (~4000 px) drops to ~1.3 MP, making Tesseract 4-8× faster with no real accuracy loss for printed text. Smaller-than-1600 px inputs are passed through unchanged.
- Adaptive thresholding (Bradley's method with integral images) binarises the downscaled image before OCR — drops hallucinated text from shadows, gradients, and surface texture on phone photos. Output is pure black-on-white, much closer to the scanned-page input Tesseract was trained on.
- Per-word confidence filter (≥ 60) drops the low-confidence noise Tesseract still emits. Re-assembles lines from kept words; status line reports how many words were dropped.
- Crop & rotate modal between image pick and OCR. After choosing an image, a modal pops up with cropperjs's drag-handle crop UI and Rotate left / Rotate right / Reset buttons. EXIF orientation is honoured automatically. Cancel / `Esc` / clicking the backdrop aborts; `Enter` accepts. Library lazy-loaded from esm.sh (no bundling).
### Changed
- "Scan image" button replaced with a floating action button at the bottom-right of the viewport, using a Material Symbols Outlined camera glyph (`photo_camera`) for consistent rendering across platforms (was a small text button in the input pane header).
- "Translate" inline button replaced with a primary FAB using the `translate` Material Symbols icon, stacked directly under the camera FAB at the bottom-right corner (closest to the thumb on phones). The camera FAB is now the *secondary* in the stack, just above. Both 56 × 56 px, same accent fill. The translate FAB gains a pulsing-ring `busy` animation during a running translation; `goBtn.textContent` swap is gone (was meaningless for an icon-only button). `body` got `~9 rem` of bottom padding so content scrolls clear of the stack.
- FAB stack rides above the on-screen keyboard on mobile via `interactive-widget=resizes-content` in the viewport meta tag (handled by Chrome on Android).
- Layout: From / To dropdowns now occupy 2 columns at the top (was 3 columns including Model).
- Model selector moved into a slide-out side drawer (hamburger menu in the top-left). Drawer opens from the left with a backdrop, closes via the X button, backdrop click, or `Esc`. Honours Android safe-area insets.
- Page header (`h1 finrod` + tagline) removed from the main view to recover vertical space. The "Finrod" wordmark now lives only inside the side drawer, set in Cinzel (classical-inscription serif, loaded from Google Fonts) alongside the Star of Fëanor mark.
- Default source language is now Dutch (was Auto-detect). Target stays English.
- Crop modal:
- No default crop box. User drags on the image to draw the region; falls back to full image if they confirm without drawing.
- Buttons are now Material Symbols icons: `rotate_left`, `rotate_right`, `invert_colors` (new — see below), `check`. Cancel kept as text. The `check` (Use this) button is larger and more prominent. The Reset button was removed (rarely useful with no default box).
- Inner padding bumped to 2 rem so cropperjs's corner handles never sit on the right/bottom edges where Android's edge-swipe back gesture fires. iOS safe-area insets honoured on the backdrop and FAB.
- Header text is now a hint ("Drag on the image to select the text region") instead of a title.
### Added
- Camera viewfinder modal on mobile (via `navigator.mediaDevices.getUserMedia` with `facingMode: environment`) — replaces the `<input type=file capture>` flow. Tapping the shutter goes directly to the crop modal, skipping the OS-level "retake / use" confirm step. Falls back to the file picker if camera access is denied or unavailable; a "Pick from file" icon (`photo_library`) inside the camera modal also routes to the picker on demand.
- Invert-colours toggle (`invert_colors`) in the crop modal. Tap to flip light-on-dark text into the dark-on-light polarity Tesseract expects. The grayscale buffer is inverted before adaptive thresholding so the binarised output is correct.
- Mobile-first modal layout (≤ 640 px): the crop and camera modals now fill the viewport (no backdrop padding, no border-radius), buttons hug the screen edges in the footer, and the camera video occupies the entire body. Body still has ~20 px horizontal padding so cropper's corner handles stay outside Android's edge-swipe back-gesture zone. iOS notch / home-bar insets honoured.
### Fixed
- "Hot: …" status no longer claims "unloads in X" when the server's `keep_alive` is `-1` (Ollama returns an absurd year-4001 `expires_at`). Threshold: any expiry >7 days drops the suffix and just shows the model name.
## [0.2.0] - 2026-06-09
### Added
- Vim bindings in the input via CodeMirror 6 + `@replit/codemirror-vim` (loaded from esm.sh, no build step). Supports `i`/`Esc`, `hjkl`, word/line motions, `dd`/`yy`/`p` (vim's internal register), undo/redo, `/` search, `:` command line. Auto-disabled on touch devices (detected via `(pointer: coarse)`) where modal editing fights the virtual keyboard.
- `Shift-H` / `Shift-L` cycle focus across the page controls (source → target → model → editor → button) like `Shift-Tab` / `Tab`. Also overridden in vim normal mode (replacing vim's viewport-top/bottom default) so the same shortcut works to leave the editor.
- On page load, the model dropdown defaults to whichever model Ollama already has hot — skips the 5-15 s disk-reload pain on the first translation. After load it follows the user's manual selection.
### Changed
- Editor font-size pinned to `1rem` to match the output pane (CodeMirror 6 ships smaller by default).
## [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`, `:<git-short-sha>`, and `:<pyproject-version>`
- 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

View File

@@ -47,14 +47,29 @@ Then on every change:
./build.sh ./build.sh
``` ```
That builds `linux/amd64,linux/arm64` and pushes `rg.nl-ams.scw.cloud/valinor/finrod:{latest, <git-short-sha>}`. 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
- `:<git-short-sha>` — immutable, traceable
- `:<pyproject-version>` (e.g. `:0.1.0`) — semver
## Deploy ## 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 ## Release workflow
2. Bump `tag:` in `valinor/apps/finrod/values.yaml` to that SHA on a branch
3. Merge → ArgoCD syncs the new image
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.

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

@@ -9,20 +9,24 @@
# docker login rg.nl-ams.scw.cloud -u nologin -p <SCW_SECRET_KEY> # docker login rg.nl-ams.scw.cloud -u nologin -p <SCW_SECRET_KEY>
# #
# Usage: # Usage:
# ./build.sh # tags :latest and :<short-sha> # ./build.sh
# TAG=v0.2.0 ./build.sh # additional explicit tag #
# Always tags:
# :latest — moving pointer
# :<git-short-sha> — immutable, traceable to a commit
# :<pyproject-version> — semver from pyproject.toml (e.g. :0.1.0)
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")"
REGISTRY="${REGISTRY:-rg.nl-ams.scw.cloud/valinor}" REGISTRY="${REGISTRY:-rg.nl-ams.scw.cloud/valinor}"
IMAGE="${REGISTRY}/finrod" IMAGE="${REGISTRY}/finrod"
SHA="$(git rev-parse --short HEAD)" SHA="$(git rev-parse --short HEAD)"
TAGS=(-t "${IMAGE}:latest" -t "${IMAGE}:${SHA}") VERSION="$(awk -F\" '/^version =/ {print $2; exit}' pyproject.toml)"
if [[ -n "${TAG:-}" ]]; then : "${VERSION:?Could not read version from pyproject.toml}"
TAGS+=(-t "${IMAGE}:${TAG}")
fi
cd "$(dirname "$0")" TAGS=(-t "${IMAGE}:latest" -t "${IMAGE}:${SHA}" -t "${IMAGE}:${VERSION}")
docker buildx build \ docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
@@ -31,4 +35,7 @@ docker buildx build \
. .
echo echo
echo "Pushed: ${IMAGE}:{latest,${SHA}${TAG:+,${TAG}}}" echo "Pushed:"
echo " ${IMAGE}:latest"
echo " ${IMAGE}:${SHA}"
echo " ${IMAGE}:${VERSION}"

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "finrod" name = "finrod"
version = "0.1.0" version = "0.3.0"
description = "Translator UI backed by Ollama" description = "Translator UI backed by Ollama"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

File diff suppressed because it is too large Load Diff