Commit Graph

628 Commits

Author SHA1 Message Date
jakeaturner
a1b899f49a fix(gpu): keep an existing AMD HSA override across reinstall and update
Upgraded AMD iGPU installs sometimes carry a working
HSA_OVERRIDE_GFX_VERSION on nomad_ollama, set by hand or by an older
install path, but have no ai.amdHsaOverride KV and no .nomad-amd-gfx
marker. The CPU-fallback banner offered Fix: Reinstall, and the
reinstall rebuilt the container without the override, so a 780M
(gfx1103) stayed on CPU.

- Add pickAmdHsaOverride to utils/amd_hsa_override.ts with the
  resolution order KV, marker, existing container override, none.
  A marker still wins even when it maps to none. KV "none" still
  disables the override.
- forceReinstall inspects nomad_ollama before removing it and saves a
  container-only override to ai.amdHsaOverride. Saving it keeps the
  value through a reinstall that fails after the removal and shows it
  in the Set GFX Override modal.
- The update path passes the old container env to the resolver, so a
  routine Update keeps the override too.
- The GPU health check passes the container env, so
  diffAmdOllamaConfig compares against what a reinstall would build.
  On the reported box, the only drift is OLLAMA_IGPU_ENABLE=1.
- diagnoseAmdCpuFallback suggests the container's current override
  when one is set. With an override applied, Ollama logs the coerced
  target (gfx1100), which maps to none and would suggest removing a
  working value.
- Remove the unused _mapGfxToHsaOverride wrapper.

parseAmdGfxTargetFromLogs is unchanged. It still matches only
gpu_type=. The "dropping integrated GPU ... compute=gfx..." line needs
a full sample from an affected box before we can write a regex for it.
We'll address in a later PR.
2026-09-29 15:54:41 -07:00
Chris Sherwood
4c75eb512b chore(branding): replace the retired N.O.M.A.D. badge with the new logo
The Command Center header, settings sidebar, chat sidebar and README still
showed the old dotted "PROJECT N.O.M.A.D." badge with the retired backronym.
The earlier naming sweep removed the text strings but could not catch a
raster asset.

Swap in the vector primary badge from the brand kit (nomad-primary.svg) and
add object-contain so the hexagon keeps its true 0.866 aspect instead of
being stretched to fill the square boxes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
jakeaturner
ac437f8429 fix(gpu): detect Ollama CPU fallback on AMD and offer the right fix
GpuPassthroughRemediationProvider returned early unless the NVIDIA
runtime was registered with Docker. AMD doesn't register one, so the
log-based CPU-fallback detection added in v1.35.0-rc.1 never ran on
AMD boxes. On a Phoenix1 iGPU running ollama:rocm with /dev/kfd and
/dev/dri passed through, Ollama logged library=cpu while gpuHealth
reported status "ok".

The gpuHealth side had its own bug. hasNvidiaRuntime and
hasRocmRuntime were set only inside the lspci probe branch. When lspci
returned a plausible controller, the else branch reported "ok" without
probing and left both flags false. A blind reinstall would not have
helped either: gfx1103 needs HSA_OVERRIDE_GFX_VERSION=11.0.0, and
without the gfx marker or ai.amdHsaOverride a reinstall rebuilds the
same CPU-bound container.

- Add resolveExpectedGpuVendor(). It reads the nomad_ollama container
  config first (nvidia DeviceRequest, ROCm image tag, /dev/kfd), then
  the nvidia runtime, then the AMD GPU type when no container exists.
  /dev/dri alone is not treated as AMD. A CPU container on an AMD box
  is trusted as deliberate (#1232 fallback, acceleration disabled).
- Gate the provider on the expected vendor. NVIDIA auto-reinstall is
  unchanged but now skips when the container requests a GPU with no
  runtime registered. AMD is detect-only: the provider logs whether a
  reinstall would change the container and never reinstalls itself.
- In gpuHealth, set the runtime flags up front and let Ollama's
  startup log decide health whenever a GPU is expected, whatever lspci
  returned. A CPU-only log now reports passthrough_failed and skips
  the nvidia-smi exec. lspci stays the display source when clean.
- Parse the first GPU "inference compute" line instead of the last
  line, which could be the CPU fallback device, and recognize Vulkan.
  Move the startup-log reader into ollama_compute so both callers
  share it.
- Report amdReinstallWouldChange, amdGfxTarget, currentHsaOverride and
  suggestedHsaOverride on AMD passthrough_failed. The gfx target is
  parsed from Ollama's "amdgpu is not supported" line (best-effort).
- Add GpuPassthroughAlert, shared by Settings > System and Settings >
  Models. When a reinstall would rebuild the same AMD container, it
  offers "Fix: Set GFX Override" instead of a reinstall. The dialog
  saves ai.amdHsaOverride and then reinstalls.
- Expose ai.amdHsaOverride through the settings API. The value lands
  in the container env, so only x.y.z, "none" or empty is accepted.
- Silence the HSA override resolver's logging on the gpuHealth poll.

The #1325 case where no GPU runtime is expected and lspci reports a
controller still returns "ok" unprobed.

Fixes #1344
Refs #1325, #1232
2026-09-29 15:54:41 -07:00
dajiaohuang
7dabc40c85 fix: bound custom URL length before persistence 2026-09-29 15:54:41 -07:00
jakeaturner
a915cdcfc9 feat(AI): add opt-in relevance check for off-topic retrieval
The post-rerank relevance floor rejects noise but passes coherent
off-topic questions, and it gets worse as the library grows. Adding
Gutenberg prose, the Python tutorial and MDN pages to the eval corpus
took the off-topic leak rate from 22% to 67% at the 0.62 floor.

Score cutoff does not fix it. The weakest correct top hit scores 0.666
while "write me a haiku about autumn leaves" pulls Walden at 0.705.
A cosine floor and a top-vs-median contrast test both cost recall
faster than they cut leaks.

- Add rag.relevanceCheck (default off). After the floor, the tasks
  model, or the chat model when none is set, gives one grammar-constrained
  on_topic verdict for the whole retrieved set. Off topic means no
  context and no citations. Any failure keeps the chunks unjudged.
- Add a Settings > Models toggle recommending an 8B+ tasks model.
  On llama3:8b the check cuts off-topic leaks from 67% to 6% for about
  5 points of core recall@5 and ~0.6s per turn. On 3B models it rejects
  most valid questions, which is why it is opt-in.
- Add 6 distractor documents and an off_topic suite (18 unanswerable
  questions, 7 distractor controls) to the eval corpus.
- Report per-tag leak rates and leaked cases with top scores in
  eval:retrieval. Add --judge-model to measure the check and --dump to
  export pre-floor candidate pools for offline gate simulation.
- Pin the check off in eval:generation so a local setting cannot move
  scores.
- Record the distractor sweep on RAG_MIN_FINAL_SCORE. The 0.62 default
  stays: 0.65 halves leaks at no measured recall cost but leaves only
  0.016 of margin.

The corpus fingerprint changes to b4ebe5dce8699f5d. Retrieval reports
from before this commit are not comparable, and a new baseline is
committed.

Refs #1341
2026-09-29 15:54:41 -07:00
jakeaturner
a013eb6394 fix(chat): stop capping answers at 1024 tokens and surface truncation
planPrompt() returned the response reserve as num_predict, and the
reserve was clamped by MAX_RESPONSE_RESERVE = 1024. Chat answers
stopped at 1024 output tokens regardless of window size, often
mid-sentence, and the normalized stream chunks dropped Ollama's
done_reason, so neither the UI nor API clients could discern between
a cut-off answer and a finished one.

- Decouple the generation cap from the reserve. num_predict is now the
  space left in the window after the prompt, less a 5% (min 64 token)
  margin for estimator error, with the reserve as its floor. Raise
  MAX_RESPONSE_RESERVE to 8192 so it only bounds prompt hold-back.
- Carry done_reason through all four OllamaService chat paths. Native
  forwards Ollama's done_reason; compat maps OpenAI's finish_reason.
- Log length-limited stops in OllamaController with num_predict,
  num_ctx and tokens generated.
- Show a truncation notice on cut-off answers, with a Continue button
  on the latest one that sends a normal follow-up turn.

Generation evals before and after this change are not length
comparable.

Closes #1342
2026-09-29 15:54:41 -07:00
Tiancheng-Xu
99fe2ef5e0 fix(ui): cover dark accent interaction states 2026-09-29 15:54:41 -07:00
Tiancheng-Xu
8d821f3ada fix(ui): restore dark mode contrast 2026-09-29 15:54:41 -07:00
Chris Sherwood
52d12a222e fix(kb): confine the orphan sweep to roots the scan actually walked
decideOrphans guards on the disk scan coming back empty, but the failure
mode it needs to defend against is per-root. _discoverKbFiles() walks
kb_uploads and zim in a loop and skips a missing root rather than
failing, because a fresh install legitimately has no kb_uploads yet.

So if the zim root is relocated, renamed, or not yet mounted (#1050),
the scan still returns a non-empty list from kb_uploads. The empty-scan
guard never fires, filterOrphanCandidates has no way to tell a root that
scanned clean from one that was never there, and every ZIM in the index
is classified as an orphan and purged in a single batch. On a
Wikipedia-scale knowledge base that is millions of vectors and a
multi-hour re-embed. It is reachable from the UI: scanAndSyncStorage is
POST /rag/sync, so the button pressed when the knowledge base looks
wrong is the button that empties it.

_discoverKbFilesWithRoots() now reports which roots were walked, and
filterOrphanCandidates allowlists against that list instead of the
configured roots. A root that wasn't there contributes no candidates; a
root that scanned clean and held no files still yields its orphans, so
the legitimate purge path is unchanged. The empty-scan guard becomes the
degenerate case of the same rule rather than a separate one.

Also builds the unit-test paths with join() instead of POSIX literals.
Written as '/data/...' the filterOrphanCandidates cases pass on Linux CI
and fail on Windows, where sep is backslash and no source ever matches a
prefix, which is exactly where they would be run while changing this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
John Cortright
d4f0f078c5 test(kb): extract orphan allowlist into a tested pure fn
Self-review pass over the last few commits:

- Extracted the kb_uploads/zim allowlist check out of scanAndSyncStorage()
  into a new filterOrphanCandidates() in kb_orphan_decision.ts, alongside
  the existing decideOrphans(). It was untested logic sitting inline in a
  large method; pulling it out next to decideOrphans() makes it a pure,
  I/O-free function the same way decideOrphans() already is, and lets it
  be covered directly instead of only indirectly through scanAndSyncStorage.
- Added unit tests for it: keeps sources under the scanned roots, excludes
  sources outside them (e.g. bundled docs), and a dedicated case for the
  sibling-directory false-positive a naive prefix check (no trailing
  separator) would let through.
- Also fixed two prettier violations introduced earlier in this PR (an
  over-length logger.info call and an unwrapped ternary in
  scanAndSyncStorage) that weren't caught by the per-edit lint checks I
  ran at the time.
2026-09-29 15:54:41 -07:00
John Cortright
6712251109 fix(kb): batch the orphan sweep's Qdrant/state deletes into one round trip
scanAndSyncStorage()'s orphan sweep purged each orphaned source in its
own sequential await, re-running _ensureCollection and a separate
Qdrant delete call per orphan. Add purgeIndexedSources() /
_deletePointsBySources(), which use Qdrant's match.any filter and a
single whereIn() query to purge a whole batch in one round trip each,
and point the sweep at it. purgeIndexedSource() now delegates to the
batched form for a single source, so there's one implementation
instead of two.
2026-09-29 15:54:41 -07:00
John Cortright
50b42fe367 fix(kb): make purge self-healing and allowlist the orphan sweep's roots
purgeIndexedSource() deleted Qdrant points before removing the
KbIngestState row. If the row-delete failed or the process died in
between, the source vanished from Qdrant's orphan-detection signal but
the ghost state row stayed forever — nothing else ever revisits a row
with no matching file and no matching Qdrant source. Reversing the
order means a failure instead leaves points in Qdrant, which the next
sync's reverse orphan sweep will find and retry; both steps are
already idempotent, so a retry from either partial state is safe.

Also switch scanAndSyncStorage()'s orphan-candidate filter from
denylisting known non-scanned roots (README.md/docs) to allowlisting
the two roots _discoverKbFiles() actually scans (kb_uploads, zim),
via a new shared _kbScanRoots() helper. A denylist silently treats any
future source root outside kb_uploads/zim as an orphan the first time
it's added — the same class of bug the denylist itself was written to
fix for docs.
2026-09-29 15:54:41 -07:00
John Cortright
7e24c7aeb8 fix(kb): guard ZIM delete purge and dedupe reconcile's delete pair
ZimService.delete() was calling purgeIndexedSource() unconditionally,
so installs without the AI Assistant (Qdrant) feature logged a false
"offline" error on every ZIM delete. Guard it on Qdrant being
installed, same as reconcileReplacedContentFile() already does.

Also point reconcileReplacedContentFile() at purgeIndexedSource()
instead of its own inline _deletePointsBySource + KbIngestState.remove
pair, so there's one shared implementation instead of two.
2026-09-29 15:54:41 -07:00
John Cortright
8eb172e684 fix(kb): purge qdrant points when a zim is deleted (#1170)
delete() removed the file, the kiwix library entry, and the
InstalledResource row, but never touched Qdrant or KbIngestState. A
deleted ZIM kept surfacing as a search citation indefinitely. The sync
sweep added in the previous commit would eventually catch these too,
but no reason to wait for the next sync when we already know exactly
what needs cleaning up right here.
2026-09-29 15:54:41 -07:00
John Cortright
18986e0527 fix(rag): reap orphaned qdrant sources during sync (#1170)
scanAndSyncStorage builds sourcesInQdrant and embeddableFiles in the
same pass but only ever checks one direction - is this on-disk file
already embedded? It never checks the reverse, so points left behind
by a deleted or replaced ZIM just sit in Qdrant forever and keep
showing up as search hits for content that doesn't exist anymore.

Added a reverse sweep that diffs the two sets and purges anything left
in Qdrant with no file backing it. Guarded so it's a no-op when the
disk scan comes back empty, so a filesystem hiccup can't get misread
as "delete everything."

Also has to exclude README.md/docs, since those get embedded by
discoverNomadDocs and live outside the kb_uploads/zim paths the
scanner actually walks. First pass without that exclusion would have
wiped the docs KB on every single sync - caught it by testing against
a live install where those sources were already indexed.

Verified live: found exactly one real orphan out of 29 indexed
sources (an old devdocs_en_bash zim superseded by a newer download),
purged it, and left the other 28 - including all the docs - alone.
2026-09-29 15:54:41 -07:00
jakeaturner
5bc47a4aff fix(drug-reference): backfill the missing dataset install row on boot
On 1.34.0 every FDA ingest finished with its labels loaded but lost the
installed_resources 'dataset' row, because resource_type was still
enum('zim','map'). The enum migration in #1243 fixes new writes but can't recreate
the lost row. Re-selecting the tier doesn't fix it either, because ZimService
skips a dataset that already has rows. So Medicine never resolved its
Standard tier.

- Add DrugInstallRowProvider. On each boot it backfills the row when an
  ingest has finished (rows present, export date set, no download
  marker, no drug job running) and no row exists.
- Put the decision in decideDrugRowReconcile() so it has unit tests.
- Add DrugReferenceService.recordInstalledRow() as the one writer used
  by the ingest job, the provider, and ZimService.
- ZimService now writes the row when a Medicine tier re-select finds a
  finished ingest with no row.

Manual ingests and reset-and-reingest runs, which dispatch without
resourceMeta, also get the row on the next boot.
2026-09-29 15:54:41 -07:00
rcorvus
9fcade9203 fix(db): allow 'dataset' in installed_resources.resource_type
The model declares `'zim' | 'map' | 'dataset'` and IngestDrugDataJob writes a
'dataset' row for the openFDA drug labels, but the column was still
enum('zim','map') from the table's original migration. Every write failed:

  Data truncated for column 'resource_type' at row 1
  insert into `installed_resources` (... `resource_type`, ...) values
  (..., 'openfda-drug-labels', 'dataset', ...)

The ingest itself succeeded — 261,671 rows landed in drug_labels — but the
install-state write-back at the end of the job was lost every time. Since
getCategoriesWithStatus() resolves a tier only when every resource id is
accounted for, and it already queries whereIn('resource_type', ['zim',
'dataset']), the missing row kept the Medicine category from ever resolving
its Standard or Comprehensive tier. Only the enum was left behind.

Verified against a live install: after the migration the previously failing
insert succeeds, and Medicine resolves installedTierSlug
'medicine-comprehensive'.

down() drops 'dataset' rows before narrowing the column, since MySQL would
otherwise truncate them to '' or fail under STRICT_TRANS_TABLES.
2026-09-29 15:54:41 -07:00
Chris Sherwood
fa34d6df4c fix(chat): render the user's message before creating the session
handleSendMessage awaited createChatSession before the optimistic render,
so on a slow request the message the user just sent did not appear at all,
with no spinner and nothing to retry. A failed session creation was worse:
a bare `return` left no message and no error, so the app looked like it had
ignored the user.

Render first, and surface a failure the same way a failed response is
surfaced. Completes #1211 alongside the stream-abort fix in #1220.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
jakeaturner
27be71b46e feat(AI): give chat a sampler instead of the backend's defaults
Chat sent num_ctx, num_predict and nothing else, so every turn decoded at
whatever the backend picked: temperature 0.8, top_p 0.9, no min_p. On a
heavily quantized 1-3B model that is the wandering, repetitive output people
read as "the local model is bad".

Adds `ai.responseStyle` with four profiles. min_p is the lever, not
temperature: it floors relative to the top token, so it truncates hard on a
confident distribution and barely at all on a flat one, which is where small
models start to wander. top_p stays at 1.0 rather than stacked on top of it.
repeat_penalty moves with the style, because a grounded answer legitimately
repeats a part number inside the 64-token window: `focused` relaxes it to
1.05, `creative` tightens it to 1.15.

`auto` is the only style that defers to the model. Plenty of library models
ship author-tuned samplers (qwen3 sets 0.6/0.95/20), so those win key by key
and NOMAD fills only the gap, which in practice is min_p. A style the user
picked by name overrides them, or it would silently do nothing on half
their models.

The compat transport drops min_p, so a profile carries a separate compatTopP.
Passing top_p 1.0 to a backend with no min_p behind it would sample the raw
distribution, which is worse than the 0.9 it replaced.

Resolved in the controller, so the grammar-constrained task calls keep their
temperature 0. An explicit temperature still outranks a profile's, which is
what keeps the eval harness deterministic.

`eval:generation --style` is new and load-bearing: temperature 0 makes min_p,
top_p and top_k inert, so a default run cannot see this change at all.
`--style=off` sends no samplers and no temperature, and is the control.

Verified against Ollama 0.32.9, min_p accepted. 22 unit tests.
2026-09-29 15:54:41 -07:00
Chris Sherwood
447b174832 fix(ai): don't classify the backend as non-Ollama when it is merely unreachable
getModels() and the _isNativeBackend() probe both set isOllamaNative =
false in their catch blocks for ANY failure of GET /api/tags - including
a plain 5s timeout while Ollama is busy or still starting, or a refused
connection while the container is down. The probe memoized that verdict
for the process lifetime. Every model download then failed with 'Model
is not available in your AI host... (model pulling is only supported
for Ollama backends)' on stock single-Ollama installs (#1349, two
reporters on GA).

The fix separates 'the backend answered and is not Ollama' from 'the
request failed'. Only an actual HTTP response - a non-2xx status, or a
200 whose body fails the Ollama shape check - proves a live non-Ollama
server and classifies false. No response at all leaves the
classification untouched (null, or a previously proven value), the
probe unmemoized for re-probing, and the download path falls through to
the pull attempt, whose existing error handling reports the real
network error as a retryable failure.

Verified live (v1.35.0-rc.1 + this patch, Ollama container stopped):
- stock behavior reproduced verbatim: dispatch fails with the
  'only supported for Ollama backends' message
- patched: the same dispatch fails with 'Failed to download model:
  getaddrinfo ENOTFOUND nomad_ollama' and stays retryable
- after starting Ollama again, the retrying BullMQ job recovered on
  its own and installed the model with no user action

Fixes #1349

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
Chris Sherwood
18e403bbaa fix(ai): bump the Ollama pin to 0.33.3 to fix the disconnect hang
ollama/ollama:0.24.0 never cancels in-flight generation when a client
disconnects: the runner process is left spinning at 300%+ CPU with no
active requests and never recovers. NOMAD triggers this organically via
its own 60s RAG embedding timeouts, so slow CPU-only boxes can wedge
themselves after a single slow request. Reported with a standalone
container repro in #1321; the cancellation fix landed upstream between
0.24 and 0.33.

Regression-tested on 0.33.3 (RTX 5060 box):
- model listing via /api/tags (NOMAD backend detection intact)
- GPU acceleration (100% GPU placement, 82.8 tok/s on llama3.1:8b)
- model pull through the NOMAD download pipeline (NDJSON progress)
- embeddings via /api/embed with nomic-embed-text (768-dim)
- streaming chat end-to-end through the Command Center UI
- the disconnect hang itself: container idles at 0.06% CPU 20s after
  a mid-generation client drop

The AMD path is unaffected: it always overrides to ollama/ollama:rocm.
The seeder does not sync container_image on existing rows, so this pin
reaches fresh installs; existing installs pick it up via the opt-in app
auto-update or a manual update.

Fixes #1321

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
chriscrosstalk
1b67a87254 feat(translate): offline translation for the Information Library (#1292)
* feat(translate): offline translation for the Information Library

Implements #1272. Ships as a Supply Depot app so users who do not need
translation never see it.

A path-preserving reverse proxy in front of kiwix-serve. /viewer, /skin/*,
/search, /random and /catalog/* are forwarded byte for byte; only text/html
under /content/ is rewritten. Preserving the path is the whole design:
viewer.js keeps the content iframe in sync with location.hash by comparing
contentWindow.location.pathname, so moving the path starts the ping-pong its
own comment warns about and forces us to patch Kiwix's JavaScript on every
Kiwix bump. Because the path never moves, no Kiwix JS is modified, and Kiwix's
header, search, library and random keep working because they are Kiwix.
Language is a cookie, so links inside the ZIM need no rewriting either.

Bergamot rather than the AI Assistant: measured on the same box and the same
passage, 12,818 words/s on CPU against llama3.1:8b at 8 words/s on an RTX 5060.
It is also more careful with names, where the LLM translated the product name
and a smaller model hallucinated a clause. No GPU, so it works on CPU-only
boxes where the LLM path is unusable.

Models come from Firefox Remote Settings, the endpoint Firefox itself uses,
not the mozilla/firefox-translations-models repo, which was archived on
2026-08-21. MPL-2.0, about 37 MB per direction.

Failure handling is deliberate throughout: a failed model fetch is non-fatal, a
translation error returns the original article with a comment rather than an
error page, and a box with no models still starts and still forwards the
library. Translation being unavailable must never make the Information Library
unreachable.

amd64 only, and pinned as such in the build workflow. Bergamot's intgemm
backend is x86 specific and there is no aarch64 wheel, so a multi-arch build
would produce an ARM image that pulls cleanly and dies at start.

Also adds .gitattributes pinning *.sh to LF. The repo stores LF but Windows
checks out CRLF, and a CRLF after a shebang makes the interpreter
unresolvable inside the image. CI builds on Linux so this never bit, but it
breaks a local image build and would have bitten the existing sidecar script
too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV

* fix(translate): vary the cache on the language cookie

Found by clicking the language buttons in a browser rather than trusting curl.

Kiwix sends ETag and Cache-Control: max-age=3600, and the proxy forwarded both
unchanged on a response whose content now varies by cookie. The browser cached
the English article for an hour and served it straight back when the reader
picked a language, so the feature looked like it simply did not work. A shared
cache would have been worse, serving one reader's language to another.

Translatable responses now send Vary: Cookie, and their ETag is namespaced by
language so revalidation still works per language rather than being turned off.
Forwarded paths are untouched, so /viewer, /skin/* and /catalog/* stay byte for
byte identical to Kiwix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV

* fix(translate): move off port 8480, which Vaultwarden already uses

Caught by seeing the two cards side by side in Supply Depot with the same port.

My port survey grepped ui_location for a bare number, and Vaultwarden's is
'https:8480' because it needs an https Open link, so it did not match and 8480
looked free. Moved to 8460, which fits the existing spacing between Meshtastic
Web on 8450 and Homebox on 8470.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV

* fix(translate): pin the base image to python 3.10

The build fails outright on 3.12: bergamot publishes no wheel for it and pip
resolves no further than 0.1.2. The spike used 3.10 for this reason and I had
modernised it without checking. Caught by building the image rather than
reading it.

Moving off this pin means moving off the 2022 PyPI wheel to a build from
mozilla/translations, which is the follow-up already noted in the Dockerfile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV

* fix(translate): translate text that lives in a bare div

Sotoki-generated StackExchange ZIMs put every question excerpt in a
<div class="...excerpt">, so on a questions list the titles translated and the
descriptions underneath them did not. Reported against the ham radio ZIM.

div could not simply be added to BLOCK_TAGS: the outermost-block rule would
claim a top-level layout wrapper and send the whole page as one job. It is now
claimed speculatively and abandoned the moment anything block-level opens
inside it, so a wrapper yields to the blocks it contains and only a genuine
text leaf is translated.

Also moves the test harness's reporting block back to the end of the file. New
checks had been appended after it, so they executed but their failures were
never reported: verified that removing div from the set now fails 3 checks,
where before it reported success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV

* fix(translate): decode numeric character references before translating

Bergamot's HTML mode decodes named entities (`&amp;` arrives as "&") but not
numeric ones. It treats `&#x27;` as literal text and escapes the ampersand on
the way out, so "If I&#x27;m in the wilderness" came back as
"Si I&amp;#x27;m dans la nature" and rendered the escape sequence visibly to
the user. Confirmed by round-tripping fragments through the model directly.

Numeric refs are now decoded before the round trip. `&`, `<` and `>` are
deliberately left encoded: decoding those would inject real markup into the
fragment and fail the balance check that gates HTML mode, dropping the block to
plain text and losing its inline links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
NgoQuocViet2001
a131e63204 fix(downloads): guard orphaned model jobs 2026-09-29 15:54:41 -07:00
NgoQuocViet2001
613ad9a9d3 fix(chat): abort abandoned response streams 2026-09-29 15:54:41 -07:00
Osamaali313
dd8df4c1ee fix(content): re-download content updates after a failed attempt
applyUpdate guarded with the raw RunDownloadJob.getByUrl and only bailed
out for active/waiting/delayed states, letting failed/completed states
fall through to dispatch. But dispatch adds the job under a deterministic
jobId (sha256 of the URL) with no removeOnFail, so once a download
exhausts its retries the failed job persists in Redis. The next apply
then hits "job already exists", dispatch returns the stale failed job,
and applyUpdate reports success while nothing is re-downloaded — the
resource is stuck forever.

Use getActiveByUrl instead, which removes any terminal job for the URL
before returning, so a fresh job is dispatched. This matches every other
dispatch site (map_service, zim_service, creator_pack_service).
2026-09-29 15:54:41 -07:00
Fabricio Policarpo
c3663009ae fix(gpu): detect Ollama CPU fallback from logs so auto-remediation fires
The GpuPassthroughRemediationProvider probed GPU passthrough by exec'ing
`nvidia-smi` inside nomad_ollama. When Ollama was created CPU-only — most
commonly because the NVIDIA container runtime was registered with Docker
only AFTER the AI Assistant was first installed (DockerService attaches a
GPU DeviceRequest only when 'nvidia' is in docker.info().Runtimes at
install time) — the container ships no nvidia-smi, so the exec returned an
error string whose letters the alphabetic-output check mistook for
"passthrough healthy". That false negative suppressed all auto-remediation,
leaving Ollama silently stuck on CPU (models run at a fraction of a token
per second) until the user manually clicked "Fix: Reinstall AI Assistant".

Replace the nvidia-smi probe with Ollama's own "inference compute" startup
log line — the ground truth for whether a GPU backend actually loaded — via
a new, unit-tested classifyOllamaComputeBackend() helper. One signal covers
both the "created CPU-only" case and the original "DeviceRequests present
but the toolkit binding tore" case. Add a cooldown on gpu.autoRemediatedAt
so a GPU that genuinely cannot be accelerated (an architecture Ollama has
no kernels for) does not trigger a reinstall on every admin boot.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
Chris Sherwood
576464abc4 feat(zim): let users browse the Kiwix library in any language
Content Explorer hardcoded `lang: 'eng'`, so it could only ever show
about 1,300 of the catalog's ~10,900 books. Adds a language selector
sourced live from the catalog's own language feed, with book counts,
defaulting to English and remembered per browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
chriscrosstalk
880c80f52f feat(kb): per-file active toggle + collection grouping/bulk toggle (#1286)
* feat(kb): add per-file active/inactive toggle for search inclusion

Implements the per-file toggle agreed on in #1119, in place of disabling
ZIM auto-discovery by default. Any indexed file (ZIM, upload, or other)
can now be excluded from RAG search results instantly, without deleting
or re-embedding its vectors.

- Vectors stay in Qdrant permanently; each point's payload carries an
  active boolean. Toggling flips this payload in place, both directions,
  with no re-embedding cost.
- Search filter is a denylist (must_not: active == false), not an
  allowlist, so a point missing the field entirely is still found by
  default -- search correctness never depends on backfill timing.
- embedAndStoreText() looks up a file's existing active state before
  writing new points, so retries, force re-embeds, and replaced-content
  reindexing never silently reactivate a file the user explicitly
  toggled off.
- Applies uniformly to all non-admin_docs buckets (ZIM, uploads, other),
  matching how the rest of this pipeline already treats buckets, rather
  than special-casing ZIM.

New POST /api/rag/files/active endpoint and KB panel toggle switch,
following the existing updateFileCollection pattern.

Real vector deletion for genuinely deleted/replaced files remains
#1170's separate concern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(kb): group personal KB uploads by collection with bulk toggle

Groups personal KB uploads into collapsible per-collection header
rows (with an "Uncategorized" bucket for files with no collection) to
reduce clutter in the file table. Each collection header also exposes
a bulk active/inactive toggle to flip every file in that collection at
once.

Also fixes three pre-existing bugs found in the collection-assignment
dropdown while building this: the popover was clipped by table cell
truncation, clipped again by the table wrapper's overflow, and the
combobox query was stale-filtering results on open.

Refs #1193

* fix(kb): widen the modal for the Active column and coerce active to a boolean

Both found by browser-verifying on NOMAD3.

The Active column adds ~57px, which put the table back over the modal width and
reintroduced the exact defect #1198 fixed: the Delete button falls off the right
edge at every viewport. Measured on the box at 1018px of table against 961px of
usable width at max-w-5xl. Widened to 6xl.

MySQL returns tinyint(1) as 0/1 rather than a boolean, and the raw 1 reached the
DOM as aria-checked="1". That is not a valid ARIA value, so a screen reader is
told nothing useful about whether the toggle is on. Coerced in the service so
every consumer sees a real boolean, with a !! guard in Switch as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV

* fix(kb): validate the bulk collection toggle's input before sanitising

sanitizeCollectionName ran before the type check, so a form-encoded request
sending collection[] as an array reached .trim() on a non-string and produced
an uncaught TypeError, surfacing as a 500. The vision controller already
guards this the same way.

Also documents why requiring `active` to be a real boolean is load-bearing
rather than mere input hygiene: a cross-site HTML form can only send the string
"true", so that check is what keeps a bulk Qdrant write across every point in
the index out of reach of a form POST from another origin. Worth a comment,
because coercing it would look like a harmless usability fix.

Found in a security review of the release PRs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV

---------

Co-authored-by: John Cortright <jcortright@zscaler.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 15:54:41 -07:00
Chris Sherwood
f97de2cb3f fix(zim): drop the dead keyword-scan fallback
`q=` searches an entry's title and summary, never its filename, so it
cannot match a resource id. It cost up to 5 paged catalog requests per
unresolved book on every check and resolved none. Verified on a test box:
identical results with and without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
41c955125f fix(zim): resolve flavoured and non-English ZIMs in the update check
OPDS indexes a book under its base name with the flavour in a separate
element, so `name=wikibooks_en_all_nopic` matched nothing and no
maxi/mini/nopic book could ever resolve an update. Retry against the
base. Also drop the `lang: 'eng'` filter, which made every non-English
installed ZIM permanently unresolvable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
jakeaturner
a1b39cdcf2 fix(zim): extract Kiwix catalog URL into system-wide constant 2026-09-29 15:54:41 -07:00
Chris Sherwood
d1a4e9ade5 fix(zim): use the OPDS catalog host, not Kiwix's gated browse host
browse.library.kiwix.org grew an anti-crawler interstitial on 2026-08-29
that answers HTTP 200 with an HTML confirmation page. Content Explorer's
remote listing 500s on it and ZIM auto-update silently reports "no
updates". opds.library.kiwix.org serves the same OPDS API ungated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
jakeaturner
e468cf363a fix(seeder): derive ui_location from the merged config, not the live row 2026-09-29 15:54:41 -07:00
Chris Sherwood
32eea2ec22 fix(seeder): keep an installed app's host port across catalog sync
The catalog-sync loop overwrote container_config and ui_location for every
non-user-modified curated service, resetting the published host port of an
install that had been moved off the catalog default. The row then disagreed
with the running container: the Open link pointed at a dead port and the
next recreate collided.

Preserve the live host ports for installed rows and apply every other
catalog change, so these installs keep receiving catalog updates instead of
being opted out of them.

Diagnosis and the original fix are @johno10661's work in #1005.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
34b4542da4 fix(dashboard): show the color picker swatches solid
The swatches reused the tile tint, which is 10% opacity. That reads well across
a 190px card and makes six 28px squares indistinguishable from one another, so
the picker looked like six empty outlines. Caught by looking at it.

Each color now carries a solid class alongside its tint: the picker shows the
color at full strength, the tile stays subtle. A test asserts the swatch class
is solid rather than an opacity variant, since reaching for the tint again is
the obvious mistake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
ce7c82a72f chore(dashboard): label the link tile field URL, drop the scheme hint
"Address" was vague for a field that takes a URL. "No scheme? We'll default
to http://" named a concept most people do not have a word for, and it was not
needed: the live preview already shows http:// appearing the moment someone
types a bare address, which demonstrates the behaviour rather than describing
it. The preview stays, the sentence goes.

The two link tile validation messages follow the same wording. The custom URL
override message is left alone; it belongs to a different feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
c33af6a605 chore(dashboard): reword the link tile color helper, and use US spelling
"still tellable apart from" read badly; it is now "distinguishable from".

Also switches the spelling I had introduced to American, matching the rest of
the product and the existing schema and constants (custom_color, PIN_COLORS).
Limited to the files this branch adds; the two pre-existing benchmark
components that use the British form are left alone rather than churned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
400d64ab55 feat(dashboard): let a link tile carry a brand colour
A link tile rendered on a flat surface colour, which read as a stark white card
against the filled green app tiles. Tiles now take one of six colours from the
brand palette, chosen when creating or editing one, and the card is tinted from
it.

Tiles stay visually distinct from managed apps regardless of colour: the dashed
border and the external-link marker carry that, not the hue, and the tint is
deliberately light so the distinction survives every option.

Class names are written out in full rather than interpolated, because Tailwind
scans source text for literal class strings and would silently drop a computed
one, leaving the tile untinted. A test asserts that.

Added as a separate migration from the one introducing is_link_tile, since that
has already run on the test box and editing it in place would leave fresh and
upgraded installs with different schemas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
a6edaf7df7 fix(dashboard): exempt link tiles from container reconciliation
_syncContainersWithDatabase flips any service marked installed to not-installed
when no container exists for it. A link tile has no container by definition, so
every tile was silently unset on the next sync and disappeared from the
dashboard within seconds of being created.

Caught by creating one in the browser and watching it not appear.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
b7b973e9b8 feat(dashboard): user-added link tiles for services NOMAD does not manage
Users want things they already run to appear on the dashboard: a NAS, a router
UI, a printer, a Pi, a container they started themselves. Adds a tile they
address themselves, with a name, one URL field, an optional description and an
icon from the curated set.

Chosen over adopting containers from the Docker daemon, which is what community
PR #1239 proposed. Adoption only reaches containers on this Docker host and has
to guess: it took the lowest published host port and assumed http, which picks
the wrong port for anything publishing several (Portainer 8000 against 9443,
Jellyfin 8096 against 8920) and breaks entirely for HTTPS. A tile the user
addresses has no guessing in it, covers the whole LAN rather than one Docker
host, and has no lifecycle that can break.

One URL field rather than separate host, port and path, because people paste
URLs and it gets https and sub-paths for free. A bare "192.168.1.50:8080" gains
http automatically, which is what most LAN devices need. normalizeCustomUrl and
getServiceLink already did exactly this work for the custom app URL override, so
both are reused rather than duplicated.

Stored as a service row with no container behind it: is_link_tile marks it,
custom_url carries the destination, and the service_name is namespaced
nomad_link_* so it can never collide with a curated entry. The seeder only
iterates its own DEFAULT_SERVICES and never prunes unknown rows, so a reseed
cannot touch or remove a tile.

The tile is deliberately styled differently from a managed app, outlined and
dashed with an external-link marker rather than filled, because if it looked the
same users would expect Start, Stop and Update and file bugs when those controls
are not there. Removing a tile arms a confirm first, since it is user-entered
data with no undo.

Icons come from a fixed 36 shared by the picker and the server-side validator,
so the two cannot disagree about what is selectable. DynamicIcon renders nothing
at all for a name it does not know, which would leave a silent hole in a tile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
d417590252 copy(dashboard): tighten the Drug Reference tile description
Drops the condition examples from the dashboard tile. Also brings it in
line with the length and punctuation of the neighbouring tiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
jakeaturner
1d1945dae4 fix(maps): undef func call, flyTo issue, visible column, and silent save failures 2026-09-29 15:54:41 -07:00
Chris Sherwood
78017a333d fix(maps): cap marker notes at 500 characters server-side
The UI enforces 500 characters but the validator did not, so the API accepted
up to the MySQL TEXT ceiling. The API needs no authentication by design, so an
uncapped free-text column reachable from any page a user visits is a cheap way
to bloat the database. Matches the limit the form already shows.

Found in a security review of the release PRs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
821f952c18 chore(deps): restore dev's package-lock
react-icons was added and then removed again during review, and npm on Windows
rewrote the lockfile in passing, stripping the libc platform annotations that
npm on Linux had written. package.json ends up identical to dev, so the correct
lockfile is dev's -- restored verbatim rather than shipping a Windows-rewritten
one into a file that governs platform-specific binaries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
f1e442fc98 feat(maps): confirm before deleting a pin, after flying to it
Deleting a pin was a single click on a trash icon with no confirmation and no
undo. Pin names are short and repetitive by nature ("Well", "Cache", "Fishing
bridge", "Fishing bridge west"), so the name in the row is not enough to tell
the user which place they are about to lose.

The trash icon now arms rather than deletes: it selects the pin and flies the
map to it, then the row offers Delete or Cancel. The user sees the actual
location before confirming.

Escape cancels. The armed state also clears if that pin stops being listed --
deleted from elsewhere, or filtered out by a search -- so a stray confirm can
never end up pointing at a different pin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
20f65c06fc fix(maps): restore the icon picker's width so the grid does not collapse
Dropping w-72 when the picker was rewritten left the absolutely positioned
popover to shrink-wrap its content. The six 1fr columns collapsed to a few
pixels each and the 18px glyphs overflowed and overlapped, so the grid rendered
as a smear. Restored the explicit width, added shrink-0 to the cells so a
narrow container can never squash them again, and centred them in their tracks.

Caught by looking at it. The DOM assertions I had run -- 36 buttons in a
grid-cols-6 -- all passed while the thing was visually broken, which is the
whole reason a frontend change needs eyes on it and not just a query.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
9c1e945815 refactor(maps): curate the marker icon set, drop the tree view
Review changes from looking at the feature running on NOMAD3.

The icon picker offered every Tabler icon *and* every Font Awesome icon via
namespace imports: 160 pages, whose first page included the Amazon and Amazon
Pay logos, an "Ad" badge and text-alignment glyphs. It also took the maps chunk
from 864 kB to 5,774 kB. Replaced with a hand-written set of 36 map-relevant
icons in a 6x6 grid -- water, shelter, medical, power, comms, transport,
terrain, hazards -- with no search box or paging, because 36 fit on screen.
Maps chunk is back to 904 kB, +40 kB over dev rather than +4,910 kB, and the
react-icons dependency is gone entirely since Tabler was already present.

Icon resolution moved into marker_icons.ts so the panel and the pin share one
definition, with a caller-supplied fallback (the pin's inner glyph defaults to
a filled circle, the list to a map pin). An icon later removed from the set
still renders as the fallback rather than blanking the marker.

Dropped the tree view and the List/Tree band: grouping pins by hue or by icon
organises them by an attribute the user picked arbitrarily, and grouping by
first letter is worse than sorting. Search and sort cover the real need. "Hide
all" lived inside the tree view, so it moved up into the header where it
applies to whatever the list shows -- it is the one bulk control worth keeping.

The title bar now closes the panel, matching the "Pins" button that opens it;
clicking a title to open but having to hit a small X to close is an asymmetry
that makes the panel feel fiddly. The list also reclaims the height the removed
band was using.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Kenneth Brewer
72e0fcf4dd feat(maps): map marker management
Salvages @kennethbrewer3's work from #836, which had drifted onto a stale base
(GitHub showed 231 files; the real change is the map UI plus a migration).

Marker visibility (individually and Hide All), per-marker icon and custom hex
colour with contrast-adjusted icon colour, search, sorting and a tree view over
the marker list, coordinate search and fly-to with URL parameters
(?lat=&lng=&zoom=), creating a marker from typed coordinates, and markers
rendered as SVGs anchored to true coordinates rather than approximately.

Name, notes and preset colour already existed; the new columns are
custom_color, icon, icon_color and visible.

Dropped from the original as stale-base noise rather than his work: unrelated
duplicate changes to the Ollama, Docker, download, RAG and ZIM services, the
API reference and getting-started docs, README/FAQ/CONTRIBUTING, the installer
script, and a revert of AppLayout to a version predating the compact sidebar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Chris Sherwood
0a93e7de80 test(ai): run the controller vision spec under Japa
It constructs OllamaController, which imports the Adonis logger service at
module scope, so it cannot run under the plain node:test runner. It failed
identically on the original branch -- the coverage has never executed. Moving
it to the Japa runner puts it in the same bucket as the other app-importing
specs, where a booted app is available.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00
Gabe Graves
8a920188de feat(ai): support local vision attachments
Closes #1176. Salvages @gabegraves's work from #1178, rebased onto the
RagPipelineService refactor that landed after it was written.

Adds JPEG/PNG/WebP attachments to chat over multipart, leaving text-only JSON
requests on their existing path. Images are normalized server-side to bounded
JPEG with metadata stripped, capped on count/size/pixels/output, and the
temporary uploads are cleaned up on every validation path. They are attached
only to the latest user message, are excluded from RAG, titles, logs and
persisted history, and are never written to storage.

Vision support is resolved per model from what the backend actually reports --
OpenAI-compatible /v1/models metadata, then Ollama /api/show, then llama.cpp
/props -- never inferred from a model name. A model that reports no vision
support has attachments disabled with a visible explanation; a backend that
reports nothing is allowed with a warning and its rejection is translated into
plain compatibility guidance rather than a raw error.

Changes from the original:

- Prompt assembly moved into RagPipelineService in the meantime, so the
  controller half is rebuilt against the trace and the multipart-aware
  collection filter is now the only one (dev's request.input() version would
  have silently dropped the collection on every image request).
- Added a native-path converter. dev split chat into native and OpenAI-compat
  paths after this was written; Ollama's native /api/chat does not take OpenAI
  content parts, it takes a sibling `images` array of bare base64. Without this
  every image request on a native Ollama backend would fail.
- Dropped the model-recommendation change (selectRecommendedModels and the
  Qwen3-VL fallback entry). Displacing a recommended model at first-time setup
  is a product decision and is being taken separately. The "Supports images"
  label in Easy Setup is kept: it describes what is already on offer rather
  than changing it.
- Dropped the DEFAULT_QUERY_REWRITE_MODEL download prompt his branch carried,
  which dev deleted in d022c2d.
- Removed a duplicate checkModelHasThinking; kept dev's memoized getModelInfo
  path, since thinking detection is not part of this feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
2026-09-29 15:54:41 -07:00