Compare commits

...

92 Commits

Author SHA1 Message Date
chriscrosstalk 0bd1c6f4f9
chore(collections): surface FDA Drug Reference in Medicine > Standard (#1167)
HOLD UNTIL 1.34.0 GA. Merging early breaks v1.33.0 installs.

The manifest is fetched live from main
(collection_manifest_service.ts: refs/heads/main/collections/kiwix-categories.json),
so every install on every version picks this up the moment it merges — not on
their next upgrade.

`type: "dataset"` does not exist anywhere in v1.33.0: zero hits across
admin/app, admin/inertia and admin/types. It is new in 1.34.0. On a v1.33.0 box
this entry would render as an installable resource with no code that
understands its type and a URL that is not a ZIM, so the install path breaks
for users who are not running the version that introduced the feature.

Held back until GA for that reason, which also means 1.34.0 would otherwise
ship a working Drug Reference that cannot be discovered from Content Explorer.
Entry is byte-identical to the one already on rc.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:26:48 -07:00
cosmistack-bot b6c6ff4f30 docs(release): finalize v1.34.0 release notes [skip ci] 2026-08-04 03:46:26 +00:00
cosmistack-bot 60b2e4568b chore(release): 1.34.0 [skip ci] 2026-08-04 03:45:48 +00:00
jakeaturner 195a0a99fa
docs: update release notes 2026-08-04 03:40:02 +00:00
chriscrosstalk 65bfd4f45a
fix(downloads): don't retry a rejected entitlement for four hours (#1205)
The 401/403 branch added in #1172 threw a plain Error, and
RunDownloadJob is registered attempts: 10 with exponential backoff from
30s. A permanent "this build has no entitlement key" rejection therefore
retried nine more times over roughly 4h15m, during which the job reads
`delayed` rather than `failed` - so the user sees a stuck download
instead of the clear message #1172 was written to give them. Each retry
also re-hits our own rate-limited Worker.

Throw a named GatedContentAuthError from the download util and translate
it to UnrecoverableError at the queue boundary in RunDownloadJob,
alongside the existing cancellation case. Declaring the class in
downloads.ts rather than throwing UnrecoverableError directly keeps
BullMQ out of a module that docker_service and map_service also use.

Harmless today because no catalog entry uses `auth`, but that ends with
the first gated card - see #1204.

Fixes #1195
2026-08-04 03:40:02 +00:00
chriscrosstalk 2159c9dec6
fix(downloads): let interrupted content downloads resume (#1202)
doResumableDownload already implements resume - stats the .tmp, sends a
Range header, handles a server that ignores it. The code was unreachable
for ZIMs because every dispatch site passed forceNew: true, which skips
the partial-file check and opens the stream with 'w' instead of 'a', so
an interrupted 12.5 GB Wikipedia download truncated and restarted at
byte 0. Maps already do this correctly (map_service.ts:681, "so retries
resume partial downloads"); ZIMs never got the same treatment.

Worse in combination with attempts: 10 - every retry also restarted from
zero, so a flaky connection re-downloaded the whole file up to ten times.

Drop forceNew from the content-download dispatch sites (it already
defaults to false) and add the guard that enabling resume requires: a
.tmp larger than the file now on the server cannot be a prefix of it,
because openZIM re-publishes builds under the same name. Resuming would
request a range past the end, 416 on every attempt, and never delete the
.tmp - so the download could never recover on its own.

Verified on a test appliance: a 470 MB partial survived a container
restart and continued rather than truncating, and a planted oversized
.tmp was discarded with the file then downloading to its exact size.

Refs #1201
2026-08-04 03:40:01 +00:00
chriscrosstalk 7472442ae8
fix(kb): keep the collection when a file is indexed after assignment (#1200)
Assigning a collection before indexing lost it silently. The per-row
value still showed, but Manage Collections and the Search in dropdown
stayed empty, because getKnowledgeCollections() facets on the Qdrant
payload while only MySQL had been written.

Five gaps on one path:

updateFileCollection() sets the payload filtered on `source`, which
matches nothing before the file is indexed. It also only persisted to
kb_ingest_state `if (row)`, so a file with no row stored the value
nowhere at all and still returned "Moved to ...".

Six of the seven EmbedFileJob.dispatch sites never pass `collection`,
and none read the existing row, so Index dispatched a job with no
knowledge of the assignment. The ZIM branch of processAndEmbedFile then
dropped `collection` even when the job had one, so ZIM content could
never be tagged at embed time by any path. Batch continuations dropped
it too, which would have tagged only batch 1.

Resolve the effective collection once inside EmbedFileJob.handle rather
than at seven call sites, thread it through the ZIM path into the point
payload, carry it across batch continuations, and make the pre-index
assignment durable with getOrCreate.

Verified end to end on a test appliance: assigned a collection to an
unindexed ZIM, indexed it across multiple batch continuations, and all
6106 chunks carry the tag.
2026-08-04 03:40:01 +00:00
chriscrosstalk 7325457242
fix(kb): stop the collection dropdown being clipped, widen the modal (#1198)
Two layout defects in the Knowledge Base modal, both found during
v1.34.0-rc.4 QA.

The collection combobox list was clipped to a single row's height on
every row of the table. The cause is not stacking order - StyledTable
gives each cell `truncate` (overflow:hidden) plus `relative`, so an
absolutely-positioned child cannot escape the cell box. No z-index can
win against a clip. The modal body is also an `overflow-y-auto`
scroller, so simply opting the cell out of `truncate` still left rows
lower down clipped by the scroller instead.

Render the list in a portal with fixed positioning instead, measured
from the input's rect and flipped above when it would run off the
bottom of the viewport. Reposition on scroll and resize (capture
listener, so any ancestor scroller counts). The click-outside handler
now also checks the portaled list, otherwise mousedown on an option
would close it before the click landed.

Separately, the modal was capped at max-w-4xl (896px) while its table
needs 906px, so the Delete button lost its right edge for everyone
regardless of monitor size. max-w-5xl gives the table room with none to
spare wasted.

Verified against a real build on a test appliance.
2026-08-04 03:40:00 +00:00
chriscrosstalk af3fb8de13
feat(dashboard): round out the v1.34 What's new highlights (#1197)
The banner shipped with two highlights while the 1.34 line delivered 21
features, so it undersold the release. Adds the three with the widest
user-facing surface: Score v2, NOMAD.md and Knowledge Base collections.

Also converts the two existing bullets from em dashes to spaced hyphens
to match project copy convention.

Kept to five bullets - this is a dashboard alert, not release notes.
2026-08-04 03:40:00 +00:00
cosmistack-bot 926c7dda84
chore(release): 1.34.0-rc.4 [skip ci] 2026-08-04 03:39:59 +00:00
jakeaturner 9e173ce3d9
docs: update release notes 2026-08-04 03:39:59 +00:00
chriscrosstalk 2ec6ef3d5c
docs: document Ubuntu 26.04 LTS as the recommended OS in the in-app docs (#1159) 2026-08-04 03:39:59 +00:00
chriscrosstalk bab2c426b0
docs: add an in-app Drug Reference guide (#1161) 2026-08-04 03:39:58 +00:00
chriscrosstalk 7afef079b6
fix(benchmark): don't submit unresolved PCI ids as the GPU model (#1165) 2026-08-04 03:39:58 +00:00
chriscrosstalk a234658469
fix(benchmark): name the fix in the remote-host block, log warm-up failures (#1164) 2026-08-04 03:39:57 +00:00
chriscrosstalk 8f6b17b565
fix(drug-reference): responsive compare columns and dark-mode readability (#1163) 2026-08-04 03:39:57 +00:00
chriscrosstalk 131cb59fc1
fix(downloads): don't 500 the jobs endpoint on an orphaned BullMQ job (#1191)
A job id can outlive its payload hash. BullMQ still returns an entry for
it with empty `data`, so `normalize(job.data.filepath)` threw and took the
whole response with it.

`fetchJobsWithStates()` includes failed jobs, and failed jobs are retained
deliberately, so one orphan made GET /api/downloads/jobs throw on every
call, permanently. Content Explorer polls that endpoint every ~3s, so the
symptom was a 500 loop and a page that never loaded. It survived restarts
and only cleared by editing Redis by hand.

Drop entries with no usable payload at the source, and guard both
normalize() calls (the pmtiles-extract map had the same pattern). With no
payload there is nothing to render anyway.

Seen on NOMAD3 running rc.3: 93 occurrences in 15 minutes.

Closes #1190

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:39:56 +00:00
chriscrosstalk 32e2c3d10e
fix(kb): respect ingest policy when a ZIM is uploaded locally (#1184)
ZimService.registerLocalUpload() dispatched EmbedFileJob unconditionally,
gated only on whether Ollama was reachable. It never read
rag.defaultIngestPolicy, so a user who deliberately chose Manual still got
sideloaded ZIMs embedded into the knowledge base behind their back.

PR #919 fixed exactly this for the post-download dispatch path. The
local-upload path was missed.

Rather than inline a third copy of the Always/Manual conditional, this
reuses decideScanAction, the same helper the scanner uses. That also means
an existing browse_only or pending_decision row is now honored instead of
being overridden by the act of re-uploading the file, which the inline
version in run_download_job.ts does not do.

Unset policy is still treated as Always, so existing installs keep their
current behavior.

Reported by @just-jbc on #1119, which also proposed disabling ZIM
auto-discovery entirely. That larger change is not included here: turning
discovery off by default would mean a user who downloads Wikipedia through
the curated flow gets no AI answers from it and no explanation why.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:39:56 +00:00
chriscrosstalk dabc869aeb
fix(chat): open full chat in place instead of a new window (#1181)
The chat modal's pop-out button called window.open('/chat', '_blank').
/chat is served by the admin app itself, so this spawned a second browser
window for a same-origin internal route.

That breaks anyone running NOMAD as an installed web app or in kiosk mode:
clicking it leaves a stray window they then have to get back out of, which
is exactly the complaint in #1123.

Navigate with router.visit instead, and relabel the button from "Open in
New Tab" to "Open Full Chat" so it describes what now happens. IconMessage
isn't in the DynamicIcon registry (deliberately curated for tree-shaking),
so use the already-registered IconArrowRight.

Refs #1123

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:39:55 +00:00
chriscrosstalk cdf6c00d4c
feat: support gated downloads for self-hosted curated content (#1172)
Adds an optional `auth: 'nomad_app_key'` discriminator to curated manifest
resources so a curated collection tier can carry content we host ourselves,
gated to official release builds. Without this, only Creator Packs could use
the entitlement Worker; curated tier installs always downloaded
unauthenticated.

No behaviour change for any existing manifest entry: absent `auth` means
unauthenticated, exactly as today.

- `auth` declared on both the type and the VineJS validator. It has to be on
  the validator or VineJS strips it silently on fetch, and the gated download
  would then go out with no header and 401 for everyone. A dedicated spec
  guards that regression.
- Gated resources are pinned to their manifest URL (resolveZimDownload skips
  the catalog comparison) and excluded from catalog update checks, so a
  resource-id collision cannot let a third-party mirror overwrite our content.
  Consequence, commented rather than implied: gated content does not
  auto-update; new versions ship via the manifest.
- 401/403 on a download now reports that an official build is required instead
  of a raw axios status, which is what a fork build will hit.
- The pure `isGatedResource` predicate is deliberately split from the
  env-reading header builder: importing `#start/env` into
  zim_download_resolution triggers env validation at import time and breaks its
  unit tests.

Reuses CREATOR_PACKS_APP_KEY rather than minting a second secret — the question
it answers ("is this an official build?") is identical for both content types.

Verified end to end on a test server: `auth` survives validation into the
cached spec, the Bearer header attaches to only the gated resource, the file
lands byte-exact with an installed_resources row and a Kiwix library entry, and
an entry with a gated URL but no `auth` field fails with the intended message.

No catalog entry is included here. Manifests are fetched live from `main`, so a
gated entry must not merge until this ships and is adopted — pre-`auth` builds
strip the field and 401.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:39:55 +00:00
chriscrosstalk c46d658fb5
fix(benchmark): don't block submission when the AI host is this machine (#1166)
The #1157 gate blocks leaderboard submission whenever ai.remoteOllamaUrl is
set, using bare truthiness with no exemption for addresses that point back at
this same box. Someone running Ollama natively on the host while NOMAD runs in
Docker is measuring THIS hardware, yet is permanently blocked from submitting
with no in-app indication of why.

That configuration is not exotic: it is how the AI assistant is expected to
work on macOS, so under the current gate a Mac could never submit at all.

Exempts host.docker.internal (and gateway.docker.internal) plus the loopback
forms. host.docker.internal is the meaningful one — from inside the admin
container it resolves to the host, and it is what a native host install uses.

A LAN address is deliberately NOT exempt. 192.168.1.50 is indistinguishable
from another machine on the same network, and wrongly exempting it would let a
genuinely remote GPU's throughput be attributed to this hardware. A false block
is recoverable by clearing the setting; a false pass silently corrupts the
board.

Unparseable values fall through to blocked rather than allowed, so a malformed
setting cannot be used to slip past the gate.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 03:39:55 +00:00
cosmistack-bot 6d06608b28
chore(release): 1.34.0-rc.3 [skip ci] 2026-08-04 03:39:54 +00:00
jakeaturner 67a91ed5f6
docs: update release notes 2026-08-04 03:39:54 +00:00
chriscrosstalk eda6560e00
feat(benchmark): official multi-arch sysbench, resolved digest, platform metadata (#1158)
Three changes that together let ARM hardware appear on the leaderboard honestly.
Shipping them separately would leave ARM half-supported either way: without the
image a Pi cannot submit at all, and without the architecture field it submits
but is indistinguishable from x86.

1. PIN THE OFFICIAL MULTI-ARCH SYSBENCH IMAGE

severalnines/sysbench publishes amd64 only, so ARM hosts could not run the
System Benchmark at all — not a graceful failure, the container simply cannot
execute. Apple Silicon could only run it under Rosetta emulation, which distorts
the measurement it is taking, and that is what drove a community macOS fork to
substitute a different benchmark and submit incomparable numbers.

Swaps to ghcr.io/crosstalk-solutions/nomad-sysbench (Debian 12 + sysbench
1.0.20+ds-5, built for linux/amd64 + linux/arm64). One digest covers both
architectures; verified that pulling the pinned manifest-list digest resolves to
arm64 on a Raspberry Pi 5 and amd64 on x86, and that RepoDigests reports the
same manifest-list digest on both — so a single allowlist entry serves both.

No rescoring: 1.0.17 -> 1.0.20 measured 1.25% apart on identical hardware with
identical flags (7170.18 vs 7259.56 events/sec), inside run-to-run noise and
~0.3% on a composite. Both digests are allowlisted server-side, so the fleet can
cross over gradually.

2. REPORT THE DIGEST ACTUALLY RESOLVED

The submission previously sent SYSBENCH_DIGEST, the constant the client was
compiled with. The leaderboard validates that field, but a constant attests to
how a client was BUILT rather than what it RAN, so any build inherits a valid
value simply by carrying the same source.

Now reads it back from the image. Uses RepoDigests (the manifest digest we
pulled by), never Id — Id is the config digest, differs per architecture, and
would never match the allowlist. Falls back to the constant if inspection yields
nothing usable, so a benchmark never fails over provenance metadata.

Still forgeable, and always will be with an open-source client. It moves the bar
from "no effort" to "deliberate", which is the distinction that matters when
judging whether a submission is a mistake or a choice.

3. RECORD CPU ARCHITECTURE AND OS

The leaderboard is a single board across instruction sets by design, with
disclosure as the fairness mechanism. Without an architecture field an ARM result
sits unlabelled beside x86 — exactly what the disclosure exists to prevent.

All three fields come from the Docker daemon, reusing the docker.info() call
_detectRunEnvironment already makes. That is deliberate: inside the admin
container os.arch() and si.osInfo() describe the CONTAINER, not the host being
benchmarked.

  cpu_architecture  Architecture       x86_64 -> amd64, aarch64 -> arm64
  os_version        OSVersion          '24.04' (already structured, no parsing)
  os_name           OperatingSystem    'Ubuntu 24.04.4 LTS' minus the version

run_environment is kept rather than replaced: "which distro" and "is this
virtualised" are different questions, and WSL2 is a real performance factor.

String handling lives in app/utils/platform_metadata.ts with unit tests, matching
the amd_hsa_override convention, so it is testable without a Docker daemon.
Unknown architectures pass through verbatim rather than being guessed at, and
os_name falls back to the full description whenever the version is missing or
absent from it — an over-long name is harmless, a wrong one is not.

Columns are nullable and the submission fields optional, so results recorded
before this shipped remain submittable.

Closes #1156
Refs #1151
2026-08-04 03:39:53 +00:00
chriscrosstalk e4967d7729
fix(benchmark): block leaderboard submission when AI runs on a remote host (#1157)
DockerService.getServiceURL() resolves ai.remoteOllamaUrl ahead of the local
container, so when a remote AI host is configured the AI channel measures THAT
machine while every other channel measures this one. The submission then reports
someone else's tok/s under this hardware's CPU, RAM and disk.

Under v2 this matters more than it did under v1: ai_tokens_per_second carries
0.30 of the weight and the score is uncapped, so a remote GPU's throughput is no
longer limited by a clamp.

Adds a guard alongside the existing submit-time checks. Uses the same truthiness
predicate as getServiceURL, so the guard fires exactly when the remote routing
it is guarding against would occur. KVStore.clearValue() nulls the value and
getValue() returns null for that, so a cleared key correctly does not trip it.

Blocks submission only. Running the benchmark locally is still useful to the
operator — it just isn't a result about this box, so it shouldn't go on a board
that ranks hardware.

Known limitation: the check reads the KV at submit time, not at measurement
time, so benchmarking with a remote host and then clearing the setting before
submitting would still get through. That is a deliberate workaround rather than
an accident, and closing it properly needs a column on benchmark_results to
record how inference was reached. Worth doing if it ever shows up in practice;
not worth a migration on the evidence available.

Refs #1151
2026-08-04 03:39:53 +00:00
chriscrosstalk b1d507a7ed
feat(creator-packs): add missing Modern Rogue banner asset (#1147)
The modern-rogue pack entry landed in collections/creator-packs.json
(#1145/#1146) but its banner image was never committed, so the app fell
back to /creator-packs/modern-rogue.webp which 404s and the card rendered
without a header.

Adds the 1060x175 banner (RGB webp), matching the existing
project-nomad.webp and crosstalk-solutions.webp, generated from the
pack's source banner.png built on the pack-build workspace.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:39:52 +00:00
Jake Turner 0e78200653
chore(collections): update stale urls (#1148) 2026-08-04 03:39:50 +00:00
chriscrosstalk 5e7da38e4a
docs: point MeshCore Web to the official meshcore.io site (#1142)
The MeshCore Web app doc listed meshcore.co.uk as the official site. The
canonical MeshCore project (github.com/meshcore-dev/MeshCore, the firmware +
protocol) declares its homepage as meshcore.io, so update the two references
in the MeshCore Web section to point there.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:38:41 +00:00
chriscrosstalk 43e431e3f4
catalog: add The Modern Rogue creator pack (#1146)
Mirror of the main-branch catalog addition so dev carries the Modern Rogue
pack and it isn't dropped on the next dev->main release merge. ZIM is uploaded
to R2 and verified serveable via the entitlement Worker.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:38:41 +00:00
jakeaturner 7fc5132e9e
fix(UI): add API reference to Settings sidebar 2026-08-04 03:38:40 +00:00
jakeaturner 678bb17fbd
chore(deps): bump axios and systeminformation in /admin 2026-08-04 03:38:40 +00:00
chriscrosstalk c3a413a049
docs: recommend Ubuntu 26.04 LTS as the default base (#1141)
Validated Project NOMAD on Ubuntu 26.04 LTS across two builds (an AMD/CPU
box and an NVIDIA RTX 5060 box), so recommend 26.04 as the default going
forward. 24.04 LTS and Debian 12 remain supported.

- bug_report.yml: add Ubuntu 26.04 (Resolute Raccoon) to the OS dropdown
- README / CONTRIBUTING / FAQ: name Ubuntu 26.04 LTS as the recommended version
- getting-started.md: correct the GPU note. NOMAD's installer sets up the
  NVIDIA Container Toolkit and Docker runtime automatically; the user only
  needs the NVIDIA driver (via "Install third-party drivers" at OS setup)
2026-08-04 03:38:39 +00:00
chriscrosstalk 9309e7460d
fix(benchmark): warm the AI model before timed runs for reproducible scores (#1140)
The AI benchmark evicted resident models (forcing the benchmark model cold)
and then went straight into the timed median-of-N loop with no warm-up. On a
cold box the model-load + GPU spin-up cost landed inside the timed runs
(observed: 173s TTFT / 5.83 tok/s vs a ~80 tok/s warm steady-state), and
consecutive runs weren't isolated (a prior run left the model warm). Because
the AI channel is uncapped and ~30% of the composite, the same machine could
post a ~2x-different NOMAD Score depending on warm/cold state (888 vs 1958
observed back-to-back).

Add one discarded warm-up inference after eviction and before the timed loop
so every timed run measures warm, steady-state throughput. Cold and warm
invocations now converge on the same score. Best-effort: a warm-up hiccup
never fails the run.

Closes #1139
2026-08-04 03:38:39 +00:00
chriscrosstalk 8c87b25343
fix(benchmark): surface a clear reason when leaderboard submission fails (#1138)
The submit flow discarded the real failure reason and always returned the
generic "Failed to submit benchmark results." to the UI. The most common
cause is the leaderboard's one-per-hour rate limit (HTTP 429), which left
users with no idea why their submission failed or that retrying shortly
would also fail.

- benchmark_controller: return a clear, actionable message. Name the rate
  limit explicitly on 429; otherwise pass through the underlying detail
  (repository error or a service validation message like "already
  submitted"), falling back to the generic only when we have nothing.
- benchmark_service: attach the raw upstream `detail` to the thrown error so
  the controller can surface it.

The frontend already renders the server `error` string, so no client change
is needed.
2026-08-04 03:38:38 +00:00
NgoQuocViet2001 223ead7c4e
fix(benchmark): remove stale progress setter (#1136) 2026-08-04 03:38:38 +00:00
chriscrosstalk 69080b3a05
feat(drug-reference): tabbed redesign with grouped search, multi-select situations, and a disclaimer gate (#1137)
* feat(drug-reference): compact header + single dashboard tile

Phase 1 of the drug-reference redesign:
- AppLayout gains an opt-in `compact` prop (small inline logo+title) so tool
  pages reclaim the ~230px the full branding block costs; drug-reference/index
  opts in.
- Consolidate the two dashboard tiles (Drug Reference + When to use what) into a
  single Drug Reference tile with a broadened description (/conditions already
  redirects to /drug-reference).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): tabbed redesign — search-by-drug + by-situation + FDA data

- Split /drug-reference into three tabs (Headless UI TabGroup): 'Search by
  drug', 'By situation', 'FDA data'. Each tab runs only its own direction,
  which removes the two-overlapping-sections confusion.
- Search by drug: results grouped by active ingredient (IngredientGroup,
  single-ingredient groups first, combos after), drug-first result rows, and a
  collapsible de-jargoned filter drawer (Over-the-counter / Prescription, Form,
  Sort) that auto-collapses once results land.
- By situation: multi-select symptom chips → an 'Treats all N selected'
  intersection section pinned on top (computed client-side from each
  situation's result set) + one union group per situation.
- FDA data: the download/ingest control + status moved behind its own tab
  (the pre-ingest empty state stays the prominent download prompt).
- DrugResultRow now leads with the active ingredient (drug-first) by default,
  or the brand when rendered inside an ingredient group.
- Rename 'Compare interactions' → 'Compare label warnings' to match what it does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): rank situation matches single-ingredient-first

The raw FDA indication match floods a situation (e.g. Headache) with
many-ingredient homeopathic products, burying real OTC drugs and leaving the
cross-situation intersection empty. Pull a wider result set (200) and sort by
active-ingredient count ascending — not a medical judgement, the same
'single-ingredient first' principle as the drug-search grouping. Now real
drugs (acetaminophen, ibuprofen) surface on top and the 'Treats all N selected'
intersection actually finds the shared OTC options. Per-situation cards cap the
display to the top 25 (ranked), intersection uses the full set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): first-open disclaimer gate + first-group-expanded

- Add a required disclaimer modal on first open of the Drug Reference (Jake's
  mechanism): comprehensive not-medical-advice notice the user must acknowledge;
  acceptance is saved to the browser's localStorage (versioned key) so it isn't
  shown again on that browser, while new browsers/devices see it on first open.
  Non-dismissible (no backdrop/Escape) — only the acknowledge button closes it.
- Search-by-drug: expand the first ingredient group by default, collapse all
  subsequent groups (IngredientGroup gains an explicit defaultOpen prop,
  replacing the size heuristic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): intersection-first multi-select

Multiple situations now lead with the 'Treats all N selected' intersection and
only break out per-situation sections when the intersection is empty (nothing
treats all) — with a 'No single option treats all N of these' explainer. Keeps
the view combined when there's a shared answer, and only fragments as a fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): compact header on detail/interactions/conditions pages

Apply the compact AppLayout header to the drug detail, interactions, and
condition pages so they match the redesigned index instead of the full-height
branding block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(drug-reference): rename interactions heading to 'Compare label warnings'

Match the page heading + title to the button label, so the name reflects what
the view does (each drug's own FDA-labeled warnings side by side, not a
cross-drug interaction checker).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:38:37 +00:00
cosmistack-bot a128079c11
chore(release): 1.34.0-rc.2 [skip ci] 2026-08-04 03:38:37 +00:00
jakeaturner f6c626650f
docs: update release notes 2026-08-04 03:38:37 +00:00
Experimentos em Série 48b0dfc8a0
fix(rag): stop re-creating payload indexes on every embedded document (#1135)
_ensureCollection() runs once per document on the embed path, but only
createCollection sat behind the collectionExists guard — the
getCollections probe and the three createPayloadIndex calls fired
unconditionally every time. On large ZIM ingestions those redundant
requests consumed roughly 45% of per-document Qdrant time, making jobs
look stalled while they were slowly progressing.

Memoize ensured collections in a per-instance Set, recorded only after
every step succeeds so partial failures retry. The cache is cleared
when the Qdrant health check resets the client (server may have been
recreated), and the entry is dropped before resetAndRebuild()
recreates the collection it just deleted.

Memoizing instead of moving the index calls inside the guard keeps
missing indexes healing on collections that predate the current
payload schema.

Closes #1129

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 03:38:36 +00:00
Jake Turner 90489e06da
fix(amd): coerce gfx1103 (780M) to HSA_OVERRIDE 11.0.0 so it stays on GPU (#1134)
PR #1076 stopped forcing HSA_OVERRIDE_GFX_VERSION=11.0.0 on
"natively-supported" AMD iGPUs. That was correct for gfx1150/gfx1151
(Strix 890M / Strix Halo, which are in the bundled rocblas allowlist)
but wrong for gfx1103 (Phoenix/Hawk Point 780M/760M), which is NOT in
that list. Without the override, ollama drops the 780M with "no rocblas
support for gfx target" and falls back to CPU on a fresh AI provision.

Extract the gfx→HSA mapping into a pure, unit-tested util and map
gfx1103 → 11.0.0 (gfx1100 kernels), the value that worked on v1.33.0 and
that restores full GPU offload in the field. gfx1150/1151 stay native.

Also harden the installer's 780M detection (Hawk Point / "Radeon 780M/
760M" strings) so the gfx marker isn't silently deleted, and upgrade the
no-marker fallback log from info to warn since it can mask CPU fallback.
2026-08-04 03:38:36 +00:00
Jake Turner f5f2944516
build(Dockerfile): run drug reference codegen step (#1132) 2026-08-04 03:38:35 +00:00
caweis 6c0271cd6f
refactor(drug-reference): make collections JSON the single source for curated data (#1130)
Generate app/data/{conditions,natural_remedies,home_remedies}.ts from the
repo-root collections/*.json via `npm run gen:curated-data`, so the JSON is the
only file edited by hand. The generated modules keep the data compiled into the
image (no runtime file read, no path fragility, which is why the data was a TS
constant), and curated_data_sync.standalone.ts fails CI if a generated module
ever drifts from its JSON.

This removes the burden of hand-keeping the .json mirror and the .ts constant in
sync. Follow-up to the review discussion on #1040.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 03:38:35 +00:00
cosmistack-bot 09a651d330
chore(release): 1.34.0-rc.1 [skip ci] 2026-08-04 03:38:34 +00:00
jakeaturner a1570023ac
docs: update release notes 2026-08-04 03:38:34 +00:00
Jake Turner 04b5c1dfd9
feat: auto-generating OpenAPI docs with Scalar UI (#1128) 2026-08-04 03:38:33 +00:00
Jake Turner 8a356384d3
feat(AI): nomad.md for custom instructions (#1127)
* feat(AI): nomad.md for custom instructions
* fix(UI): broken HTML tag close
2026-08-04 03:38:33 +00:00
caweis 762a4a12ad
Add offline FDA drug reference (labels, interaction view, conditions, remedies) (#1040)
* feat(drug-reference): offline FDA drug labels, conditions, and remedies

Adds an offline medical-reference feature with three coupled layers:

- Drug Reference: full-text search over openFDA drug-label indications,
  a detail page per label, and a side-by-side single-drug comparison view.
  A two-phase background pipeline downloads the openFDA label parts to the
  storage volume (resumable) and ingests them into the search table.
- Conditions ("When to use what"): a curated spine of first-aid situations
  that maps each situation to matching OTC drugs, each linking back to its
  Drug Reference detail page.
- Curated remedies: hand-authored natural and home-remedy entries drawn from
  US-government public-domain sources (NCCIH, CDC, MedlinePlus, FDA), shown
  with their source links and the same safety disclaimers as the rest of the
  feature.

The drug-reference and conditions layers are intentionally coupled: the drug
detail page shows the situations a drug treats, and the conditions controller
reads the same drug_labels table.

Safety surfaces ship as written. The amber SafetyBanner ("informational only,
not medical advice, not an FDA endorsement, not a drug-interaction checker, in
an emergency call emergency services") renders on the condition pages and the
search page; the detail and comparison pages carry their own "not a cross-drug
interaction checker" callout; and every page carries the openFDA CC0 source
citation and no-FDA-affiliation footer.

Wiring on this branch:
- start/routes.ts: the /drug-reference and /conditions page GETs plus their
  /api/* groups.
- commands/queue/work.ts: the drug-download and drug-ingest queues, both at
  concurrency 1. The two drug queues get a per-queue stall override
  (lockDuration 1_800_000, maxStalledCount 3) because each part is one long
  stream; every other queue keeps the existing 300000 default.
- inertia/pages/home.tsx: Drug Reference and "When to use what" tiles. The
  icon and display_order are a starting point, open to change.
- types/kv_store.ts: the two drugReference.* keys the pipeline reads and writes.
- package.json: yauzl and stream-json (plus their @types), used by the ingest
  job to stream the label JSON out of the downloaded zips.

The app reads the conditions and remedy data from the compiled TS constants in
app/data/; the repo-root collections/*.json files are the browseable mirrors.
The natural-remedies standalone test reads collections/natural_remedies.json to
assert the two stay in sync, so that file is also a test fixture.

The four standalone tests pass (drug_interactions, drug_ingest_status,
conditions, natural_remedies). tsc reports no errors in the feature code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(drug-reference): drop a fork-internal comment reference

* fix(drug-reference): address review — defer FULLTEXT, fallback remedies prop, strip fork refs

- migration: wrap the FULLTEXT ALTER in this.defer so it runs after the
  deferred createTable (was silently swallowed, index never created)
- controller: add remedies:[] to the index() error fallback (required prop)
- strip fork-internal issue/spec references from ported comments
- tsconfig: exclude tests/standalone (node --experimental-strip-types only)
- correct the varchar(768) byte-math comment; extend remedy-spine test

* fix(drug-reference): make the interaction comparison readable at five drugs

The comparison view laid its columns out on an equal-fraction CSS grid
(repeat(N, minmax(0, 1fr))), so each added drug shrank every column; at the
five-drug maximum the FDA interaction text was squeezed into unreadable slivers.

Lay the columns out with flex instead: full-width and stacked on phones, then
fixed-width columns that scroll sideways from the sm: breakpoint up, so they
never shrink below a readable width. Theme the columns with the same palette as
the rest of the page (they were on stock gray), and give the headers a fixed
min-height so columns line up when drug names wrap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(collections): route a 'dataset' tier resource to the drug pipeline

Add an optional `type` discriminator to SpecResource ('zim' | 'dataset',
absent == 'zim'), so the tier installer can carry a DB-ingested resource
alongside ZIM files. ZimService.downloadCategoryTier branches on it: a
'dataset' resource dispatches the existing FDA download+ingest pipeline
instead of RunDownloadJob, guarded against duplicate dispatch by the drug
ingest status. Every existing manifest entry has no `type` and keeps the
exact ZIM path.

Widen InstalledResource.resource_type to include 'dataset' and exclude
dataset rows from the ZIM/map catalog-update scan (datasets aren't
filename-versioned; their freshness path is separate). No dataset rows are
written yet: the InstalledResource 'dataset' row on ingest-ready, the
manifest entry, install-gating, and the downloads-aggregator integration
are follow-up commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(drug-reference): rework into an opt-in medicine-standard tier dataset

Reshapes the offline FDA drug reference from a built-in feature into curated
content installed by selecting the Medicine / Standard tier, per maintainer
direction.

- install-state: the ingest writes an installed_resources 'dataset' row on
  ready (version = the openFDA export_date), threaded installer to download to
  ingest; the tier-status math and the home-tile gate read it. Manual ingests
  write no row, so install-state stays tied to the curated path.
- manifest: declare the dataset in the medicine-standard tier (runtime fetches
  the remote manifest, so this also needs to land upstream).
- install-gating: the drug-reference home tiles render only when installed.
- uninstall: DrugReferenceService.uninstall() stops the two drug queues, deletes
  the on-disk parts, truncates drug_labels (schema kept), clears the KV markers,
  and drops the install row. Best-effort, logged, scoped to drug data only.
- downloads: the download phase reports the canonical {percent, downloadedBytes,
  totalBytes} shape as one drug-data card in the Active Downloads aggregator with
  cancel/remove; the heavy ingest stays in the IngestStatus surface with an
  Indexing handoff on the card.
- auto-update: a daily DrugAutoUpdateJob compares the manifest export_date and
  re-downloads when newer, gated on installed + no active job.

typecheck clean; the drug standalone suites pass. Three points are flagged in
code for the maintainer: the InstalledResource 'dataset' approach, the tier
home, and the export_date string format.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(downloads): keep the drug download card live across all parts

The Active Downloads card filtered to the deterministic jobId, but the
download's continuations run under auto-generated jobIds (only part 0 uses the
deterministic one). So the card tracked part 0 and then vanished while parts
2..N kept downloading. The queue is concurrency 1, so collapse to whichever
single part is in flight and report the deterministic jobId: one card tracks
aggregate progress through the whole download and cancel/remove still routes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(drug-reference): co-locate a persistent safety note with affirmative remedy guidance

Add RemedySafetyNote at the head of every natural-remedy section — the two on
Drug Reference and the one on "When to use what" — so the "informational only,
not medical advice, seek real medical care in an emergency" framing appears with
the guidance itself, not only in the page-top banner. Replaces the terse
per-section caveat with the same amber alert language as SafetyBanner.

Addresses the upstream #1040 review request that the disclaimer be unmistakable
and present wherever affirmative self-care guidance appears, not a one-time banner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drug-reference): fold dataset freshness into the content-auto-update path

The drug dataset auto-updated on its own daily cron that ignored the
contentAutoUpdate.* master switch, so it would refresh even with content
auto-update turned off, and it didn't ride the content-update path the way
ZIMs and maps do.

Move the export_date freshness/apply orchestration onto
DrugReferenceService.attemptAutoUpdate(), add
ContentAutoUpdateService.attemptDrugDataset() gated on the same enabled +
window config, and have the hourly ContentAutoUpdateJob drive both. Retire
the standalone DrugAutoUpdateJob. The ZIM/map attempt() path is unchanged.

Addresses the upstream #1040 request to wire the openFDA export_date check
into the content updater so the dataset updates alongside ZIMs and maps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(drug-reference): strengthen the remedy note to consult a clinician before combining with meds

Widen the affirmative-remedy safety note from "talk to a clinician before use"
to explicitly cover using a remedy AND combining one with a medication the user
already takes — the interaction case is the higher-risk path for an off-grid
user self-treating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(drug-reference): gate affirmative remedy content behind an off-by-default flag

Add drugReference.remediesEnabled (default off), independent of the tier
install. When off, the server emits no remedy data at any boundary — the
drug-reference page prop, conditions show, and the /api/conditions/drugs
situation search — and the "Natural" filter is hidden, so installing the
medicine-standard tier lights up the verbatim FDA label search and the
condition-to-OTC matching but not the hand-authored self-care and herbal
sections. No user-facing toggle: it is flipped on after a clinician content-pass.

Implements the upstream #1040 split-by-risk request: the regulated label content
ships with the tier; the authored remedy guidance stays gated until sign-off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jake Turner <52841588+jakeaturner@users.noreply.github.com>
2026-08-04 03:38:33 +00:00
jakeaturner abc5730667
fix(KVStore): missing apps.homebox key 2026-08-04 03:38:32 +00:00
jakeaturner b005a671c6
fix(Benchmark): various typescript errors 2026-08-04 03:38:32 +00:00
chriscrosstalk e711134f04
feat(benchmark): lock Score v2 AI reference to 13.2 (measured, was placeholder) (#1097)
Replaces the stale 40.5 placeholder (a llama3.2:1b CPU-era number) with 13.2,
measured on the Reference Build (NOMAD6, 780M) under the v2 AI harness with
llama3.1:8b and iGPU acceleration (OLLAMA_IGPU_ENABLE — the provisioning fix in
PR #1074). This is the AI channel's reference for the uncapped v2 score.

Must ship together with #1074 in v1.34.0: the reference assumes iGPU-accelerated
AMD boxes, so shipping it without the iGPU fix would score AMD installs on CPU
numbers against a GPU bar. The exact value gets a final confirm against the
shipping Ollama config before GA.

Must stay byte-identical to the leaderboard's score_service.ts REFERENCE_SCORES_V2
(the server recomputes the score on submit); the matching leaderboard change is a
sibling PR. Changing the leaderboard reference has no effect on existing rows —
the v1->v2 backfill neutralizes the AI channel (ratio 1 regardless of the
reference), and there are no real v2 submissions yet.

Sibling to the Score v2 app client (#1094); stacked on it (same file/constant).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:38:31 +00:00
chriscrosstalk 0a2666a2da
feat(benchmark): dashboard re-run banner prompting a Score v2 re-run (#1096)
Shows a dismissible dashboard banner to users who submitted a benchmark under
v1 scoring but don't yet have a Score v2 result, nudging them to re-run so the
community leaderboard gets their v2 number.

- Show logic (benchmark_service.shouldShowRerunBanner): has a result with
  submitted_to_repository=true AND no result with nomad_score_v2 AND the
  dismiss KV isn't set. Self-clears two ways — dismiss sets the KV, and any v2
  run gives a result a nomad_score_v2 so the condition flips off on its own.
- New GET /api/benchmark/rerun-banner + useBenchmarkRerunBanner hook (mirrors
  useUpdateAvailable), rendered as a dismissible Alert on the dashboard with a
  "Re-run benchmark" CTA to /settings/benchmark.
- New KV key benchmark.rerunBannerDismissed (boolean) in both KV_STORE_SCHEMA
  and the SETTINGS_KEYS whitelist (the PATCH /system/settings validator enum);
  dismiss writes it via the existing updateSetting endpoint + invalidates the
  query.

Sibling to the Score v2 app client (#1094); stacked on it for the
nomad_score_v2 column. Browser-verified on the NOMAD3 dev env: show/dismiss/
reload-persist/self-clear-on-v2 all correct.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:38:31 +00:00
Chris Sherwood 363a99818c
feat(benchmark): Score v2 app client — raws, uncapped score, v2 payload + UI
Phase 4 of the NOMAD Score v2 effort (stacked on #1089). Captures the raw
channel values the leaderboard scores from, computes the uncapped v2 score
in-app (byte-matching the leaderboard's server-side recompute), submits the
v2 payload, and surfaces it in the benchmark UI.

- Capture raws: per-channel sysbench values + O_DIRECT disk (W4), single/multi
  thread CPU + memory, W6 consistency companions, and run-environment metadata
  (run_environment, storage_path_type, gpu_compute_detected, #1016).
- AI hardening: reference model llama3.1:8b, num_predict=256, VRAM eviction
  before the run + unload own model after, pre-flight disk check (~6.5GB, only
  when the model is uncached), median-of-3 run (W7).
- Score: _calculateNomadScoreV2 (uncapped, reference->1000) byte-matches the
  leaderboard recompute. nomad_score_v2 stays null unless a full run with AI.
- Payload: submitToRepository sends the v2 raws + score alongside legacy v1.
- UI: v2 headline score, legacy v1 as secondary, raws/env in details.
- Migration: 13 nullable columns; double (not knex float(8,2)) so millions-scale
  raws keep full precision and don't break the leaderboard byte-match.

Reference constant REFERENCE_SCORES_V2.ai_tokens_per_second is still the stale
40.5 placeholder (TODO in source); locking it to the measured 13.2 is a
follow-up gated on the AMD iGPU fix (#1074). Live prod v2 submit is separately
blocked by the leaderboard TTFT floor (tracked in the leaderboard follow-up).

Verified on NOMAD3 (RTX 5060) dev env: migration applies, raws recorded,
v2 score computed, backend + benchmark.tsx typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:38:30 +00:00
NgoQuocViet2001 42e1d584ef
fix(content): resolve current ZIM URL before download (#1091)
* fix(content): resolve current ZIM URL before download
* fix: compare ZIM catalog versions numerically
2026-08-04 03:38:30 +00:00
Roberto dd2b5ce3e7
fix(maps): warn when world basemap missing instead of silent grey map (#1104)
An install that never went online while visiting /maps never provisions
world.pmtiles (it is lazily extracted from the internet), leaving the map
blank/grey offline outside any downloaded regional extracts.

Detect the world basemap's presence on disk and surface it to the UI so:
- /maps shows a clear notice explaining the base map isn't downloaded and
  how to fix it, instead of a silent grey screen.
- /settings/maps offers an explicit "Download Base Map" action (~15 MB) to
  provision it deliberately while online.

Adds MapService.checkWorldBasemapExists() / provisionWorldBasemap(), a
POST /maps/setup-world-basemap endpoint, and threads worldBasemapExists
through the maps page props.

Refs #1030

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 03:38:29 +00:00
chriscrosstalk 8068e05770
feat(AI): per-model thinking toggle with global default (off) (#1079)
* feat(AI): per-model thinking toggle with global default (off)

Stop forcing thinking on for every capable model. Adds a global default
(ai.autoThinking, ships OFF) and a per-model override in the chat window,
shown only for thinking-capable models and remembered client-side.

The /v1 (OpenAI-compat) endpoint ignores `think`; reasoning_effort is the
real lever. The controller resolves per-request preference -> global default
-> OFF (gated on capability), and the service maps that to reasoning_effort
('none' to suppress on a capable model, 'medium' for gpt-oss, unset to let a
capable model default thinking on). A new thinkingCapable flag keeps
non-Ollama backends from ever receiving reasoning_effort.

installed-models is enriched with a `thinking` flag (checkModelHasThinking,
now memoized) so the picker knows which models get the toggle.

Stacks on #1078 (reasoning-field read + client-disconnect abort).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(AI): thinking toggle as NOMAD Switch + info tooltip

Address review feedback on the per-model thinking control:
- Use the shared Switch component (matches AI Assistant settings) instead
  of a raw checkbox.
- Label "Thinking:" with a colon to match the adjacent "Model:" label.
- Add an InfoTooltip explaining what thinking does and where the default
  lives. Extend InfoTooltip with optional `position` ('top'|'bottom') and
  `align` ('center'|'right') so it opens downward and expands leftward from
  the right-edge header slot instead of being clipped/crushed against the
  viewport edge. Defaults preserve existing (benchmark page) behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:38:29 +00:00
chriscrosstalk 12b0a77873
feat(creator-packs): gated per-creator video packs, offline via Kiwix (#1106)
* feat(creator-packs): backend rail for gated creator content packs

Phase 1 of Creator Packs: the app-side download rail for per-creator
video ZIMs served from the private R2 bucket behind the entitlement
Worker. No UI yet (Phase 2).

- Thread optional requestHeaders through the download util + job so the
  ZIM fetch can carry `Authorization: Bearer <APP_KEY>` on both the HEAD
  probe and the GET stream (Worker streams the ZIM directly, gated).
- Add resourceMetadata.skip_embedding and gate the RAG/EmbedFileJob
  branch on it so video ZIMs are never sent to the knowledge base.
- New creator_packs ManifestType + creatorPacksSpecSchema (display
  metadata only, no url) + CollectionManifestService wiring and
  getCreatorPacksWithStatus() status join.
- collections/creator-packs.json catalog with the two seed packs.
- CreatorPackService.installPack: ensure Kiwix (auto-install if absent),
  resolve the stable Worker URL, dispatch the gated download with
  skip_embedding. Typed result codes.
- CreatorPacksController + GET/POST /api/creator-packs routes.
- CREATOR_PACKS_APP_KEY / CREATOR_PACKS_WORKER_BASE env (release-injected
  key; app degrades to a clear not_configured error when unset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(creator-packs): Phase 2 UI — Content Explorer, settings page, wizard step

Adds the three user-facing surfaces for Creator Packs on top of the Phase 1
backend rail. All surfaces HIDE entirely on builds without the release-injected
key (configured=false) so a fork never shows a broken install button.

- Backend: CreatorPackService.isConfigured() + `configured` flag on the
  GET /api/creator-packs response; /settings/creator-packs route + controller
  action.
- api client + useCreatorPacks hook (shared cached source of configured/packs/
  downloads for every surface) + CreatorPackCard (status badges, art fallback,
  update pill) + CreatorPacksSection (install-on-click grid + confirm modal with
  the license note), reused by the Content Explorer block and settings page.
- Content Explorer: Creator Packs section before Additional Content.
- Settings: new /settings/creator-packs manage page + conditional nav item.
- Easy Setup wizard: new Creator Packs step after Content. Replaces the fragile
  hardcoded AI-skip math with a computed activeSteps list so the two optional
  steps (Creator Packs + AI) navigate correctly in every on/off combination;
  selection state, storage projection, review summary, and finish-install wired.

Browser-tested on NOMAD3 across all surfaces (configured + fork paths), incl.
live install→download→cancel and AI-on/off wizard renumbering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(creator-packs): use branded pack banners on the cards

Replace the generic colored-block cards with the branded 1060x175 banners we
build into each pack ZIM, shown on both the settings page and the Easy Setup
wizard step (and the Content Explorer block, via the shared card).

- Bundle the seed-pack banners as local webp under admin/public/creator-packs/
  so they render offline (no external image dependency, matching the existing
  /rogue-support-banner.webp convention).
- CreatorPackCard is now banner-forward: the banner is the hero, with a compact
  status/metadata footer (videos · size, Installed/Downloading/Selected/Install,
  update pill). Falls back to a simple header if an image is ever missing.
- Prefer an optional catalog `banner_url` (future remote creators) over the
  bundled-by-id path; add banner_url to the CreatorPack type + validator.
- Widen the card grids to 1–2 columns since banners are wide.

Browser-tested on NOMAD3: banners render on the settings page and wizard step;
selection/installed/downloading states and storage projection still correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(creator-packs): Phase 3 — uninstall, license, release key-injection

Completes the Creator Packs feature.

- Uninstall: CreatorPackService.uninstallPack reuses ZimService.delete (removes
  the ZIM, drops it from the Kiwix library, clears the InstalledResource) behind
  DELETE /api/creator-packs/:id. A trash control on installed cards appears only
  on the settings "manage" surface (allowUninstall) — never on the Content
  Explorer block or the wizard — with a danger confirm modal.
- License: draft "Project NOMAD Creator Pack License v1.0" at
  collections/creator-pack-license.md (marked DRAFT — pending legal review;
  personal-use, no-redistribution, official-channel-only, creators retain
  copyright). The install modal links to it ("View license").
- Release key-injection: Dockerfile ARG/ENV CREATOR_PACKS_APP_KEY in the runtime
  stage (empty default → source/CI builds ship unconfigured and hide the UI) +
  build-primary-image.yml passes it from the CREATOR_PACKS_APP_KEY CI secret.
  NOTE FOR JAKE: add that repo secret = the entitlement Worker's key.
- Update flow was already functional (installed-with-update cards → "Update
  pack"); verified.

Browser-tested on NOMAD3: uninstall round-trip (file + DB row + kiwix library
cleaned, then reinstalled), settings-only uninstall control, install-modal
license link, and the "Update available" pill via a temporary catalog bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(downloads): clean up header merge logic

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: jakeaturner <jturner@cosmistack.com>
2026-08-04 03:38:27 +00:00
Andrew Barnes e175c5cb4a
fix(chat): make conversation layout responsive (#1090) 2026-08-04 03:37:42 +00:00
just-jbc 4a12049931
fix(rag): add proper .docx text extraction via mammoth (#1100)
* fix(rag): add proper .docx text extraction via mammoth

.docx files were classified as plain text and routed through raw-text
extraction (extractTXTText). Since .docx is a ZIP archive containing XML,
this produced garbage content in the Knowledge Base — XML tags and binary
noise instead of the actual document text.

Adds a dedicated 'docx' file type (split out of the generic 'text' bucket
in determineFileType) and a mammoth-based extractor that parses the
document XML properly.

* fix: clean up package-lock.json diff

* chore(deps): pin mammoth version

---------

Co-authored-by: John Cortright <jcortright@zscaler.com>
Co-authored-by: jakeaturner <jturner@cosmistack.com>
2026-08-04 03:37:42 +00:00
just-jbc ac141a33b4
feat(rag): add subject/collection organization to knowledge base (#1063)
* feat(rag): add subject/collection organization to knowledge base

- Add nullable collection field to KbIngestState, propagated through the
  embed job, RAG service, and Qdrant point payloads (indexed for filtering)
- Add upload-time category selection and per-file collection reassignment
  in the Knowledge Base modal, with a filterable Stored Files table
- Add a 'Search in' collection filter to the chat interface, threaded
  through to searchSimilarDocuments as an optional Qdrant filter
- Fix .docx extraction: previously routed through raw-text extraction
  (garbage output for a ZIP-based XML format); adds a proper mammoth-based
  extractor and a dedicated 'docx' file-type case

* feat(rag): support dynamic KB collection creation, rename, and removal

Extends collection organization with a Manage Collections UI: collections
are created on the fly when a file is assigned to a new name, can be
renamed (bulk-updates every tagged file and Qdrant point), and can be
removed (reassigns tagged files back to Uncategorized rather than
deleting anything).

* fix(rag): use dynamic collections query in chat search filter

chat/index.tsx still imported the static KB_COLLECTIONS constant for its
'Search in' dropdown, inconsistent with KnowledgeBaseModal.tsx which already
uses the live getKnowledgeCollections() query. Renamed/added collections
via the new Manage Collections UI weren't reflected in the chat filter.

* feat(rag): broaden preset tags and add creatable collection combobox

Replaces the survival-specific preset list with general-purpose starter
tags (recipes, diy, health, technology, finance, travel, hobbies,
reference, survival, energy) so the Knowledge Base reads well for
home-lab/reference use, not just prepping.

Adds sanitizeCollectionName() (trim, lowercase, length cap) applied on
every write path server-side, and a dependency-free CollectionCombobox
component replacing the plain <select> + window.prompt pattern for
tagging — autocompletes against presets + tags already in use, with a
'+ Create' option for anything new.

* chore(rag): remove .docx fix from this branch, split into #1100

Per review feedback, the .docx extraction fix is unrelated to the
collections feature and can merge independently. Moved to a standalone
PR (Crosstalk-Solutions/project-nomad#1100) off dev.

* chore: remove unrelated diff noise (lockfile, comments, indentation)

---------

Co-authored-by: John Cortright <jcortright@zscaler.com>
2026-08-04 03:37:42 +00:00
chriscrosstalk 3f450a41ae
feat(benchmark): harness hardening — fail loudly + pin sysbench + record provenance (#1089)
Score v2 Phase 1. Safe under v1 — no scoring, weight, reference, or
submission-payload changes; only the benchmark's failure behavior and
forensic metadata.

- Fail-on-parse-miss (W3): the four SCORED sysbench metrics (CPU
  events/sec, memory ops/sec, disk read/write MiB/s) now THROW when the
  regex misses or the value is <= 0, instead of silently returning 0.
  A parse failure (e.g. an upstream image output-format change) now
  fails the run with a clear error via the existing _runBenchmark
  try/catch, rather than submitting a phantom zero sub-score. Secondary/
  informational fields keep their existing defaults.
- Pin sysbench by digest (W3): severalnines/sysbench@sha256:64cd003b...
  (was :latest), so a latest-tag format change can't break the parsers
  fleet-wide. Digest validated on the NOMAD6 reference build.
- Record provenance (W7): sysbench_digest + ollama_version (from Ollama
  /api/version, null-tolerant) stored on each result. New nullable
  columns + additive migration.

Verified: pinned digest pulls/runs/parses on NOMAD3; migration applies
(columns present); typecheck clean.

Part of the NOMAD Score v2 effort.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:41 +00:00
chriscrosstalk 03db9da24a
fix(benchmark): partial runs are not the NOMAD Score (relabel + renormalize) (#1088)
A System-Only or AI-Only run is a partial result, not the NOMAD Score
(which is the full-benchmark composite). Two problems addressed:

1. Scoring bug: AI-only runs were NOT renormalized. _calculateNomadScore
   always added the system weights (0.60) to the denominator even for an
   AI-only run (default-zero system scores), so an excellent AI-only run
   scored ~39.8 -- scaled against the full NOMAD 100 where AI is only 40%
   -- while system-only already renormalized correctly. Fix: pass
   systemScores only when the system benchmarks actually ran, so AI-only
   renormalizes to its own 0-100 (39.8 -> ~99.7). Full and System-only
   scores are unchanged.

2. Presentation: partial runs were shown with the full "NOMAD Score"
   label + big gauge, outweighing the small "Partial" notice. Now partial
   runs are relabelled "System Score" / "AI Score" with a PARTIAL badge,
   a muted (neutral) gauge + number, and a "run a Full Benchmark for your
   NOMAD Score" CTA -- applied to both the persistent score section and
   the Phase 3 ScoreReveal via a shared getScoreDisplay() helper. Adds a
   `muted` prop to CircularGauge.

Browser-validated on NOMAD3: AI-only now shows "AI Score" + PARTIAL,
muted gauge, 99.7 (was 39.8); Full still shows "NOMAD Score" green.
Implements the display-layer fix from the Score v2 red-team's W2; the
AI-ceiling saturation (W1) remains v2 work.

Part of #1082.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:41 +00:00
chriscrosstalk c5cfcbf238
feat(benchmark): end-of-run score reveal + NVIDIA GPU-util overlay (#1087)
Phase 3 of the live benchmark run experience.

- ScoreReveal: replaces the abrupt run-view unmount with a deliberate
  end-of-run "REPORT" card -- animated NOMAD score gauge + odometer
  count-up number, sub-score gauges cascading in, Continue button +
  5s auto-dismiss. Takes score scale as a prop so it survives Score v2.
- GPU-util overlay (NVIDIA): during the AI stage, a ~1Hz nvidia-smi poll
  inside the Ollama container feeds live GPU utilization + VRAM into the
  telemetry frames; shown in the AI hero, hidden when absent (AMD/none).
  Poller is side-effect-only and cleared in a finally -- scored numbers
  unchanged.
- Disk polish: reset in-test buffers on stage transition so the write
  stage no longer briefly shows the carried disk-read value.

Browser-validated on NOMAD3 (RTX 5060): GPU overlay live (1% util,
0.2/8.0 GB VRAM during model load); reveal cascade + count-up; AI-only
score 39.8 (scored path unchanged).

Part of #1082 (tracker stays open).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:40 +00:00
chriscrosstalk f211a72c62
feat(benchmark): authoritative in-test sysbench numbers + results strip (#1085)
Phase 2 of the live benchmark run experience. Streams sysbench
--report-interval=1 interim lines over a Docker attach so the run view
shows authoritative in-test CPU events/sec and disk read/write MiB/s
(overlaying the Phase 1 host-proxy disk numbers), and fills a
"results so far" strip as each stage completes.

- _runSysbenchCommandStreaming: attaches to the container output for
  live onLine callbacks, but returns the authoritative output via
  container.logs() after exit -- byte-identical to _runSysbenchCommand,
  so the SCORED numbers are unchanged (attach 'data' can flush after
  container.wait() resolves, which would truncate the final report).
- CPU + disk stages stream interim eps: / reads:/writes: MiB/s into
  setStageMetric; memory stays non-streaming (too fast to sample).
- _emitPartialResult broadcasts each finished stage's raw result on the
  progress channel; useBenchmarkRun accumulates them; ResultsSoFar
  renders the chip strip.
- Frontend: live CPU ev/s readout+sparkline, disk hero switches to
  "Benchmark throughput" when in-test numbers arrive.

Browser-validated on NOMAD3 (System-Only): CPU 6536 ev/s live, disk
10399 MB/s benchmark throughput, results strip, final score 68.3.

Part of #1082 (tracker stays open for Phase 3).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:40 +00:00
chriscrosstalk 8d6b2045ef
feat(benchmark): live telemetry during benchmark runs (#1082) (#1084)
Phase 1 of the live benchmark-run experience. Replaces the opaque (and
in sync mode, simulated) progress bar with a real-time run view driven by
actual host telemetry.

- Async run path: the UI now dispatches to the queue worker and keys off
  SSE instead of faking stage progress with client-side timers.
- New BenchmarkTelemetrySampler broadcasts per-core CPU load, CPU temp
  (best-effort, hidden when unavailable), and disk MB/s at 1 Hz over a new
  benchmark-telemetry SSE channel. Runs in the orchestration process, never
  the sysbench container, so it cannot affect scores.
- BenchmarkProgress carries the ordered stage plan + index so the frontend
  renders a live stage rail.
- AI benchmark streams /api/generate for live tokens/sec and true TTFT; the
  scored numbers still come from Ollama's authoritative final eval fields.
- Frontend: useBenchmarkRun hook owns both subscriptions; self-contained SVG
  components (StageRail, CoreGrid, Sparkline, LiveReadout) + BenchmarkRunView,
  styled in the desert palette. No chart library added.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:39 +00:00
jakeaturner ee1b6a594e
chore(deps): bump tar, vite, and dockerode in admin 2026-08-04 03:37:39 +00:00
chriscrosstalk 063c784889
fix(content): refresh installed ZIMs when a download completes to prune ghost entries (#1099)
The Content Explorer remote list already filters out installed ZIMs client-side
(flatData excludes localNames), but the installed-files query has
refetchOnWindowFocus disabled and was never invalidated on download completion.
So when a ZIM finished downloading it dropped off the active-downloads list
(isDownloading -> false) while localFiles stayed stale (isPresent -> false),
letting the just-installed ZIM reappear as a ghost entry until the page remounted.

Add an effect that invalidates the ['zim-files'] query whenever a job drops off
the polled downloads list, so the completed install is picked up and pruned.

Fix originally diagnosed by @johno10661 in #771; reimplemented focused onto
current dev (the accumulated-page/filter groundwork already landed via #731).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:38 +00:00
chriscrosstalk 3096c1e81a
fix(downloads): send a descriptive User-Agent so Wikimedia mirrors don't 403 (#1114)
download.kiwix.org routes the large Wikimedia-family ZIMs (Wikipedia,
Wikiversity, Wikibooks — including the flagship full Wikipedia) to
dumps.wikimedia.org, which enforces a User-Agent policy and returns HTTP
403 for requests with a missing or generic (axios/x) User-Agent. Because
doResumableDownload sent no User-Agent, every Wikimedia-hosted ZIM failed
to download while Kiwix-mirror-hosted ZIMs succeeded — so a curated set or
Easy Setup run would silently stall on exactly the highest-value content.

Add a descriptive User-Agent to the HEAD and GET requests. Verified
against the live mirror: default/empty UA -> 403, ProjectNOMAD UA -> 200/206.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:38 +00:00
chriscrosstalk f679182218
feat(dashboard): add dismissable "What's new" banner for v1.34 (#1112)
Surface post-upgrade release highlights on the Command Center: a
dismissable banner keyed to the running build's release line (major.minor).
It appears when a user upgrades into the line and every patch within it,
then disappears on the next minor. Dismissal is remembered per-line in
localStorage, so dismissing v1.34 won't suppress a future v1.35 note.

Reuses the existing dismissable Alert component and the dashboard's top
banner slot; reads the running version from the appVersion shared prop.
No schema or migration needed — to surface a new release's highlights,
bump WHATS_NEW.version and replace its highlights.

Initial content: Creator Packs and the offline Medication Reference.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:37 +00:00
chriscrosstalk 23b5125404
fix(easy-setup): streamline wizard + robust model recommendations (#1110)
Step 1: remove the "Additional Tools" (Notes/Data Tools) section from
onboarding and point users to Supply Depot, the browsable app catalog,
for everything beyond the three core capabilities.

Step 2: add a note that individual countries and a full global map can
be installed any time from the Maps Manager.

Step 4: default the KB auto-index policy to "Ask me first" (Manual)
instead of "Yes, always" — auto-indexing has cost/resource implications
a non-technical user won't anticipate from the toggle alone.

ollama_service: the recommended-models fallback only fired on a null
result, so a successful-but-empty upstream response (models: []) showed
"No recommended AI models available" and poisoned the 24h cache. Now the
fallback fires on empty too, empty results are never cached, empty caches
are ignored, and the upstream request has a 10s timeout.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:37 +00:00
chriscrosstalk e9214e21b8
docs(install): make storage-relocation guidance accurate and consistent (#1103)
The two compose comments contradicted each other on NOMAD_STORAGE_PATH (one
called it optional/"explicit", the other "MUST match") and neither warned about
the failure modes that actually break relocation. A user hit exactly this on
#1050: a case-mismatched path silently produced an empty Kiwix library.

- Frame the admin /app/storage host path as the single source of truth; the
  admin auto-detects it (#938) and child apps follow, so no per-service edits.
- NOMAD_STORAGE_PATH is a fallback that should be kept in sync, not a hard
  requirement (reconciles the contradiction).
- Add the real gotchas: move existing data first (keep zim/models subfolders),
  paths are case-sensitive, and update the disk-collector volume too or host
  disk stats point at the wrong place.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:37 +00:00
chriscrosstalk b620adbf66
feat(debug-info): add storage, docker, GPU health, and auto-update diagnostics (#1102)
The Debug Info bundle carried no storage-path info, which stalled diagnosis of
relocation issues like #1050 (moved data not seen by the admin). Add the fields
support actually asks for, all best-effort so one failure never blanks the bundle:

- Storage: resolved host storage root (#938), container path, whether
  NOMAD_STORAGE_PATH is set, and the Kiwix library book count (0 books is the
  tell for an empty/wrong-path library).
- Docker Engine version (reporters currently paste it by hand; needed for
  container/updater issues).
- GPU passthrough health (gpuHealth.status + detected gpu.type) for the
  passthrough-lost-after-update class (#755/#878).
- Auto-update status for core/apps/content plus any auto-disabled reason,
  since the auto-update trilogy shipped.

Adds a public DockerService.getHostStorageRoot() wrapper over the existing
#938 resolver.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:36 +00:00
chriscrosstalk f2a713d1f3
fix(updater): prune superseded images after update to reclaim disk (#1101)
* fix(updater): prune superseded images after update to reclaim disk (#858)

The sidecar updater pulled new image versions on every update but never
removed the old ones, so /var/lib/containerd grew unbounded across
releases (50+ GB of orphaned layers observed on long-running installs).

After a confirmed-successful recreate, prune (1) dangling layers left by
re-pulled moving tags and (2) superseded tags of the core services this
updater manages (the images in compose.yml), keeping the refs now in use.

Deliberately avoids `docker system/image prune -a`: that would delete
images for installed-but-stopped Supply Depot / curated services and
force a re-pull that fails on an offline box. Scoped strictly to
compose-managed repositories; optional/offline images are never touched.
Uses `docker rmi` without -f so anything still referenced by a container
is refused rather than force-removed. Best-effort; never fails the update.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(updater): scope image pruning to those directly used by NOMAD

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: jakeaturner <jturner@cosmistack.com>
2026-08-04 03:37:36 +00:00
chriscrosstalk 5a6735eaf1
fix(install): define missing header_red + colors in uninstall/update scripts (#1098)
The uninstall and update scripts called header_red() and referenced color
variables (GREEN/RED/RESET) that were never defined, so running them printed
"header_red: command not found" instead of the section banners. Also corrects
the update script's success message (it said "installation completed" and
pointed at an undefined ${nomad_dir} path) and fixes two spelling typos.

- uninstall_nomad.sh: add Color Codes block + header_red()
- update_nomad.sh: add header_red(); fix success wording and start_nomad.sh path
- CONTRIBUTING.md, admin/constants/ollama.ts: spelling fixes

Reported by @fix2015 in #1058; reimplemented in-house.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:35 +00:00
chriscrosstalk e810d7f1ab
docs(contributing): add UI Consistency section (#1080)
Capture the frontend conventions so new UI (including community PRs) stays
visually and behaviorally uniform with the rest of the GUI: reuse the shared
component library (Switch, InfoTooltip, StyledModal, Input), match sibling
label punctuation and typography tokens, use theme tokens for dark mode, keep
tooltips from clipping, and test UI changes in a real browser before
submitting. Notes when a raw control (checkbox, radio, native select) is still
the right call.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:35 +00:00
chriscrosstalk c4c2bdc02a
fix(AI): stream thinking from /v1 reasoning field + abort on client disconnect (#1078)
Two defects made chat hang forever with thinking-capable models on the OpenAI-compat
(/v1) path, which NOMAD uses for both local and remote Ollama:

1. Field mismatch. chatStream()/chat() read `delta.thinking` / `message.thinking`,
   but Ollama's /v1 endpoint emits thinking tokens as `reasoning`. All thinking output
   was silently dropped, so the SSE stream was nothing but empty content+thinking chunks
   and never reached done. Now read `thinking ?? reasoning` in both paths (the inline
   <think>-tag parser for other backends is unchanged).

2. No abort on client disconnect. When the user gave up and closed the chat, the
   upstream generation kept decoding server-side. With Ollama's default
   OLLAMA_NUM_PARALLEL=1 that abandoned request occupied the only slot, so every later
   chat/RAG request queued behind it and the whole assistant appeared dead. The
   controller now wires an AbortController to the response 'close' event and threads the
   signal into the OpenAI SDK request, so a disconnect aborts the upstream generation.

Verified on NOMAD2 (qwen3:0.6b, which reports the `thinking` capability and emits
`reasoning` on /v1): before, the stream was endless empty chunks; after, thinking streams
visibly and reaches done. On disconnect, Ollama's decode counter freezes and the server
logs `cancel task` / `slot release`, freeing the slot for the next request.

Note: thinking is still force-on for capable models here; a user-facing per-model
thinking toggle (default off) is a planned follow-up.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:34 +00:00
chriscrosstalk 5e58b596e7
fix(supply-depot): generate Homebox API key pepper so it stops crash-looping (#1077)
Homebox >= 0.26 (ghcr.io/sysadminsmedia/homebox:0.26.2) panics at boot unless
HBOX_AUTH_API_KEY_PEPPER is set to a >= 32-byte value. The seeder ships no Env
block, so every clean install crash-loops immediately.

Generate a per-install pepper, persist it in the KV store
(apps.homebox.apiKeyPepper), and inject it as HBOX_AUTH_API_KEY_PEPPER at every
container-create path. The pepper is generated once and reused: rotating it would
invalidate every API key a user has issued from Homebox.

Inject at all three create paths so no lifecycle action drops it:
- _createContainer (install / force-reinstall)
- the service update path (heals a pre-fix container on update)
- recreateCustomAppContainer (the Edit / reconfigure rebuild — the in-app docs
  tell users to add HBOX_OPTIONS_ALLOW_REGISTRATION=false via Manage > Edit,
  which rebuilds Env from container_config alone and would otherwise drop the
  pepper and re-trigger the crash loop)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:34 +00:00
chriscrosstalk 535f2e6778
fix(AI): stop forcing HSA_OVERRIDE=11.0.0 on natively-supported AMD iGPUs (#1076)
The current ollama:rocm image bundles ROCm 7.2, which natively supports the
RDNA 3/3.5 mobile parts (780M gfx1103, 890M gfx1150, Strix Halo gfx1151).
Forcing HSA_OVERRIDE_GFX_VERSION=11.0.0 coerces them onto gfx1100's kernels:
unnecessary on the 890M and a source of faults on the 780M, which lacks the
gfx1100 WMMA instructions those kernels use.

- Map gfx1103/gfx1150/gfx1151 to no override (was 11.0.0) so ROCm discovers
  them natively.
- Change the no-marker default from 11.0.0 to null. A hardcoded default gets
  more wrong as ROCm adds native targets; native discovery is the safer
  forward-looking default. Hardware that still needs coercion (e.g. RDNA 2
  iGPUs on an install without the gfx marker) can force a value via the
  ai.amdHsaOverride KV.
- Keep gfx1031..gfx1036 (RDNA 2 iGPUs like the 680M) on 10.3.0 — still not
  natively supported.

Verified on NOMAD2 (Radeon 890M): after a Force Reinstall the container has
no HSA_OVERRIDE, Ollama discovers the GPU as its native gfx1150 with the
rocm_v7_2 libraries, and a model loads at 100% GPU with no faults.

Depends on #1056 context; see companion issue for the 780M follow-up.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:33 +00:00
chriscrosstalk dfa68fac6d
fix(AI): set OLLAMA_IGPU_ENABLE on AMD provisioning so iGPUs are used (#1074)
Ollama's scheduler drops integrated GPUs unless OLLAMA_IGPU_ENABLE=1 is set.
NOMAD sets HSA_OVERRIDE_GFX_VERSION for AMD but never this flag, so AMD APUs
(780M/890M/8060S) silently fell back to CPU-only inference despite correct
/dev/kfd and /dev/dri passthrough.

Set OLLAMA_IGPU_ENABLE=1 whenever AMD acceleration is configured, on both the
install and update provisioning paths. The flag is a no-op on discrete AMD
cards, so it's safe to set unconditionally within the AMD branch. On the update
path we also strip any prior value so containers provisioned before this change
pick up the flag on their next update.

Verified on NOMAD2 (Ryzen AI 9 HX 370 / Radeon 890M): before, Ollama logged
"dropping integrated GPU" and ran on CPU; after, it reports the 890M as an
iGPU ROCm inference device and models load at 100% GPU.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:33 +00:00
Chris Sherwood 15e66e511d
feat(brand): add ™ to Project NOMAD wordmark on prominent surfaces
Wordmark application filed (USPTO serial 99912179). Mark the
persistent Command Center footer wordmark (both the AppLayout footer
and the settings/docs sidebar footer) and the first mention on the
Legal Notices page. Browser-tab titles and body copy left unmarked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:33 +00:00
Chris Sherwood 957e79bbc2
chore: standardize brand name to Project NOMAD, retire backronym
Retire the dotted "N.O.M.A.D." styling everywhere in favor of
"Project NOMAD" (no periods) ahead of the trademark filing, and
remove the "Node for Offline Media, Archives, and Data" backronym
from all copy except a single origin-story line in the About page
(corrected to "Maps" rather than "Media").

Scope is display strings only: docs, admin UI labels/titles, install
script output, Dockerfile labels, package.json description, and issue
templates. Code identifiers, container/service names, env vars, CSS
tokens, URLs, and the project-nomad slug are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:32 +00:00
jarvisxyz 5d85b67132
feat: Expandable rows in Kiwix Library browser (#1060)
* feat: expandable rows in Kiwix Library browser (rebased onto dev)

Click a row in the Content Explorer's 'Browse the Kiwix Library' table
to expand it and reveal the full description (no longer truncated) along
with additional metadata: author, publisher, language, category,
article count, media count, issue date, file size, tags, and file name.

Changes:
- Extend RemoteZimFileEntry type with optional metadata fields
  (language, publisher, category, tags, article_count, media_count, issued)
- Update zim_service.ts listRemote() to map these fields from the raw
  Kiwix API response in the paginated accumulator loop
- Remove @tanstack/react-virtual virtualization from remote-explorer.tsx
  (12 items per page — virtualization not needed and incompatible with
  variable-height expanded rows)
- Use StyledTable's built-in expandable prop with expandedRowRender
  to show full details when a row is clicked
- Preserve upstream's custom libraries source selector and directory browser

* fix(RemoteExplorer): guard against invalid dates being rendered

---------

Co-authored-by: eizus <hello@cdr.xyz>
Co-authored-by: jakeaturner <jturner@cosmistack.com>
2026-08-04 03:37:32 +00:00
jarvisxyz 4db0010a0d
fix(downloads): add retry button and resource download link for failed downloads (#1059)
Previously, failed downloads showed only an alert icon and a dismiss (X)
button — the user had no way to retry or reach the resource page without
manually re-adding the download and finding the source URL elsewhere.

This commit adds:
- POST /api/downloads/jobs/:jobId/retry endpoint (controller + service)
- retryDownloadJob() API method on the frontend
- Failed-state UI in ActiveDownloads.tsx now shows:
  - Retry button (re-dispatches the original download job)
  - 'Download page' external link (when the download URL is an HTTP(S) URL)
- Loading state on the retry button while the request is in-flight
- Screenshots documenting before/after UI

Co-authored-by: eizus <hello@cdr.xyz>
2026-08-04 03:37:31 +00:00
chriscrosstalk 8ceb2535c2
chore(catalog): sunset orphaned Meshtastic Daemon card (#1049)
Meshtastic Daemon was pulled from DEFAULT_SERVICES because it can't work
without hands-on setup (radio MAC address, etc.). The seeder never deletes,
so every early-access deployment keeps an orphaned nomad_meshtasticd row and
still shows the broken card.

Migration mirrors the legacy-Kolibri sunset: drop the row where installed=0,
flag is_deprecated where installed=1 (keeps it manageable, hides from catalog).
Runs automatically on each box's next update.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:31 +00:00
chriscrosstalk ab87e29dd5
chore(KB): filter non-content sections + render tables in ZIM extraction (#1044)
Closes #902.

Two gaps in the structured ZIM extraction path:

1. NON_CONTENT_HEADING_PATTERNS was only used by the structure heuristic to
   count meaningful sections, never at section-emit time. Sections under
   "See also" / "References" / "External links" / etc. were still chunked and
   embedded. They're now flagged when the heading opens and dropped.

2. <table> elements were run through cheerio's `.text()`, concatenating every
   cell with no separators ("AgeDoseAdult500mg") into unsearchable word salad.
   New tableToText() joins cells with " | " and rows with newlines so
   row/column structure survives into the chunk.

Refactor: moved extractStructuredContent out of ZIMExtractionService into a
pure, cheerio-only util (app/utils/zim_html.ts) so it can be unit-tested
without the native @openzim/libzim binding. Service delegates to it; behavior
is otherwise unchanged. Adds tests/unit/zim_html.spec.ts (6 tests).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 03:37:30 +00:00
chriscrosstalk ed1fed8d2a
feat(maps): add notes input to map pin placement popup (#926)
The map_markers backend has accepted a `notes` column since PR #770 and
the popup display path was wired up to render it (commit 6328256), but
the placement UI never got an input. Result: notes are stored,
displayed when present, and impossible to actually enter via the UI.

Add a notes textarea below the name input in the placement popup,
thread the value through `addMarker` and `createMapMarker`, and trim +
null-coalesce on save. Notes display in the marker popup on click is
unchanged and now actually reachable.

- admin/inertia/lib/api.ts: extend createMapMarker request type with
  optional notes
- admin/inertia/hooks/useMapMarkers.ts: addMarker accepts and forwards
  notes (response already populated notes into local state, so no
  display-side change needed)
- admin/inertia/components/maps/MapComponent.tsx: markerNotes state,
  textarea after name input, threaded into handleSaveMarker

Edit-mode for existing markers (so users can backfill notes on
already-placed pins) is intentionally out of scope here - selected-marker
popup is still read-only. That's a follow-up PR if there's demand.
2026-08-04 03:37:30 +00:00
chriscrosstalk 885aebd109
content(collections): fix four dead Wikipedia download URLs (#1189)
Four of the five Wikipedia packages pointed at 2025-12 builds that openZIM
has since rolled forward and deleted. Every one returns 404:

  Quick Reference                  wikipedia_en_top_mini_2025-12    404
  Popular Articles                 wikipedia_en_top_nopic_2025-12   404
  Complete Wikipedia (Compact)     wikipedia_en_all_mini_2025-12    404
  Complete Wikipedia (No Images)   wikipedia_en_all_nopic_2025-12   404

Only Complete Wikipedia (Full) still resolved, and at 124 GB that's the
option almost nobody picks. So in practice a user choosing any sensible
Wikipedia size during Easy Setup got a failed download.

Repointed all four at the current 2026-06 builds and corrected every
size_mb from the measured Content-Length. The old figures were estimates
and had drifted by up to 8%.

Sizes are decimal MB, consistent with kiwix-categories.json.

Refs #1171

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 16:22:20 -07:00
chriscrosstalk 66c6676740
ci: fix collection url validation (#1188)
Co-authored-by: jakeaturner <jturner@cosmistack.com>
2026-08-02 16:18:19 -07:00
Chris Sherwood c693cd5cd8 content(collections): rebalance Survival & Preparedness and fix a broken URL
The category was 10.8 GB of which 89% was YouTube channel archives. Nothing
in Essential or Standard could be read without watching a video, which is
the wrong shape for the situations this content is for: low power, low
bandwidth, or just looking something up quickly.

Fixes a live 404. canadian_prepper_preppingfood_en_2025-09.zim no longer
exists upstream, so anyone installing the Comprehensive tier today hits a
broken download (#1171). openZIM renamed it, and because resource_id is
derived from the on-disk filename by parseZimFilename(), the id has to
change with it or the installed file would never match its catalog entry.

Adds 5 GB of searchable text across the three tiers:

  Essential      water treatment, food preparation, knots (144 MB total)
  Standard       Ready.gov, outdoors Q&A, amateur radio Q&A
  Comprehensive  post-disaster library, Hundred Rabbits

Amateur radio in particular filled a gap: communications is a core
preparedness topic and the category had nothing on it.

Text share goes from 11% to 36%. Essential grows by 144 MB, which is 6%,
and stops being video-only.

Deliberately not included: gardening.stackexchange and Gutenberg Agriculture
are already curated under Agriculture & Food, and survivorlibrary.com is the
single most on-mission corpus in the Kiwix library but is 252 GB.

All URLs verified with range requests; all ids verified to match their
filename base.

Refs #1171, #1149

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 12:30:09 -07:00
Chris Sherwood 60f3dd57c0 content(collections): add CD3WD to Survival & Preparedness comprehensive
CD3WD is a compilation of appropriate-technology and development
literature: food production and storage, water and sanitation,
construction, health, and village-scale manufacturing without
industrial supply chains. Requested in #1149.

The survival category was until now almost entirely YouTube channel
archives, with Project Gutenberg military science as the only text
reference. CD3WD adds a substantial practical reference at 581 MB,
which is the smallest resource in the category by a wide margin.

Verified live: HTTP 206 on a range request, 581,165,229 bytes.

Refs #1149

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 12:30:09 -07:00
chriscrosstalk 56cafe564c
catalog: add The Modern Rogue creator pack (#1145)
Adds the Modern Rogue pack (Brian Brushwood) to the Creator Packs catalog.
37 videos, 5177 MB. ZIM (modern-rogue_2026-07.zim) is uploaded to R2 and
verified serveable via the entitlement Worker.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 11:48:27 -07:00
jakeaturner 81667eb45b
chore(collections): add creator-packs.json to main 2026-07-24 03:54:14 +00:00
cosmistack-bot 6a4f02dd46 docs(release): finalize v1.33.0 release notes [skip ci] 2026-06-23 17:52:28 +00:00
214 changed files with 22899 additions and 1557 deletions

View File

@ -1,5 +1,5 @@
name: Bug Report name: Bug Report
description: Report a bug or issue with Project N.O.M.A.D. description: Report a bug or issue with Project NOMAD
title: "[Bug]: " title: "[Bug]: "
labels: ["bug", "needs-triage"] labels: ["bug", "needs-triage"]
body: body:
@ -10,9 +10,9 @@ body:
**Before submitting:** **Before submitting:**
- Search existing issues to avoid duplicates - Search existing issues to avoid duplicates
- Ensure you're running the latest version of N.O.M.A.D. - Ensure you're running the latest version of NOMAD
- Redact any personal or sensitive information from logs/configs - Redact any personal or sensitive information from logs/configs
- Please don't submit issues related to running N.O.M.A.D. on Unraid or another NAS - we don't have plans to support these kinds of platforms at this time - Please don't submit issues related to running NOMAD on Unraid or another NAS - we don't have plans to support these kinds of platforms at this time
- type: dropdown - type: dropdown
id: issue-category id: issue-category
@ -75,8 +75,8 @@ body:
- type: input - type: input
id: nomad-version id: nomad-version
attributes: attributes:
label: N.O.M.A.D. Version label: NOMAD Version
description: What version of N.O.M.A.D. are you running? (Check Settings > Update or run `docker ps` and check nomad_admin image tag) description: What version of NOMAD are you running? (Check Settings > Update or run `docker ps` and check nomad_admin image tag)
placeholder: "e.g., 1.29.0" placeholder: "e.g., 1.29.0"
validations: validations:
required: true required: true
@ -85,8 +85,9 @@ body:
id: os id: os
attributes: attributes:
label: Operating System label: Operating System
description: What OS are you running N.O.M.A.D. on? description: What OS are you running NOMAD on?
options: options:
- Ubuntu 26.04 (Resolute Raccoon)
- Ubuntu 24.04 - Ubuntu 24.04
- Ubuntu 22.04 - Ubuntu 22.04
- Ubuntu 20.04 - Ubuntu 20.04
@ -150,7 +151,7 @@ body:
Include any relevant logs or error messages. **Please redact any personal/sensitive information.** Include any relevant logs or error messages. **Please redact any personal/sensitive information.**
Useful commands for collecting logs: Useful commands for collecting logs:
- N.O.M.A.D. management app: `docker logs nomad_admin` - NOMAD management app: `docker logs nomad_admin`
- Ollama: `docker logs nomad_ollama` - Ollama: `docker logs nomad_ollama`
- Qdrant: `docker logs nomad_qdrant` - Qdrant: `docker logs nomad_qdrant`
- Specific service: `docker logs nomad_<service-name>` - Specific service: `docker logs nomad_<service-name>`
@ -185,9 +186,9 @@ body:
options: options:
- label: I have searched for existing issues that might be related to this bug - label: I have searched for existing issues that might be related to this bug
required: true required: true
- label: I am running the latest version of Project N.O.M.A.D. (or have noted my version above) - label: I am running the latest version of Project NOMAD (or have noted my version above)
required: true required: true
- label: I have redacted any personal or sensitive information from logs and screenshots - label: I have redacted any personal or sensitive information from logs and screenshots
required: true required: true
- label: This issue is NOT related to running N.O.M.A.D. on an unsupported/non-Debian-based OS - label: This issue is NOT related to running NOMAD on an unsupported/non-Debian-based OS
required: false required: false

View File

@ -8,10 +8,10 @@ contact_links:
about: Check the official documentation and guides about: Check the official documentation and guides
- name: 🏆 Community Leaderboard - name: 🏆 Community Leaderboard
url: https://benchmark.projectnomad.us url: https://benchmark.projectnomad.us
about: View the N.O.M.A.D. benchmark leaderboard about: View the NOMAD benchmark leaderboard
- name: 🤝 Contributing Guide - name: 🤝 Contributing Guide
url: https://github.com/Crosstalk-Solutions/project-nomad/blob/main/CONTRIBUTING.md url: https://github.com/Crosstalk-Solutions/project-nomad/blob/main/CONTRIBUTING.md
about: Learn how to contribute to Project N.O.M.A.D. about: Learn how to contribute to Project NOMAD
- name: 📅 Roadmap - name: 📅 Roadmap
url: https://roadmap.projectnomad.us url: https://roadmap.projectnomad.us
about: See our public roadmap, vote on features, and suggest new ones about: See our public roadmap, vote on features, and suggest new ones

View File

@ -1,20 +1,20 @@
name: Feature Request name: Feature Request
description: Suggest a new feature or enhancement for Project N.O.M.A.D. description: Suggest a new feature or enhancement for Project NOMAD
title: "[Feature]: " title: "[Feature]: "
labels: ["enhancement", "needs-discussion"] labels: ["enhancement", "needs-discussion"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Thanks for your interest in improving Project N.O.M.A.D.! Before you submit a feature request, consider checking our [roadmap](https://roadmap.projectnomad.us) to see if it's already planned or in progress. You're welcome to suggest new ideas there if you don't plan on opening PRs yourself. Thanks for your interest in improving Project NOMAD! Before you submit a feature request, consider checking our [roadmap](https://roadmap.projectnomad.us) to see if it's already planned or in progress. You're welcome to suggest new ideas there if you don't plan on opening PRs yourself.
**Please note:** Feature requests are not guaranteed to be implemented. All requests are evaluated based on alignment with the project's goals, feasibility, and community demand. **Please note:** Feature requests are not guaranteed to be implemented. All requests are evaluated based on alignment with the project's goals, feasibility, and community demand.
**Before submitting:** **Before submitting:**
- Search existing feature requests and our [roadmap](https://roadmap.projectnomad.us) to avoid duplicates - Search existing feature requests and our [roadmap](https://roadmap.projectnomad.us) to avoid duplicates
- Consider if this aligns with N.O.M.A.D.'s mission: offline-first knowledge and education - Consider if this aligns with NOMAD's mission: offline-first knowledge and education
- Consider the technical feasibility of the feature: N.O.M.A.D. is designed to be containerized and run on a wide range of hardware, so features that require heavy resources (aside from GPU-intensive tasks) or complex host configurations may be less likely to be implemented - Consider the technical feasibility of the feature: NOMAD is designed to be containerized and run on a wide range of hardware, so features that require heavy resources (aside from GPU-intensive tasks) or complex host configurations may be less likely to be implemented
- Consider the scope of the feature: Small, focused enhancements that can be implemented incrementally are more likely to be implemented than large, broad features that would require significant development effort or have an unclear path forward - Consider the scope of the feature: Small, focused enhancements that can be implemented incrementally are more likely to be implemented than large, broad features that would require significant development effort or have an unclear path forward
- If you're able to contribute code, testing, or documentation, that significantly increases the chances of your feature being implemented - If you're able to contribute code, testing, or documentation, that significantly increases the chances of your feature being implemented
@ -95,7 +95,7 @@ body:
attributes: attributes:
label: How important is this feature to you? label: How important is this feature to you?
options: options:
- Critical - Blocking my use of N.O.M.A.D. - Critical - Blocking my use of NOMAD
- High - Would significantly improve my experience - High - Would significantly improve my experience
- Medium - Would be nice to have - Medium - Would be nice to have
- Low - Minor convenience - Low - Minor convenience
@ -144,7 +144,7 @@ body:
options: options:
- label: I have searched for existing feature requests that might be similar - label: I have searched for existing feature requests that might be similar
required: true required: true
- label: This feature aligns with N.O.M.A.D.'s mission of offline-first knowledge and education - label: This feature aligns with NOMAD's mission of offline-first knowledge and education
required: true required: true
- label: I understand that feature requests are not guaranteed to be implemented - label: I understand that feature requests are not guaranteed to be implemented
required: true required: true

View File

@ -52,3 +52,4 @@ jobs:
VERSION=${{ inputs.version }} VERSION=${{ inputs.version }}
BUILD_DATE=${{ github.event.workflow_run.created_at }} BUILD_DATE=${{ github.event.workflow_run.created_at }}
VCS_REF=${{ github.sha }} VCS_REF=${{ github.sha }}
CREATOR_PACKS_APP_KEY=${{ secrets.CREATOR_PACKS_APP_KEY }}

View File

@ -28,22 +28,46 @@ jobs:
CHECKED=$((CHECKED + 1)) CHECKED=$((CHECKED + 1))
printf "Checking: %s ... " "$url" printf "Checking: %s ... " "$url"
# Use Range: bytes=0-0 to avoid downloading the full file. # HEAD transfers no body at all, so nothing is downloaded even when a
# --max-filesize 1 aborts early if the server ignores the Range header # server ignores Range headers. curl can still exit non-zero (DNS,
# and returns 200 with the full body. The HTTP status is still captured. # TLS, timeout), so capture the status without letting `set -e`
# (bash -e) kill the whole step mid-loop.
METHOD="HEAD"
CURL_EXIT=0
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--range 0-0 \ --head \
--max-filesize 1 \
--max-time 30 \ --max-time 30 \
--retry 2 \
--retry-delay 2 \
--location \ --location \
"$url") "$url") || CURL_EXIT=$?
# Some servers refuse HEAD outright. Fall back to a single-byte
# ranged GET for those. --max-filesize caps the damage if the server
# ignores the Range header, but has to stay comfortably above a
# redirect body: --location applies the limit to the 3xx body too,
# and a cap below that aborts on the redirect itself.
case "$HTTP_CODE" in
403|405|501)
METHOD="GET"
CURL_EXIT=0
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--range 0-0 \
--max-filesize 8192 \
--max-time 30 \
--retry 2 \
--retry-delay 2 \
--location \
"$url") || CURL_EXIT=$?
;;
esac
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "206" ]; then if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "206" ]; then
echo "OK ($HTTP_CODE)" echo "OK ($HTTP_CODE via $METHOD)"
else else
echo "FAILED ($HTTP_CODE)" echo "FAILED (HTTP $HTTP_CODE via $METHOD, curl exit $CURL_EXIT)"
FAILED=$((FAILED + 1)) FAILED=$((FAILED + 1))
FAILED_URLS="$FAILED_URLS\n - $url (HTTP $HTTP_CODE)" FAILED_URLS="$FAILED_URLS\n - $url (HTTP $HTTP_CODE via $METHOD, curl exit $CURL_EXIT)"
fi fi
done <<< "$URLS" done <<< "$URLS"

View File

@ -1,8 +1,8 @@
# Contributing to Project N.O.M.A.D. # Contributing to Project NOMAD
Thank you for your interest in contributing to Project N.O.M.A.D.! Community contributions are what keep this project growing and improving. Please read this guide fully before getting started — it will save you (and the maintainers) a lot of time. Thank you for your interest in contributing to Project NOMAD! Community contributions are what keep this project growing and improving. Please read this guide fully before getting started — it will save you (and the maintainers) a lot of time.
> **Note:** Acceptance of contributions is not guaranteed. All pull requests are evaluated based on quality, relevance, and alignment with the project's goals. The maintainers of Project N.O.M.A.D. ("Nomad") reserve the right accept, deny, or modify any pull request at their sole discretion. > **Note:** Acceptance of contributions is not guaranteed. All pull requests are evaluated based on quality, relevance, and alignment with the project's goals. The maintainers of Project NOMAD ("NOMAD") reserve the right to accept, deny, or modify any pull request at their sole discretion.
--- ---
@ -12,6 +12,7 @@ Thank you for your interest in contributing to Project N.O.M.A.D.! Community con
- [Before You Start](#before-you-start) - [Before You Start](#before-you-start)
- [Getting Started](#getting-started) - [Getting Started](#getting-started)
- [Development Workflow](#development-workflow) - [Development Workflow](#development-workflow)
- [UI Consistency](#ui-consistency)
- [Commit Messages](#commit-messages) - [Commit Messages](#commit-messages)
- [Release Notes](#release-notes) - [Release Notes](#release-notes)
- [Versioning](#versioning) - [Versioning](#versioning)
@ -48,11 +49,11 @@ When opening an issue:
--- ---
## Getting Started with Contributing ## Getting Started with Contributing
**Please note**: this is the Getting Started guide for developing and contributing to Nomad, NOT [installing Nomad](https://github.com/Crosstalk-Solutions/project-nomad/blob/main/README.md) for regular use! **Please note**: this is the Getting Started guide for developing and contributing to NOMAD, NOT [installing NOMAD](https://github.com/Crosstalk-Solutions/project-nomad/blob/main/README.md) for regular use!
### Prerequisites ### Prerequisites
- A Debian-based OS (Ubuntu recommended) - A Debian-based OS (Ubuntu 26.04 LTS recommended)
- `sudo`/root privileges - `sudo`/root privileges
- Docker installed and running - Docker installed and running
- A stable internet connection (required for dependency downloads) - A stable internet connection (required for dependency downloads)
@ -72,7 +73,7 @@ When opening an issue:
``` ```
### Avoid Installing a Release Version Locally ### Avoid Installing a Release Version Locally
Because Nomad relies heavily on Docker, we actually recommend against installing a release version of the project on the same local machine where you are developing. This can lead to conflicts with ports, volumes, and other resources. Instead, you can run your development version in a separate Docker environment while keeping your local machine clean. It certainly __can__ be done, but it adds complexity to your setup and workflow. If you choose to install a release version locally, please ensure you have a clear strategy for managing potential conflicts and resource usage. Because NOMAD relies heavily on Docker, we actually recommend against installing a release version of the project on the same local machine where you are developing. This can lead to conflicts with ports, volumes, and other resources. Instead, you can run your development version in a separate Docker environment while keeping your local machine clean. It certainly __can__ be done, but it adds complexity to your setup and workflow. If you choose to install a release version locally, please ensure you have a clear strategy for managing potential conflicts and resource usage.
--- ---
@ -92,7 +93,7 @@ Because Nomad relies heavily on Docker, we actually recommend against installing
git checkout -b feature/add-new-tool git checkout -b feature/add-new-tool
``` ```
3. **Make your changes.** Follow existing code style and conventions. Test your changes locally against a running N.O.M.A.D. instance before submitting. 3. **Make your changes.** Follow existing code style and conventions. Test your changes locally against a running NOMAD instance before submitting.
4. **Add release notes** (see [Release Notes](#release-notes) below). 4. **Add release notes** (see [Release Notes](#release-notes) below).
@ -102,6 +103,34 @@ Because Nomad relies heavily on Docker, we actually recommend against installing
--- ---
## UI Consistency
NOMAD's guiding principle is that **user-friendliness is paramount**: a control that looks or behaves differently from the rest of the app reads as broken to a non-technical user. New frontend (inertia/React) work should be visually and behaviorally uniform with what is already there. Before adding a UI element, look at its neighbors and reuse the shared building blocks rather than hand-rolling a one-off.
**Reuse the shared components** in `admin/inertia/components/` (and `.../components/inputs/`):
| Need | Use | Not |
|------|-----|-----|
| Binary on/off setting | `Switch` | a raw `<input type="checkbox">` |
| Explanatory hover help | `InfoTooltip` | a raw `title=` attribute or a bespoke tooltip |
| Modal / confirmation dialog | `StyledModal` | a hand-built overlay |
| Text field | `Input` | a bare `<input>` |
| Section heading | `StyledSectionHeader` | ad-hoc heading markup |
Grep for an existing component before building a new one.
**Match the neighbors.** Copy the exact classes and conventions of adjacent elements:
- **Labels:** match punctuation and casing of sibling labels. If the field beside yours reads `Model:` (with a colon), yours should read `Thinking:`, not `Thinking`. Reuse the same typography tokens (e.g. `text-sm text-text-secondary`).
- **Theme:** use design tokens (`text-*`, `bg-*`, `border-*`) so the element works in light and dark mode. Never hardcode colors.
- **Placement:** make sure popovers and tooltips are not clipped or crushed against a viewport edge, including when the trigger sits near an edge of the screen.
**When a raw control is fine.** These conventions are about matching intent, not banning primitives. A raw checkbox is appropriate for a multi-select list or a consent box; radio groups and native selects are fine where a shared component does not exist. The point is to reach for the shared component when your case matches its intent (a binary setting toggle should be a `Switch`), not to eliminate primitives.
**Test UI changes in a real browser.** Most of these conventions are judgment calls that tooling cannot fully enforce, so the single most important habit is to load your change in a browser against a running instance before submitting. Several classes of issue (clipped or cramped tooltips, layout breaking at different window widths, blank-screen render errors) are invisible to type-checking and only show up when you actually look at the page. Check the states that should appear *and* the states that should be hidden, and try more than one window width when layout or positioning is involved.
---
## Commit Messages ## Commit Messages
This project uses [Conventional Commits](https://www.conventionalcommits.org/). All commit messages must follow this format: This project uses [Conventional Commits](https://www.conventionalcommits.org/). All commit messages must follow this format:
@ -166,10 +195,10 @@ This project uses [Semantic Versioning](https://semver.org/). Versions are manag
Have questions or want to discuss ideas before opening an issue? Join the community: Have questions or want to discuss ideas before opening an issue? Join the community:
- **Discord:** [Join the Crosstalk Solutions server](https://discord.com/invite/crosstalksolutions) — the best place to get help, share your builds, and talk with other N.O.M.A.D. users - **Discord:** [Join the Crosstalk Solutions server](https://discord.com/invite/crosstalksolutions) — the best place to get help, share your builds, and talk with other NOMAD users
- **Website:** [www.projectnomad.us](https://www.projectnomad.us) - **Website:** [www.projectnomad.us](https://www.projectnomad.us)
- **Benchmark Leaderboard:** [benchmark.projectnomad.us](https://benchmark.projectnomad.us) - **Benchmark Leaderboard:** [benchmark.projectnomad.us](https://benchmark.projectnomad.us)
--- ---
*Project N.O.M.A.D. is licensed under the [Apache License 2.0](LICENSE).* *Project NOMAD is licensed under the [Apache License 2.0](LICENSE).*

View File

@ -28,6 +28,14 @@ FROM base AS build
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules /app/node_modules COPY --from=deps /app/node_modules /app/node_modules
ADD admin/ ./ ADD admin/ ./
# Regenerate the curated drug-reference data modules
# (app/data/{conditions,natural_remedies,home_remedies}.ts) from their single
# source of truth — the repo-root collections/*.json — so the JSON is what gets
# compiled into the image and the committed .ts can never silently drift from it
# in a build. The gen script resolves ../../collections relative to admin/scripts,
# which is /collections once admin/ has been copied to /app.
COPY collections/ /collections/
RUN npm run gen:curated-data
RUN node ace build RUN node ace build
# Production stage # Production stage
@ -62,8 +70,8 @@ RUN set -eux; \
/usr/local/bin/pmtiles version /usr/local/bin/pmtiles version
# Labels # Labels
LABEL org.opencontainers.image.title="Project N.O.M.A.D" \ LABEL org.opencontainers.image.title="Project NOMAD" \
org.opencontainers.image.description="The Project N.O.M.A.D Official Docker image" \ org.opencontainers.image.description="The Project NOMAD Official Docker image" \
org.opencontainers.image.version="${VERSION}" \ org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.created="${BUILD_DATE}" \ org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.revision="${VCS_REF}" \ org.opencontainers.image.revision="${VCS_REF}" \
@ -73,6 +81,17 @@ LABEL org.opencontainers.image.title="Project N.O.M.A.D" \
org.opencontainers.image.licenses="Apache-2.0" org.opencontainers.image.licenses="Apache-2.0"
ENV NODE_ENV=production ENV NODE_ENV=production
# Creator Packs entitlement key, injected into OFFICIAL release builds at build
# time (--build-arg CREATOR_PACKS_APP_KEY=... from the CREATOR_PACKS_APP_KEY CI
# secret; see build-primary-image.yml). Baked as an ENV so admin/start/env.ts
# reads it at runtime. Empty by default, so builds from source (and any build
# without the secret) ship UNCONFIGURED and hide the Creator Packs UI. The key
# lands in this public image layer (extractable — the accepted ceiling); rotate
# via `wrangler secret put APP_KEY` + a new image if it leaks.
ARG CREATOR_PACKS_APP_KEY=""
ENV CREATOR_PACKS_APP_KEY=$CREATOR_PACKS_APP_KEY
WORKDIR /app WORKDIR /app
COPY --from=production-deps /app/node_modules /app/node_modules COPY --from=production-deps /app/node_modules /app/node_modules
COPY --from=build /app/build /app COPY --from=build /app/build /app

24
FAQ.md
View File

@ -1,6 +1,6 @@
# Frequently Asked Questions (FAQ) # Frequently Asked Questions (FAQ)
Find answers to some of the most common questions about Project N.O.M.A.D. Find answers to some of the most common questions about Project NOMAD
## Can I customize the port(s) that NOMAD uses? ## Can I customize the port(s) that NOMAD uses?
@ -26,30 +26,30 @@ Long answer: Custom storage paths, mount points, and external drives (like iSCSI
## Why does NOMAD require a Debian-based OS? ## Why does NOMAD require a Debian-based OS?
Project N.O.M.A.D. is currently designed to run on Debian-based Linux distributions (with Ubuntu being the recommended distro) because our installation scripts and Docker configurations are optimized for this environment. While it's technically possible to run the Docker containers on other operating systems that support Docker, we have not tested or optimized the installation process for non-Debian-based systems, so we cannot guarantee a smooth experience on those platforms at this time. Project NOMAD is currently designed to run on Debian-based Linux distributions (with Ubuntu 26.04 LTS being the recommended version) because our installation scripts and Docker configurations are optimized for this environment. While it's technically possible to run the Docker containers on other operating systems that support Docker, we have not tested or optimized the installation process for non-Debian-based systems, so we cannot guarantee a smooth experience on those platforms at this time.
Support for other operating systems will come in the future, but because our development resources are limited as a free and open-source project, we needed to prioritize our efforts and focus on a narrower set of supported platforms for the initial release. We chose Debian-based Linux as our starting point because it's widely used, easy to spin up, and provides a stable environment for running Docker containers. Support for other operating systems will come in the future, but because our development resources are limited as a free and open-source project, we needed to prioritize our efforts and focus on a narrower set of supported platforms for the initial release. We chose Debian-based Linux as our starting point because it's widely used, easy to spin up, and provides a stable environment for running Docker containers.
For Windows users, the [WSL2 install guide](https://www.projectnomad.us/install/wsl2) provides a community-supported path. Community members have also published guides for other platforms (e.g. macOS) in our Discord community and [Github Discussions](https://github.com/Crosstalk-Solutions/project-nomad/discussions), so if you're interested in running N.O.M.A.D. on a non-Debian-based system, we recommend checking there for any available resources or guides. However, keep in mind that if you choose to run N.O.M.A.D. on a non-Debian-based system, you may encounter issues that we won't be able to provide support for, and you may need to have a higher level of technical expertise to troubleshoot and resolve any problems that arise. For Windows users, the [WSL2 install guide](https://www.projectnomad.us/install/wsl2) provides a community-supported path. Community members have also published guides for other platforms (e.g. macOS) in our Discord community and [Github Discussions](https://github.com/Crosstalk-Solutions/project-nomad/discussions), so if you're interested in running NOMAD on a non-Debian-based system, we recommend checking there for any available resources or guides. However, keep in mind that if you choose to run NOMAD on a non-Debian-based system, you may encounter issues that we won't be able to provide support for, and you may need to have a higher level of technical expertise to troubleshoot and resolve any problems that arise.
## Can I run NOMAD on a Raspberry Pi or other ARM-based device? ## Can I run NOMAD on a Raspberry Pi or other ARM-based device?
Project N.O.M.A.D. is currently designed to run on x86-64 architecture, and we have not yet tested or optimized it for ARM-based devices like the Raspberry Pi (and have not published any official images for ARM architecture). Project NOMAD is currently designed to run on x86-64 architecture, and we have not yet tested or optimized it for ARM-based devices like the Raspberry Pi (and have not published any official images for ARM architecture).
Support for ARM-based devices is on our roadmap, but our initial focus was on x86-64 hardware due to its widespread use and compatibility with a wide range of applications. Support for ARM-based devices is on our roadmap, but our initial focus was on x86-64 hardware due to its widespread use and compatibility with a wide range of applications.
Community members have forked and published their own ARM-compatible images and installation guides for running N.O.M.A.D. on Raspberry Pi and other ARM-based devices in our Discord community and [Github Discussions](https://github.com/Crosstalk-Solutions/project-nomad/discussions), but these are not officially supported by the core development team, and we cannot guarantee their functionality or provide support for any issues that arise when using these community-created resources. Community members have forked and published their own ARM-compatible images and installation guides for running NOMAD on Raspberry Pi and other ARM-based devices in our Discord community and [Github Discussions](https://github.com/Crosstalk-Solutions/project-nomad/discussions), but these are not officially supported by the core development team, and we cannot guarantee their functionality or provide support for any issues that arise when using these community-created resources.
## What are the hardware requirements for running NOMAD? ## What are the hardware requirements for running NOMAD?
Project N.O.M.A.D. itself is quite lightweight and can run on even modest x86-64 hardware, but the tools and resources you choose to install with N.O.M.A.D. will determine the specs required for your unique deployment. Please see the [Hardware Guide](https://www.projectnomad.us/hardware) for detailed build recommendations at various price points. Project NOMAD itself is quite lightweight and can run on even modest x86-64 hardware, but the tools and resources you choose to install with NOMAD will determine the specs required for your unique deployment. Please see the [Hardware Guide](https://www.projectnomad.us/hardware) for detailed build recommendations at various price points.
## Does NOMAD support languages other than English? ## Does NOMAD support languages other than English?
As of March 2026, Project N.O.M.A.D.'s UI is only available in English, and the majority of the tools and resources available through N.O.M.A.D. are also primarily in English. However, we have multi-language support on our roadmap for a future release, and we are actively working on adding support for additional languages both in the UI and in the available tools/resources. If you're interested in contributing to this effort, please check out our [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines on how to get involved. As of March 2026, Project NOMAD's UI is only available in English, and the majority of the tools and resources available through NOMAD are also primarily in English. However, we have multi-language support on our roadmap for a future release, and we are actively working on adding support for additional languages both in the UI and in the available tools/resources. If you're interested in contributing to this effort, please check out our [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines on how to get involved.
## What technologies is NOMAD built with? ## What technologies is NOMAD built with?
Project N.O.M.A.D. is built using a combination of technologies, including: Project NOMAD is built using a combination of technologies, including:
- **Docker:** for containerization of the Command Center and its dependencies - **Docker:** for containerization of the Command Center and its dependencies
- **Node.js & TypeScript:** for the backend of the Command Center, particularly the [AdonisJS](https://adonisjs.com/) framework - **Node.js & TypeScript:** for the backend of the Command Center, particularly the [AdonisJS](https://adonisjs.com/) framework
- **React:** for the frontend of the Command Center, utilizing [Vite](https://vitejs.dev/) and [Inertia.js](https://inertiajs.com/) under the hood - **React:** for the frontend of the Command Center, utilizing [Vite](https://vitejs.dev/) and [Inertia.js](https://inertiajs.com/) under the hood
@ -59,7 +59,7 @@ Project N.O.M.A.D. is built using a combination of technologies, including:
NOMAD makes use of the Docker-outside-of-Docker ("DooD") pattern, which allows the Command Center to manage and orchestrate other Docker containers on the host machine without needing to run Docker itself inside a container. This approach provides better performance and compatibility with a wider range of host environments while still allowing for powerful container management capabilities through the Command Center's UI. NOMAD makes use of the Docker-outside-of-Docker ("DooD") pattern, which allows the Command Center to manage and orchestrate other Docker containers on the host machine without needing to run Docker itself inside a container. This approach provides better performance and compatibility with a wider range of host environments while still allowing for powerful container management capabilities through the Command Center's UI.
## Can I run NOMAD if I have existing Docker containers on my machine? ## Can I run NOMAD if I have existing Docker containers on my machine?
Yes, you can safely run Project N.O.M.A.D. on a machine that already has existing Docker containers. NOMAD is designed to coexist with other Docker containers and will not interfere with them as long as there are no port conflicts or resource constraints. Yes, you can safely run Project NOMAD on a machine that already has existing Docker containers. NOMAD is designed to coexist with other Docker containers and will not interfere with them as long as there are no port conflicts or resource constraints.
All of NOMAD's containers are prefixed with `nomad_` in their names, so they can be easily identified and managed separately from any other containers you may have running. Just make sure to review the ports that NOMAD's core services (Command Center, MySQL, Redis) use during installation and adjust them if necessary to avoid conflicts with your existing containers. All of NOMAD's containers are prefixed with `nomad_` in their names, so they can be easily identified and managed separately from any other containers you may have running. Just make sure to review the ports that NOMAD's core services (Command Center, MySQL, Redis) use during installation and adjust them if necessary to avoid conflicts with your existing containers.
@ -76,17 +76,17 @@ NOMAD by default uses Ollama inside of a docker container to run LLM Models for
No, the AI features in NOMAD (Ollama, Qdrant, custom RAG pipeline, etc.) are all optional and not required to use the core functionality of NOMAD. No, the AI features in NOMAD (Ollama, Qdrant, custom RAG pipeline, etc.) are all optional and not required to use the core functionality of NOMAD.
## Is NOMAD actually free? Are there any hidden costs? ## Is NOMAD actually free? Are there any hidden costs?
Yes, Project N.O.M.A.D. is completely free and open-source software licensed under the Apache License 2.0. There are no hidden costs or fees associated with using NOMAD itself, and we don't have any plans to introduce "premium" features or paid tiers. Yes, Project NOMAD is completely free and open-source software licensed under the Apache License 2.0. There are no hidden costs or fees associated with using NOMAD itself, and we don't have any plans to introduce "premium" features or paid tiers.
Aside from the cost of the hardware you choose to run it on, there are no costs associated with using NOMAD. Aside from the cost of the hardware you choose to run it on, there are no costs associated with using NOMAD.
## Do you sell hardware or pre-built devices with NOMAD pre-installed? ## Do you sell hardware or pre-built devices with NOMAD pre-installed?
No, we do not sell hardware or pre-built devices with NOMAD pre-installed at this time. Project N.O.M.A.D. is a free and open-source software project, and we provide detailed installation instructions and hardware recommendations for users to set up their own NOMAD instances on compatible hardware of their choice. The tradeoff to this DIY approach is some additional setup time and technical know-how required on the user's end, but it also allows for greater flexibility and customization in terms of hardware selection and configuration to best suit each user's unique needs, budget, and preferences. No, we do not sell hardware or pre-built devices with NOMAD pre-installed at this time. Project NOMAD is a free and open-source software project, and we provide detailed installation instructions and hardware recommendations for users to set up their own NOMAD instances on compatible hardware of their choice. The tradeoff to this DIY approach is some additional setup time and technical know-how required on the user's end, but it also allows for greater flexibility and customization in terms of hardware selection and configuration to best suit each user's unique needs, budget, and preferences.
## How quickly are issues resolved when reported? ## How quickly are issues resolved when reported?
We strive to address and resolve issues as quickly as possible, but please keep in mind that Project N.O.M.A.D. is a free and open-source project maintained by a small team of volunteers. We prioritize issues based on their severity, impact on users, and the resources required to resolve them. Critical issues that affect a large number of users are typically addressed more quickly, while less severe issues may take longer to resolve. Aside from the development efforts needed to address the issue, we do our best to conduct thorough testing and validation to ensure that any fix we implement doesn't introduce new issues or regressions, which also adds to the time it takes to resolve an issue. We strive to address and resolve issues as quickly as possible, but please keep in mind that Project NOMAD is a free and open-source project maintained by a small team of volunteers. We prioritize issues based on their severity, impact on users, and the resources required to resolve them. Critical issues that affect a large number of users are typically addressed more quickly, while less severe issues may take longer to resolve. Aside from the development efforts needed to address the issue, we do our best to conduct thorough testing and validation to ensure that any fix we implement doesn't introduce new issues or regressions, which also adds to the time it takes to resolve an issue.
We also encourage community involvement in troubleshooting and resolving issues, so if you encounter a problem, please consider checking our Discord community and Github Discussions for potential solutions or workarounds while we work on an official fix. We also encourage community involvement in troubleshooting and resolving issues, so if you encounter a problem, please consider checking our Discord community and Github Discussions for potential solutions or workarounds while we work on an official fix.

View File

@ -1,10 +1,8 @@
<div align="center"> <div align="center">
<img src="admin/public/project_nomad_logo.webp" width="200" height="200"/> <img src="admin/public/project_nomad_logo.webp" width="200" height="200"/>
# Project N.O.M.A.D. # Project NOMAD
### Node for Offline Media, Archives, and Data ### Knowledge That Never Goes Offline
**Knowledge That Never Goes Offline**
[![Website](https://img.shields.io/badge/Website-projectnomad.us-blue)](https://www.projectnomad.us) [![Website](https://img.shields.io/badge/Website-projectnomad.us-blue)](https://www.projectnomad.us)
[![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2)](https://discord.com/invite/crosstalksolutions) [![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2)](https://discord.com/invite/crosstalksolutions)
@ -14,10 +12,10 @@
--- ---
Project N.O.M.A.D. is a self-contained, offline-first knowledge and education server packed with critical tools, knowledge, and AI to keep you informed and empowered — anytime, anywhere. Project NOMAD is a self-contained, offline-first knowledge and education server packed with critical tools, knowledge, and AI to keep you informed and empowered — anytime, anywhere.
## Installation & Quickstart ## Installation & Quickstart
Project N.O.M.A.D. can be installed on any Debian-based operating system (we recommend Ubuntu). Installation is completely terminal-based, and all tools and resources are designed to be accessed through the browser, so there's no need for a desktop environment if you'd rather setup N.O.M.A.D. as a "server" and access it through other clients. Project NOMAD can be installed on any Debian-based operating system (we recommend Ubuntu 26.04 LTS; 24.04 LTS and Debian 12 are also supported). Installation is completely terminal-based, and all tools and resources are designed to be accessed through the browser, so there's no need for a desktop environment if you'd rather setup NOMAD as a "server" and access it through other clients.
*Note: sudo/root privileges are required to run the install script* *Note: sudo/root privileges are required to run the install script*
@ -30,7 +28,7 @@ curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/r
sudo bash install_nomad.sh sudo bash install_nomad.sh
``` ```
Project N.O.M.A.D. is now installed on your device! Open a browser and navigate to `http://localhost:8080` (or `http://DEVICE_IP:8080`) to start exploring! Project NOMAD is now installed on your device! Open a browser and navigate to `http://localhost:8080` (or `http://DEVICE_IP:8080`) to start exploring!
For a complete step-by-step walkthrough (including Ubuntu installation), see the [Installation Guide](https://www.projectnomad.us/install). For Windows users, see the [WSL2 install guide](https://www.projectnomad.us/install/wsl2) — community-supported path covering native Docker and Docker Desktop install routes. For a complete step-by-step walkthrough (including Ubuntu installation), see the [Installation Guide](https://www.projectnomad.us/install). For Windows users, see the [WSL2 install guide](https://www.projectnomad.us/install/wsl2) — community-supported path covering native Docker and Docker Desktop install routes.
@ -38,7 +36,7 @@ For a complete step-by-step walkthrough (including Ubuntu installation), see the
For more control over the installation process, copy and paste the [Docker Compose template](https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/management_compose.yaml) into a `docker-compose.yml` file and customize it to your liking (be sure to replace any placeholders with your actual values). Then, run `docker compose up -d` to start the Command Center and its dependencies. Note: this method is recommended for advanced users only, as it requires familiarity with Docker and manual configuration before starting. For more control over the installation process, copy and paste the [Docker Compose template](https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/management_compose.yaml) into a `docker-compose.yml` file and customize it to your liking (be sure to replace any placeholders with your actual values). Then, run `docker compose up -d` to start the Command Center and its dependencies. Note: this method is recommended for advanced users only, as it requires familiarity with Docker and manual configuration before starting.
## How It Works ## How It Works
N.O.M.A.D. is a management UI ("Command Center") and API that orchestrates a collection of containerized tools and resources via [Docker](https://www.docker.com/). It handles installation, configuration, and updates for everything — so you don't have to. NOMAD is a management UI ("Command Center") and API that orchestrates a collection of containerized tools and resources via [Docker](https://www.docker.com/). It handles installation, configuration, and updates for everything — so you don't have to.
**Built-in capabilities include:** **Built-in capabilities include:**
- **AI Chat with Knowledge Base** — local AI chat powered by [Ollama](https://ollama.com/) or you can use OpenAI API compatible software such as LM Studio or llama.cpp, with document upload and semantic search (RAG via [Qdrant](https://qdrant.tech/)) - **AI Chat with Knowledge Base** — local AI chat powered by [Ollama](https://ollama.com/) or you can use OpenAI API compatible software such as LM Studio or llama.cpp, with document upload and semantic search (RAG via [Qdrant](https://qdrant.tech/))
@ -52,7 +50,7 @@ N.O.M.A.D. is a management UI ("Command Center") and API that orchestrates a col
- **Automatic Updates** — opt-in, hands-off updates for the core software, installed apps, and offline content, on a schedule you control - **Automatic Updates** — opt-in, hands-off updates for the core software, installed apps, and offline content, on a schedule you control
- **Easy Setup Wizard** — guided first-time configuration with curated content collections - **Easy Setup Wizard** — guided first-time configuration with curated content collections
N.O.M.A.D. also includes built-in tools like a Wikipedia content selector, ZIM library manager, and content explorer. NOMAD also includes built-in tools like a Wikipedia content selector, ZIM library manager, and content explorer.
## What's Included ## What's Included
@ -68,18 +66,18 @@ N.O.M.A.D. also includes built-in tools like a Wikipedia content selector, ZIM l
| Supply Depot | Built-in | One-click app catalog + bring-your-own custom Docker containers | | Supply Depot | Built-in | One-click app catalog + bring-your-own custom Docker containers |
## Device Requirements ## Device Requirements
While many similar offline survival computers are designed to be run on bare-minimum, lightweight hardware, Project N.O.M.A.D. is quite the opposite. To install and run the While many similar offline survival computers are designed to be run on bare-minimum, lightweight hardware, Project NOMAD is quite the opposite. To install and run the
available AI tools, we highly encourage the use of a beefy, GPU-backed device to make the most of your install. available AI tools, we highly encourage the use of a beefy, GPU-backed device to make the most of your install.
At its core, however, N.O.M.A.D. is still very lightweight. For a barebones installation of the management application itself, the following minimal specs are required: At its core, however, NOMAD is still very lightweight. For a barebones installation of the management application itself, the following minimal specs are required:
*Note: Project N.O.M.A.D. is not sponsored by any hardware manufacturer and is designed to be as hardware-agnostic as possible. The hardware listed below is for example/comparison use only* *Note: Project NOMAD is not sponsored by any hardware manufacturer and is designed to be as hardware-agnostic as possible. The hardware listed below is for example/comparison use only*
#### Minimum Specs #### Minimum Specs
- Processor: 2 GHz dual-core processor or better - Processor: 2 GHz dual-core processor or better
- RAM: 4GB system memory - RAM: 4GB system memory
- Storage: At least 5 GB free disk space - Storage: At least 5 GB free disk space
- OS: Debian-based (Ubuntu recommended) - OS: Debian-based (Ubuntu 26.04 LTS recommended)
- Stable internet connection (required during install only) - Stable internet connection (required during install only)
To run LLMs and other included AI tools: To run LLMs and other included AI tools:
@ -89,35 +87,35 @@ To run LLMs and other included AI tools:
- RAM: 32 GB system memory - RAM: 32 GB system memory
- Graphics: NVIDIA RTX 3060 or AMD equivalent or better (more VRAM = run larger models) - Graphics: NVIDIA RTX 3060 or AMD equivalent or better (more VRAM = run larger models)
- Storage: At least 250 GB free disk space (preferably on SSD) - Storage: At least 250 GB free disk space (preferably on SSD)
- OS: Debian-based (Ubuntu recommended) - OS: Debian-based (Ubuntu 26.04 LTS recommended)
- Stable internet connection (required during install only) - Stable internet connection (required during install only)
**For detailed build recommendations at three price points ($150$1,000+), see the [Hardware Guide](https://www.projectnomad.us/hardware).** **For detailed build recommendations at three price points ($150$1,000+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
Again, Project N.O.M.A.D. itself is quite lightweight — it's the tools and resources you choose to install with N.O.M.A.D. that will determine the specs required for your unique deployment Again, Project NOMAD itself is quite lightweight — it's the tools and resources you choose to install with NOMAD that will determine the specs required for your unique deployment
#### Running AI models on a different host #### Running AI models on a different host
By default, N.O.M.A.D.'s installer will attempt to setup Ollama on the host when the AI Assistant is installed. However, if you would like to run the AI model on a different host, you can go to the settings of the AI assistant and input a URL for either an ollama or OpenAI-compatible API server (such as LM Studio). By default, NOMAD's installer will attempt to setup Ollama on the host when the AI Assistant is installed. However, if you would like to run the AI model on a different host, you can go to the settings of the AI assistant and input a URL for either an ollama or OpenAI-compatible API server (such as LM Studio).
Note that if you use Ollama on a different host, you must start the server with this option: `OLLAMA_HOST=0.0.0.0`. Note that if you use Ollama on a different host, you must start the server with this option: `OLLAMA_HOST=0.0.0.0`.
Ollama is the preferred way to use the AI assistant, as it has features such as model download that OpenAI API does not support. So when using LM Studio, for example, you will have to use LM Studio to download models. Ollama is the preferred way to use the AI assistant, as it has features such as model download that OpenAI API does not support. So when using LM Studio, for example, you will have to use LM Studio to download models.
You are responsible for the setup of Ollama/OpenAI server on the other host. You are responsible for the setup of Ollama/OpenAI server on the other host.
## Frequently Asked Questions (FAQ) ## Frequently Asked Questions (FAQ)
For answers to common questions about Project N.O.M.A.D., please see our [FAQ](FAQ.md) page. For answers to common questions about Project NOMAD, please see our [FAQ](FAQ.md) page.
## About Internet Usage & Privacy ## About Internet Usage & Privacy
Project N.O.M.A.D. is designed for offline usage. An internet connection is only required during the initial installation (to download dependencies) and if you (the user) decide to download additional tools and resources at a later time. Otherwise, N.O.M.A.D. does not require an internet connection and has ZERO built-in telemetry. Project NOMAD is designed for offline usage. An internet connection is only required during the initial installation (to download dependencies) and if you (the user) decide to download additional tools and resources at a later time. Otherwise, NOMAD does not require an internet connection and has ZERO built-in telemetry.
To test internet connectivity, N.O.M.A.D. first attempts to make a request to Cloudflare's utility endpoint, `https://1.1.1.1/cdn-cgi/trace`. If that endpoint is unreachable (for example, because your network blocks `1.1.1.1`), it falls back to other endpoints the application already contacts (the GitHub API and the Project N.O.M.A.D. API) and considers the connection online if any of them respond. To test internet connectivity, NOMAD first attempts to make a request to Cloudflare's utility endpoint, `https://1.1.1.1/cdn-cgi/trace`. If that endpoint is unreachable (for example, because your network blocks `1.1.1.1`), it falls back to other endpoints the application already contacts (the GitHub API and the Project NOMAD API) and considers the connection online if any of them respond.
You can override the endpoint used for this check in two ways. The connectivity test URL can be configured from the UI under **Settings → Advanced** (stored locally on your instance), or you can set the `INTERNET_STATUS_TEST_URL` environment variable. When set, the environment variable always takes precedence over the UI-configured value. If neither is set, the built-in defaults above are used. You can override the endpoint used for this check in two ways. The connectivity test URL can be configured from the UI under **Settings → Advanced** (stored locally on your instance), or you can set the `INTERNET_STATUS_TEST_URL` environment variable. When set, the environment variable always takes precedence over the UI-configured value. If neither is set, the built-in defaults above are used.
## About Security ## About Security
By design, Project N.O.M.A.D. is intended to be open and available without hurdles — it includes no authentication. If you decide to connect your device to a local network after install (e.g. for allowing other devices to access its resources), you can block/open ports to control which services are exposed. By design, Project NOMAD is intended to be open and available without hurdles — it includes no authentication. If you decide to connect your device to a local network after install (e.g. for allowing other devices to access its resources), you can block/open ports to control which services are exposed.
**Will authentication be added in the future?** Maybe. It's not currently a priority, but if there's enough demand for it, we may consider building in an optional authentication layer in a future release to support use cases where multiple users need access to the same instance but with different permission levels (e.g. family use with parental controls, classroom use with teacher/admin accounts, etc.). We have a suggestion for this on our public roadmap, so if this is something you'd like to see, please upvote it here: https://roadmap.projectnomad.us/posts/1/user-authentication-please-build-in-user-auth-with-admin-user-roles **Will authentication be added in the future?** Maybe. It's not currently a priority, but if there's enough demand for it, we may consider building in an optional authentication layer in a future release to support use cases where multiple users need access to the same instance but with different permission levels (e.g. family use with parental controls, classroom use with teacher/admin accounts, etc.). We have a suggestion for this on our public roadmap, so if this is something you'd like to see, please upvote it here: https://roadmap.projectnomad.us/posts/1/user-authentication-please-build-in-user-auth-with-admin-user-roles
For now, we recommend using network-level controls to manage access if you're planning to expose your N.O.M.A.D. instance to other devices on a local network. N.O.M.A.D. is not designed to be exposed directly to the internet, and we strongly advise against doing so unless you really know what you're doing, have taken appropriate security measures, and understand the risks involved. For now, we recommend using network-level controls to manage access if you're planning to expose your NOMAD instance to other devices on a local network. NOMAD is not designed to be exposed directly to the internet, and we strongly advise against doing so unless you really know what you're doing, have taken appropriate security measures, and understand the risks involved.
## Contributing ## Contributing
Contributions are welcome and appreciated! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute to the project. Contributions are welcome and appreciated! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute to the project.
@ -167,10 +165,10 @@ It prints the resolved decision — current version, whether the clock is inside
## License ## License
Project N.O.M.A.D. is licensed under the [Apache License 2.0](LICENSE). Project NOMAD is licensed under the [Apache License 2.0](LICENSE).
## Helper Scripts ## Helper Scripts
Once installed, Project N.O.M.A.D. has a few helper scripts should you ever need to troubleshoot issues or perform maintenance that can't be done through the Command Center. All of these scripts are found in Project N.O.M.A.D.'s install directory, `/opt/project-nomad` Once installed, Project NOMAD has a few helper scripts should you ever need to troubleshoot issues or perform maintenance that can't be done through the Command Center. All of these scripts are found in Project NOMAD's install directory, `/opt/project-nomad`
### ###

View File

@ -23,3 +23,8 @@ REDIS_PORT=6379
# On Windows dev, use an absolute path like: C:/nomad-storage # On Windows dev, use an absolute path like: C:/nomad-storage
# On Linux production, use: /opt/project-nomad/storage # On Linux production, use: /opt/project-nomad/storage
NOMAD_STORAGE_PATH=/opt/project-nomad/storage NOMAD_STORAGE_PATH=/opt/project-nomad/storage
# Creator Packs (gated content downloads). Official release builds inject
# CREATOR_PACKS_APP_KEY at build time; leave unset for a build that can't install
# packs. CREATOR_PACKS_WORKER_BASE overrides the entitlement Worker origin.
# CREATOR_PACKS_APP_KEY=
# CREATOR_PACKS_WORKER_BASE=https://nomad-packs-worker.chris-556.workers.dev

View File

@ -184,9 +184,22 @@ export default class BenchmarkController {
// Pass through the status code from the service if available, otherwise default to 400 // Pass through the status code from the service if available, otherwise default to 400
const statusCode = (error as any).statusCode || 400 const statusCode = (error as any).statusCode || 400
logger.error({ err: error }, '[BenchmarkController] Benchmark submit failed') logger.error({ err: error }, '[BenchmarkController] Benchmark submit failed')
// Surface a clear, actionable reason to the UI instead of a generic failure.
// The rate limiter (429) is the most common cause, so name it explicitly;
// otherwise pass through the underlying detail (repository error or a service
// validation message) and fall back to a safe generic only when we have none.
let errorMessage: string
if (statusCode === 429) {
errorMessage = 'You can only submit one benchmark per hour. Please wait a bit and try again.'
} else {
errorMessage =
(error as any).detail || (error as any).message || 'Failed to submit benchmark results.'
}
return response.status(statusCode).send({ return response.status(statusCode).send({
success: false, success: false,
error: 'Failed to submit benchmark results.', error: errorMessage,
}) })
} }
} }
@ -248,6 +261,13 @@ export default class BenchmarkController {
return this.benchmarkService.getStatus() return this.benchmarkService.getStatus()
} }
/**
* Whether to show the dashboard "re-run under Score v2" banner
*/
async rerunBanner({}: HttpContext) {
return { show: await this.benchmarkService.shouldShowRerunBanner() }
}
/** /**
* Get benchmark settings * Get benchmark settings
*/ */

View File

@ -0,0 +1,114 @@
import type { HttpContext } from '@adonisjs/core/http'
import logger from '@adonisjs/core/services/logger'
import { ConditionService } from '#services/condition_service'
import { conditionDrugsValidator } from '#validators/conditions'
import { affirmativeRemediesEnabled } from '../utils/affirmative_remedies.js'
/**
* "When to use what" condition-first HTTP boundary (Phase 1).
*
* Two Inertia pages (index / show) + a small JSON API (drugs). Mirrors the
* DrugReferenceController chain:
* - index/show render Inertia
* - the JSON action returns a plain object
* - slug guard on show (404 when not in the curated spine)
* - never leak exceptions to the UI
*/
export default class ConditionsController {
private get service() {
return new ConditionService()
}
/**
* GET /conditions legacy browse route.
* Situation browsing now lives directly on the unified Drug Reference page, so
* the standalone browse route permanently redirects there. Any old bookmark or
* in-app link lands on the same content. The condition detail route
* (/conditions/:slug) is unchanged situation chips still deep-link to it via
* /drug-reference?situation=<slug>.
*/
async index({ response }: HttpContext) {
return response.redirect('/drug-reference')
}
/**
* GET /conditions/:slug condition detail page.
* 404s when the slug is not a curated condition.
*/
async show({ inertia, params, response }: HttpContext) {
const slug = String(params.slug ?? '').trim()
if (!slug) {
return response.notFound({ error: 'invalid condition' })
}
try {
const condition = this.service.findCondition(slug)
if (!condition) {
return response.notFound({ error: 'Condition not found' })
}
const [result, drugRowCount, remediesOn] = await Promise.all([
this.service.drugsForSlug(slug),
this.service.drugRowCount(),
affirmativeRemediesEnabled(),
])
return inertia.render('conditions/show', {
condition: result?.condition ?? null,
drugs: result?.drugs ?? [],
// Affirmative remedies gated off by default (#1040); the OTC/condition
// match above is regulated label text and stays live.
remedies: remediesOn ? (result?.remedies ?? []) : [],
drugRowCount,
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[ConditionsController] show(${slug}) failed: ${msg}`)
return response.internalServerError({ error: 'Could not load condition' })
}
}
/**
* GET /api/conditions/drugs?slug= | ?q=
* Returns { condition, drugs } for a curated condition or a free-text
* situation. Requires exactly one of slug/q.
*/
async drugsApi({ request, response }: HttpContext) {
try {
const params = await request.validateUsing(conditionDrugsValidator)
if (params.slug && params.q) {
return response.badRequest({ error: 'Provide either slug or q, not both' })
}
const filterOpts = {
route: params.route,
sort: params.sort,
}
// Strip affirmative remedies from the situation-search response when the
// gate is closed (#1040); the OTC drug matches are regulated label text and
// are returned either way.
const remediesOn = await affirmativeRemediesEnabled()
if (params.slug) {
const result = await this.service.drugsForSlug(params.slug, params.limit, filterOpts)
if (!result) {
return response.notFound({ error: 'Condition not found' })
}
return remediesOn ? result : { ...result, remedies: [] }
}
if (params.q) {
const result = await this.service.drugsForFreeText(params.q, params.limit, filterOpts)
return remediesOn ? result : { ...result, remedies: [] }
}
return response.badRequest({ error: 'Provide a slug or q query parameter' })
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.warn(`[ConditionsController] drugsApi failed: ${msg}`)
return response.badRequest({ error: msg })
}
}
}

View File

@ -0,0 +1,62 @@
import { CreatorPackService } from '#services/creator_pack_service'
import { DownloadService } from '#services/download_service'
import { QueueService } from '#services/queue_service'
import logger from '@adonisjs/core/services/logger'
import type { HttpContext } from '@adonisjs/core/http'
export default class CreatorPacksController {
private creatorPackService = new CreatorPackService()
/**
* Catalog with per-pack status, plus live progress for any in-flight pack
* downloads (ZIM download jobs filtered client-side by collection_ref once
* the UI needs it; for now the raw zim job list is enough for progress).
*/
async index({ response }: HttpContext) {
try {
const configured = this.creatorPackService.isConfigured()
const packs = await this.creatorPackService.listPacksWithStatus()
const downloadService = new DownloadService(QueueService.getInstance())
const downloads = await downloadService.listDownloadJobs('zim')
return { configured, packs, downloads }
} catch (error: any) {
logger.error('[CreatorPacksController] Failed to list creator packs:', error?.message || error)
return response.status(500).send({ message: 'Failed to load creator packs' })
}
}
async install({ params, response }: HttpContext) {
const packId = params.id as string
const result = await this.creatorPackService.installPack(packId)
switch (result.code) {
case 'dispatched':
return response.status(202).send({
message: 'Pack download started',
filename: result.filename,
})
case 'already_installed':
return { message: 'Pack is already installed' }
case 'already_downloading':
return { message: 'Pack download is already in progress' }
case 'not_found':
return response.status(404).send({ message: `Creator pack not found: ${packId}` })
case 'not_configured':
return response.status(503).send({
message: 'Creator Packs are not configured on this server',
})
}
}
async uninstall({ params, response }: HttpContext) {
const packId = params.id as string
const result = await this.creatorPackService.uninstallPack(packId)
switch (result.code) {
case 'uninstalled':
return { message: 'Pack uninstalled', filename: result.filename }
case 'not_installed':
return response.status(404).send({ message: `Creator pack is not installed: ${packId}` })
}
}
}

View File

@ -24,4 +24,8 @@ export default class DownloadsController {
async cancelJob({ params }: HttpContext) { async cancelJob({ params }: HttpContext) {
return this.downloadService.cancelJob(params.jobId) return this.downloadService.cancelJob(params.jobId)
} }
async retryJob({ params }: HttpContext) {
return this.downloadService.retryFailedJob(params.jobId)
}
} }

View File

@ -0,0 +1,295 @@
import type { HttpContext } from '@adonisjs/core/http'
import logger from '@adonisjs/core/services/logger'
import { DrugReferenceService } from '#services/drug_reference_service'
import { ConditionService } from '#services/condition_service'
import { searchDrugValidator, interactionsValidator } from '#validators/drug_reference'
import { parseCompareIds } from '../../util/compare_ids.js'
import { situationsForIndications } from '../../util/conditions.js'
import { affirmativeRemediesEnabled } from '../utils/affirmative_remedies.js'
/**
* Drug Reference v1 HTTP boundary.
*
* Two Inertia pages (index / show) + a small JSON API (search / status /
* download). Mirrors the WorkshopController / InventoryController chain:
* - index/show render Inertia
* - JSON actions return plain objects
* - Integer-id guard on show
* - Never leak exceptions to the UI
*/
export default class DrugReferenceController {
private get service() {
return new DrugReferenceService()
}
/**
* GET /drug-reference unified search page.
* Passes the current row count and ingest status so the empty-state
* "download first" prompt can render server-side. Also passes the curated
* condition spine so the always-visible situation chips (and the situation
* drugs direction of the unified surface) can render server-side.
*/
async index({ inertia }: HttpContext) {
try {
const conditionService = new ConditionService()
const [status, count, remediesOn] = await Promise.all([
this.service.getIngestStatus(),
this.service.rowCount(),
affirmativeRemediesEnabled(),
])
return inertia.render('drug-reference/index', {
ingestStatus: status,
rowCount: count,
conditions: conditionService.listConditions(),
// Affirmative remedy content is gated off by default (#1040): keep it out
// of the payload entirely when disabled, and tell the page so it can hide
// the "Natural" filter too. Drug search + condition matching are unaffected.
remedies: remediesOn ? conditionService.listRemedies() : [],
remediesEnabled: remediesOn,
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceController] index failed: ${msg}`)
return inertia.render('drug-reference/index', {
ingestStatus: null,
rowCount: 0,
conditions: [],
remedies: [],
remediesEnabled: false,
})
}
}
/**
* GET /drug-reference/:id detail page.
*/
async show({ inertia, params, response }: HttpContext) {
const id = Number(params.id)
if (!Number.isInteger(id) || id <= 0) {
return response.notFound({ error: 'invalid id' })
}
try {
const label = await this.service.find(id)
if (!label) {
return response.notFound({ error: 'Drug label not found' })
}
// Reverse link — the other direction of the symbiotic relationship: which
// curated situations does THIS label's indications text treat? Matched
// server-side against the curated spine so searchTerms stay server-only.
const situations = situationsForIndications(
label.indications,
new ConditionService().allConditions()
)
return inertia.render('drug-reference/show', { label, situations })
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceController] show(${id}) failed: ${msg}`)
return response.internalServerError({ error: 'Could not load drug label' })
}
}
/**
* GET /api/drug-reference/search
* Returns a slim collapsed result list (brand+generic pairs).
*/
async search({ request, response }: HttpContext) {
try {
const params = await request.validateUsing(searchDrugValidator)
const results = await this.service.search(params.q, {
productType: params.product_type,
route: params.route,
sort: params.sort,
limit: params.limit,
offset: params.offset,
scope: params.scope,
})
return { results }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.warn(`[DrugReferenceController] search failed: ${msg}`)
return response.badRequest({ error: msg })
}
}
/**
* GET /api/drug-reference/status
* Returns the live ingest status DTO.
*/
async status({ response }: HttpContext) {
try {
const status = await this.service.getIngestStatus()
return status
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceController] status failed: ${msg}`)
return response.internalServerError({ error: 'Could not read ingest status' })
}
}
/**
* GET /drug-reference/interactions side-by-side label comparison page.
* Passes rowCount + ingestStatus so the empty-state prompt can render,
* mirroring the index() pattern. The actual entry data is loaded client-side
* via /api/drug-reference/interactions?ids= so the page is shareable via URL.
*/
async interactions({ inertia }: HttpContext) {
try {
const [status, count] = await Promise.all([
this.service.getIngestStatus(),
this.service.rowCount(),
])
return inertia.render('drug-reference/interactions', {
ingestStatus: status,
rowCount: count,
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceController] interactions page failed: ${msg}`)
return inertia.render('drug-reference/interactions', {
ingestStatus: null,
rowCount: 0,
})
}
}
/**
* GET /api/drug-reference/interactions?ids=1,2,3
* Validates parses fetches and returns { entries: DrugInteractionEntry[] }.
* Never leaks exceptions; integer-guards ids via parseCompareIds.
*/
async interactionsApi({ request, response }: HttpContext) {
try {
const params = await request.validateUsing(interactionsValidator)
const ids = parseCompareIds(params.ids ?? '')
const entries = await this.service.getInteractionsFor(ids)
return { entries }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.warn(`[DrugReferenceController] interactionsApi failed: ${msg}`)
return response.badRequest({ error: msg })
}
}
/**
* POST /api/drug-reference/download
* Triggers the download phase (idempotent deduped on deterministic jobId).
* The download auto-chains the ingest phase on completion.
*/
async download({ response }: HttpContext) {
try {
const result = await this.service.triggerDownload()
return { success: true, created: result.created, message: result.message }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceController] download trigger failed: ${msg}`)
return response.internalServerError({ error: 'Could not trigger download' })
}
}
/**
* POST /api/drug-reference/ingest
* Manually (re-)runs the ingest phase from the already-downloaded on-disk
* parts, with no re-download. Returns 404 when nothing is on disk so the UI
* can keep its guard honest even if the button is reached out of band.
*/
async ingest({ response }: HttpContext) {
try {
const result = await this.service.triggerIngestFromDisk()
if (result.nothingDownloaded) {
return response.notFound({ error: result.message })
}
return { success: true, created: result.created, message: result.message }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceController] ingest trigger failed: ${msg}`)
return response.internalServerError({ error: 'Could not trigger ingest' })
}
}
/**
* POST /api/drug-reference/reset-ingest
* Force-clears a wedged ingest job (e.g. one left 'active' by a worker killed
* mid-ingest during an upgrade) and restarts it from the on-disk parts. The
* escape hatch for a stuck "Indexing…" state.
*/
async resetIngest({ response }: HttpContext) {
try {
const result = await this.service.resetAndReingest()
if (result.nothingDownloaded) {
return response.notFound({ error: result.message })
}
return { success: true, created: result.created, message: result.message }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceController] reset-ingest failed: ${msg}`)
return response.internalServerError({ error: 'Could not reset ingest' })
}
}
/**
* POST /api/drug-reference/uninstall
* Uninstall the offline FDA drug dataset: stop in-flight jobs, delete on-disk
* parts, TRUNCATE drug_labels, clear KV markers, and remove the install-state
* row (which auto-hides the home tiles). The curated-tier "remove" action.
* Reports partial failures rather than masking them.
*/
async uninstall({ response }: HttpContext) {
try {
const result = await this.service.uninstall()
if (!result.success) {
return response.internalServerError({
success: false,
rowsDropped: result.rowsDropped,
error: result.message,
})
}
return { success: true, rowsDropped: result.rowsDropped, message: result.message }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceController] uninstall failed: ${msg}`)
return response.internalServerError({ error: 'Could not uninstall drug reference' })
}
}
/**
* GET /api/drug-reference/ingest-log
* Tail the persisted app log for ingest/download lines. In production the logger
* writes JSON to /app/storage/logs/admin.log (both the admin and worker
* containers share that volume), so the worker's [IngestDrugDataJob] trace lands
* there even when its stdout never reaches the log viewer. This surfaces it over
* HTTP so the exact stall stage (zip-open vs first-record vs batch) is visible
* without container access. Reads only the last slice of the file.
*/
async ingestLog({ request, response }: HttpContext) {
const LOG_PATH = '/app/storage/logs/admin.log'
const TAIL_BYTES = 128 * 1024
const limit = Math.min(Number(request.input('lines', 400)) || 400, 2000)
try {
const { stat, open } = await import('node:fs/promises')
const st = await stat(LOG_PATH)
const start = Math.max(0, st.size - TAIL_BYTES)
const fh = await open(LOG_PATH, 'r')
try {
const buf = Buffer.alloc(st.size - start)
await fh.read(buf, 0, buf.length, start)
const all = buf.toString('utf8').split('\n')
// Keep only ingest/download/worker-relevant lines; for JSON pino lines the
// substring match still works against the embedded "msg" field.
const re =
/IngestDrugDataJob|DownloadDrugDataJob|DrugReference|drug-ingest|drug-download|unhandledRejection|uncaughtException|queue:work|stalled/i
const matched = all.filter((l) => re.test(l)).slice(-limit)
return { ok: true, path: LOG_PATH, size: st.size, count: matched.length, lines: matched }
} finally {
await fh.close()
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return response.notFound({ ok: false, path: LOG_PATH, error: msg })
}
}
}

View File

@ -1,4 +1,6 @@
import { SystemService } from '#services/system_service' import { SystemService } from '#services/system_service'
import { DrugReferenceService } from '#services/drug_reference_service'
import logger from '@adonisjs/core/services/logger'
import { inject } from '@adonisjs/core' import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
@ -18,7 +20,38 @@ export default class HomeController {
return inertia.render('home', { return inertia.render('home', {
system: { system: {
services services
} },
// Gate the Drug Reference / "When to use what" tiles behind the FDA
// dataset install state. Installed when the curated-tier ingest has
// reached 'ready' OR an install is in flight (downloading/ingesting) —
// so the tile appears the moment the user opts in and persists through
// the long install, rather than popping in only at the very end.
drugReferenceInstalled: await this.computeDrugReferenceInstalled(),
}) })
} }
/**
* True when the offline FDA drug dataset is installed or installing. Reads the
* two-phase ingest status: ready (fully installed) or an active phase
* (downloading/downloaded/ingesting). rowCount > 0 covers a populated table
* whose job history was pruned. Never throws a status read failure hides the
* tiles (fail-closed) rather than 500-ing the dashboard.
*/
private async computeDrugReferenceInstalled(): Promise<boolean> {
try {
const status = await new DrugReferenceService().getIngestStatus()
const installing =
status.phase === 'downloading' ||
status.phase === 'downloaded' ||
status.phase === 'ingesting'
return status.phase === 'ready' || installing || status.rowCount > 0
} catch (err) {
logger.error(
`[HomeController] drug-reference install check failed: ${
err instanceof Error ? err.message : String(err)
}`
)
return false
}
}
} }

View File

@ -19,10 +19,14 @@ export default class MapsController {
async index({ inertia }: HttpContext) { async index({ inertia }: HttpContext) {
const baseAssetsCheck = await this.mapService.ensureBaseAssets() const baseAssetsCheck = await this.mapService.ensureBaseAssets()
const regionFiles = await this.mapService.listRegions() const [regionFiles, worldBasemapExists] = await Promise.all([
this.mapService.listRegions(),
this.mapService.checkWorldBasemapExists(),
])
return inertia.render('maps', { return inertia.render('maps', {
maps: { maps: {
baseAssetsExist: baseAssetsCheck, baseAssetsExist: baseAssetsCheck,
worldBasemapExists,
regionFiles: regionFiles.files, regionFiles: regionFiles.files,
}, },
}) })
@ -35,6 +39,24 @@ export default class MapsController {
return { success: true } return { success: true }
} }
async setupWorldBasemap({ response }: HttpContext) {
try {
const ready = await this.mapService.provisionWorldBasemap()
if (!ready) {
return response.status(500).send({
message:
'Could not download the base map. Please connect this NOMAD to the internet and try again.',
})
}
return { success: true }
} catch {
return response.status(500).send({
message:
'Could not download the base map. Please connect this NOMAD to the internet and try again.',
})
}
}
async downloadRemote({ request }: HttpContext) { async downloadRemote({ request }: HttpContext) {
const payload = await request.validateUsing(remoteDownloadValidator) const payload = await request.validateUsing(remoteDownloadValidator)
assertNotPrivateUrl(payload.url) assertNotPrivateUrl(payload.url)

View File

@ -0,0 +1,22 @@
import { NomadMdService } from '#services/nomad_md_service'
import { updateNomadMdSchema } from '#validators/nomad_md'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
@inject()
export default class NomadMdController {
constructor(private nomadMdService: NomadMdService) {}
async show({ response }: HttpContext) {
const content = await this.nomadMdService.read()
return response.status(200).json({ content })
}
async update({ request, response }: HttpContext) {
const { content } = await request.validateUsing(updateNomadMdSchema)
// Empty request strings arrive as undefined (AdonisJS null-coerces them);
// treat that as an explicit "clear the file".
await this.nomadMdService.write(content ?? '')
return response.status(200).json({ success: true, message: 'NOMAD.md saved successfully' })
}
}

View File

@ -1,5 +1,6 @@
import { ChatService } from '#services/chat_service' import { ChatService } from '#services/chat_service'
import { DockerService } from '#services/docker_service' import { DockerService } from '#services/docker_service'
import { NomadMdService } from '#services/nomad_md_service'
import { OllamaService } from '#services/ollama_service' import { OllamaService } from '#services/ollama_service'
import { RagService } from '#services/rag_service' import { RagService } from '#services/rag_service'
import Service from '#models/service' import Service from '#models/service'
@ -20,7 +21,8 @@ export default class OllamaController {
private chatService: ChatService, private chatService: ChatService,
private dockerService: DockerService, private dockerService: DockerService,
private ollamaService: OllamaService, private ollamaService: OllamaService,
private ragService: RagService private ragService: RagService,
private nomadMdService: NomadMdService
) { } ) { }
async availableModels({ request }: HttpContext) { async availableModels({ request }: HttpContext) {
@ -71,16 +73,28 @@ export default class OllamaController {
reqData.messages.unshift(systemPrompt) reqData.messages.unshift(systemPrompt)
} }
// Inject the user-managed NOMAD.md as its own leading system message so the
// user's persistent instructions take precedence, while the default
// formatting prompt and any RAG context below remain intact. A missing or
// blank file yields null and changes nothing.
const nomadPrompt = await this.nomadMdService.getSystemPrompt()
if (nomadPrompt) {
logger.debug('[OllamaController] Injecting NOMAD.md system prompt')
reqData.messages.unshift({ role: 'system' as const, content: nomadPrompt })
}
// Query rewriting for better RAG retrieval with manageable context // Query rewriting for better RAG retrieval with manageable context
// Will return user's latest message if no rewriting is needed // Will return user's latest message if no rewriting is needed
const rewrittenQuery = await this.rewriteQueryWithContext(reqData.messages, reqData.model) const rewrittenQuery = await this.rewriteQueryWithContext(reqData.messages, reqData.model)
logger.debug(`[OllamaController] Rewritten query for RAG: "${rewrittenQuery}"`) logger.debug(`[OllamaController] Rewritten query for RAG: "${rewrittenQuery}"`)
if (rewrittenQuery) { if (rewrittenQuery) {
const collectionFilter: string | null = request.input('collection', null)
const relevantDocs = await this.ragService.searchSimilarDocuments( const relevantDocs = await this.ragService.searchSimilarDocuments(
rewrittenQuery, rewrittenQuery,
5, // Top 5 most relevant chunks 5, // Top 5 most relevant chunks
0.3 // Minimum similarity score of 0.3 0.3, // Minimum similarity score of 0.3
collectionFilter ?? undefined
) )
logger.debug(`[RAG] Retrieved ${relevantDocs.length} relevant documents for query: "${rewrittenQuery}"`) logger.debug(`[RAG] Retrieved ${relevantDocs.length} relevant documents for query: "${rewrittenQuery}"`)
@ -144,13 +158,21 @@ export default class OllamaController {
logger.debug(`[OllamaController] Large system prompt (~${estimatedSystemTokens} tokens), requesting num_ctx: ${numCtx}`) logger.debug(`[OllamaController] Large system prompt (~${estimatedSystemTokens} tokens), requesting num_ctx: ${numCtx}`)
} }
// Check if the model supports "thinking" capability for enhanced response generation // Check if the model supports "thinking" capability for enhanced response generation.
// Thinking is only enabled when the model supports it AND the user wants it: the explicit
// per-request preference wins, otherwise the global default (ai.autoThinking, default OFF).
// If gpt-oss model, it requires a text param for "think" https://docs.ollama.com/api/chat // If gpt-oss model, it requires a text param for "think" https://docs.ollama.com/api/chat
const thinkingCapability = await this.ollamaService.checkModelHasThinking(reqData.model) const thinkingCapability = await this.ollamaService.checkModelHasThinking(reqData.model)
const think: boolean | 'medium' = thinkingCapability ? (reqData.model.startsWith('gpt-oss') ? 'medium' : true) : false let thinkingEnabled = false
if (thinkingCapability) {
thinkingEnabled = reqData.think ?? ((await KVStore.getValue('ai.autoThinking')) ?? false)
}
const think: boolean | 'medium' =
thinkingEnabled ? (reqData.model.startsWith('gpt-oss') ? 'medium' : true) : false
// Separate sessionId from the Ollama request payload — Ollama rejects unknown fields // Separate sessionId and the resolved thinking preference from the Ollama request payload —
const { sessionId, ...ollamaRequest } = reqData // Ollama rejects unknown fields, and `think` is re-derived above (not forwarded raw).
const { sessionId, think: _thinkPref, ...ollamaRequest } = reqData
// Save user message to DB before streaming if sessionId provided // Save user message to DB before streaming if sessionId provided
let userContent: string | null = null let userContent: string | null = null
@ -164,14 +186,33 @@ export default class OllamaController {
if (reqData.stream) { if (reqData.stream) {
logger.debug(`[OllamaController] Initiating streaming response for model: "${reqData.model}" with think: ${think}`) logger.debug(`[OllamaController] Initiating streaming response for model: "${reqData.model}" with think: ${think}`)
// Headers already flushed above // Headers already flushed above.
const stream = await this.ollamaService.chatStream({ ...ollamaRequest, think, numCtx }) // Abort the upstream generation if the client disconnects — otherwise an abandoned
// request keeps decoding server-side and, with Ollama's default OLLAMA_NUM_PARALLEL=1,
// blocks every later chat/RAG request until the model is manually stopped (#1065).
const abortController = new AbortController()
response.response.on('close', () => abortController.abort())
const stream = await this.ollamaService.chatStream({
...ollamaRequest,
think,
thinkingCapable: thinkingCapability,
numCtx,
signal: abortController.signal,
})
let fullContent = '' let fullContent = ''
for await (const chunk of stream) { try {
if (chunk.message?.content) { for await (const chunk of stream) {
fullContent += chunk.message.content if (chunk.message?.content) {
fullContent += chunk.message.content
}
response.response.write(`data: ${JSON.stringify(chunk)}\n\n`)
} }
response.response.write(`data: ${JSON.stringify(chunk)}\n\n`) } catch (err) {
if (abortController.signal.aborted) {
logger.debug('[OllamaController] Client disconnected; aborted upstream Ollama generation')
return
}
throw err
} }
response.response.end() response.response.end()
@ -189,7 +230,7 @@ export default class OllamaController {
} }
// Non-streaming (legacy) path // Non-streaming (legacy) path
const result = await this.ollamaService.chat({ ...ollamaRequest, think, numCtx }) const result = await this.ollamaService.chat({ ...ollamaRequest, think, thinkingCapable: thinkingCapability, numCtx })
if (sessionId && result?.message?.content) { if (sessionId && result?.message?.content) {
await this.chatService.addMessage(sessionId, 'assistant', result.message.content) await this.chatService.addMessage(sessionId, 'assistant', result.message.content)
@ -369,7 +410,14 @@ export default class OllamaController {
} }
async installedModels({ }: HttpContext) { async installedModels({ }: HttpContext) {
return await this.ollamaService.getModels() const models = await this.ollamaService.getModels()
// Enrich each model with its thinking capability so the chat picker knows which models
// to show the per-model thinking toggle for. checkModelHasThinking memoizes /api/show
// results, so this stays cheap on repeat loads. Best-effort per model.
const thinking = await Promise.all(
models.map((m) => this.ollamaService.checkModelHasThinking(m.name))
)
return models.map((m, i) => ({ ...m, thinking: thinking[i] }))
} }
/** /**
@ -456,3 +504,4 @@ export default class OllamaController {
} }
} }
} }

View File

@ -0,0 +1,74 @@
import { createRequire } from 'node:module'
import { readFileSync } from 'node:fs'
import type { HttpContext } from '@adonisjs/core/http'
import { buildOpenApiDocument } from '#start/openapi/generator'
const require = createRequire(import.meta.url)
/**
* The self-contained Scalar browser bundle, read from the installed
* `@scalar/api-reference` package and cached in memory. Served from this app
* (not a CDN) so the API reference works on an offline appliance.
*/
let scalarBundle: string | null = null
function getScalarBundle(): string {
if (scalarBundle === null) {
const entry = require.resolve('@scalar/api-reference')
const marker = '/node_modules/@scalar/api-reference/'
const root = entry.slice(0, entry.lastIndexOf(marker) + marker.length - 1)
scalarBundle = readFileSync(`${root}/dist/browser/standalone.js`, 'utf-8')
}
return scalarBundle
}
/**
* The reference page mounts Scalar against our locally served bundle and spec.
* Scalar auto-detects the `#api-reference` element and reads `data-url`.
*/
const REFERENCE_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nomad Admin API Reference</title>
</head>
<body>
<div id="app"></div>
<script src="/reference/assets/standalone.js"></script>
<script>
Scalar.createApiReference('#app', {
url: '/api/openapi.json',
hideClientButton: true,
telemetry: false,
theme: 'saturn',
})
</script>
</body>
</html>`
export default class OpenApiController {
/**
* The generated OpenAPI 3.1 document.
*/
async spec({ response }: HttpContext) {
return response.json(buildOpenApiDocument())
}
/**
* The interactive Scalar API reference page.
*/
async reference({ response }: HttpContext) {
return response.header('content-type', 'text/html').send(REFERENCE_HTML)
}
/**
* The locally bundled Scalar JS (kept off any CDN for offline use).
*/
async standalone({ response }: HttpContext) {
return response
.header('content-type', 'application/javascript; charset=utf-8')
.header('cache-control', 'public, max-age=86400')
.send(getScalarBundle())
}
}

View File

@ -9,6 +9,7 @@ import { sanitizeFilename } from '../utils/fs.js'
import { basename } from 'node:path' import { basename } from 'node:path'
import { deleteFileSchema, embedFileSchema, estimateBatchSchema, fileSourceSchema, getJobStatusSchema } from '#validators/rag' import { deleteFileSchema, embedFileSchema, estimateBatchSchema, fileSourceSchema, getJobStatusSchema } from '#validators/rag'
import logger from '@adonisjs/core/services/logger' import logger from '@adonisjs/core/services/logger'
import { sanitizeCollectionName } from '../../constants/kb_collections.js'
@inject() @inject()
export default class RagController { export default class RagController {
@ -20,6 +21,8 @@ export default class RagController {
return response.status(400).json({ error: 'No file uploaded' }) return response.status(400).json({ error: 'No file uploaded' })
} }
const collection = sanitizeCollectionName(request.input('collection', null))
const randomSuffix = randomBytes(6).toString('hex') const randomSuffix = randomBytes(6).toString('hex')
const sanitizedName = sanitizeFilename(uploadedFile.clientName) const sanitizedName = sanitizeFilename(uploadedFile.clientName)
@ -34,6 +37,7 @@ export default class RagController {
const result = await EmbedFileJob.dispatch({ const result = await EmbedFileJob.dispatch({
filePath: fullPath, filePath: fullPath,
fileName, fileName,
...(collection ? { collection } : {}),
}) })
return response.status(202).json({ return response.status(202).json({
@ -42,9 +46,9 @@ export default class RagController {
fileName, fileName,
filePath: `/${RagService.UPLOADS_STORAGE_PATH}/${fileName}`, filePath: `/${RagService.UPLOADS_STORAGE_PATH}/${fileName}`,
alreadyProcessing: !result.created, alreadyProcessing: !result.created,
...(collection ? { collection } : {}),
}) })
} }
public async getActiveJobs({ response }: HttpContext) { public async getActiveJobs({ response }: HttpContext) {
const jobs = await EmbedFileJob.listActiveJobs() const jobs = await EmbedFileJob.listActiveJobs()
return response.status(200).json(jobs) return response.status(200).json(jobs)
@ -68,6 +72,57 @@ export default class RagController {
return response.status(200).json({ files }) return response.status(200).json({ files })
} }
public async getKnowledgeCollections({ response }: HttpContext) {
const collections = await this.ragService.getKnowledgeCollections()
return response.status(200).json({ collections })
}
public async updateFileCollection({ request, response }: HttpContext) {
const source: string | null = request.input('source', null)
// sanitizeCollectionName trims/lowercases/caps length, and returns null
// for empty input — which doubles as "clear back to Uncategorized".
const collection = sanitizeCollectionName(request.input('collection', null))
if (!source) {
return response.status(400).json({ error: 'source is required.' })
}
const result = await this.ragService.updateFileCollection(source, collection)
if (!result.success) {
return response.status(500).json({ error: result.message })
}
return response.status(200).json({ message: result.message })
}
public async renameKnowledgeCollection({ request, response }: HttpContext) {
const oldName = sanitizeCollectionName(request.input('oldName', null))
const newName = sanitizeCollectionName(request.input('newName', null))
if (!oldName || !newName) {
return response.status(400).json({ error: 'oldName and newName are required.' })
}
const result = await this.ragService.renameKnowledgeCollection(oldName, newName)
if (!result.success) {
return response.status(500).json({ error: result.message })
}
return response.status(200).json({ message: result.message })
}
public async deleteKnowledgeCollection({ request, response }: HttpContext) {
const name = sanitizeCollectionName(request.input('name', null))
if (!name) {
return response.status(400).json({ error: 'name is required.' })
}
const result = await this.ragService.deleteKnowledgeCollection(name)
if (!result.success) {
return response.status(500).json({ error: result.message })
}
return response.status(200).json({ message: result.message })
}
public async getFileWarnings({ response }: HttpContext) { public async getFileWarnings({ response }: HttpContext) {
const result = await this.ragService.computeFileWarnings() const result = await this.ragService.computeFileWarnings()
return response.status(200).json(result) return response.status(200).json(result)

View File

@ -45,10 +45,14 @@ export default class SettingsController {
async maps({ inertia }: HttpContext) { async maps({ inertia }: HttpContext) {
const baseAssetsCheck = await this.mapService.ensureBaseAssets() const baseAssetsCheck = await this.mapService.ensureBaseAssets()
const regionFiles = await this.mapService.listRegions() const [regionFiles, worldBasemapExists] = await Promise.all([
this.mapService.listRegions(),
this.mapService.checkWorldBasemapExists(),
])
return inertia.render('settings/maps', { return inertia.render('settings/maps', {
maps: { maps: {
baseAssetsExist: baseAssetsCheck, baseAssetsExist: baseAssetsCheck,
worldBasemapExists,
regionFiles: regionFiles.files, regionFiles: regionFiles.files,
}, },
}) })
@ -66,6 +70,7 @@ export default class SettingsController {
const aiAssistantCustomName = await KVStore.getValue('ai.assistantCustomName') const aiAssistantCustomName = await KVStore.getValue('ai.assistantCustomName')
const remoteOllamaUrl = await KVStore.getValue('ai.remoteOllamaUrl') const remoteOllamaUrl = await KVStore.getValue('ai.remoteOllamaUrl')
const ollamaFlashAttention = await KVStore.getValue('ai.ollamaFlashAttention') const ollamaFlashAttention = await KVStore.getValue('ai.ollamaFlashAttention')
const autoThinking = await KVStore.getValue('ai.autoThinking')
return inertia.render('settings/models', { return inertia.render('settings/models', {
models: { models: {
availableModels: availableModels?.models || [], availableModels: availableModels?.models || [],
@ -75,6 +80,7 @@ export default class SettingsController {
aiAssistantCustomName: aiAssistantCustomName ?? '', aiAssistantCustomName: aiAssistantCustomName ?? '',
remoteOllamaUrl: remoteOllamaUrl ?? '', remoteOllamaUrl: remoteOllamaUrl ?? '',
ollamaFlashAttention: ollamaFlashAttention ?? true, ollamaFlashAttention: ollamaFlashAttention ?? true,
autoThinking: autoThinking ?? false,
}, },
}, },
}) })
@ -99,6 +105,10 @@ export default class SettingsController {
return inertia.render('settings/zim/remote-explorer') return inertia.render('settings/zim/remote-explorer')
} }
async creatorPacks({ inertia }: HttpContext) {
return inertia.render('settings/creator-packs')
}
async benchmark({ inertia }: HttpContext) { async benchmark({ inertia }: HttpContext) {
const latestResult = await this.benchmarkService.getLatestResult() const latestResult = await this.benchmarkService.getLatestResult()
const status = this.benchmarkService.getStatus() const status = this.benchmarkService.getStatus()

View File

@ -0,0 +1,438 @@
/* eslint-disable */
/**
* GENERATED FILE DO NOT EDIT.
*
* Single source of truth: collections/conditions.json
* Regenerate after editing the JSON: `npm run gen:curated-data` (from admin/).
* curated_data_sync.standalone.ts fails CI if this file drifts from the JSON.
*
* Curated "when to use what" condition spine (Phase 1). US-government / public-domain sourcing.
*/
import type { ConditionsFile } from '../../types/conditions.js'
export const CONDITIONS_FILE: ConditionsFile = {
"version": "2026-06-07",
"conditions": [
{
"slug": "pain",
"label": "Pain",
"category": "Pain, fever & inflammation",
"searchTerms": [
"pain",
"aches",
"minor aches",
"pain relief",
"analgesic"
]
},
{
"slug": "headache",
"label": "Headache",
"category": "Pain, fever & inflammation",
"searchTerms": [
"headache",
"migraine",
"head pain",
"tension headache"
]
},
{
"slug": "muscle-joint-pain",
"label": "Muscle & joint pain",
"category": "Pain, fever & inflammation",
"searchTerms": [
"muscular aches",
"muscle aches",
"backache",
"arthritis",
"joint pain",
"minor pain of arthritis"
]
},
{
"slug": "fever",
"label": "Fever",
"category": "Pain, fever & inflammation",
"searchTerms": [
"fever",
"reduces fever",
"fever reducer",
"temporarily reduces fever"
]
},
{
"slug": "menstrual-cramps",
"label": "Menstrual cramps",
"category": "Pain, fever & inflammation",
"searchTerms": [
"menstrual cramps",
"menstrual pain",
"period pain",
"premenstrual"
]
},
{
"slug": "cough",
"label": "Cough",
"category": "Cold, cough & allergy",
"searchTerms": [
"cough",
"cough suppressant",
"coughing",
"controls cough"
]
},
{
"slug": "nasal-congestion",
"label": "Nasal congestion",
"category": "Cold, cough & allergy",
"searchTerms": [
"nasal congestion",
"stuffy nose",
"sinus congestion",
"decongestant",
"nasal decongestant"
]
},
{
"slug": "sore-throat",
"label": "Sore throat",
"category": "Cold, cough & allergy",
"searchTerms": [
"sore throat",
"sore mouth",
"minor sore throat",
"throat pain"
]
},
{
"slug": "common-cold",
"label": "Common cold",
"category": "Cold, cough & allergy",
"searchTerms": [
"common cold",
"cold symptoms",
"cold",
"flu symptoms"
]
},
{
"slug": "allergic-reaction",
"label": "Allergies & allergic reactions",
"category": "Cold, cough & allergy",
"searchTerms": [
"allergy",
"allergic reactions",
"hay fever",
"antihistamine",
"runny nose",
"sneezing",
"itchy watery eyes"
]
},
{
"slug": "heartburn",
"label": "Heartburn",
"category": "Stomach & digestion",
"searchTerms": [
"heartburn",
"acid indigestion",
"acid reducer",
"antacid",
"sour stomach"
]
},
{
"slug": "indigestion",
"label": "Indigestion & upset stomach",
"category": "Stomach & digestion",
"searchTerms": [
"indigestion",
"upset stomach",
"gas",
"bloating",
"fullness",
"antacid"
]
},
{
"slug": "nausea-vomiting",
"label": "Nausea & vomiting",
"category": "Stomach & digestion",
"searchTerms": [
"nausea",
"vomiting",
"upset stomach associated with nausea"
]
},
{
"slug": "diarrhea",
"label": "Diarrhea",
"category": "Stomach & digestion",
"searchTerms": [
"diarrhea",
"antidiarrheal",
"loose stools",
"travelers diarrhea"
]
},
{
"slug": "constipation",
"label": "Constipation",
"category": "Stomach & digestion",
"searchTerms": [
"constipation",
"laxative",
"irregularity",
"occasional constipation",
"stool softener"
]
},
{
"slug": "motion-sickness",
"label": "Motion sickness",
"category": "Stomach & digestion",
"searchTerms": [
"motion sickness",
"travel sickness",
"seasickness",
"car sickness"
]
},
{
"slug": "gas",
"label": "Gas & bloating",
"category": "Stomach & digestion",
"searchTerms": [
"gas",
"bloating",
"flatulence",
"antigas",
"pressure"
]
},
{
"slug": "wounds-cuts",
"label": "Wounds & cuts",
"category": "Skin & wounds",
"searchTerms": [
"minor cuts",
"scrapes",
"wounds",
"first aid antiseptic",
"first aid to help prevent infection",
"abrasions"
]
},
{
"slug": "burns",
"label": "Burns",
"category": "Skin & wounds",
"searchTerms": [
"burns",
"minor burns",
"sunburn",
"scald",
"minor burn"
]
},
{
"slug": "insect-bites-stings",
"label": "Insect bites & stings",
"category": "Skin & wounds",
"searchTerms": [
"insect bites",
"insect stings",
"bug bites",
"bee sting",
"itching from insect bites"
]
},
{
"slug": "skin-rash-itch",
"label": "Rash & itching",
"category": "Skin & wounds",
"searchTerms": [
"itching",
"rash",
"skin irritation",
"itchy skin",
"minor skin irritations",
"eczema"
]
},
{
"slug": "poison-ivy",
"label": "Poison ivy & plant rashes",
"category": "Skin & wounds",
"searchTerms": [
"poison ivy",
"poison oak",
"poison sumac",
"rashes due to poison ivy"
]
},
{
"slug": "fungal-infection",
"label": "Athletes foot & ringworm",
"category": "Skin & wounds",
"searchTerms": [
"athletes foot",
"ringworm",
"jock itch",
"antifungal",
"fungal infection",
"tinea"
]
},
{
"slug": "dry-skin",
"label": "Dry & chapped skin",
"category": "Skin & wounds",
"searchTerms": [
"dry skin",
"chapped skin",
"chapped lips",
"skin protectant",
"cracked skin"
]
},
{
"slug": "acne",
"label": "Acne",
"category": "Skin & wounds",
"searchTerms": [
"acne",
"pimples",
"blackheads",
"acne treatment"
]
},
{
"slug": "eye-irritation",
"label": "Eye irritation & dryness",
"category": "Eyes, ears & mouth",
"searchTerms": [
"eye irritation",
"dry eyes",
"red eyes",
"eye redness",
"itchy eyes",
"lubricant eye"
]
},
{
"slug": "earache",
"label": "Earache & ear wax",
"category": "Eyes, ears & mouth",
"searchTerms": [
"earache",
"ear wax",
"earwax removal",
"ear pain",
"swimmers ear"
]
},
{
"slug": "canker-sores",
"label": "Canker & cold sores",
"category": "Eyes, ears & mouth",
"searchTerms": [
"canker sores",
"cold sores",
"mouth sores",
"fever blisters",
"oral pain"
]
},
{
"slug": "toothache",
"label": "Toothache",
"category": "Eyes, ears & mouth",
"searchTerms": [
"toothache",
"tooth pain",
"dental pain",
"oral analgesic"
]
},
{
"slug": "sleeplessness",
"label": "Sleeplessness",
"category": "Sleep, stress & general",
"searchTerms": [
"sleeplessness",
"insomnia",
"sleep aid",
"difficulty falling asleep",
"nighttime"
]
},
{
"slug": "dehydration",
"label": "Dehydration",
"category": "Sleep, stress & general",
"searchTerms": [
"dehydration",
"oral rehydration",
"electrolyte",
"fluid loss",
"replaces electrolytes"
]
},
{
"slug": "hemorrhoids",
"label": "Hemorrhoids",
"category": "Sleep, stress & general",
"searchTerms": [
"hemorrhoids",
"hemorrhoidal",
"anal itching",
"rectal"
]
},
{
"slug": "yeast-infection",
"label": "Vaginal yeast infection",
"category": "Infections (OTC-treatable)",
"searchTerms": [
"vaginal yeast infection",
"yeast infection",
"vaginal antifungal",
"candidiasis"
]
},
{
"slug": "pinworm",
"label": "Pinworm",
"category": "Infections (OTC-treatable)",
"searchTerms": [
"pinworm",
"pinworm infection",
"pinworm treatment"
]
},
{
"slug": "cold-sore-lip",
"label": "Chapped & sun-protected lips",
"category": "Sleep, stress & general",
"searchTerms": [
"lip protectant",
"chapped lips",
"sunburn protection lips",
"lip balm"
]
},
{
"slug": "eye-allergy",
"label": "Eye allergies",
"category": "Eyes, ears & mouth",
"searchTerms": [
"eye allergy",
"itchy eyes due to allergies",
"ocular itching",
"allergic conjunctivitis"
]
}
]
}

View File

@ -0,0 +1,328 @@
/* eslint-disable */
/**
* GENERATED FILE DO NOT EDIT.
*
* Single source of truth: collections/home_remedies.json
* Regenerate after editing the JSON: `npm run gen:curated-data` (from admin/).
* curated_data_sync.standalone.ts fails CI if this file drifts from the JSON.
*
* Non-herbal home-care / self-care measures from US-government public-domain pages (CDC, NIH/NLM MedlinePlus, FDA); each entry carries its own sourceUrl.
*/
import type { NaturalRemediesFile } from '../../types/conditions.js'
export const HOME_REMEDIES_FILE: NaturalRemediesFile = {
"version": "2026-06-10",
"source": {
"name": "US government health guidance (CDC, NIH/NHLBI, MedlinePlus/NLM, FDA)",
"url": "https://www.cdc.gov",
"license": "Public domain (US government works)"
},
"remedies": [
{
"slug": "honey-for-cough",
"name": "Honey (for cough)",
"commonNames": [],
"conditions": [
"cough",
"sore-throat",
"common-cold"
],
"uses": "Honey may be used to relieve cough in adults and children at least 1 year old. One to two teaspoons can be taken directly or stirred into a warm (not hot) beverage.",
"how": "Give one to two teaspoons of honey directly by mouth, or stir it into a warm (not hot) drink. The CDC recommends this as a home measure for cough associated with the common cold.",
"evidence": "CDC lists honey among recommended home measures for easing cough and sore throat associated with the common cold; it is one of several non-medication strategies mentioned alongside rest, fluids, and humidifier use.",
"cautions": "Never give honey to infants under 1 year old — it can contain Clostridium botulinum spores that cause infant botulism, a rare but serious illness. This warning applies regardless of honey type or brand.",
"sourceUrl": "https://www.cdc.gov/common-cold/treatment/index.html"
},
{
"slug": "fluids-and-rest",
"name": "Fluids and rest",
"commonNames": [],
"conditions": [
"common-cold",
"fever",
"diarrhea",
"nausea-vomiting"
],
"uses": "Getting plenty of rest and drinking adequate fluids (water, clear broths, juice, or sports drinks) supports recovery from colds, fever, diarrhea, and nausea. Adults with diarrhea should drink water, fruit juices, sports drinks, sodas without caffeine, and salty broths.",
"how": "Get plenty of rest and drink plenty of fluids. If keeping liquids down is difficult, take small sips of water or suck on ice chips frequently rather than trying to drink large amounts at once.",
"evidence": "CDC recommends rest and fluids as primary home care measures for the common cold. MedlinePlus (NIH/NIDDK) similarly lists these as the foundation of diarrhea self-care, and advises nausea patients to take in small amounts of clear liquids often to stay hydrated.",
"cautions": "Severely ill individuals, those with signs of dehydration (no urination, sunken eyes, extreme thirst), or those unable to keep any fluid down should seek medical care promptly. Caffeine and alcohol are not effective rehydration choices.",
"sourceUrl": "https://www.cdc.gov/common-cold/treatment/index.html"
},
{
"slug": "oral-rehydration",
"name": "Oral rehydration solution (ORS)",
"commonNames": [
"ORS",
"rehydration salts"
],
"conditions": [
"dehydration",
"diarrhea"
],
"uses": "Oral rehydration solutions replace fluids and electrolytes lost through diarrhea or other causes of dehydration. For mild to moderate dehydration, drinking water is the first step; sports drinks or oral rehydration solutions (such as Pedialyte) are recommended when electrolytes are also depleted, especially for children.",
"how": "Use a commercially prepared oral rehydration solution (available without a prescription) and follow the package directions. For adults with electrolyte losses, sports drinks can help; if liquids are hard to keep down, take small sips frequently or suck on ice chips rather than drinking large amounts at once.",
"evidence": "MedlinePlus (NIH/NIDDK) states that treatment for dehydration involves replacing lost fluids and electrolytes, and that oral rehydration solutions for children are available without a prescription. The same source recommends sports drinks for adults when electrolytes have been lost alongside fluids.",
"cautions": "Seek immediate medical care for signs of severe dehydration: no urination for 8 or more hours, rapid heartbeat, confusion, or inability to keep fluids down. Infants and small children with diarrhea should use formulated ORS (not plain water) to replace electrolytes safely.",
"sourceUrl": "https://medlineplus.gov/dehydration.html"
},
{
"slug": "cool-compress-fever",
"name": "Cool compress (for fever and insect bites)",
"commonNames": [],
"conditions": [
"fever",
"insect-bites-stings",
"eye-allergy",
"eye-irritation"
],
"uses": "A clean cloth soaked in cool (not ice-cold) water and placed on the forehead or bitten area can help reduce discomfort from fever, insect bites, and eye allergy symptoms. For insect stings, ice wrapped in a washcloth should be applied for 10 minutes on and 10 minutes off.",
"how": "Soak a clean cloth in cool water, wring it out, and place it on the forehead or affected area. For insect stings, wrap ice in a cloth and apply for 10 minutes on, then 10 minutes off — never place ice directly on bare skin.",
"evidence": "MedlinePlus (NIH/NIAID) notes that applying cool compresses is recommended for allergic conjunctivitis and eye burning and irritation. For insect bites and stings, a cool or iced compress is a standard first-line self-care step described in MedlinePlus search guidance consistent with NIH resources.",
"cautions": "Do not apply ice or an ice-cold compress directly to bare skin for extended periods — wrap ice in cloth and limit applications to 1015 minutes to avoid frostbite or tissue damage. For fever, cool compresses supplement (but do not replace) appropriate fever-reducing medicine when indicated; consult a healthcare provider if fever is high, prolonged, or accompanied by severe symptoms.",
"sourceUrl": "https://medlineplus.gov/insectbitesandstings.html"
},
{
"slug": "ice-and-elevation",
"name": "Ice and elevation (RICE method)",
"commonNames": [
"RICE",
"Rest-Ice-Compression-Elevation"
],
"conditions": [
"muscle-joint-pain",
"insect-bites-stings"
],
"uses": "Applying ice wrapped in cloth to a strained muscle, sprain, or bite site — combined with rest, compression, and elevation of the injured area — reduces swelling and pain. Ice should be applied for 1015 minutes every 13 hours during the first few days of injury.",
"how": "Wrap ice in a cloth or towel and apply to the injured area for 1015 minutes at a time. Rest the area, wrap it snugly with a bandage to reduce swelling, and elevate it above heart level when possible.",
"evidence": "MedlinePlus (NIH/NIAMS) describes the RICE method as standard first-line treatment for sprains and strains: resting the area, icing it, compressing it with a bandage, and elevating it above heart level when possible. Ice use for the first 3 days is specifically mentioned.",
"cautions": "Never apply ice directly to skin — always wrap it in a cloth or towel. If swelling worsens significantly, numbness develops, or you suspect a fracture, seek medical evaluation. Do not use heat during the first 4872 hours of an acute soft-tissue injury.",
"sourceUrl": "https://medlineplus.gov/sprainsandstrains.html"
},
{
"slug": "heating-pad",
"name": "Heating pad or warm compress",
"commonNames": [],
"conditions": [
"menstrual-cramps",
"muscle-joint-pain",
"earache"
],
"uses": "Applying a heating pad or hot water bottle to the lower abdomen eases menstrual cramps. A warm compress applied to the ear can relieve earache discomfort. Heat may also be used on strained muscles after the first 4872 hours of an acute injury.",
"how": "Place a heating pad or hot water bottle on the lower abdomen for menstrual cramps, or hold a warm cloth against the affected ear for earache. Use a low or medium heat setting and put a cloth between the pad and skin to prevent burns.",
"evidence": "MedlinePlus (NIH/NLM) lists using a heating pad or hot water bottle on the lower abdomen, along with taking a warm bath, as home care measures for period pain. For earache, placing a warm cloth on the affected ear is listed among comfort measures in NIH/NLM resources for acute ear infection self-care.",
"cautions": "Never fall asleep with a heating pad on — burns can result. Use a low or medium setting and place a cloth between the pad and skin. For ear pain, do not insert anything into the ear canal; if pain is severe, accompanied by drainage, hearing loss, or fever, see a healthcare provider to rule out infection requiring antibiotics.",
"sourceUrl": "https://medlineplus.gov/periodpain.html"
},
{
"slug": "humidifier-and-steam",
"name": "Humidifier or steam inhalation",
"commonNames": [
"cool-mist vaporizer",
"steam inhalation"
],
"conditions": [
"nasal-congestion",
"cough",
"common-cold"
],
"uses": "Using a clean humidifier or cool-mist vaporizer adds moisture to the air and can help relieve nasal congestion and cough. Breathing steam from a bowl of hot water or a running shower 24 times daily also loosens nasal secretions.",
"how": "Fill a clean cool-mist humidifier or vaporizer with water and run it in the room. Clean the device daily per the manufacturer's instructions to prevent mold and bacteria buildup.",
"evidence": "CDC recommends using a clean humidifier or cool-mist vaporizer as a home care measure for common cold symptoms including congestion and cough. MedlinePlus similarly notes that a humidifier can break up mucus and that steam inhalation from a shower is a recognized congestion-relief strategy.",
"cautions": "Clean the humidifier daily per manufacturer instructions to prevent mold and bacterial growth. Use cool-mist humidifiers rather than warm-mist (steam) versions for children to avoid burn risk. When inhaling steam from hot water, use caution to avoid scalding; keep a safe distance and do not cover your head over a pot of boiling water.",
"sourceUrl": "https://www.cdc.gov/common-cold/treatment/index.html"
},
{
"slug": "saline-nasal-rinse",
"name": "Saline nasal rinse",
"commonNames": [
"neti pot",
"nasal irrigation",
"saline nasal wash"
],
"conditions": [
"nasal-congestion",
"common-cold"
],
"uses": "Saline nasal rinses flush pollen, dust, and excess mucus from the nasal passages and add moisture. They can be performed with a neti pot, squeeze bottle, or bulb syringe using a prepared saline solution.",
"how": "Use only distilled, sterile, or previously boiled-and-cooled water — never tap water. After each use, rinse the device with the same safe water, then air-dry it thoroughly or wipe dry before storing.",
"evidence": "CDC recommends saline nasal spray or drops as a home care measure for the common cold. The FDA confirms that nasal irrigation devices are 'usually safe and effective products when used and cleaned properly,' with the critical safety requirement being the type of water used.",
"cautions": "Use only distilled, sterile, or previously boiled (and cooled) water — never tap water. The FDA warns that tap water can harbor organisms including amoebas that are safe to swallow but can cause serious or potentially fatal infections in the nasal passages. Boiled water should be cooled to lukewarm and stored in a clean, closed container for no more than 24 hours. Always clean and dry the device after each use.",
"sourceUrl": "https://www.fda.gov/consumers/consumer-updates/rinsing-your-sinuses-neti-pots-safe"
},
{
"slug": "oatmeal-bath",
"name": "Oatmeal or cool bath",
"commonNames": [
"colloidal oatmeal bath"
],
"conditions": [
"skin-rash-itch",
"poison-ivy",
"dry-skin"
],
"uses": "Soaking in a lukewarm oatmeal bath or taking a cool bath can relieve itching and skin irritation from rashes, poison ivy, eczema, and dry skin. Colloidal oatmeal bath products are available at drugstores.",
"how": "Fill a tub with lukewarm (not hot) water and add a colloidal oatmeal bath product according to package directions, or use plain cool water. After soaking, pat skin dry gently and apply a fragrance-free moisturizer immediately to lock in moisture.",
"evidence": "MedlinePlus (NLM) recommends taking 'lukewarm or oatmeal baths' as a self-care measure for itching, alongside cool compresses and moisturizing lotion. Oatmeal bath products are specifically noted to relieve symptoms of eczema and psoriasis; short, cooler baths are described as better than long, hot baths for skin conditions.",
"cautions": "Use lukewarm — not hot — water; hot water can worsen skin dryness and irritation. After bathing, pat skin dry gently (do not rub) and immediately apply a fragrance-free moisturizer to lock in moisture. If a rash is spreading rapidly, is accompanied by fever, or involves the face or genitals, consult a healthcare provider.",
"sourceUrl": "https://medlineplus.gov/itching.html"
},
{
"slug": "cool-running-water-on-burns",
"name": "Cool running water on burns",
"commonNames": [],
"conditions": [
"burns"
],
"uses": "For minor burns, immediately run cool (not cold) water slowly over the burned area for 1015 minutes to stop the burning process and reduce pain. After cooling, cover the burn with a clean, dry cloth or sterile bandage.",
"how": "Run cool water slowly over the burned area for several minutes, then cover with a clean, dry cloth or bandage. Do not apply ice, butter, or any creams — these can worsen tissue damage.",
"evidence": "CDC burn first-aid materials instruct: run cool water slowly over the burn area for several minutes, then cover with a clean, dry cloth or bandage. MedlinePlus (NIH/NIGMS) similarly specifies cool running water for 1015 minutes followed by a dry sterile dressing as the appropriate first-aid response for minor burns.",
"cautions": "Do not apply ice, ice water, butter, first-aid creams, sprays, or home remedies — these can worsen tissue damage or introduce infection. Do not break blisters unless directed by a healthcare provider. Do not try to remove clothing or debris stuck to the burn. Seek immediate medical care for large burns, burns on the face, eyes, hands, or feet, burns from chemicals or electricity, or any burn with extreme pain, numbness, or deep tissue involvement.",
"sourceUrl": "https://medlineplus.gov/burns.html"
},
{
"slug": "clean-and-cover-wounds",
"name": "Clean and cover wounds and cuts",
"commonNames": [],
"conditions": [
"wounds-cuts"
],
"uses": "For minor cuts and scrapes, rinse the wound thoroughly with cool clean water to remove dirt, apply gentle pressure with gauze to stop bleeding, then cover with a clean bandage. Wash with soap and water to reduce infection risk.",
"how": "Apply firm but gentle pressure with gauze to stop bleeding; if blood soaks through, add more gauze on top without removing the first layer. Rinse the wound with cool clean water, then cover it with a clean dry bandage.",
"evidence": "CDC guidance on wound care states: put pressure on a bleeding cut until it stops, gently pour clean water over the wound to clean it, then apply a clean, dry bandage. MedlinePlus (NLM) similarly advises rinsing cuts with cool water and applying firm but gentle pressure to stop bleeding.",
"cautions": "Watch for signs of infection in the days following — increasing redness, swelling, warmth, pus, or red streaks spreading from the wound require prompt medical evaluation. Seek immediate care for wounds that are deep, gaping, caused by an animal or human bite, or associated with a puncture from a potentially contaminated object (tetanus risk).",
"sourceUrl": "https://medlineplus.gov/firstaid.html"
},
{
"slug": "salt-water-gargle",
"name": "Salt-water gargle",
"commonNames": [],
"conditions": [
"sore-throat",
"common-cold",
"canker-sores"
],
"uses": "Gargling with warm salt water several times a day can ease sore throat pain and may also help relieve canker sore discomfort. A standard preparation is ½ teaspoon (3 grams) of salt dissolved in 1 cup (240 mL) of warm water.",
"how": "Dissolve salt in a cup of warm water, take a mouthful, tilt your head back, and gargle for several seconds before spitting it out. Repeat as needed throughout the day to help ease sore throat pain.",
"evidence": "MedlinePlus (NLM) states that gargling may ease sore throat pain, listing it alongside lozenges and fluids. The same resource notes that salt-water rinses may help with canker sore discomfort, though mouthwashes containing alcohol should be avoided as they irritate the tissue.",
"cautions": "Gargling with salt water provides symptomatic relief only and does not treat the underlying cause of a sore throat. Sore throat accompanied by high fever, difficulty swallowing or breathing, drooling, a stiff neck, or lasting more than a week should be evaluated by a healthcare provider, as strep throat and other conditions require different treatment.",
"sourceUrl": "https://medlineplus.gov/sorethroat.html"
},
{
"slug": "fiber-and-water-constipation",
"name": "Dietary fiber and water (for constipation and hemorrhoids)",
"commonNames": [],
"conditions": [
"constipation",
"hemorrhoids"
],
"uses": "Eating more fruits, vegetables, and whole grains (which are high in fiber) and drinking plenty of water each day are the foundational self-care steps for preventing and relieving constipation and reducing hemorrhoid discomfort.",
"how": "Eat more fruits, vegetables, and whole grains each day and drink plenty of fluids. Increase fiber gradually to avoid gas and bloating.",
"evidence": "MedlinePlus (NIH/NIDDK) lists increased dietary fiber and adequate fluid intake as primary self-care measures for both constipation and hemorrhoids. The hemorrhoids topic page specifically recommends eating high-fiber foods and drinking enough fluids every day as the first-line home treatment.",
"cautions": "Increase dietary fiber gradually to avoid gas and bloating. If constipation is new, severe, accompanied by blood in the stool, or associated with significant weight loss, see a healthcare provider to rule out underlying conditions. Hemorrhoid symptoms persisting beyond one week of home treatment, or any rectal bleeding, warrant medical evaluation.",
"sourceUrl": "https://medlineplus.gov/constipation.html"
},
{
"slug": "sitz-bath",
"name": "Sitz bath (for hemorrhoids)",
"commonNames": [],
"conditions": [
"hemorrhoids"
],
"uses": "A sitz bath — sitting in a few inches of warm water for 1015 minutes, several times a day — relieves the pain and itching of hemorrhoids. A special sitz bath tub that fits over a toilet is available at pharmacies.",
"how": "Fill a tub or sitz bath basin with a few inches of comfortably warm water and sit in it for 10 to 15 minutes. Repeat several times a day, keeping the area clean and dry between baths.",
"evidence": "MedlinePlus (NLM) lists taking warm baths several times a day, including sitz baths, as a recommended home care measure to relieve hemorrhoid pain. The recommendation is to sit in warm water for 10 to 15 minutes per session.",
"cautions": "The water should be comfortably warm — not hot — to avoid burns. Keep the area clean and dry between baths. If hemorrhoid symptoms do not improve after one week of home treatment, or if there is rectal bleeding, see a healthcare provider.",
"sourceUrl": "https://medlineplus.gov/hemorrhoids.html"
},
{
"slug": "elevate-head-heartburn",
"name": "Elevate head of bed (for heartburn)",
"commonNames": [],
"conditions": [
"heartburn",
"indigestion"
],
"uses": "Raising the head of the bed 46 inches (using blocks under the bed frame or a wedge support) prevents stomach acid from backing up into the esophagus during sleep, reducing nighttime heartburn and GERD symptoms.",
"how": "Place blocks under the legs at the head of the bed frame, or use a foam wedge under the mattress, to raise the sleeping surface about 6 inches. Using extra pillows under only the head is less effective because it bends the body at the waist rather than tilting the whole torso.",
"evidence": "MedlinePlus heartburn resources (sourced from NIH/NIDDK) consistently recommend elevating the head during sleep as a lifestyle measure for heartburn and GERD, noting that this position helps prevent reflux. Sleeping with the head raised about 6 inches is a specific recommendation described in the Medical Encyclopedia entry.",
"cautions": "Using extra pillows under the head is less effective than raising the entire upper body — pillows can cause neck strain and do not sufficiently change the angle. Heartburn that is frequent, severe, or accompanied by difficulty swallowing, unexplained weight loss, or vomiting blood requires medical evaluation.",
"sourceUrl": "https://medlineplus.gov/heartburn.html"
},
{
"slug": "bland-small-meals",
"name": "Small, frequent bland meals",
"commonNames": [
"BRAT diet",
"bland diet"
],
"conditions": [
"nausea-vomiting",
"indigestion",
"diarrhea"
],
"uses": "Eating 68 small bland meals throughout the day (crackers, toast, baked chicken, rice, potatoes) instead of 3 large meals reduces nausea and eases indigestion. As diarrhea symptoms improve, soft bland foods can be introduced gradually.",
"how": "Eat smaller meals more often and stick to bland foods, avoiding spicy, fatty, or salty options. If you have trouble keeping food down, start with small sips of clear liquids frequently and add bland solids only when tolerated.",
"evidence": "MedlinePlus (NLM) recommends small, frequent bland meals and avoiding spicy, fatty, or salty foods as the primary dietary self-care for nausea and vomiting. For indigestion, MedlinePlus notes that avoiding foods and situations that trigger symptoms is the main home strategy. For diarrhea, 'soft, bland food' is recommended as symptoms improve.",
"cautions": "The BRAT diet (bananas, rice, applesauce, toast) was historically promoted but MedlinePlus notes there is not strong evidence it is better than a standard bland diet; it probably does not cause harm. If nausea or vomiting persists beyond 2448 hours, is accompanied by severe pain, or prevents adequate fluid intake, seek medical care.",
"sourceUrl": "https://medlineplus.gov/nauseaandvomiting.html"
},
{
"slug": "dark-quiet-room-headache",
"name": "Rest in a dark, quiet room (for headache)",
"commonNames": [],
"conditions": [
"headache",
"sleeplessness"
],
"uses": "Resting with eyes closed in a dark, quiet room is a recommended non-medication self-care measure during a headache or migraine. Drinking water to prevent dehydration and placing a cool cloth on the forehead are often combined with this rest.",
"how": "Go to a quiet, darkened room, close your eyes, and rest. Drink water and place a cool damp cloth on your forehead to help ease discomfort.",
"evidence": "MedlinePlus (NLM) describes resting in a quiet, darkened room as one of the key things you can do to feel better during a headache or migraine, alongside drinking water and using relaxation techniques. These recommendations are attributed to NIH sources on headache and migraine management.",
"cautions": "Sudden severe ('thunderclap') headache, headache with fever, stiff neck, confusion, or vision changes may signal a serious condition and require emergency evaluation. Frequent headaches that disrupt daily life should be discussed with a healthcare provider rather than managed solely at home.",
"sourceUrl": "https://medlineplus.gov/headache.html"
},
{
"slug": "sleep-hygiene",
"name": "Sleep hygiene practices",
"commonNames": [
"good sleep habits"
],
"conditions": [
"sleeplessness"
],
"uses": "A consistent set of behavioral practices — consistent bedtime and wake time, a cool and dark bedroom, avoiding screens and caffeine near bedtime, and regular exercise — helps adults achieve and maintain adequate sleep.",
"how": "Go to bed and wake up at the same time every day. Keep the bedroom quiet, dark, and cool; turn off electronic devices at least 30 minutes before bedtime; and avoid caffeine in the afternoon and evening and large meals or alcohol before bed.",
"evidence": "CDC and NHLBI both recommend these specific practices for healthy sleep: going to bed and waking at the same time daily, keeping the bedroom quiet, cool, and dark, turning off screens at least 30 minutes before bed, avoiding caffeine in the afternoon and evening, and avoiding large meals or alcohol before sleep. NHLBI notes these habits are particularly important for shift workers and people with insomnia.",
"cautions": "Good sleep hygiene can help relieve short-term insomnia; persistent insomnia lasting more than a few weeks should be evaluated by a healthcare provider. Sleep difficulties accompanied by snoring, gasping during sleep, or excessive daytime sleepiness may indicate obstructive sleep apnea, which requires medical diagnosis.",
"sourceUrl": "https://www.cdc.gov/sleep/about/index.html"
},
{
"slug": "pinworm-hygiene",
"name": "Hygiene measures for pinworm",
"commonNames": [],
"conditions": [
"pinworm"
],
"uses": "Thorough handwashing with soap and warm water (especially after toilet use and before eating), daily morning bathing with soap and water, daily underwear changes, short and clean fingernails, and laundering of bedding and pajamas in hot water are the key household self-care measures for managing pinworm infection.",
"how": "Bathe after waking up each morning and wash hands regularly, especially after using the bathroom. Change underwear daily, wash pajamas and bed sheets often, and avoid nail biting to prevent reinfection.",
"evidence": "CDC identifies handwashing as 'the most important way to prevent the spread of pinworms.' MedlinePlus (NIH/NIAID) lists bathe after waking up, wash pajamas and bed sheets often, wash hands regularly, change underwear every day, and avoid nail biting and scratching the anal area as the core preventive hygiene steps.",
"cautions": "Hygiene alone typically cannot eliminate an active pinworm infection — over-the-counter antiparasitic medication (pyrantel pamoate) is generally needed, and all household members should be treated simultaneously. Medication is typically repeated after 2 weeks because it kills worms but not eggs; the second dose treats worms that hatched after the first dose.",
"sourceUrl": "https://medlineplus.gov/pinworms.html"
},
{
"slug": "keep-feet-dry-athlete-foot",
"name": "Keep feet clean and dry (for fungal infection)",
"commonNames": [
"athlete's foot self-care"
],
"conditions": [
"fungal-infection"
],
"uses": "Keeping the feet clean, dry, and cool — including washing daily with soap and water, drying carefully between the toes, wearing clean cotton socks, and not walking barefoot in public showers or locker rooms — supports treatment and prevents spread of athlete's foot and other tinea infections.",
"how": "Keep feet clean, dry, and cool; wear clean socks and avoid walking barefoot in public areas such as locker room showers (use flip-flops instead). Apply an over-the-counter antifungal cream as directed on the package for most cases of athlete's foot.",
"evidence": "MedlinePlus (CDC-sourced) advises: keep your feet clean, dry, and cool; wear clean socks; avoid walking barefoot in public areas; wear flip-flops in locker room showers; keep toenails clean and clipped short. Over-the-counter antifungal creams work for most cases of athlete's foot.",
"cautions": "If over-the-counter antifungal treatment does not improve the infection within 24 weeks, see a healthcare provider. Spreading or worsening redness, warmth, and swelling — especially in people with diabetes or circulatory problems — warrants prompt medical care. Nail fungal infections are more difficult to treat than skin infections and often require prescription therapy.",
"sourceUrl": "https://medlineplus.gov/athletesfoot.html"
}
]
}

View File

@ -0,0 +1,316 @@
/* eslint-disable */
/**
* GENERATED FILE DO NOT EDIT.
*
* Single source of truth: collections/natural_remedies.json
* Regenerate after editing the JSON: `npm run gen:curated-data` (from admin/).
* curated_data_sync.standalone.ts fails CI if this file drifts from the JSON.
*
* Hand-curated subset of NCCIH "Herbs at a Glance" (nccih.nih.gov), mapped to the condition spine. US-government / public domain; attribution lives in the file `source` field and the UI caveat.
*/
import type { NaturalRemediesFile } from '../../types/conditions.js'
export const NATURAL_REMEDIES_FILE: NaturalRemediesFile = {
"version": "2026-06-10",
"source": {
"name": "NCCIH — Herbs at a Glance",
"url": "https://www.nccih.nih.gov/health/herbsataglance",
"license": "Public domain (US government work; NCCIH)"
},
"remedies": [
{
"slug": "aloe-vera",
"name": "Aloe Vera",
"commonNames": [
"Aloe barbadensis miller"
],
"conditions": [
"burns",
"acne",
"dry-skin"
],
"uses": "Aloe vera gel is applied topically for burns, acne, and various skin conditions including psoriasis and radiation-related skin damage. Oral use is promoted for digestive conditions, though topical applications have more research support.",
"how": "Apply gel from the inner leaf of the aloe plant (or a commercially prepared aloe gel) directly to the affected skin area. Topical use is the application with the most research support; oral aloe products carry more serious risks and should not be used without consulting a healthcare provider.",
"evidence": "Research suggests topical aloe gel may speed burn healing and reduce burn-related pain. Two small studies indicate aloe gel, combined with other treatments, may improve acne; evidence for other skin uses is limited.",
"cautions": "Topical use is generally well tolerated, though occasional burning or itching may occur. Oral use carries more serious risks: the latex can cause abdominal pain and diarrhea, oral extracts have been linked to cases of acute hepatitis, and animal studies associated non-decolorized extracts with gastrointestinal cancer. Likely unsafe during pregnancy; may interact with medications such as digoxin.",
"sourceUrl": "https://www.nccih.nih.gov/health/aloe-vera"
},
{
"slug": "boswellia",
"name": "Boswellia",
"commonNames": [
"Boswellia serrata",
"Indian frankincense"
],
"conditions": [
"muscle-joint-pain"
],
"uses": "Boswellia resin extract is traditionally used to reduce inflammation and pain, and is promoted as a dietary supplement to support joint health and mobility, particularly for osteoarthritis.",
"how": "Boswellia is taken orally as a dietary supplement. Clinical trials have used doses up to 1,000 mg daily for up to 6 months; follow the product label and consult a healthcare provider before starting.",
"evidence": "Some studies suggest oral boswellia may help reduce inflammation and pain associated with osteoarthritis, but larger rigorous trials are needed; topical use lacks sufficient evidence of effectiveness.",
"cautions": "Extracts up to 1,000 mg daily have been used safely in clinical trials lasting up to 6 months. Consult a healthcare provider before use, especially if pregnant, breastfeeding, managing asthma, or taking medications, as interactions remain unclear.",
"sourceUrl": "https://www.nccih.nih.gov/health/boswellia"
},
{
"slug": "bromelain",
"name": "Bromelain",
"commonNames": [
"pineapple enzyme",
"Ananas comosus extract"
],
"conditions": [
"muscle-joint-pain",
"nasal-congestion"
],
"uses": "Bromelain, an enzyme from pineapple, is promoted for reducing postoperative pain and swelling, sinusitis, osteoarthritis, and exercise-induced muscle soreness. A topical formulation has FDA approval for debridement of severe burns.",
"evidence": "Some studies suggest oral bromelain may reduce certain symptoms after wisdom tooth surgery; evidence for sinusitis is insufficient. The topical formulation has been successfully used by health professionals for burn debridement as an alternative to surgical debridement.",
"cautions": "Oral bromelain is generally well tolerated; the most common side effects are stomach upset and diarrhea. Talk with a healthcare provider before use alongside any medications, as harmful interactions are possible. Safety during pregnancy and breastfeeding is unclear.",
"sourceUrl": "https://www.nccih.nih.gov/health/bromelain"
},
{
"slug": "butterbur",
"name": "Butterbur",
"commonNames": [
"Petasites hybridus"
],
"conditions": [
"headache",
"allergic-reaction"
],
"uses": "Butterbur root extract is used for migraine prevention and for reducing symptoms of allergic rhinitis (hay fever). It has been studied for reducing migraine frequency in both adults and children.",
"how": "Butterbur is taken orally as a root extract for migraine prevention or as a leaf extract for allergic rhinitis symptoms. Use only products that are certified and labeled as free of pyrrolizidine alkaloids (PA-free), as the untreated plant contains compounds that can damage the liver.",
"evidence": "Studies of a butterbur root extract suggest it may reduce migraine frequency; a leaf extract may help with allergic rhinitis symptoms. However, the American Academy of Neurology withdrew its 2012 recommendation in 2015 due to safety concerns.",
"cautions": "The plant naturally contains pyrrolizidine alkaloids (PAs) that can damage the liver and lungs and may cause cancer; only PA-free certified products should be used. Even PA-free products have been linked to rare cases of liver injury. Side effects include belching, diarrhea, drowsiness, rash, and stomach upset. Avoid during pregnancy; people allergic to ragweed or related plants are at higher risk of reactions.",
"sourceUrl": "https://www.nccih.nih.gov/health/butterbur"
},
{
"slug": "chamomile",
"name": "Chamomile",
"commonNames": [
"Matricaria chamomilla",
"German chamomile",
"Chamaemelum nobile"
],
"conditions": [
"sleeplessness",
"indigestion",
"common-cold",
"sore-throat",
"skin-rash-itch"
],
"uses": "Chamomile is promoted for insomnia, indigestion, anxiety, the common cold, and infant colic; it is also used topically for skin conditions and as a mouthwash for oral inflammation.",
"how": "Chamomile is commonly consumed as a tea (considered safe in amounts typically found in teas), taken as an oral supplement, applied topically to skin, or used as a mouthwash for oral inflammation. Consult a healthcare provider before using oral supplements rather than tea.",
"evidence": "Evidence is limited: some preliminary studies suggest chamomile supplements may help with anxiety, and combination products may help childhood diarrhea and infant colic. A 2019 review found minimal evidence for insomnia, with one study showing no benefit. Evidence for cold, sore throat, and skin uses in people is insufficient.",
"cautions": "Generally considered safe in typical amounts. Side effects may include nausea, dizziness, and allergic reactions, including severe hypersensitivity; risk is higher for those sensitive to ragweed, chrysanthemums, marigolds, or daisies. May interact with blood thinners, birth control pills, sedatives, and liver-metabolized drugs. Safety during pregnancy and breastfeeding is unknown.",
"sourceUrl": "https://www.nccih.nih.gov/health/chamomile"
},
{
"slug": "echinacea",
"name": "Echinacea",
"commonNames": [
"Echinacea purpurea",
"purple coneflower"
],
"conditions": [
"common-cold"
],
"uses": "Echinacea is primarily marketed for the common cold and upper respiratory tract infections, based on the idea that it may support immune system function.",
"evidence": "Studies indicate that taking echinacea may slightly reduce the chances of catching a cold, though evidence regarding whether it shortens cold duration is inconclusive. Evidence for other conditions, including eczema, is unclear.",
"cautions": "E. purpurea extracts appear likely safe for short periods in adults; allergic reactions can occur, with digestive symptoms most common. Children may experience rashes potentially linked to allergic reactions. Theoretical interactions with immunosuppressants and certain other drugs; consult a healthcare provider before use with medications. Limited data on safety in early pregnancy.",
"sourceUrl": "https://www.nccih.nih.gov/health/echinacea"
},
{
"slug": "elderberry",
"name": "Elderberry",
"commonNames": [
"Sambucus nigra"
],
"conditions": [
"common-cold"
],
"uses": "Elderberry has been used in folk medicine to treat colds and flu, and is promoted as a dietary supplement for colds, flu, and other upper respiratory infections.",
"how": "Elderberry is used as a prepared dietary supplement (syrup, capsule, or lozenge) rather than raw fruit. Never eat raw or unripe elderberries — they contain cyanide-producing substances that cause nausea, vomiting, and severe diarrhea; cooking the berries eliminates this toxin.",
"evidence": "A small number of studies suggest elderberry may relieve symptoms of flu, colds, or other upper respiratory infections; however, the overall evidence is limited and insufficient for most other claimed benefits.",
"cautions": "Raw or unripe elderberries contain cyanide-producing substances that can cause nausea, vomiting, and severe diarrhea; cooking eliminates this toxin. Consult a healthcare provider before use, especially if taking medications. Limited safety data for pregnancy and breastfeeding.",
"sourceUrl": "https://www.nccih.nih.gov/health/elderberry"
},
{
"slug": "feverfew",
"name": "Feverfew",
"commonNames": [
"Tanacetum parthenium"
],
"conditions": [
"headache"
],
"uses": "Feverfew is promoted for migraine headache prevention and for minor head and tension pain. Topically, it is marketed for itching and skin irritation.",
"evidence": "A 2020 systematic review of seven migraine studies found inconsistent results, though some evidence suggests feverfew may reduce migraine frequency and associated symptoms such as nausea and light sensitivity. There is little or no evidence supporting feverfew for other health conditions.",
"cautions": "Side effects include nausea, digestive issues, bloating, and mouth sores from chewing fresh leaves. People sensitive to ragweed may experience allergic reactions. Feverfew may slow blood clotting and should be stopped at least 2 weeks before scheduled surgery. It may interact with migraine medications and should be avoided during pregnancy due to potential effects on uterine contractions.",
"sourceUrl": "https://www.nccih.nih.gov/health/feverfew"
},
{
"slug": "ginger",
"name": "Ginger",
"commonNames": [
"Zingiber officinale"
],
"conditions": [
"nausea-vomiting",
"menstrual-cramps",
"indigestion"
],
"uses": "Ginger is traditionally and commonly used for nausea and vomiting, indigestion, menstrual cramps, and osteoarthritis.",
"evidence": "Research shows ginger may be helpful for nausea and vomiting associated with pregnancy. Studies suggest ginger dietary supplements might be helpful for reducing the severity of menstrual cramps and for knee osteoarthritis symptoms; effectiveness for chemotherapy- or post-surgery-related nausea remains uncertain.",
"cautions": "Side effects when taken orally include abdominal discomfort, heartburn, diarrhea, and mouth and throat irritation. Possible interactions with medications exist; consult a healthcare provider before use, especially during pregnancy or breastfeeding.",
"sourceUrl": "https://www.nccih.nih.gov/health/ginger"
},
{
"slug": "goldenseal",
"name": "Goldenseal",
"commonNames": [
"Hydrastis canadensis"
],
"conditions": [
"common-cold",
"diarrhea",
"wounds-cuts"
],
"uses": "Goldenseal is marketed for the common cold and upper respiratory infections, diarrhea, constipation, and other digestive conditions. Historically, Native Americans used it for digestive disorders, wounds, and skin conditions.",
"evidence": "There is not enough evidence to determine whether goldenseal is useful for any health condition; no rigorous studies have been done in people. Very little of the active compound berberine is absorbed when goldenseal is taken orally, making findings from berberine studies potentially inapplicable.",
"cautions": "Short-term use at approximately 3 grams daily appears to be without serious adverse effects, though longer-term safety is uncertain. Goldenseal significantly affects drug metabolism; one study found it decreased metformin levels by about 25%. Some commercial products contain unlisted ingredients or substitute herbs. Avoid during pregnancy, breastfeeding, and in infants due to potential harm from berberine.",
"sourceUrl": "https://www.nccih.nih.gov/health/goldenseal"
},
{
"slug": "horse-chestnut",
"name": "Horse Chestnut",
"commonNames": [
"Aesculus hippocastanum"
],
"conditions": [
"hemorrhoids"
],
"uses": "Horse chestnut seed extract is promoted for chronic venous insufficiency (leg pain and swelling from poor circulation) and has historically been used for hemorrhoids, arthritis, and menstrual cramps.",
"how": "Use only standardized horse chestnut seed extract (a dietary supplement) — never raw seeds, bark, flowers, or leaves, which are toxic when taken orally. Research has used standardized extract for up to 12 weeks; follow the product label and consult a healthcare provider before use.",
"evidence": "A 2012 systematic review found horse chestnut seed extract can improve symptoms of chronic venous insufficiency, comparable to compression stockings in one study; however, more rigorous trials are needed. Evidence for other uses, including hemorrhoids, is insufficient.",
"cautions": "Raw seeds, bark, flowers, and leaves are unsafe to take orally due to toxic components; only standardized extracts with the toxin removed should be used, and research shows safety for up to 12 weeks. Side effects include dizziness, digestive upset, headache, and itching. Safety during pregnancy and breastfeeding is unknown; consult a healthcare provider before use.",
"sourceUrl": "https://www.nccih.nih.gov/health/horse-chestnut"
},
{
"slug": "lavender",
"name": "Lavender",
"commonNames": [
"Lavandula angustifolia"
],
"conditions": [
"sleeplessness"
],
"uses": "Lavender is promoted as an oral supplement and aromatherapy agent for calming anxiety, stress, and sleep difficulties; it is also used topically and in aromatherapy for depression symptoms and pain.",
"how": "Lavender is used as an oral supplement (capsule or tea), as an aromatherapy oil (inhaled or diffused), or applied topically to skin. Oral lavender oil products have been studied for anxiety; aromatherapy involves inhaling the scent from a diffuser or a few drops on a cloth.",
"evidence": "Studies suggest oral lavender oil might be beneficial for anxiety, including anxiety with co-occurring depression; evidence for sleep improvement is insufficient, and it is unclear whether aromatherapy with lavender benefits anxiety, stress, or depression.",
"cautions": "Oral lavender products may cause diarrhea, headache, nausea, or burping. Topical application can trigger allergic skin reactions. Potential interactions with sedative drugs warrant discussion with a healthcare provider. Safety during pregnancy and breastfeeding is unknown.",
"sourceUrl": "https://www.nccih.nih.gov/health/lavender"
},
{
"slug": "licorice-root",
"name": "Licorice Root",
"commonNames": [
"Glycyrrhiza glabra"
],
"conditions": [
"canker-sores",
"sore-throat",
"burns",
"skin-rash-itch"
],
"uses": "Licorice root is promoted for digestive and respiratory support and is used topically for skin conditions. Research has focused on mouth rinses for canker sores, gargles or lozenges for sore throat prevention, and topical gels for eczema and burn healing.",
"how": "For canker sores, licorice is used as a mouth rinse or gargle; for sore throat prevention around surgery, as a gargle or lozenge; for eczema or burn healing, as a topical gel applied to the skin. Avoid long-term or high-dose oral use due to serious cardiovascular risks.",
"evidence": "Preliminary studies suggest licorice mouth rinses may reduce canker sore pain and size, and lozenges or gargles may prevent sore throat after surgical intubation. Topical gels show some promise for eczema symptoms and burn healing, though more research is needed. There is not enough high-quality evidence to support its use for most conditions.",
"cautions": "Licorice contains glycyrrhizin, which can cause serious adverse effects including irregular heartbeat and cardiac arrest, especially with long-term or large-dose use. Even small amounts can be risky for people with high blood pressure, high salt intake, or heart or kidney conditions. Large amounts during pregnancy increase miscarriage risk and are considered unsafe. Interactions with corticosteroids have been documented; consult a healthcare provider before use.",
"sourceUrl": "https://www.nccih.nih.gov/health/licorice-root"
},
{
"slug": "passionflower",
"name": "Passionflower",
"commonNames": [
"Passiflora incarnata"
],
"conditions": [
"sleeplessness"
],
"uses": "Passionflower is marketed as a dietary supplement for anxiety, sleep problems, and stress.",
"how": "Passionflower is taken as a tea (studied for up to 7 nights) or as an oral extract supplement (studied for up to 8 weeks). Follow package directions and consult a healthcare provider before use, especially if you take other medications or are scheduled for surgery.",
"evidence": "Some research suggests oral passionflower may help reduce anxiety symptoms and improve total sleep time in adults with insomnia, though findings on falling or staying asleep are mixed and conclusions are not definite.",
"cautions": "Taking passionflower alongside anesthesia or other surgical medications may slow the nervous system too much; avoid use before surgery. Should not be used during pregnancy as it may induce uterine contractions. Possible side effects include drowsiness, dizziness, and confusion. Discuss with a healthcare provider before combining with medications.",
"sourceUrl": "https://www.nccih.nih.gov/health/passionflower"
},
{
"slug": "peppermint-oil",
"name": "Peppermint Oil",
"commonNames": [
"Mentha x piperita"
],
"conditions": [
"headache",
"nausea-vomiting",
"indigestion"
],
"uses": "Peppermint oil is promoted for irritable bowel syndrome, indigestion, headaches, muscle tension, and nausea. Topical application is used for tension headaches, and inhaled peppermint oil is used for nausea.",
"how": "For IBS, peppermint oil is taken as enteric-coated capsules (which reduce heartburn compared to plain capsules). For tension headaches, apply peppermint oil topically to the skin. For nausea, peppermint oil is inhaled as aromatherapy.",
"evidence": "A 2022 review found peppermint oil was better than placebo at improving overall IBS symptoms and abdominal pain. A 2024 review concluded that inhaling peppermint oil was particularly effective at reducing nausea and vomiting in patients with cancer. Evidence suggests potential benefit for tension headaches when applied topically, though peppermint oil taken alone may worsen indigestion in some people.",
"cautions": "Oral side effects may include heartburn, nausea, abdominal pain, and dry mouth; enteric-coated capsules reduce heartburn risk. Never apply topically to infants' or young children's faces due to menthol's respiratory effects. Skin irritation is possible with topical use. Consult a healthcare provider before use with medications, as interactions may occur.",
"sourceUrl": "https://www.nccih.nih.gov/health/peppermint-oil"
},
{
"slug": "tea-tree-oil",
"name": "Tea Tree Oil",
"commonNames": [
"Melaleuca alternifolia"
],
"conditions": [
"fungal-infection",
"acne",
"wounds-cuts",
"insect-bites-stings",
"burns"
],
"uses": "Tea tree oil is marketed for external use including acne, athlete's foot, toenail fungus, and lice. Traditional Aboriginal uses included treating wounds, burns, and insect bites.",
"how": "Apply tea tree oil topically to the affected skin area only — for acne or athlete's foot, use a product containing tea tree oil as directed on the label. Never swallow tea tree oil; oral ingestion can cause serious symptoms including confusion, unsteadiness, and coma.",
"evidence": "A small amount of research suggests tea tree oil might be helpful for acne and athlete's foot (fungal infection), though more studies are needed. Evidence for toenail fungus, lice, eyelid inflammation, and oral health uses is insufficient or uncertain. Traditional wound, burn, and insect bite uses lack rigorous clinical research.",
"cautions": "Critical warning: tea tree oil must not be swallowed — oral ingestion can cause serious symptoms including confusion, unsteadiness, inability to walk, and coma. Topical use is generally safe for most adults, though some experience skin redness or irritation. Older products or those exposed to heat, light, or air may increase reaction risk. Products appear safe during pregnancy and breastfeeding when used topically only.",
"sourceUrl": "https://www.nccih.nih.gov/health/tea-tree-oil"
},
{
"slug": "turmeric",
"name": "Turmeric",
"commonNames": [
"Curcuma longa",
"curcumin"
],
"conditions": [
"muscle-joint-pain",
"indigestion",
"skin-rash-itch"
],
"uses": "Turmeric is promoted for osteoarthritis, itching, depression, allergies, and high cholesterol; topical applications target joint pain. Historically, it was used in traditional medicine for indigestion, colds, skin infections, and liver disease.",
"how": "Turmeric is taken orally as a dietary supplement or applied topically to the skin. Conventionally formulated oral turmeric or curcumin supplements are considered likely safe in recommended amounts for up to 2 to 3 months; follow the product label and consult a healthcare provider before use.",
"evidence": "For osteoarthritis, initial evidence is positive for knee pain relief and joint function, though higher-quality research is needed. For most other claimed uses, researchers cannot definitively confirm turmeric's effectiveness, and overall evidence remains inconclusive.",
"cautions": "Standard turmeric formulations are likely safe in recommended amounts for up to 23 months. Common side effects include nausea, vomiting, acid reflux, and digestive issues. Enhanced bioavailability products have caused liver damage in some users; symptoms include fatigue, dark urine, and jaundice. Use during pregnancy may be unsafe; breastfeeding safety is unclear. Consult a healthcare provider before use with medications.",
"sourceUrl": "https://www.nccih.nih.gov/health/turmeric"
},
{
"slug": "valerian",
"name": "Valerian",
"commonNames": [
"Valeriana officinalis"
],
"conditions": [
"sleeplessness"
],
"uses": "Valerian is promoted for insomnia, anxiety, stress, and depression; historically, it was also used for migraine, fatigue, and stomach issues.",
"how": "Valerian is taken orally as a dietary supplement; clinical studies have used doses of 300 to 600 mg daily for up to 6 weeks. Follow the product label and consult a healthcare provider before use, particularly if combining with sedatives or alcohol.",
"evidence": "Evidence is limited and inconsistent. The American Academy of Sleep Medicine recommended against using valerian for chronic insomnia in adults (2017). Studies on anxiety, menstrual cramps, and other conditions show inadequate evidence of benefit.",
"cautions": "Appears generally safe for short-term use at 300600 mg daily for up to 6 weeks; long-term safety is unknown. Common side effects include headache, stomach upset, mental dullness, and vivid dreams. Abrupt discontinuation after long-term use may cause withdrawal symptoms including anxiety and insomnia. Avoid combining with alcohol or sedatives. Safety during pregnancy and breastfeeding is unclear.",
"sourceUrl": "https://www.nccih.nih.gov/health/valerian"
}
]
}

View File

@ -30,7 +30,17 @@ export class ContentAutoUpdateJob {
const result = await contentAutoUpdateService.attempt() const result = await contentAutoUpdateService.attempt()
logger.info(`[ContentAutoUpdateJob] ${result.started} started: ${result.reason}`) logger.info(`[ContentAutoUpdateJob] ${result.started} started: ${result.reason}`)
return result
// The FDA drug dataset refreshes on the same cycle and master switch as the
// ZIM/map catalog (its apply path differs, so it's a separate step, not part
// of attempt()). Governed by the same `contentAutoUpdate.*` settings.
const drugResult = await contentAutoUpdateService.attemptDrugDataset()
logger.info(`[ContentAutoUpdateJob] ${drugResult.started} started: ${drugResult.reason}`)
return {
started: result.started + drugResult.started,
reason: `${result.reason}; ${drugResult.reason}`,
}
} }
static async schedule() { static async schedule() {

View File

@ -0,0 +1,345 @@
import { Job } from 'bullmq'
import { access, mkdir, constants } from 'node:fs/promises'
import logger from '@adonisjs/core/services/logger'
import { QueueService } from '#services/queue_service'
import { doResumableDownload } from '../utils/downloads.js'
import { parseDrugLabelManifest, partZipPath, manifestBytesTotal } from '../../util/drug_labels.js'
import type {
DownloadDrugDataJobParams,
DrugLabelManifest,
DownloadStateMarker,
DrugDatasetResourceMeta,
} from '../../types/drug_reference.js'
/** Where all part zips are staged on the bind-mounted storage volume. */
export const STORAGE_BASE = '/app/storage/drug-data'
const MANIFEST_URL = 'https://api.fda.gov/download.json'
/**
* Phase A Download (network-only failure domain).
*
* Pass 0 fetches the openFDA manifest; each later pass downloads ONE part to
* disk (resumable via doResumableDownload, so a worker restart resumes from the
* .tmp rather than re-downloading). Continuations use queue.add with NO jobId
* the same rule as the embed/ingest chains, so BullMQ dedupe doesn't swallow the
* next part against the lingering parent. After the last part lands we write the
* `drugReference.downloadState` KV marker and, when auto-chaining, dispatch the
* ingest phase. Parts are NEVER deleted here they persist until a full ingest
* succeeds so the manual "Ingest into search" path can re-run from disk.
*/
export class DownloadDrugDataJob {
static get queue() {
return 'drug-download'
}
static get key() {
return 'download-drug-data'
}
/** Deterministic jobId — only one download at a time, re-runnable. */
static get jobId() {
return 'drug-data-download'
}
// ─── Public API ────────────────────────────────────────────────────────────
/**
* Dispatch the initial download (pass 0). Idempotent on the deterministic
* jobId. A finished/failed prior job under that id is cleared first so the
* "Download FDA data" button can always restart (resume is handled at the
* file level by doResumableDownload).
*
* @param autoChain - dispatch the ingest phase after the last part. Default true.
* @param resourceMeta - install-state identity when the install came through a
* curated tier. Carried through the chain so the ingest job writes the
* `installed_resources` row on `ready`. Omit for a manual download (no row).
*/
static async dispatch(autoChain = true, resourceMeta?: DrugDatasetResourceMeta) {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const existing = await queue.getJob(this.jobId)
if (existing) {
const state = await existing.getState()
if (state === 'active' || state === 'waiting' || state === 'delayed') {
return { job: existing, created: false, message: 'Drug data download already running' }
}
try {
await existing.remove()
} catch {
// Best-effort: fall through to add, which surfaces any genuine conflict.
}
}
try {
const job = await queue.add(
this.key,
{ autoChain, resourceMeta } satisfies DownloadDrugDataJobParams,
{
jobId: this.jobId,
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { count: 5 },
removeOnFail: { count: 5 },
}
)
return { job, created: true, message: 'Drug data download dispatched' }
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error)
if (msg.includes('job already exists')) {
const stillThere = await queue.getJob(this.jobId)
return { job: stillThere, created: false, message: 'Drug data download already running' }
}
throw error
}
}
static async getJob(): Promise<Job | undefined> {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
return await queue.getJob(this.jobId)
}
// ─── Job handler ───────────────────────────────────────────────────────────
async handle(job: Job) {
const params = job.data as DownloadDrugDataJobParams
const partIndex = params.partIndex ?? 0
const autoChain = params.autoChain ?? true
const startedAt = params.startedAt ?? Date.now()
const resourceMeta = params.resourceMeta
logger.info(`[DownloadDrugDataJob] Starting pass partIndex=${partIndex}`)
// Pre-flight: storage drive writable.
await this.verifyStorageAvailable(job)
// Pass 0: fetch the manifest.
let manifest: DrugLabelManifest
if (partIndex === 0 || !params.manifest) {
await job.updateData({
...job.data,
phase: 'manifest',
partIndex: 0,
totalParts: 0,
currentPartName: null,
autoChain,
startedAt,
})
await job.updateProgress(0)
logger.info('[DownloadDrugDataJob] Fetching manifest from api.fda.gov/download.json')
manifest = await this.fetchManifest()
logger.info(
`[DownloadDrugDataJob] Manifest: export_date=${manifest.export_date} ` +
`total_records=${manifest.total_records} parts=${manifest.partitions.length}`
)
} else {
manifest = params.manifest
}
const totalParts = params.totalParts ?? manifest.partitions.length
if (partIndex >= totalParts) {
logger.warn(
`[DownloadDrugDataJob] partIndex ${partIndex} >= totalParts ${totalParts}, nothing to do`
)
return
}
const partition = manifest.partitions[partIndex]
const zipPath = partZipPath(STORAGE_BASE, partition)
const partName = partition.display_name || partition.file
logger.info(
`[DownloadDrugDataJob] Downloading part ${partIndex + 1}/${totalParts}: ${partName}`
)
await job.updateData({
...job.data,
phase: 'downloading',
partIndex,
totalParts,
currentPartName: partName,
manifest,
autoChain,
startedAt,
bytesDownloaded: 0,
})
await job.updateProgress(Math.floor((partIndex / totalParts) * 100))
await mkdir(STORAGE_BASE, { recursive: true })
// Aggregate-across-parts byte accounting for the Active Downloads card. The
// drug job fans the manifest's N partitions into BullMQ continuations, but
// the user sees ONE download — so progress is reported as bytes across the
// whole set, not the current part. `priorPartsBytes` is the actual bytes of
// the parts already on disk (from the running recordedParts list);
// `manifestTotalBytes` is the sum of every partition's manifest size_mb.
const priorRecorded =
(params as { recordedParts?: DownloadStateMarker['parts'] }).recordedParts ?? []
const priorPartsBytes = priorRecorded.reduce((acc, p) => acc + (p.bytes || 0), 0)
const manifestTotalBytes = manifestBytesTotal(manifest)
logger.info(`[DownloadDrugDataJob] ${partition.file}${zipPath}`)
let partBytes = 0
await doResumableDownload({
url: partition.file,
filepath: zipPath,
timeout: 300_000, // 5-minute per-chunk timeout
allowedMimeTypes: [], // skip MIME check — zip content-type varies across CDNs
onProgress: (progress) => {
partBytes = progress.downloadedBytes
const downloadFraction = progress.downloadedBytes / (progress.totalBytes || 1)
const pct = Math.floor(((partIndex + downloadFraction) / totalParts) * 100)
const downloadedBytes = priorPartsBytes + progress.downloadedBytes
// Emit the canonical {percent, downloadedBytes, totalBytes} object the
// Active Downloads aggregator + the byte/speed readout expect (the old
// bare-int progress couldn't carry bytes). parseProgress in
// DownloadService still tolerates a bare int for back-compat, but only
// the object surfaces a live byte/speed readout. Fire-and-forget; swallow
// transient reject so it can't crash the worker.
void job
.updateProgress({
percent: pct,
downloadedBytes,
totalBytes: manifestTotalBytes,
lastProgressTime: Date.now(),
})
.catch(() => {})
void job.updateData({ ...job.data, bytesDownloaded: progress.downloadedBytes }).catch(() => {})
},
})
logger.info(`[DownloadDrugDataJob] Download complete: ${zipPath} (${partBytes} bytes)`)
// Record this part for the download-state marker (written after the last one).
const recordedParts =
(params as { recordedParts?: DownloadStateMarker['parts'] }).recordedParts ?? []
recordedParts.push({ index: partIndex, name: partName, path: zipPath, bytes: partBytes })
const nextIndex = partIndex + 1
if (nextIndex < totalParts) {
// Continuation — NO jobId (let BullMQ auto-generate). The critical rule.
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(DownloadDrugDataJob.queue)
const continuationParams: DownloadDrugDataJobParams & {
recordedParts: DownloadStateMarker['parts']
} = {
partIndex: nextIndex,
manifest,
totalParts,
autoChain,
startedAt,
recordedParts,
resourceMeta,
}
await queue.add(DownloadDrugDataJob.key, continuationParams, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { count: 5 },
removeOnFail: { count: 5 },
})
logger.info(
`[DownloadDrugDataJob] Dispatched continuation for part ${nextIndex + 1}/${totalParts}`
)
} else {
// Last part — write the download-state marker. Parts stay on disk until a
// full ingest succeeds; do NOT delete them here.
await this.writeDownloadState(manifest, totalParts, recordedParts)
await job.updateData({
...job.data,
phase: 'downloaded',
partIndex,
totalParts,
currentPartName: null,
})
await job.updateProgress(100)
logger.info(
`[DownloadDrugDataJob] All ${totalParts} parts downloaded. ` +
`export_date=${manifest.export_date}`
)
if (autoChain) {
const { IngestDrugDataJob } = await import('#jobs/ingest_drug_data_job')
// Forward the install-state identity so the ingest job writes the
// `installed_resources` row on `ready`. undefined for a manual download.
await IngestDrugDataJob.dispatch(resourceMeta)
logger.info('[DownloadDrugDataJob] Auto-chained ingest phase')
}
}
return { partIndex, totalParts }
}
// ─── Private helpers ───────────────────────────────────────────────────────
private async verifyStorageAvailable(job: Job): Promise<void> {
try {
await access(STORAGE_BASE, constants.W_OK)
} catch {
try {
await mkdir(STORAGE_BASE, { recursive: true })
} catch (mkdirErr) {
await job.updateData({ ...job.data, phase: 'failed' })
throw new Error(
`Storage drive not available: cannot write to ${STORAGE_BASE} (${
mkdirErr instanceof Error ? mkdirErr.message : String(mkdirErr)
})`
)
}
}
}
private async fetchManifest(): Promise<DrugLabelManifest> {
return DownloadDrugDataJob.fetchManifest()
}
/**
* Fetch + parse the openFDA download manifest. The SINGLE source of truth for
* the openFDA manifest call (Maxim 4): the download job uses it on pass 0, and
* the freshness check (DrugReferenceService.checkForUpdate, driven by
* attemptAutoUpdate) reuses it so there is exactly one place that knows the URL and the
* offline-error translation.
*/
static async fetchManifest(): Promise<DrugLabelManifest> {
let json: unknown
try {
const resp = await fetch(MANIFEST_URL)
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} from ${MANIFEST_URL}`)
}
json = await resp.json()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (
msg.includes('ENOTFOUND') ||
msg.includes('ECONNREFUSED') ||
msg.includes('ECONNRESET') ||
msg.includes('fetch failed')
) {
throw new Error(`No internet — connect to download FDA drug data. (${msg})`)
}
throw err
}
return parseDrugLabelManifest(json)
}
private async writeDownloadState(
manifest: DrugLabelManifest,
totalParts: number,
parts: DownloadStateMarker['parts']
): Promise<void> {
const KVStore = (await import('#models/kv_store')).default
const marker: DownloadStateMarker = {
export_date: manifest.export_date,
totalParts,
totalRecords: manifest.total_records,
parts,
completedAtMs: Date.now(),
}
await KVStore.setValue('drugReference.downloadState', JSON.stringify(marker))
}
}

View File

@ -20,9 +20,10 @@ export interface EmbedFileJobParams {
isFinalBatch?: boolean // Whether this is the last batch (prevents premature deletion) isFinalBatch?: boolean // Whether this is the last batch (prevents premature deletion)
// Running total of chunks embedded across prior batches in this dispatch chain. // Running total of chunks embedded across prior batches in this dispatch chain.
// Carried forward so the final batch can persist an accurate `chunks_embedded` // Carried forward so the final batch can persist an accurate `chunks_embedded`
// count via KbIngestState.markIndexed (see #933 without this, only the last // count via KbIngestState.markIndexed (see #933 -- without this, only the last
// batch's chunk count was stored while Qdrant held the full set). // batch's chunk count was stored while Qdrant held the full set).
chunksSoFar?: number chunksSoFar?: number
collection?: string
} }
export class EmbedFileJob { export class EmbedFileJob {
@ -56,7 +57,16 @@ export class EmbedFileJob {
} }
async handle(job: Job) { async handle(job: Job) {
const { filePath, fileName, batchOffset, totalArticles } = job.data as EmbedFileJobParams const { filePath, fileName, batchOffset, totalArticles, collection } = job.data as EmbedFileJobParams
// Only the direct KB-upload controller passes `collection` on dispatch; the other
// six dispatch sites (download auto-index, scan/sync, re-embed, local ZIM upload,
// replaced-file reconcile, and this job's own ZIM batch continuation) do not. Fall
// back to whatever the file is already assigned to, so an assignment made *before*
// the file was indexed still reaches the vectors. Resolving it here rather than at
// each dispatch site keeps one source of truth and covers batch continuations too.
const effectiveCollection =
collection ?? (await KbIngestState.findBy('file_path', filePath))?.collection ?? undefined
const isZimBatch = batchOffset !== undefined const isZimBatch = batchOffset !== undefined
const batchInfo = isZimBatch ? ` (batch offset: ${batchOffset})` : '' const batchInfo = isZimBatch ? ` (batch offset: ${batchOffset})` : ''
@ -136,7 +146,8 @@ export class EmbedFileJob {
filePath, filePath,
allowDeletion, allowDeletion,
batchOffset, batchOffset,
onProgress onProgress,
effectiveCollection
) )
if (!result.success) { if (!result.success) {
@ -190,6 +201,9 @@ export class EmbedFileJob {
totalArticles: totalArticles || result.totalArticles, totalArticles: totalArticles || result.totalArticles,
isFinalBatch: false, // Explicitly not final isFinalBatch: false, // Explicitly not final
chunksSoFar: chunksSoFarNext, chunksSoFar: chunksSoFarNext,
// Carry the collection across batches, otherwise only batch 1 of a ZIM
// would be tagged and the rest would land uncategorized.
...(effectiveCollection ? { collection: effectiveCollection } : {}),
}) })
// Calculate progress based on articles processed. // Calculate progress based on articles processed.
@ -242,7 +256,7 @@ export class EmbedFileJob {
// BullMQ's :completed retention (50 jobs) ages out, so the state row is // BullMQ's :completed retention (50 jobs) ages out, so the state row is
// the only durable record of "this file finished embedding". // the only durable record of "this file finished embedding".
try { try {
await KbIngestState.markIndexed(filePath, totalChunks) await KbIngestState.markIndexed(filePath, totalChunks, effectiveCollection)
} catch (stateErr) { } catch (stateErr) {
logger.warn( logger.warn(
`[EmbedFileJob] Failed to persist ingest state for ${fileName}: %s`, `[EmbedFileJob] Failed to persist ingest state for ${fileName}: %s`,

View File

@ -0,0 +1,837 @@
import { Job } from 'bullmq'
import { promises as fsPromises } from 'node:fs'
import { access, constants } from 'node:fs/promises'
import { Writable } from 'node:stream'
import logger from '@adonisjs/core/services/logger'
import { QueueService } from '#services/queue_service'
import { mapDrugLabelRecord, parseDownloadState, partZipPath } from '../../util/drug_labels.js'
import { STORAGE_BASE } from '#jobs/download_drug_data_job'
import type {
IngestDrugDataJobParams,
DrugLabelManifest,
DrugLabelPartition,
DrugDatasetResourceMeta,
} from '../../types/drug_reference.js'
const BATCH_SIZE = 500
/**
* Dedupe a batch by set_id, CASE-INSENSITIVELY (last occurrence wins). MySQL's
* uniq_drug_labels_set_id index uses a case-insensitive collation, so two ids
* differing only in case are ONE row to the database the dedupe key must agree
* with the database's notion of equality or a batch can still collide with
* itself.
*/
function dedupeBySetId(
rows: ReturnType<typeof mapDrugLabelRecord>[]
): NonNullable<ReturnType<typeof mapDrugLabelRecord>>[] {
const bySetId = new Map<string, NonNullable<ReturnType<typeof mapDrugLabelRecord>>>()
for (const r of rows) {
if (r) bySetId.set(r.set_id.toLowerCase(), r)
}
return [...bySetId.values()]
}
/**
* Upsert one batch with MySQL-native `INSERT … ON DUPLICATE KEY UPDATE`
* (knex onConflict().merge()) instead of Lucid's updateOrCreateMany.
*
* WHY: updateOrCreateMany SELECTs existing rows and matches them to incoming
* rows IN JAVASCRIPT a case-sensitive string compare. The DB's unique key on
* set_id is case-INsensitive, so when openFDA ships the same set_id with
* different casing across parts (seen live: part 5's '93A0696B-' colliding
* with an earlier part's variant), Lucid misses the match, INSERTs, and the
* unique key rejects it aborting the run. A native upsert makes the unique
* key itself the arbiter: same-key rows update, new rows insert, intra-batch
* duplicates take the update path. No JS equality anywhere.
*
* `ingested_at` is stamped explicitly (raw knex bypasses Lucid's autoCreate);
* merge() refreshes every inserted column on conflict, preserving the previous
* "re-ingest updates the row + timestamp" behavior.
*/
async function upsertDrugLabelBatch(
rows: NonNullable<ReturnType<typeof mapDrugLabelRecord>>[]
): Promise<number> {
if (rows.length === 0) return 0
const { default: db } = await import('@adonisjs/lucid/services/db')
const now = new Date()
const withTs = rows.map((r) => ({ ...r, ingested_at: now }))
await db.knexQuery().table('drug_labels').insert(withTs).onConflict('set_id').merge()
return rows.length
}
// A single 500-row updateOrCreateMany should finish in seconds even on the
// FULLTEXT-indexed drug_labels table. If one batch exceeds this, the DB is
// locked/overloaded — reject loudly so the ingest FAILS VISIBLY (and retries via
// BullMQ) instead of hanging forever with the worker's lock still renewing,
// which reads in the UI as a frozen "part 1 of 13, 0 rows". 0.2.714 removed the
// un-awaited-update worker crash; this removes the remaining silent hang.
const UPSERT_TIMEOUT_MS = 120_000
// No record parsed within this window (the zip-open + JSON-parse stage, BEFORE the
// first batch) means the part is almost certainly corrupt/truncated: yauzl's
// inflate stream hangs with no 'end' and no 'error', so the ingest sat on
// "part 1, 0 rows" forever with the worker's lock still renewing. Fail loud
// instead. Once records flow, the per-batch upsert timeout governs.
const STALL_MS = 90_000
/**
* updateOrCreateMany with a hard timeout. mysql2 has no default query timeout, so
* a stuck DB call (metadata lock, exhausted connection pool, FULLTEXT stall)
* would otherwise never settle and the part-stream would back-pressure to a halt.
* Promise.race turns that into a rejection the caller can log + fail + retry.
*/
async function withUpsertTimeout<T>(
work: Promise<T>,
rowCount: number,
timeoutMs: number
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
`updateOrCreateMany timed out after ${timeoutMs}ms (batch of ${rowCount} rows) — ` +
'the database may be locked or overloaded'
)
),
timeoutMs
)
})
try {
return await Promise.race([work, timeout])
} finally {
if (timer) clearTimeout(timer)
}
}
// ─── Local type aliases for yauzl callbacks ───────────────────────────────────
// yauzl/stream-json are loaded via dynamic import() with @ts-ignore (see
// streamIngestPart). The real @types ship in devDependencies and resolve on the
// target machine; loading them lazily keeps the pure util/ helpers importable in
// tests without the streaming deps. These local interfaces give the callback
// parameters explicit types without importing yauzl's own types (which aren't
// resolvable in the inertia tsconfig context).
import type { Readable } from 'node:stream'
interface YauzlEntry { fileName: string }
interface YauzlZipFile {
readEntry(): void
openReadStream(entry: YauzlEntry, cb: (err: Error | null, stream: Readable | null) => void): void
on(event: 'entry', listener: (entry: YauzlEntry) => void): this
on(event: 'end', listener: () => void): this
on(event: 'error', listener: (err: Error) => void): this
}
/**
* Phase B Ingest (parse/DB-only failure domain, ZERO network I/O).
*
* Each pass reads ONE on-disk part and streams it into drug_labels via the
* memory-safe streamIngestPart pipeline (yauzl stream-json batched
* updateOrCreateMany). The part list comes from the manifest in job data OR, for
* a manual "Ingest into search" run with no manifest, is rebuilt from the
* `drugReference.downloadState` KV marker. A missing on-disk part fails loudly
* ("run Download first") rather than silently under-ingesting. Continuations use
* queue.add with NO jobId. After the LAST part: write the final KV status, then
* delete the downloaded parts and clear the download-state marker (the per-part
* unlink that used to run during download moves here parts persist until a
* full ingest succeeds).
*/
export class IngestDrugDataJob {
static get queue() {
return 'drug-ingest'
}
static get key() {
return 'ingest-drug-data'
}
/** Deterministic jobId — only one ingest at a time, re-runnable. */
static get jobId() {
return 'drug-labels-ingest'
}
// ─── Public API ────────────────────────────────────────────────────────────
/**
* Dispatch the initial ingest (pass 0). Idempotent on the deterministic jobId.
* A finished/failed prior job under that id is cleared first so a re-ingest can
* always restart (upserts are idempotent on set_id).
*
* @param resourceMeta - install-state identity, present only when the install
* came through a curated tier. Carried through the chain so the final pass
* writes the `installed_resources` row on `ready`. Absent on a manual ingest.
*/
static async dispatch(resourceMeta?: DrugDatasetResourceMeta) {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const existing = await queue.getJob(this.jobId)
if (existing) {
const state = await existing.getState()
if (state === 'active' || state === 'waiting' || state === 'delayed') {
return { job: existing, created: false, message: 'Drug label ingest already running' }
}
try {
await existing.remove()
} catch {
// Best-effort: fall through to add.
}
}
try {
const job = await queue.add(
this.key,
{ resourceMeta } satisfies IngestDrugDataJobParams,
{
jobId: this.jobId,
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { count: 5 },
removeOnFail: { count: 5 },
}
)
return { job, created: true, message: 'Drug label ingest dispatched' }
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error)
if (msg.includes('job already exists')) {
const stillThere = await queue.getJob(this.jobId)
return { job: stillThere, created: false, message: 'Drug label ingest already running' }
}
throw error
}
}
static async getJob(): Promise<Job | undefined> {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
return await queue.getJob(this.jobId)
}
// ─── Job handler ───────────────────────────────────────────────────────────
async handle(job: Job) {
const params = job.data as IngestDrugDataJobParams
const partIndex = params.partIndex ?? 0
const runningIngested = params.recordsIngested ?? 0
const runningSkipped = params.recordsSkipped ?? 0
const startedAt = params.startedAt ?? Date.now()
const resourceMeta = params.resourceMeta
// Progress baseline (pass 0 only): the table's row count BEFORE this run.
// Without it, a re-ingest into a populated table shows ~100% from second
// zero because the live row count dominates the shown counter.
let startRowCount = params.startRowCount
if (startRowCount === undefined) {
try {
const { default: db } = await import('@adonisjs/lucid/services/db')
const result = await db.rawQuery('SELECT COUNT(*) AS cnt FROM drug_labels')
const rows = result[0] as Array<{ cnt: number | string }>
startRowCount = Number(rows[0]?.cnt ?? 0)
} catch {
startRowCount = 0
}
}
logger.info(`[IngestDrugDataJob] Starting pass partIndex=${partIndex}`)
// Resolve the part list: manifest in job data, else the KV download marker.
const { manifest, exportDate } = await this.resolvePartSource(params)
const totalParts = params.totalParts ?? manifest.partitions.length
if (partIndex >= totalParts) {
logger.warn(
`[IngestDrugDataJob] partIndex ${partIndex} >= totalParts ${totalParts}, nothing to do`
)
return
}
const partition = manifest.partitions[partIndex]
const zipPath = partZipPath(STORAGE_BASE, partition)
const partName = partition.display_name || partition.file
// Guard: the part MUST already be on disk. No re-download here — fail loud so
// a missing part can't silently produce a "ready" status with fewer rows.
try {
await access(zipPath, constants.R_OK)
} catch {
await job.updateData({ ...job.data, phase: 'failed' })
throw new Error(
`Part ${partIndex + 1}/${totalParts} not downloaded (${zipPath}). ` +
'Run Download FDA data first.'
)
}
logger.info(
`[IngestDrugDataJob] Ingesting part ${partIndex + 1}/${totalParts}: ${partName}`
)
await job.updateData({
...job.data,
phase: 'ingesting',
partIndex,
totalParts,
currentPartName: partName,
recordsIngested: runningIngested,
recordsSkipped: runningSkipped,
manifest,
startedAt,
startRowCount,
})
await job.updateProgress(Math.floor((partIndex / totalParts) * 100))
const { recordsIngested: partIngested, recordsSkipped: partSkipped } =
await this.streamIngestPart(
job,
zipPath,
partIndex,
totalParts,
runningIngested,
runningSkipped
)
const totalIngested = runningIngested + partIngested
const totalSkipped = runningSkipped + partSkipped
logger.info(
`[IngestDrugDataJob] Part ${partIndex + 1} done: ` +
`ingested=${partIngested} skipped=${partSkipped} running_total=${totalIngested}`
)
const nextIndex = partIndex + 1
if (nextIndex < totalParts) {
// Continuation — NO jobId. The critical rule.
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(IngestDrugDataJob.queue)
const continuationParams: IngestDrugDataJobParams = {
partIndex: nextIndex,
manifest,
totalParts,
recordsIngested: totalIngested,
recordsSkipped: totalSkipped,
startedAt,
startRowCount,
resourceMeta,
}
await queue.add(IngestDrugDataJob.key, continuationParams, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { count: 5 },
removeOnFail: { count: 5 },
})
logger.info(`[IngestDrugDataJob] Dispatched continuation for part ${nextIndex + 1}/${totalParts}`)
await job.updateData({
...job.data,
phase: 'ingesting',
partIndex,
totalParts,
recordsIngested: totalIngested,
recordsSkipped: totalSkipped,
})
} else {
// Final part — write KV status, mark ready, THEN reclaim disk.
await this.writeFinalStatus(exportDate, resourceMeta)
await job.updateData({
...job.data,
phase: 'ready',
partIndex,
totalParts,
currentPartName: null,
recordsIngested: totalIngested,
recordsSkipped: totalSkipped,
})
await job.updateProgress(100)
logger.info(
`[IngestDrugDataJob] Ingest complete. ` +
`total_ingested=${totalIngested} total_skipped=${totalSkipped} ` +
`export_date=${exportDate}`
)
// Reclaim disk only after a FULL ingest succeeds.
await this.deleteDownloadedParts(manifest, totalParts)
}
return { partIndex, totalIngested, totalSkipped }
}
// ─── Private helpers ───────────────────────────────────────────────────────
/**
* Resolve the ordered partition list + export_date for this ingest run.
*
* Prefers the manifest carried in job data (auto-chained from the download job,
* or a continuation pass). Falls back to the KV download-state marker so a
* manual "Ingest into search" with no manifest still works the marker stores
* each part's on-disk path and manifest index, which is enough to drive
* streamIngestPart without re-fetching the manifest from the network. Fails
* loudly if neither source is present.
*/
private async resolvePartSource(
params: IngestDrugDataJobParams
): Promise<{ manifest: DrugLabelManifest; exportDate: string }> {
if (params.manifest) {
return { manifest: params.manifest, exportDate: params.manifest.export_date }
}
const KVStore = (await import('#models/kv_store')).default
const marker = parseDownloadState(await KVStore.getValue('drugReference.downloadState'))
if (!marker) {
throw new Error('Nothing downloaded — run Download FDA data first.')
}
// Rebuild a manifest-shaped partition list from the marker. partZipPath uses
// path.basename(partition.file), so feeding the recorded path as `file`
// resolves back to the same on-disk path.
const ordered = [...marker.parts].sort((a, b) => a.index - b.index)
const partitions: DrugLabelPartition[] = ordered.map((p) => ({
display_name: p.name,
file: p.path,
size_mb: '0',
records: 0,
}))
const manifest: DrugLabelManifest = {
export_date: marker.export_date,
// The marker persists the real manifest total (~259k) so a rebuilt
// manifest carries the same label-count denominator the auto-chained run
// would have had — keeping the "X of ~259k" counter, the records-based
// progress %, and the ETA alive on the manual-ingest path. Pre-totalRecords
// markers parse back as 0; the service treats 0 as unknown and falls back.
total_records: marker.totalRecords,
partitions,
}
return { manifest, exportDate: marker.export_date }
}
/**
* Stream-unzip the part, stream-parse the JSON, batch-upsert into drug_labels.
*
* Memory-safe: never loads the full JSON into memory.
* Pipeline: yauzl entry read-stream stream-json Pick+StreamArray Writable batching.
* Back-pressure: the Writable's `write()` method calls `callback()` only after
* the DB upsert resolves, so Node's stream machinery naturally pauses the upstream
* pipe chain when BATCH_SIZE is reached and an upsert is in flight.
*/
private async streamIngestPart(
job: Job,
zipPath: string,
partIndex: number,
totalParts: number,
runningIngested: number,
runningSkipped: number
): Promise<{ recordsIngested: number; recordsSkipped: number }> {
// Dynamic imports for the streaming deps (yauzl, stream-json). @ts-ignore
// covers local dev where node_modules hasn't been refreshed with the new
// deps yet; it is a no-op once the real @types are installed on the target
// machine, and it also covers stream-json's deep `.js` subpaths that the
// @types package doesn't map. Importing here (not at module top) keeps the
// pure util/ helpers loadable in tests without these deps.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — yauzl resolved at runtime from dependencies
const yauzl = await import('yauzl')
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — stream-json resolved at runtime from dependencies
const createParser = (await import('stream-json')).default.parser
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — stream-json deep subpath resolved at runtime
const createPick = (await import('stream-json/filters/Pick.js')).default.pick
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — stream-json deep subpath resolved at runtime
const createStreamArray = (await import('stream-json/streamers/StreamArray.js')).default.streamArray
// Defensive: stream-json 1.9.1 (CJS) exposes its factories only as
// `.default.parser` / `.default.pick` / `.default.streamArray` under ESM
// dynamic import — there is NO `parser`/`pick`/`streamArray` named export, so
// destructuring those silently yielded `undefined`. Calling an undefined
// "factory" then threw inside the yauzl openReadStream callback (not a
// promise), the process-level backstop swallowed the uncaughtException, the
// streamIngestPart promise never settled, and the 90s watchdog fired in a
// retry loop with zero records. THIS was the real ingest hang. Validate the
// imports here so any future interop slip fails loud in the async body.
if (
typeof createParser !== 'function' ||
typeof createPick !== 'function' ||
typeof createStreamArray !== 'function'
) {
throw new Error(
'stream-json factory imports did not resolve to functions ' +
`(parser=${typeof createParser}, pick=${typeof createPick}, streamArray=${typeof createStreamArray})`
)
}
let recordsIngested = 0
let recordsSkipped = 0
let batch: ReturnType<typeof mapDrugLabelRecord>[] = []
let batchNum = 0
let firstRecordSeen = false
// Cast to `any` so callback parameters get explicit annotations below rather
// than triggering implicit-any in tsconfigs that don't find the yauzl types.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const yauzlOpen = (yauzl as any).open as (
path: string,
opts: { lazyEntries: boolean; autoClose: boolean },
cb: (err: Error | null, zipFile: YauzlZipFile | null) => void
) => void
return new Promise<{ recordsIngested: number; recordsSkipped: number }>((resolveRaw, rejectRaw) => {
// Stall watchdog. The per-batch upsert has a timeout, but the stage BEFORE
// the first record (zip-open + JSON parse) had none — a corrupt/truncated
// part makes yauzl's inflate stream hang with no 'end' and no 'error', so
// the ingest froze on "part 1, 0 rows". If no record is parsed within
// STALL_MS, fail the part loudly (the reason lands in the job's failedReason
// → the status panel) and destroy the stuck stream. resolve/reject are
// wrapped (shadowing the raw executor params) so every existing handler
// routes through the once-guard + watchdog cleanup.
let settled = false
let activeReadStream: Readable | null = null
let watchdog: ReturnType<typeof setInterval> | undefined
const watchStart = Date.now()
const settle = () => {
settled = true
if (watchdog) clearInterval(watchdog)
try {
activeReadStream?.destroy()
} catch {
// best-effort stream teardown
}
}
const resolve = (v: { recordsIngested: number; recordsSkipped: number }) => {
if (settled) return
settle()
resolveRaw(v)
}
const reject = (e: Error) => {
if (settled) return
settle()
rejectRaw(e)
}
watchdog = setInterval(() => {
if (!settled && !firstRecordSeen && Date.now() - watchStart > STALL_MS) {
reject(
new Error(
`Ingest stalled: no records parsed from part ${partIndex + 1}/${totalParts} ` +
`within ${Math.round(STALL_MS / 1000)}s. The downloaded part is likely corrupt ` +
'or truncated — re-download FDA data, then ingest again.'
)
)
}
}, 10_000)
yauzlOpen(zipPath, { lazyEntries: true, autoClose: true }, (err, zipFile) => {
if (err || !zipFile) {
reject(err ?? new Error(`Failed to open zip: ${zipPath}`))
return
}
zipFile.on('error', reject)
zipFile.readEntry()
zipFile.on('entry', (entry) => {
// Skip directory entries
if (/\/$/.test(entry.fileName)) {
zipFile.readEntry()
return
}
// Open the single JSON entry as a read stream — never buffer it
zipFile.openReadStream(entry, (streamErr, readStream) => {
if (streamErr || !readStream) {
reject(streamErr ?? new Error(`Could not open zip entry ${entry.fileName}`))
return
}
logger.info(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: reading zip entry ${entry.fileName}`
)
activeReadStream = readStream
// The JSON envelope is { meta, results: [...] }
// Pick the `results` path → StreamArray emits one record at a time
const jsonParser = createParser({ jsonStreaming: false })
const pick = createPick({ filter: 'results' })
const streamArray = createStreamArray()
// Writable that accumulates batches and flushes with back-pressure.
// The callback is called only after the async upsert completes, which
// naturally applies back-pressure via the pipe chain.
const batchWriter = new Writable({
objectMode: true,
write(chunk: { value: unknown }, _encoding: BufferEncoding, callback: (err?: Error | null) => void) {
const record = chunk.value
if (!firstRecordSeen) {
firstRecordSeen = true
logger.info(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: first record received from parser`
)
}
// Map the record
let row: ReturnType<typeof mapDrugLabelRecord>
try {
row = mapDrugLabelRecord(record as Parameters<typeof mapDrugLabelRecord>[0])
} catch (mapErr) {
logger.warn(
`[IngestDrugDataJob] mapDrugLabelRecord threw: ${mapErr instanceof Error ? mapErr.message : String(mapErr)}`
)
recordsSkipped++
callback()
return
}
if (!row) {
recordsSkipped++
callback()
return
}
batch.push(row)
if (batch.length < BATCH_SIZE) {
// Not full yet — don't block the stream
callback()
return
}
// Batch full — flush and hold the callback until the upsert
// resolves (this is the back-pressure point).
const currentBatch = dedupeBySetId(batch)
batch = []
const myBatch = ++batchNum
const t0 = Date.now()
logger.info(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: upserting batch ${myBatch} (${currentBatch.length} rows)…`
)
withUpsertTimeout(
upsertDrugLabelBatch(currentBatch),
currentBatch.length,
UPSERT_TIMEOUT_MS
)
.then((rowCount) => {
recordsIngested += rowCount
logger.info(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: batch ${myBatch} ok in ${Date.now() - t0}ms ` +
`(${rowCount} rows; part running ${recordsIngested})`
)
// Update progress: parts-done fraction + within-part fraction
const withinFraction = recordsIngested / Math.max(1, 20000)
const pct = Math.floor(
((partIndex + withinFraction) / totalParts) * 100
)
// Fire-and-forget progress writes. Swallow transient Redis/
// job-update rejections: an un-awaited reject would otherwise
// bubble to an unhandledRejection and crash the worker, which
// BullMQ then reports as "job stalled more than allowable
// limit" (a dead worker stops renewing its lock).
void job
.updateProgress(
Math.min(pct, Math.floor(((partIndex + 1) / totalParts) * 100) - 1)
)
.catch(() => {})
void job
.updateData({
...job.data,
recordsIngested: runningIngested + recordsIngested,
recordsSkipped: runningSkipped + recordsSkipped,
})
.catch(() => {})
callback()
})
.catch((upsertErr: unknown) => {
const msg = upsertErr instanceof Error ? upsertErr.message : String(upsertErr)
if (/timed out/i.test(msg)) {
// Systemic DB hang — fail the part loudly so BullMQ retries.
logger.error(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: batch ${myBatch} TIMED OUT after ${Date.now() - t0}ms: ${msg}`
)
callback(upsertErr instanceof Error ? upsertErr : new Error(msg))
return
}
// Per-batch data error (a row the schema rejects, etc.) — log,
// count as skipped, and CONTINUE. One bad batch must not abort
// the whole ~259k ingest (over-correcting to fail-loud here is
// what let a single duplicate set_id kill the run at part 5).
logger.error(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: batch ${myBatch} skipped after error (${Date.now() - t0}ms): ${msg}`
)
recordsSkipped += currentBatch.length
callback()
})
},
final(callback: (err?: Error | null) => void) {
// Flush the last partial batch
if (batch.length === 0) {
callback()
return
}
const remainingBatch = dedupeBySetId(batch)
batch = []
const t0 = Date.now()
logger.info(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: upserting final batch (${remainingBatch.length} rows)…`
)
withUpsertTimeout(
upsertDrugLabelBatch(remainingBatch),
remainingBatch.length,
UPSERT_TIMEOUT_MS
)
.then((rowCount) => {
recordsIngested += rowCount
logger.info(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: final batch ok in ${Date.now() - t0}ms (${rowCount} rows)`
)
callback()
})
.catch((upsertErr: unknown) => {
const msg = upsertErr instanceof Error ? upsertErr.message : String(upsertErr)
if (/timed out/i.test(msg)) {
logger.error(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: final batch TIMED OUT after ${Date.now() - t0}ms: ${msg}`
)
callback(upsertErr instanceof Error ? upsertErr : new Error(msg))
return
}
logger.error(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: final batch skipped after error (${Date.now() - t0}ms): ${msg}`
)
recordsSkipped += remainingBatch.length
callback()
})
},
})
batchWriter.on('finish', () => {
logger.info(
`[IngestDrugDataJob] part ${partIndex + 1}/${totalParts}: stream finished — ` +
`ingested=${recordsIngested} skipped=${recordsSkipped} batches=${batchNum}`
)
resolve({ recordsIngested, recordsSkipped })
})
batchWriter.on('error', reject)
readStream.on('error', reject)
jsonParser.on('error', reject)
pick.on('error', reject)
streamArray.on('error', reject)
// Pipeline: readStream → jsonParser → pick → streamArray → batchWriter
readStream.pipe(jsonParser).pipe(pick).pipe(streamArray).pipe(batchWriter)
})
})
zipFile.on('end', () => {
// All zip entries enumerated. The batchWriter 'finish' event resolves
// the promise once the last batch flushes.
})
})
})
}
private async writeFinalStatus(
exportDate: string,
resourceMeta?: DrugDatasetResourceMeta
): Promise<void> {
// Lazy import to keep module top level free of Lucid
const KVStore = (await import('#models/kv_store')).default
await KVStore.setValue('drugReference.lastUpdatedExportDate', exportDate)
// Install-state write-back: when this ingest was kicked off by a curated-tier
// install, record an `installed_resources` row so the tier-status math (which
// is row-driven for ZIM/map) recognizes the dataset uniformly — the home-tile
// gate and the tier "installed" badge both read these rows. resource_type
// 'dataset' was widened onto the model in the foundational slice. The row's
// `version` is the openFDA export_date (the real freshness key), not the
// manifest placeholder. Manual downloads pass no resourceMeta → no row, which
// is correct: install-state belongs to the curated-tier path only.
if (!resourceMeta) return
try {
const { default: InstalledResource } = await import('#models/installed_resource')
const { DateTime } = await import('luxon')
const totalBytes = await this.totalDownloadedBytes()
await InstalledResource.updateOrCreate(
{ resource_id: resourceMeta.resourceId, resource_type: 'dataset' },
{
version: exportDate,
collection_ref: resourceMeta.collectionRef,
url: 'https://api.fda.gov/download.json',
// No single on-disk file — the parts are deleted after ingest; the
// installed artifact is the DB table. Record the staging dir for
// provenance; uninstall keys off resource_id, never this path.
file_path: STORAGE_BASE,
file_size_bytes: totalBytes,
installed_at: DateTime.now(),
}
)
logger.info(
`[IngestDrugDataJob] Wrote installed_resources row for ${resourceMeta.resourceId} (export_date=${exportDate})`
)
} catch (err) {
// A failed row write must NOT abort a completed ingest — the data is
// already searchable. Log loud; the tier badge will simply read
// not-installed until the next install reconcile.
logger.error(
`[IngestDrugDataJob] Failed to write installed_resources row for ${resourceMeta.resourceId}: ${
err instanceof Error ? err.message : String(err)
}`
)
}
}
/**
* Sum the recorded part sizes from the download-state KV marker, for the
* `installed_resources.file_size_bytes` column. Best-effort: the parts are
* deleted right after a full ingest, so the marker (written before deletion) is
* the only durable size source. Returns null when the marker is absent/empty so
* the column stays NULL rather than reporting a wrong 0.
*/
private async totalDownloadedBytes(): Promise<number | null> {
try {
const KVStore = (await import('#models/kv_store')).default
const { parseDownloadState } = await import('../../util/drug_labels.js')
const marker = parseDownloadState(await KVStore.getValue('drugReference.downloadState'))
if (!marker || marker.parts.length === 0) return null
const sum = marker.parts.reduce((acc, p) => acc + (p.bytes || 0), 0)
return sum > 0 ? sum : null
} catch {
return null
}
}
/**
* Delete the downloaded part zips and clear the download-state marker, run once
* after a full ingest succeeds (reclaims ~1.7 GB). A failed unlink is logged
* but never aborts a completed ingest.
*/
private async deleteDownloadedParts(
manifest: DrugLabelManifest,
totalParts: number
): Promise<void> {
for (let i = 0; i < totalParts; i++) {
const partition = manifest.partitions[i]
if (!partition) continue
const zipPath = partZipPath(STORAGE_BASE, partition)
try {
await fsPromises.unlink(zipPath)
logger.info(`[IngestDrugDataJob] Deleted zip: ${zipPath}`)
} catch (err) {
logger.warn(
`[IngestDrugDataJob] Could not delete zip ${zipPath}: ${err instanceof Error ? err.message : String(err)}`
)
}
}
const KVStore = (await import('#models/kv_store')).default
await KVStore.clearValue('drugReference.downloadState')
}
}

View File

@ -1,7 +1,7 @@
import { Job, UnrecoverableError } from 'bullmq' import { Job, UnrecoverableError } from 'bullmq'
import { RunDownloadJobParams, DownloadProgressData } from '../../types/downloads.js' import { RunDownloadJobParams, DownloadProgressData } from '../../types/downloads.js'
import { QueueService } from '#services/queue_service' import { QueueService } from '#services/queue_service'
import { doResumableDownload } from '../utils/downloads.js' import { doResumableDownload, GatedContentAuthError } from '../utils/downloads.js'
import { createHash } from 'crypto' import { createHash } from 'crypto'
import { DockerService } from '#services/docker_service' import { DockerService } from '#services/docker_service'
import { ZimService } from '#services/zim_service' import { ZimService } from '#services/zim_service'
@ -67,7 +67,7 @@ export class RunDownloadJob {
} }
async handle(job: Job) { async handle(job: Job) {
const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype, resourceMetadata } = const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype, resourceMetadata, requestHeaders } =
job.data as RunDownloadJobParams job.data as RunDownloadJobParams
// Register abort controller for this job // Register abort controller for this job
@ -110,6 +110,7 @@ export class RunDownloadJob {
timeout, timeout,
allowedMimeTypes, allowedMimeTypes,
forceNew, forceNew,
requestHeaders,
signal: abortController.signal, signal: abortController.signal,
onProgress(progress) { onProgress(progress) {
const progressPercent = (progress.downloadedBytes / (progress.totalBytes || 1)) * 100 const progressPercent = (progress.downloadedBytes / (progress.totalBytes || 1)) * 100
@ -208,9 +209,11 @@ export class RunDownloadJob {
const zimService = new ZimService(dockerService) const zimService = new ZimService(dockerService)
await zimService.downloadRemoteSuccessCallback([url], true) await zimService.downloadRemoteSuccessCallback([url], true)
// Only touch the knowledge base if AI Assistant (Ollama) is installed // Only touch the knowledge base if AI Assistant (Ollama) is installed.
// skip_embedding opts a ZIM out entirely — Creator Pack video ZIMs are
// media galleries, not text, so they must never be embedded or KB-reconciled.
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama') const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
if (ollamaUrl) { if (ollamaUrl && !resourceMetadata?.skip_embedding) {
// A content UPDATE replaces a prior file at a DIFFERENT path // A content UPDATE replaces a prior file at a DIFFERENT path
// (version is in the filename). A fresh install has no prior row; // (version is in the filename). A fresh install has no prior row;
// a same-version re-download keeps the same path. The two cases // a same-version re-download keeps the same path. The two cases
@ -313,6 +316,13 @@ export class RunDownloadJob {
if (userCancelled || abortController.signal.reason === 'user-cancel') { if (userCancelled || abortController.signal.reason === 'user-cancel') {
throw new UnrecoverableError(`Download cancelled: ${error.message}`) throw new UnrecoverableError(`Download cancelled: ${error.message}`)
} }
// A rejected entitlement is permanent - this build either has the key or it
// doesn't. Left as a plain Error it consumes all 10 attempts with exponential
// backoff from 30s, so the job reads `delayed` for ~4h15m and the user sees a
// stuck download instead of the message above.
if (error instanceof GatedContentAuthError) {
throw new UnrecoverableError(error.message)
}
throw error throw error
} finally { } finally {
if (cancelPollInterval !== null) { if (cancelPollInterval !== null) {

View File

@ -56,6 +56,79 @@ export default class BenchmarkResult extends BaseModel {
@column() @column()
declare ai_time_to_first_token: number | null declare ai_time_to_first_token: number | null
// Harness forensic metadata (nullable — added in Score v2 Phase 1)
@column()
declare sysbench_digest: string | null
@column()
declare ollama_version: string | null
// Platform metadata (nullable). Sourced from the Docker daemon, not
// systeminformation: inside the admin container si.osInfo()/os.arch()
// describe the container, not the host.
@column()
declare cpu_architecture: string | null
@column()
declare os_name: string | null
@column()
declare os_version: string | null
// NOMAD Score v2 raw channels (nullable — added in Score v2 Phase 4). Populated
// on full benchmarks under benchmark_version >= 2.0.0; the leaderboard recomputes
// the score from these on submit. cpu_events_multi is measured at
// cpu_benchmark_threads; memory_ops_per_sec at memory_threads. Disk figures are
// O_DIRECT MB/s. cpu_total_events/cpu_total_time are the W6 consistency companions.
@column()
declare cpu_events_single: number | null
@column()
declare cpu_events_multi: number | null
@column()
declare cpu_benchmark_threads: number | null
@column()
declare cpu_total_events: number | null
@column()
declare cpu_total_time: number | null
@column()
declare memory_ops_per_sec: number | null
@column()
declare memory_threads: number | null
@column()
declare disk_read_mb_per_sec: number | null
@column()
declare disk_write_mb_per_sec: number | null
// Uncapped NOMAD Score v2 (null for pre-v2 rows and system-only runs). The
// legacy nomad_score below is retained in parallel for display continuity.
// columnName + serializeAs pinned: the snake_case strategy would otherwise map
// this property to `nomad_score_v_2` (splitting the digit) for both the DB column
// (which the migration doesn't create) and the JSON key (which the frontend reads
// as nomad_score_v2). Pin both so DB, API, and UI all agree.
@column({ columnName: 'nomad_score_v2', serializeAs: 'nomad_score_v2' })
declare nomad_score_v2: number | null
// Best-effort run environment metadata (issue #1016)
@column()
declare run_environment: string | null
@column()
declare storage_path_type: string | null
@column({
// Nullable tri-state; coerce the SQLite 0/1 int to a real boolean when present.
consume: (value: number | null) => (value === null || value === undefined ? null : Boolean(value)),
})
declare gpu_compute_detected: boolean | null
// Composite NOMAD score (0-100) // Composite NOMAD score (0-100)
@column() @column()
declare nomad_score: number declare nomad_score: number

View File

@ -0,0 +1,116 @@
import { DateTime } from 'luxon'
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
/**
* Drug Reference v1 openFDA drug label catalog entry.
*
* One row per FDA `set_id` (stable GUID for a labeling, across all revisions).
* Re-ingesting updates existing rows idempotently via `set_id` UNIQUE key.
*
* Enum-ish columns (product_type) are plain varchars validated at the edge
* the stl_files / inventory_items convention: no native DB enums, so the
* schema can evolve without ALTER TABLE.
*
* All section-text columns are optional: label records frequently omit
* sections (e.g. OTC labels have no `boxed_warning`). The mapper returns
* null for any absent or empty section.
*/
export default class DrugLabel extends BaseModel {
static table = 'drug_labels'
static namingStrategy = new SnakeCaseNamingStrategy()
@column({ isPrimary: true })
declare id: number
/** openFDA set_id — stable GUID across label revisions. Idempotent upsert key. */
@column()
declare set_id: string
/** openFDA id — per-revision GUID for provenance. */
@column()
declare spl_id: string | null
@column()
declare version: string | null
/** openfda.brand_name[0] — first element only. */
@column()
declare brand_name: string | null
/** openfda.generic_name joined with ", " (labels can list several). */
@column()
declare generic_name: string | null
/** openfda.manufacturer_name[0]. */
@column()
declare manufacturer: string | null
/** openfda.product_ndc joined with ", ". */
@column()
declare product_ndc: string | null
/** openfda.route joined with ", ". */
@column()
declare route: string | null
/**
* openfda.product_type[0]. Drives OTC vs Rx badge + filter.
* Expected values: 'HUMAN OTC DRUG' | 'HUMAN PRESCRIPTION DRUG'.
*/
@column()
declare product_type: string | null
/**
* Normalized brand+generic blob for FULLTEXT and LIKE search.
* Built by normalizeDrugName() at ingest time; never re-computed on read.
* Max 768 chars stays inside InnoDB utf8mb4 index key-length budget.
*/
@column()
declare searchable_name: string | null
/** Flattened indications_and_usage sections, joined with \n\n. */
@column()
declare indications: string | null
/** Flattened dosage_and_administration sections. */
@column()
declare dosage: string | null
/** Flattened warnings sections. */
@column()
declare warnings: string | null
/** Flattened boxed_warning sections (absent on most OTC labels). */
@column()
declare boxed_warning: string | null
/**
* Flattened drug_interactions label text.
* Single-drug label info only NOT a pairwise cross-drug checker.
*/
@column()
declare drug_interactions: string | null
/** Flattened contraindications sections. */
@column()
declare contraindications: string | null
/** Flattened when_using sections (common on OTC labels). */
@column()
declare when_using: string | null
/** Flattened stop_use sections (common on OTC labels). */
@column()
declare stop_use: string | null
/**
* Parsed from effective_time (YYYYMMDD YYYY-MM-DD).
* Stored as a plain date string, not a Luxon DateTime, because
* the source format is just a date (no time component).
*/
@column()
declare source_updated_at: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true, columnName: 'ingested_at' })
declare ingested_at: DateTime
}

View File

@ -11,7 +11,7 @@ export default class InstalledResource extends BaseModel {
declare resource_id: string declare resource_id: string
@column() @column()
declare resource_type: 'zim' | 'map' declare resource_type: 'zim' | 'map' | 'dataset'
@column() @column()
declare collection_ref: string | null declare collection_ref: string | null

View File

@ -28,6 +28,9 @@ export default class KbIngestState extends BaseModel {
@column() @column()
declare chunks_embedded: number declare chunks_embedded: number
@column()
declare collection: string | null
@column() @column()
declare last_error: string | null declare last_error: string | null
@ -37,18 +40,19 @@ export default class KbIngestState extends BaseModel {
@column.dateTime({ autoCreate: true, autoUpdate: true }) @column.dateTime({ autoCreate: true, autoUpdate: true })
declare updated_at: DateTime declare updated_at: DateTime
static async getOrCreate(filePath: string): Promise<KbIngestState> { static async getOrCreate(filePath: string, collection?: string): Promise<KbIngestState> {
return this.firstOrCreate( return this.firstOrCreate(
{ file_path: filePath }, { file_path: filePath },
{ file_path: filePath, state: 'pending_decision', chunks_embedded: 0 } { file_path: filePath, state: 'pending_decision', chunks_embedded: 0, collection: collection ?? null }
) )
} }
static async markIndexed(filePath: string, chunksEmbedded: number): Promise<void> { static async markIndexed(filePath: string, chunksEmbedded: number, collection?: string): Promise<void> {
const row = await this.getOrCreate(filePath) const row = await this.getOrCreate(filePath, collection)
row.state = 'indexed' row.state = 'indexed'
row.chunks_embedded = chunksEmbedded row.chunks_embedded = chunksEmbedded
row.last_error = null row.last_error = null
if (collection) row.collection = collection
await row.save() await row.save()
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,102 @@
import transmit from '@adonisjs/transmit/services/main'
import si from 'systeminformation'
import logger from '@adonisjs/core/services/logger'
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
import type { BenchmarkStatus, BenchmarkTelemetry } from '../../types/benchmark.js'
const SAMPLE_INTERVAL_MS = 1000
/**
* Samples host telemetry (per-core CPU load, temperature, disk throughput) on a
* timer and broadcasts it over SSE while a benchmark runs. Read-only: the sampler
* runs in the orchestration process, never inside the sysbench container, so it
* cannot influence the scored numbers.
*
* Signals come from `systeminformation`, which reads the host's /proc and /sys
* (not namespaced for these counters), so this works from inside a container too.
* Temperature legitimately isn't available on every host (VMs, some hwmon-less
* boards) and is reported as null rather than faked.
*/
export class BenchmarkTelemetrySampler {
private timer: NodeJS.Timeout | null = null
private readonly benchmarkId: string | null
private status: BenchmarkStatus = 'starting'
private startedAt = Date.now()
private stageMetric: BenchmarkTelemetry['stage_metric'] | undefined
private gpu: BenchmarkTelemetry['gpu'] | undefined
private sampling = false
constructor(benchmarkId: string | null) {
this.benchmarkId = benchmarkId
}
start() {
if (this.timer) return
this.startedAt = Date.now()
// Prime systeminformation's per-second deltas and emit an initial frame,
// then continue on the interval.
void this._sample()
this.timer = setInterval(() => void this._sample(), SAMPLE_INTERVAL_MS)
}
/** Tag subsequent frames with the current stage; clears any prior in-test state. */
setStage(status: BenchmarkStatus) {
this.status = status
this.stageMetric = undefined
this.gpu = undefined
}
/** Inject an in-test metric (e.g. live AI tokens/sec) into subsequent frames. */
setStageMetric(kind: NonNullable<BenchmarkTelemetry['stage_metric']>['kind'], value: number, ttftMs?: number) {
this.stageMetric = { kind, value, ...(ttftMs !== undefined ? { ttft_ms: ttftMs } : {}) }
}
/** Inject NVIDIA GPU stats into subsequent frames (null clears them). */
setGpuStats(gpu: NonNullable<BenchmarkTelemetry['gpu']> | null) {
this.gpu = gpu ?? undefined
}
stop() {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
}
private async _sample() {
// Skip if the previous sample is still in flight (currentLoad can take a
// couple hundred ms); never let samples pile up.
if (this.sampling) return
this.sampling = true
try {
const [load, temp, fs] = await Promise.all([
si.currentLoad().catch(() => null),
si.cpuTemperature().catch(() => null),
si.fsStats().catch(() => null),
])
const overall = load ? Math.max(0, Math.round(load.currentLoad)) : 0
const perCore = load?.cpus?.map((c) => Math.max(0, Math.round(c.load))) ?? []
const tempC = temp && typeof temp.main === 'number' && temp.main > 0 ? Math.round(temp.main) : null
const readMb = fs && typeof fs.rx_sec === 'number' && fs.rx_sec >= 0 ? Number((fs.rx_sec / 1e6).toFixed(1)) : 0
const writeMb = fs && typeof fs.wx_sec === 'number' && fs.wx_sec >= 0 ? Number((fs.wx_sec / 1e6).toFixed(1)) : 0
const payload: BenchmarkTelemetry = {
benchmark_id: this.benchmarkId,
status: this.status,
t: Date.now() - this.startedAt,
cpu: { overall, per_core: perCore },
temp_c: tempC,
disk: { read_mb_s: readMb, write_mb_s: writeMb },
...(this.stageMetric ? { stage_metric: this.stageMetric } : {}),
...(this.gpu ? { gpu: this.gpu } : {}),
}
transmit.broadcast(BROADCAST_CHANNELS.BENCHMARK_TELEMETRY, payload)
} catch (err) {
logger.debug(`[BenchmarkTelemetry] sample failed: ${err.message}`)
} finally {
this.sampling = false
}
}
}

View File

@ -8,7 +8,8 @@ import InstalledResource from '#models/installed_resource'
import WikipediaSelection from '#models/wikipedia_selection' import WikipediaSelection from '#models/wikipedia_selection'
import { QueueService } from './queue_service.js' import { QueueService } from './queue_service.js'
import { RunDownloadJob } from '#jobs/run_download_job' import { RunDownloadJob } from '#jobs/run_download_job'
import { zimCategoriesSpecSchema, mapsSpecSchema, wikipediaSpecSchema } from '#validators/curated_collections' import { zimCategoriesSpecSchema, mapsSpecSchema, wikipediaSpecSchema, creatorPacksSpecSchema } from '#validators/curated_collections'
import { isGatedResource } from '../utils/hosted_content.js'
import { import {
ensureDirectoryExists, ensureDirectoryExists,
listDirectoryContents, listDirectoryContents,
@ -19,8 +20,10 @@ import type {
ManifestType, ManifestType,
ZimCategoriesSpec, ZimCategoriesSpec,
MapsSpec, MapsSpec,
CreatorPacksSpec,
CategoryWithStatus, CategoryWithStatus,
CollectionWithStatus, CollectionWithStatus,
CreatorPackWithStatus,
SpecResource, SpecResource,
SpecTier, SpecTier,
} from '../../types/collections.js' } from '../../types/collections.js'
@ -29,12 +32,14 @@ const SPEC_URLS: Record<ManifestType, string> = {
zim_categories: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/kiwix-categories.json', zim_categories: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/kiwix-categories.json',
maps: 'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/collections/maps.json', maps: 'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/collections/maps.json',
wikipedia: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json', wikipedia: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json',
creator_packs: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/creator-packs.json',
} }
const VALIDATORS: Record<ManifestType, any> = { const VALIDATORS: Record<ManifestType, any> = {
zim_categories: zimCategoriesSpecSchema, zim_categories: zimCategoriesSpecSchema,
maps: mapsSpecSchema, maps: mapsSpecSchema,
wikipedia: wikipediaSpecSchema, wikipedia: wikipediaSpecSchema,
creator_packs: creatorPacksSpecSchema,
} }
export class CollectionManifestService { export class CollectionManifestService {
@ -98,15 +103,28 @@ export class CollectionManifestService {
const spec = await this.getSpecWithFallback<ZimCategoriesSpec>('zim_categories') const spec = await this.getSpecWithFallback<ZimCategoriesSpec>('zim_categories')
if (!spec) return [] if (!spec) return []
const installedResources = await InstalledResource.query().where('resource_type', 'zim') // Include 'dataset' rows alongside 'zim' so a curated tier carrying the FDA
// drug dataset reads "installed" once the ingest writes its row (the tier-
// status math below treats every resolved resource id uniformly — a dataset
// resource is just another id to account for).
const installedResources = await InstalledResource.query().whereIn('resource_type', [
'zim',
'dataset',
])
const installedMap = new Map(installedResources.map((r) => [r.resource_id, r])) const installedMap = new Map(installedResources.map((r) => [r.resource_id, r]))
// In-flight ZIM download resource IDs from the BullMQ queue. Used to // In-flight ZIM + dataset download resource IDs from the BullMQ queues. Used
// surface the user's tier intent immediately on submit, before any single // to surface the user's tier intent immediately on submit, before any single
// file has finished downloading. Failed jobs are excluded so a stuck // file has finished downloading. Failed jobs are excluded so a stuck queue
// queue entry doesn't keep claiming the user's pick forever. // entry doesn't keep claiming the user's pick forever.
const inFlightIds = await this.getInFlightZimResourceIds() const inFlightIds = await this.getInFlightZimResourceIds()
// Whether the in-flight drug dataset is in its INGEST (indexing) phase — the
// download finished and the heavy ingest is running. Lets the wizard card
// flip "(downloading)" → "(indexing)" at the handoff (Req 7) instead of
// showing a stale "downloading" through the long index.
const drugIndexing = await this.isDrugDatasetIndexing()
return spec.categories.map((category) => { return spec.categories.map((category) => {
const installedTierSlug = this.getInstalledTierForCategory(category.tiers, installedMap) const installedTierSlug = this.getInstalledTierForCategory(category.tiers, installedMap)
const downloadingTierSlug = this.getDownloadingTierForCategory( const downloadingTierSlug = this.getDownloadingTierForCategory(
@ -115,10 +133,47 @@ export class CollectionManifestService {
inFlightIds, inFlightIds,
installedTierSlug installedTierSlug
) )
return { ...category, installedTierSlug, downloadingTierSlug } // Only mark "indexing" when this category's downloading tier actually
// carries a dataset resource that is the one indexing.
const downloadingTierIndexing =
drugIndexing && downloadingTierSlug
? CollectionManifestService.resolveTierResources(
category.tiers.find((t) => t.slug === downloadingTierSlug)!,
category.tiers
).some((r) => r.type === 'dataset')
: false
return { ...category, installedTierSlug, downloadingTierSlug, downloadingTierIndexing }
}) })
} }
/**
* True when the FDA drug dataset's INGEST is in flight while its DOWNLOAD is
* not i.e. the handoff into the indexing phase. Drives the wizard card's
* "(indexing)" label. Defensive: any queue read failure returns false (the card
* just keeps showing "(downloading)") rather than breaking the categories list.
*/
private async isDrugDatasetIndexing(): Promise<boolean> {
try {
const { DownloadDrugDataJob } = await import('#jobs/download_drug_data_job')
const { IngestDrugDataJob } = await import('#jobs/ingest_drug_data_job')
const queueService = QueueService.getInstance()
const ingestQueue = queueService.getQueue(IngestDrugDataJob.queue)
const ingestJobs = await ingestQueue.getJobs(['active', 'waiting', 'delayed'])
if (ingestJobs.length === 0) return false
const downloadQueue = queueService.getQueue(DownloadDrugDataJob.queue)
const downloadJobs = await downloadQueue.getJobs(['active', 'waiting', 'delayed'])
return downloadJobs.length === 0
} catch (error: any) {
logger.warn(
'[CollectionManifestService] Could not determine drug indexing state:',
error?.message || error
)
return false
}
}
private async getInFlightZimResourceIds(): Promise<Set<string>> { private async getInFlightZimResourceIds(): Promise<Set<string>> {
const ids = new Set<string>() const ids = new Set<string>()
try { try {
@ -134,9 +189,47 @@ export class CollectionManifestService {
// unreachable — just report no in-flight downloads. // unreachable — just report no in-flight downloads.
logger.warn('[CollectionManifestService] Could not read download queue:', error?.message || error) logger.warn('[CollectionManifestService] Could not read download queue:', error?.message || error)
} }
// Also surface an in-flight curated-tier drug-dataset install. The drug
// download/ingest run on their own queues (not RunDownloadJob), carrying the
// dataset's resource id in resourceMeta — read it so the wizard shows the
// Medicine tier as "downloading" the moment the user opts in, mirroring the
// ZIM behaviour. Scanned independently so a missing drug queue can't blank
// the ZIM in-flight set above.
await this.addInFlightDrugDatasetId(ids)
return ids return ids
} }
/**
* Add the FDA drug dataset's manifest resource id to `ids` when its download or
* ingest is in flight. The dataset only counts as "downloading" while no
* install-state row exists yet once ingest writes the row it is "installed"
* (handled by the installedMap), so a still-running ingest correctly reads as
* the in-flight tier intent here. resourceMeta is only present on a curated-tier
* install, so a manual (non-tier) drug download never claims a tier slug.
*/
private async addInFlightDrugDatasetId(ids: Set<string>): Promise<void> {
try {
const { DownloadDrugDataJob } = await import('#jobs/download_drug_data_job')
const { IngestDrugDataJob } = await import('#jobs/ingest_drug_data_job')
const queueService = QueueService.getInstance()
for (const queueName of [DownloadDrugDataJob.queue, IngestDrugDataJob.queue]) {
const queue = queueService.getQueue(queueName)
const jobs = await queue.getJobs(['waiting', 'active', 'delayed'])
for (const job of jobs) {
const resourceId = job.data?.resourceMeta?.resourceId
if (typeof resourceId === 'string') ids.add(resourceId)
}
}
} catch (error: any) {
logger.warn(
'[CollectionManifestService] Could not read drug dataset queue:',
error?.message || error
)
}
}
/** /**
* Highest tier whose every resource is installed OR has an in-flight * Highest tier whose every resource is installed OR has an in-flight
* download. Returns undefined when there are no in-flight downloads for this * download. Returns undefined when there are no in-flight downloads for this
@ -189,6 +282,72 @@ export class CollectionManifestService {
}) })
} }
/**
* Per-pack install status for Creator Packs. Packs are single ZIMs, so this is
* a one-resource-per-pack join (simpler than the tiered category logic):
* catalog InstalledResource (resource_type 'zim', matched on resource_id)
* the in-flight download queue. `available_update_version` is set when an
* installed pack's version trails the catalog (a creator published a rebuild).
*/
async getCreatorPacksWithStatus(): Promise<CreatorPackWithStatus[]> {
const spec = await this.getSpecWithFallback<CreatorPacksSpec>('creator_packs')
if (!spec) return []
const installedResources = await InstalledResource.query().where('resource_type', 'zim')
const installedMap = new Map(installedResources.map((r) => [r.resource_id, r]))
const inFlightIds = await this.getInFlightZimResourceIds()
return spec.packs.map((pack) => {
const installed = installedMap.get(pack.resource_id)
if (installed) {
const hasUpdate = installed.version !== pack.version
return {
...pack,
status: 'installed' as const,
installed_version: installed.version,
...(hasUpdate ? { available_update_version: pack.version } : {}),
}
}
if (inFlightIds.has(pack.resource_id)) {
return { ...pack, status: 'downloading' as const }
}
return { ...pack, status: 'available' as const }
})
}
/**
* Resource ids in the ZIM manifest that we host ourselves behind the
* entitlement Worker (`auth: 'nomad_app_key'`).
*
* Used to keep gated content out of the Kiwix-catalog update path. Those
* resources are not in the openzim catalog, so they can never legitimately
* match there but a resource-id collision would otherwise let a third-party
* mirror present itself as a newer version and overwrite our content. Their
* versions come from the manifest instead.
*
* Reads the CACHED spec rather than refetching: this sits on the scheduled
* update-check path and does not need a network round-trip. A gated resource
* cannot be installed without the manifest having been fetched first, so the
* cache is always populated by the time it matters.
*
* Returns an empty set if the manifest has never been cached, which correctly
* degrades to current behaviour rather than skipping every update.
*/
async getGatedZimResourceIds(): Promise<Set<string>> {
const ids = new Set<string>()
const spec = await this.getCachedSpec<ZimCategoriesSpec>('zim_categories')
if (!spec) return ids
for (const category of spec.categories) {
for (const tier of category.tiers) {
for (const resource of tier.resources) {
if (isGatedResource(resource)) ids.add(resource.id)
}
}
}
return ids
}
// ---- Tier resolution ---- // ---- Tier resolution ----
static resolveTierResources(tier: SpecTier, allTiers: SpecTier[]): SpecResource[] { static resolveTierResources(tier: SpecTier, allTiers: SpecTier[]): SpecResource[] {

View File

@ -10,6 +10,7 @@ import type {
ContentUpdateCheckResult, ContentUpdateCheckResult,
} from '../../types/collections.js' } from '../../types/collections.js'
import { KiwixCatalogService, reconcileResourceUpdateState } from './kiwix_catalog_service.js' import { KiwixCatalogService, reconcileResourceUpdateState } from './kiwix_catalog_service.js'
import { CollectionManifestService } from './collection_manifest_service.js'
const MAP_STORAGE_PATH = '/storage/maps' const MAP_STORAGE_PATH = '/storage/maps'
@ -24,7 +25,21 @@ export class CollectionUpdateService {
* state (version + cool-off anchor) so the auto-updater can act on it later. * state (version + cool-off anchor) so the auto-updater can act on it later.
*/ */
async checkForUpdates(): Promise<ContentUpdateCheckResult> { async checkForUpdates(): Promise<ContentUpdateCheckResult> {
const installed = await InstalledResource.all() // ZIM/map catalog update path only — exclude `dataset` resources (e.g. the
// FDA drug labels), which are not filename-versioned and get their own
// freshness path. No-op today (no dataset rows are written in this slice).
const allInstalled = await InstalledResource.query().whereNot('resource_type', 'dataset')
// Content we host ourselves is versioned by the manifest, not by the Kiwix
// catalog, so it has no business in this check. Excluding it also means a
// resource-id collision can't let a third-party mirror present itself as a
// newer version and overwrite our content. See resolveZimDownload, which
// pins the same resources to their manifest URL on the install path.
const gatedIds = await new CollectionManifestService().getGatedZimResourceIds()
const installed = allInstalled.filter(
(r) => !(r.resource_type === 'zim' && gatedIds.has(r.resource_id))
)
if (installed.length === 0) { if (installed.length === 0) {
return { return {
updates: [], updates: [],
@ -35,7 +50,11 @@ export class CollectionUpdateService {
try { try {
const catalog = new KiwixCatalogService() const catalog = new KiwixCatalogService()
const latestByKey = await catalog.getLatestForResources( const latestByKey = await catalog.getLatestForResources(
installed.map((r) => ({ resource_id: r.resource_id, resource_type: r.resource_type })) // `dataset` rows are filtered out above, so the type narrows to ZIM/map.
installed.map((r) => ({
resource_id: r.resource_id,
resource_type: r.resource_type as 'zim' | 'map',
}))
) )
const now = DateTime.now() const now = DateTime.now()
@ -47,7 +66,7 @@ export class CollectionUpdateService {
if (latest && latest.version > resource.version) { if (latest && latest.version > resource.version) {
updates.push({ updates.push({
resource_id: resource.resource_id, resource_id: resource.resource_id,
resource_type: resource.resource_type, resource_type: resource.resource_type as 'zim' | 'map',
installed_version: resource.version, installed_version: resource.version,
latest_version: latest.version, latest_version: latest.version,
download_url: latest.download_url, download_url: latest.download_url,
@ -101,7 +120,6 @@ export class CollectionUpdateService {
timeout: 30000, timeout: 30000,
allowedMimeTypes: allowedMimeTypes:
update.resource_type === 'zim' ? ZIM_MIME_TYPES : PMTILES_MIME_TYPES, update.resource_type === 'zim' ? ZIM_MIME_TYPES : PMTILES_MIME_TYPES,
forceNew: true,
filetype: update.resource_type, filetype: update.resource_type,
title: update.resource_id, title: update.resource_id,
totalBytes: update.size_bytes, totalBytes: update.size_bytes,

View File

@ -0,0 +1,329 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import { CONDITIONS_FILE } from '../data/conditions.js'
import { NATURAL_REMEDIES_FILE } from '../data/natural_remedies.js'
import { HOME_REMEDIES_FILE } from '../data/home_remedies.js'
import {
parseConditionsFile,
parseNaturalRemediesFile,
findConditionBySlug,
toConditionSummary,
buildIndicationQuery,
orderOtcFirst,
remediesForCondition,
remediesForFreeText,
} from '../../util/conditions.js'
import { PRODUCT_TYPES } from '../../types/drug_reference.js'
import type {
Condition,
ConditionSummary,
ConditionDrugsResult,
NaturalRemedy,
NaturalRemediesFile,
} from '../../types/conditions.js'
import type { DrugSearchResult } from '../../types/drug_reference.js'
/**
* "When to use what" condition-first service (Phase 1 + Phase 2).
*
* Resolves a curated condition (or a free-text situation) to the OTC drugs whose
* FDA label indications match it, plus the natural remedies (NCCIH) whose curated
* condition mapping includes the resolved slug.
*
* Reuses the Drug Reference indication-search machinery: the same combined-FULLTEXT
* index (ft_drug_labels_name_indications), the same MAX(MATCH ) aggregate pattern
* required under MySQL 8 ONLY_FULL_GROUP_BY, and the same brand+generic collapse
* then re-buckets results OTC-first.
*
* Natural remedies are in-memory (no DB table, no migration) one module-level
* parse of NATURAL_REMEDIES_FILE, reused across all requests.
*/
/**
* Module-level merged remedies corpus (fail-soft): the NCCIH herbs plus the
* non-herbal home-care measures (CDC/NIH/FDA), each entry tagged with its kind
* so the UI can badge them apart. Slugs are disjoint between the two files
* (validated at curation time).
*/
const HERB_FILE = parseNaturalRemediesFile(NATURAL_REMEDIES_FILE)
const HOME_FILE = parseNaturalRemediesFile(HOME_REMEDIES_FILE)
const REMEDIES_FILE: NaturalRemediesFile = {
version: HERB_FILE.version,
source: HERB_FILE.source,
remedies: [
...HERB_FILE.remedies.map((r) => ({ ...r, kind: 'herb' as const })),
...HOME_FILE.remedies.map((r) => ({ ...r, kind: 'self-care' as const })),
],
}
export class ConditionService {
/** Parsed (fail-soft) curated spine — bad entries are dropped, not fatal. */
private get spine(): Condition[] {
return parseConditionsFile(CONDITIONS_FILE).conditions
}
/** All curated conditions as client-facing summaries (no searchTerms). */
listConditions(): ConditionSummary[] {
return this.spine.map(toConditionSummary)
}
/**
* The full curated natural-remedies list (NCCIH herbs). Small enough to ship
* as page props, which lets the Drug Reference search match remedies by name
* client-side and offer a "Natural" browse with no extra round trip.
*/
listRemedies(): NaturalRemedy[] {
return REMEDIES_FILE.remedies
}
/**
* The full curated spine (WITH searchTerms) for server-side matching such as
* the drug-detail reverse link (situationsForIndications). Stays server-only
* searchTerms are a search-implementation detail the client never receives.
*/
allConditions(): Condition[] {
return this.spine
}
/** Find a curated condition by slug. Returns null when absent. */
findCondition(slug: string): Condition | null {
return findConditionBySlug(this.spine, slug)
}
/**
* Resolve a curated condition (by slug) to its matching OTC drugs and natural
* remedies. Returns null when the slug is not in the curated spine so the
* controller can 404. Drugs are OTC-only and OTC-first-ordered.
*/
async drugsForSlug(
slug: string,
limit = 50,
opts?: { route?: string; sort?: 'relevance' | 'name' }
): Promise<ConditionDrugsResult | null> {
const condition = this.findCondition(slug)
if (!condition) return null
const drugs = await this.searchIndications(condition.searchTerms, limit, opts)
const remedies: NaturalRemedy[] = remediesForCondition(REMEDIES_FILE, slug)
return { condition: toConditionSummary(condition), drugs, remedies }
}
/**
* Resolve a free-text situation (off-list condition) to matching OTC drugs and
* natural remedies. Treats the raw query as a single search term. Returns a
* synthetic condition summary echoing the query so the UI can render a consistent
* header.
*
* Remedy resolution for free text:
* 1. Try to resolve the query to a curated condition slug (exact or substring
* match on slug/label). If found, use remediesForCondition this is the
* primary path (e.g. "burns" typed free-form still finds aloe/tea-tree).
* 2. Secondary fallback: remediesForFreeText searches remedy name/uses by
* substring so an unmapped query ("athlete's foot") can still surface a
* relevant remedy. Results from both paths are unioned and de-duped by slug.
*/
async drugsForFreeText(
query: string,
limit = 50,
opts?: { route?: string; sort?: 'relevance' | 'name' }
): Promise<ConditionDrugsResult> {
const trimmed = query.trim()
const drugs = trimmed.length > 0 ? await this.searchIndications([trimmed], limit, opts) : []
// Phase 2: union condition-mapped + free-text-matched remedies.
const remedyMap = new Map<string, NaturalRemedy>()
if (trimmed.length > 0) {
// Primary: resolve to a curated condition slug the same way the client-side
// matchSituation helper does (case-insensitive exact/substring on slug+label).
const q = trimmed.toLowerCase()
const matchedCondition =
this.spine.find((c) => c.slug.toLowerCase() === q || c.label.toLowerCase() === q) ??
this.spine.find(
(c) =>
c.label.toLowerCase().includes(q) || c.slug.replace(/-/g, ' ').toLowerCase().includes(q)
) ??
null
if (matchedCondition) {
for (const r of remediesForCondition(REMEDIES_FILE, matchedCondition.slug)) {
remedyMap.set(r.slug, r)
}
}
// Secondary: name/uses substring fallback.
for (const r of remediesForFreeText(REMEDIES_FILE, trimmed)) {
if (!remedyMap.has(r.slug)) remedyMap.set(r.slug, r)
}
}
return {
condition: { slug: '', label: trimmed, category: 'Search' },
drugs,
remedies: Array.from(remedyMap.values()),
}
}
/**
* Core search: FULLTEXT over (searchable_name, indications) for the OR-expanded
* searchTerms, OTC-filtered, collapsed by brand+generic, OTC-first-ordered.
*
* Strategy mirrors DrugReferenceService:
* 1. FULLTEXT NATURAL LANGUAGE MODE on the built query (requires the query
* to have a >= 3-char token; innodb_ft_min_token_size = 3).
* 2. LIKE fallback when the built query is empty/too short OR FULLTEXT throws
* (e.g. the index is absent) runs each term as a LIKE clause.
* Both paths force product_type = OTC and then orderOtcFirst (a no-op on the
* already-OTC set, but kept so a future relaxation of the OTC filter stays
* correctly ordered).
*/
async searchIndications(
searchTerms: string[],
limit = 50,
opts?: { route?: string; sort?: 'relevance' | 'name' }
): Promise<DrugSearchResult[]> {
const ftQuery = buildIndicationQuery(searchTerms)
// A FULLTEXT query needs a token >= innodb_ft_min_token_size (3). If the
// longest bare token is shorter, NATURAL LANGUAGE MODE returns nothing, so
// go straight to LIKE.
const longestToken = ftQuery
.replace(/"/g, ' ')
.split(/\s+/)
.reduce((max, t) => Math.max(max, t.length), 0)
const useFulltext = ftQuery.length > 0 && longestToken >= 3
if (useFulltext) {
try {
const rows = await this.searchIndicationFulltext(ftQuery, limit, opts)
return orderOtcFirst(rows)
} catch (err) {
logger.warn(
`[ConditionService] FULLTEXT indication search failed, falling back to LIKE: ${
err instanceof Error ? err.message : String(err)
}`
)
}
}
const rows = await this.searchIndicationLike(searchTerms, limit, opts)
return orderOtcFirst(rows)
}
/**
* FULLTEXT OTC indication search.
*
* MATCHes over (searchable_name, indications) must exactly match the
* ft_drug_labels_name_indications index column list. The MAX(MATCH ) wrapper
* is LOAD-BEARING: MySQL 8 ONLY_FULL_GROUP_BY rejects a bare MATCH() in SELECT
* under GROUP BY; wrapping it in MAX() makes it an aggregate. Copied verbatim
* from DrugReferenceService.searchIndicationFulltext, with a fixed OTC filter.
*/
private async searchIndicationFulltext(
ftQuery: string,
limit: number,
opts?: { route?: string; sort?: 'relevance' | 'name' }
): Promise<DrugSearchResult[]> {
let sql = `
SELECT
MIN(id) AS id,
brand_name,
generic_name,
MIN(manufacturer) AS manufacturer,
MIN(route) AS route,
MIN(product_type) AS product_type,
COUNT(*) AS labelCount,
MAX(MATCH(searchable_name, indications) AGAINST(? IN NATURAL LANGUAGE MODE)) AS relevance
FROM drug_labels
WHERE MATCH(searchable_name, indications) AGAINST(? IN NATURAL LANGUAGE MODE)
AND product_type = ?
`
const bindings: unknown[] = [ftQuery, ftQuery, PRODUCT_TYPES.OTC]
if (opts?.route) {
sql += ' AND route LIKE ?'
bindings.push(`%${opts.route.toUpperCase()}%`)
}
sql += `
GROUP BY brand_name, generic_name
ORDER BY ${opts?.sort === 'name' ? 'COALESCE(brand_name, generic_name) ASC' : 'relevance DESC'}
LIMIT ?
`
bindings.push(limit)
const rows = await db.rawQuery(sql, bindings)
return this.mapSearchRows(rows[0])
}
/**
* LIKE OTC indication fallback (FULLTEXT unavailable or query too short).
*
* ORs a LIKE clause per search term over `indications` so any synonym can
* surface a match. OTC-filtered and collapsed identically to the FULLTEXT path.
*/
private async searchIndicationLike(
searchTerms: string[],
limit: number,
opts?: { route?: string; sort?: 'relevance' | 'name' }
): Promise<DrugSearchResult[]> {
const terms = searchTerms.map((t) => t.trim()).filter((t) => t.length > 0)
if (terms.length === 0) return []
const likeClauses = terms.map(() => 'indications LIKE ?').join(' OR ')
let sql = `
SELECT
MIN(id) AS id,
brand_name,
generic_name,
MIN(manufacturer) AS manufacturer,
MIN(route) AS route,
MIN(product_type) AS product_type,
COUNT(*) AS labelCount
FROM drug_labels
WHERE (${likeClauses})
AND product_type = ?
`
const bindings: unknown[] = [...terms.map((t) => `%${t}%`), PRODUCT_TYPES.OTC]
if (opts?.route) {
sql += ' AND route LIKE ?'
bindings.push(`%${opts.route.toUpperCase()}%`)
}
sql += `
GROUP BY brand_name, generic_name
ORDER BY ${opts?.sort === 'name' ? 'COALESCE(brand_name, generic_name) ASC' : 'brand_name ASC'}
LIMIT ?
`
bindings.push(limit)
const rows = await db.rawQuery(sql, bindings)
return this.mapSearchRows(rows[0])
}
/** Map raw collapsed rows to DrugSearchResult — mirrors DrugReferenceService. */
private mapSearchRows(rows: any[]): DrugSearchResult[] {
if (!Array.isArray(rows)) return []
return rows.map((row) => ({
id: Number(row.id),
brand_name: row.brand_name ?? null,
generic_name: row.generic_name ?? null,
manufacturer: row.manufacturer ?? null,
route: row.route ?? null,
product_type: row.product_type ?? null,
labelCount: Number(row.labelCount ?? row.labelcount ?? 1),
}))
}
/**
* Current drug_labels row count drives the index page empty-state (no data
* point the user to Drug Reference to download first). Mirrors
* DrugReferenceService.rowCount; returns 0 on any error.
*/
async drugRowCount(): Promise<number> {
try {
const result = await db.rawQuery('SELECT COUNT(*) AS cnt FROM drug_labels')
const rows = result[0] as Array<{ cnt: number | string }>
return Number(rows[0]?.cnt ?? 0)
} catch {
return 0
}
}
}

View File

@ -4,6 +4,7 @@ import KVStore from '#models/kv_store'
import InstalledResource from '#models/installed_resource' import InstalledResource from '#models/installed_resource'
import { DownloadService } from '#services/download_service' import { DownloadService } from '#services/download_service'
import { CollectionUpdateService } from '#services/collection_update_service' import { CollectionUpdateService } from '#services/collection_update_service'
import { DrugReferenceService } from '#services/drug_reference_service'
import { import {
KiwixCatalogService, KiwixCatalogService,
reconcileResourceUpdateState, reconcileResourceUpdateState,
@ -269,9 +270,18 @@ export class ContentAutoUpdateService {
await this.maybeResetWindowBudget(config, now) await this.maybeResetWindowBudget(config, now)
// Local catalog check + persist available-update state for every resource. // Local catalog check + persist available-update state for every resource.
const installed = await InstalledResource.all() // ZIM/map catalog path only — `dataset` resources are excluded here because
// their freshness key (openFDA `export_date`) and apply path (the drug
// download/ingest chain) don't fit the catalog-version model. They refresh
// via `attemptDrugDataset()`, which the same content-update job runs under
// the same master switch + window.
const installed = await InstalledResource.query().whereNot('resource_type', 'dataset')
const latestByKey = await this.catalog.getLatestForResources( const latestByKey = await this.catalog.getLatestForResources(
installed.map((r) => ({ resource_id: r.resource_id, resource_type: r.resource_type })) // `dataset` rows are filtered out above, so the type narrows to ZIM/map.
installed.map((r) => ({
resource_id: r.resource_id,
resource_type: r.resource_type as 'zim' | 'map',
}))
) )
for (const resource of installed) { for (const resource of installed) {
const latest = latestByKey.get(`${resource.resource_type}:${resource.resource_id}`) ?? null const latest = latestByKey.get(`${resource.resource_type}:${resource.resource_id}`) ?? null
@ -321,7 +331,8 @@ export class ContentAutoUpdateService {
const result = await this.collectionUpdateService.applyUpdate( const result = await this.collectionUpdateService.applyUpdate(
{ {
resource_id: candidate.resource.resource_id, resource_id: candidate.resource.resource_id,
resource_type: candidate.resource.resource_type, // `dataset` rows are filtered out of `installed` above, so ZIM/map.
resource_type: candidate.resource.resource_type as 'zim' | 'map',
installed_version: candidate.resource.version, installed_version: candidate.resource.version,
latest_version: candidate.version, latest_version: candidate.version,
download_url: candidate.download_url, download_url: candidate.download_url,
@ -372,6 +383,39 @@ export class ContentAutoUpdateService {
} }
} }
/**
* Freshness pass for the FDA drug dataset (`resource_type` 'dataset'), run by
* the same hourly ContentAutoUpdateJob as the ZIM/map `attempt()` and gated on
* the SAME master switch + window. The dataset's apply path differs from the
* catalog loop (openFDA `export_date` + the drug download/ingest chain rather
* than an InstalledResource catalog-version row), so it runs as its own step
* instead of through `selectUnderCap` but sharing `contentAutoUpdate.*` means
* it never updates while content auto-update is off, and refreshes alongside
* ZIMs and maps when it is on. Isolated so a drug-side failure can't affect the
* catalog run.
*/
async attemptDrugDataset(): Promise<{ started: number; reason: string }> {
const config = await this.getConfig()
if (!config.enabled) {
return { started: 0, reason: 'Content auto-update is disabled' }
}
if (!isWithinWindow(config.windowStart, config.windowEnd, DateTime.now())) {
return {
started: 0,
reason: `Outside update window (${config.windowStart}-${config.windowEnd})`,
}
}
try {
const result = await new DrugReferenceService().attemptAutoUpdate()
return { started: result.started ? 1 : 0, reason: `drug dataset: ${result.reason}` }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.warn(`[ContentAutoUpdateService] Drug dataset freshness check failed: ${message}`)
return { started: 0, reason: `drug dataset check failed: ${message}` }
}
}
/** /**
* Evaluate what the next run *would* do, without hitting the network, * Evaluate what the next run *would* do, without hitting the network,
* persisting state, or dispatching anything. Operates on the available-update * persisting state, or dispatching anything. Operates on the available-update
@ -407,7 +451,9 @@ export class ContentAutoUpdateService {
const now = overrides.now ?? DateTime.now() const now = overrides.now ?? DateTime.now()
const withinWindow = isWithinWindow(config.windowStart, config.windowEnd, now) const withinWindow = isWithinWindow(config.windowStart, config.windowEnd, now)
const pending = await InstalledResource.query().whereNotNull('available_update_version') const pending = await InstalledResource.query()
.whereNotNull('available_update_version')
.whereNot('resource_type', 'dataset')
const eligible = pending.filter( const eligible = pending.filter(
(r) => this.resourceEligibility(r, config.cooloffHours, now).eligible (r) => this.resourceEligibility(r, config.cooloffHours, now).eligible
) )
@ -506,7 +552,9 @@ export class ContentAutoUpdateService {
const config = await this.getConfig() const config = await this.getConfig()
const now = DateTime.now() const now = DateTime.now()
const pending = await InstalledResource.query().whereNotNull('available_update_version') const pending = await InstalledResource.query()
.whereNotNull('available_update_version')
.whereNot('resource_type', 'dataset')
const resources: ContentAutoUpdateResourceStatus[] = pending.map((resource) => { const resources: ContentAutoUpdateResourceStatus[] = pending.map((resource) => {
const verdict = this.resourceEligibility(resource, config.cooloffHours, now) const verdict = this.resourceEligibility(resource, config.cooloffHours, now)
const size = resource.available_update_size_bytes ?? null const size = resource.available_update_size_bytes ?? null
@ -514,7 +562,8 @@ export class ContentAutoUpdateService {
config.maxBytesPerWindow > 0 && size !== null && size > config.maxBytesPerWindow config.maxBytesPerWindow > 0 && size !== null && size > config.maxBytesPerWindow
return { return {
resource_id: resource.resource_id, resource_id: resource.resource_id,
resource_type: resource.resource_type, // `dataset` rows are filtered out of `pending` above, so ZIM/map.
resource_type: resource.resource_type as 'zim' | 'map',
current_version: resource.version, current_version: resource.version,
available_update_version: resource.available_update_version, available_update_version: resource.available_update_version,
size_bytes: size, size_bytes: size,

View File

@ -0,0 +1,177 @@
import env from '#start/env'
import logger from '@adonisjs/core/services/logger'
import { join } from 'node:path'
import { DockerService } from '#services/docker_service'
import { ZimService } from '#services/zim_service'
import { CollectionManifestService } from '#services/collection_manifest_service'
import { RunDownloadJob } from '#jobs/run_download_job'
import { ZIM_STORAGE_PATH } from '../utils/fs.js'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import type { CreatorPacksSpec, CreatorPackWithStatus } from '../../types/collections.js'
// ZIMs report as octet-stream from the Worker (Content-Type is set explicitly to
// application/octet-stream in worker/src/index.js). Mirror the ZimService allowlist.
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
// Default entitlement Worker origin. Overridable via CREATOR_PACKS_WORKER_BASE so
// we can move to a branded packs.projectnomad.us domain without a code change.
const DEFAULT_WORKER_BASE = 'https://nomad-packs-worker.chris-556.workers.dev'
/**
* Outcome of an install request. Discriminated on `code` so the controller can
* map each case to an HTTP status without string-matching thrown messages.
* (See feedback: typed failure codes over ad-hoc Error parsing.)
*/
export type InstallPackResult =
| { code: 'dispatched'; filename: string }
| { code: 'already_installed' }
| { code: 'already_downloading' }
| { code: 'not_found' }
| { code: 'not_configured' }
export type UninstallPackResult =
| { code: 'uninstalled'; filename: string }
| { code: 'not_installed' }
/**
* Creator Packs install rail. Diverges from the curated-collections rail in
* exactly one way: instead of a static manifest URL, the ZIM is fetched from the
* entitlement Worker with a bearer `Authorization` header. From RunDownloadJob
* onward everything is the shared rail (resumable download InstalledResource
* Kiwix rebuildFromDisk). Video ZIMs opt out of KB embedding via skip_embedding.
*/
export class CreatorPackService {
private dockerService: DockerService
private manifestService: CollectionManifestService
constructor(dockerService?: DockerService, manifestService?: CollectionManifestService) {
this.dockerService = dockerService ?? new DockerService()
this.manifestService = manifestService ?? new CollectionManifestService()
}
private get workerBase(): string {
return (env.get('CREATOR_PACKS_WORKER_BASE') || DEFAULT_WORKER_BASE).replace(/\/+$/, '')
}
/**
* Whether this build can actually install packs i.e. the release-injected
* app key is present. Forks built from source have no key; the UI uses this to
* HIDE the Creator Packs surfaces entirely rather than show install buttons
* that would 503. The public catalog still lists, but nothing is installable.
*/
isConfigured(): boolean {
return !!env.get('CREATOR_PACKS_APP_KEY')
}
/** Stable, per-version download URL. Not signed — the gate is the auth header. */
packUrl(id: string, version: string): string {
return `${this.workerBase}/packs/${id}_${version}.zim`
}
async listPacksWithStatus(): Promise<CreatorPackWithStatus[]> {
return this.manifestService.getCreatorPacksWithStatus()
}
/**
* Install a pack by catalog id: ensure Kiwix is present (auto-install if not),
* then dispatch the gated ZIM download onto the shared rail.
*/
async installPack(packId: string): Promise<InstallPackResult> {
const appKey = env.get('CREATOR_PACKS_APP_KEY')
if (!appKey) {
logger.error('[CreatorPackService] CREATOR_PACKS_APP_KEY is not set; cannot install packs')
return { code: 'not_configured' }
}
const spec = await this.manifestService.getSpecWithFallback<CreatorPacksSpec>('creator_packs')
const pack = spec?.packs.find((p) => p.id === packId)
if (!pack) {
return { code: 'not_found' }
}
const { default: InstalledResource } = await import('#models/installed_resource')
const existing = await InstalledResource.query()
.where('resource_type', 'zim')
.where('resource_id', pack.resource_id)
.first()
if (existing && existing.version === pack.version) {
return { code: 'already_installed' }
}
const url = this.packUrl(pack.id, pack.version)
if (await RunDownloadJob.getActiveByUrl(url)) {
return { code: 'already_downloading' }
}
// Kiwix is a hard prerequisite — it's what serves the pack. Auto-install it if
// absent (its own preinstall bootstraps a mini-wiki ZIM so the container can
// start). Best-effort: if the install can't be kicked off we still download the
// pack so the bytes are on disk; Kiwix hot-reloads it once installed.
await this.ensureKiwixInstalled()
const filename = `${pack.id}_${pack.version}.zim`
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
await RunDownloadJob.dispatch({
url,
filepath,
timeout: 30000,
allowedMimeTypes: ZIM_MIME_TYPES,
filetype: 'zim',
title: pack.name,
totalBytes: pack.size_mb ? pack.size_mb * 1024 * 1024 : undefined,
requestHeaders: { Authorization: `Bearer ${appKey}` },
resourceMetadata: {
resource_id: pack.resource_id,
version: pack.version,
collection_ref: 'creator-packs',
skip_embedding: true,
},
})
logger.info(`[CreatorPackService] Dispatched download for pack ${pack.id} (${filename})`)
return { code: 'dispatched', filename }
}
/**
* Uninstall an installed pack: delete the ZIM (which via ZimService.delete also
* removes it from the Kiwix library and clears its InstalledResource row).
* Uses the INSTALLED version (not the catalog's) so we remove the file that's
* actually on disk even if a newer version has since been published.
*/
async uninstallPack(packId: string): Promise<UninstallPackResult> {
const { default: InstalledResource } = await import('#models/installed_resource')
const installed = await InstalledResource.query()
.where('resource_type', 'zim')
.where('resource_id', packId)
.first()
if (!installed) {
return { code: 'not_installed' }
}
const filename = `${packId}_${installed.version}.zim`
const zimService = new ZimService(this.dockerService)
await zimService.delete(filename)
logger.info(`[CreatorPackService] Uninstalled pack ${packId} (${filename})`)
return { code: 'uninstalled', filename }
}
private async ensureKiwixInstalled(): Promise<void> {
try {
const kiwixUrl = await this.dockerService.getServiceURL(SERVICE_NAMES.KIWIX)
if (kiwixUrl) return // already installed and resolvable
logger.info('[CreatorPackService] Kiwix not installed; auto-installing before pack download')
const result = await this.dockerService.createContainerPreflight(SERVICE_NAMES.KIWIX)
if (!result.success) {
// e.g. "already installing" — not fatal, the pack download proceeds regardless.
logger.warn(`[CreatorPackService] Kiwix preflight did not start: ${result.message}`)
}
} catch (error: any) {
logger.warn(
`[CreatorPackService] Kiwix auto-install failed (continuing with pack download): ${error?.message || error}`
)
}
}
}

View File

@ -4,6 +4,7 @@ import logger from '@adonisjs/core/services/logger'
import { inject } from '@adonisjs/core' import { inject } from '@adonisjs/core'
import transmit from '@adonisjs/transmit/services/main' import transmit from '@adonisjs/transmit/services/main'
import { doResumableDownloadWithRetry } from '../utils/downloads.js' import { doResumableDownloadWithRetry } from '../utils/downloads.js'
import { mapGfxToHsaOverride } from '../utils/amd_hsa_override.js'
import { join } from 'path' import { join } from 'path'
import os from 'node:os' import os from 'node:os'
import env from '#start/env' import env from '#start/env'
@ -21,6 +22,7 @@ import { SERVICE_NAMES } from '../../constants/service_names.js'
import { exec } from 'child_process' import { exec } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import { readFile, mkdir, copyFile, chown, chmod, access, writeFile } from 'node:fs/promises' import { readFile, mkdir, copyFile, chown, chmod, access, writeFile } from 'node:fs/promises'
import { randomBytes } from 'node:crypto'
import KVStore from '#models/kv_store' import KVStore from '#models/kv_store'
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js' import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js' import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
@ -512,6 +514,15 @@ export class DockerService {
* Falls back to NOMAD_STORAGE_PATH / the production default if the admin * Falls back to NOMAD_STORAGE_PATH / the production default if the admin
* container or its storage mount can't be inspected. * container or its storage mount can't be inspected.
*/ */
/**
* Public accessor for the resolved host path backing `/app/storage`. Used by the
* Debug Info bundle so support can see where content actually lives on the host
* (the #1050 class of "moved my data, admin doesn't see it" reports).
*/
async getHostStorageRoot(): Promise<string> {
return this._resolveHostStorageRoot()
}
private async _resolveHostStorageRoot(): Promise<string> { private async _resolveHostStorageRoot(): Promise<string> {
if (this._hostStorageRoot) return this._hostStorageRoot if (this._hostStorageRoot) return this._hostStorageRoot
const fallback = env.get('NOMAD_STORAGE_PATH', DockerService.DEFAULT_HOST_STORAGE_ROOT) const fallback = env.get('NOMAD_STORAGE_PATH', DockerService.DEFAULT_HOST_STORAGE_ROOT)
@ -807,9 +818,21 @@ export class DockerService {
if (hsaOverride) { if (hsaOverride) {
ollamaEnv.push(`HSA_OVERRIDE_GFX_VERSION=${hsaOverride}`) ollamaEnv.push(`HSA_OVERRIDE_GFX_VERSION=${hsaOverride}`)
} }
// Ollama's scheduler drops integrated GPUs unless this is set (issue #1056), so
// AMD APUs (780M/890M/8060S) fall back to CPU-only despite correct device
// passthrough. It's a no-op on discrete AMD cards, so it's safe to set whenever
// AMD acceleration is configured.
ollamaEnv.push('OLLAMA_IGPU_ENABLE=1')
} }
} }
const appEnv: string[] = []
if (service.service_name === SERVICE_NAMES.HOMEBOX) {
// Homebox >= 0.26 panics at boot without a >= 32-byte pepper (#1043). Generate once,
// persist, and reuse so updates/reinstalls don't invalidate issued API keys.
appEnv.push(`HBOX_AUTH_API_KEY_PEPPER=${await this._resolveHomeboxPepper()}`)
}
this._broadcast( this._broadcast(
service.service_name, service.service_name,
'creating', 'creating',
@ -827,7 +850,7 @@ export class DockerService {
HostConfig: gpuHostConfig, HostConfig: gpuHostConfig,
...(containerConfig?.WorkingDir && { WorkingDir: containerConfig.WorkingDir }), ...(containerConfig?.WorkingDir && { WorkingDir: containerConfig.WorkingDir }),
...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }), ...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }),
Env: [...(containerConfig?.Env ?? []), ...ollamaEnv], Env: [...(containerConfig?.Env ?? []), ...ollamaEnv, ...appEnv],
...(service.container_command ? { Cmd: service.container_command.split(' ') } : {}), ...(service.container_command ? { Cmd: service.container_command.split(' ') } : {}),
// Ensure container is attached to the Nomad docker network in production // Ensure container is attached to the Nomad docker network in production
...(process.env.NODE_ENV === 'production' && { ...(process.env.NODE_ENV === 'production' && {
@ -1455,16 +1478,39 @@ export class DockerService {
* gfx1030 (RX 6800/6700/etc.), gfx1100/1101/1102 (RX 7900/7800/7600) are on AMD's * gfx1030 (RX 6800/6700/etc.), gfx1100/1101/1102 (RX 7900/7800/7600) are on AMD's
* official ROCm allowlist forcing an override on these breaks GPU discovery. * official ROCm allowlist forcing an override on these breaks GPU discovery.
* gfx1035 / gfx1036 (RDNA 2 iGPUs like 680M) need 10.3.0 to coerce to gfx1030. * gfx1035 / gfx1036 (RDNA 2 iGPUs like 680M) need 10.3.0 to coerce to gfx1030.
* gfx1103 / gfx1150 / gfx1151 (RDNA 3/3.5 iGPUs like 780M / 890M / Strix Halo) need 11.0.0. * gfx1150 / gfx1151 (RDNA 3.5 iGPUs like 890M / Strix Halo) ARE on the bundled rocblas
* allowlist, so they need NO override. gfx1103 (Phoenix 780M/760M) is NOT it must be
* coerced to 11.0.0 (gfx1100 kernels) or ollama drops it to CPU. See ../utils/amd_hsa_override.ts.
* *
* Resolution order: * Resolution order:
* 1. KV `ai.amdHsaOverride` manual user override; accepts 'none' (disable) or a semver-style value. * 1. KV `ai.amdHsaOverride` manual user override; accepts 'none' (disable) or a semver-style value.
* 2. Marker file `/app/storage/.nomad-amd-gfx` written by install_nomad.sh. * 2. Marker file `/app/storage/.nomad-amd-gfx` written by install_nomad.sh.
* 3. Default: '11.0.0' preserves prior behavior so existing iGPU users don't regress on * 3. Default: none let ROCm discover the GPU natively. Users on hardware that still
* upgrade. Discrete-card users on existing installs can opt out via the KV. * needs coercion can force a value via the KV. A hardcoded default gets more wrong
* as ROCm adds native targets, so null is the safer forward-looking default.
* *
* Returns null when no override should be applied. * Returns null when no override should be applied.
*/ */
/**
* Resolve the Homebox API-key pepper, generating and persisting one on first use.
*
* Homebox >= 0.26 panics at boot unless HBOX_AUTH_API_KEY_PEPPER is set to a value of at
* least 32 bytes (#1043). The pepper must be STABLE across updates and reinstalls:
* rotating it invalidates every API key a user has issued from Homebox. So we generate it
* once and persist it in the KV store, then reuse it for the life of the install.
*/
private async _resolveHomeboxPepper(): Promise<string> {
const existing = await KVStore.getValue('apps.homebox.apiKeyPepper')
if (typeof existing === 'string' && existing.length >= 32) {
return existing
}
// 48 raw bytes -> 64 base64 chars, comfortably over Homebox's 32-byte floor.
const pepper = randomBytes(48).toString('base64')
await KVStore.setValue('apps.homebox.apiKeyPepper', pepper)
logger.info('[DockerService] Generated and persisted Homebox API key pepper')
return pepper
}
private async _resolveAmdHsaOverride(): Promise<string | null> { private async _resolveAmdHsaOverride(): Promise<string | null> {
const manualRaw = await KVStore.getValue('ai.amdHsaOverride') const manualRaw = await KVStore.getValue('ai.amdHsaOverride')
if (manualRaw !== null && manualRaw !== undefined && String(manualRaw).trim() !== '') { if (manualRaw !== null && manualRaw !== undefined && String(manualRaw).trim() !== '') {
@ -1490,24 +1536,18 @@ export class DockerService {
// install_nomad.sh. Fall through to the default. // install_nomad.sh. Fall through to the default.
} }
logger.info('[DockerService] No AMD gfx marker; defaulting HSA override to 11.0.0 for backward compatibility') logger.warn(
return '11.0.0' '[DockerService] AMD GPU configured but no gfx marker (/app/storage/.nomad-amd-gfx) and no ' +
'ai.amdHsaOverride KV; relying on native ROCm discovery. iGPUs not on the bundled rocblas ' +
'allowlist (e.g. 780M/gfx1103, 680M/gfx1035) will silently fall back to CPU. Set the ' +
'ai.amdHsaOverride KV (e.g. 11.0.0 for a 780M) and force-reinstall the AI service if so.'
)
return null
} }
private _mapGfxToHsaOverride(gfx: string): string | null { private _mapGfxToHsaOverride(gfx: string): string | null {
// Officially supported by ROCm — no override needed // Pure mapping lives in ../utils/amd_hsa_override.ts so it stays unit-testable.
if (gfx === 'gfx1030' || gfx === 'gfx1100' || gfx === 'gfx1101' || gfx === 'gfx1102') { return mapGfxToHsaOverride(gfx)
return null
}
// RDNA 2 variants + iGPUs (gfx1031..gfx1036, e.g. Rembrandt 680M)
if (/^gfx103[1-6]$/.test(gfx)) {
return '10.3.0'
}
// RDNA 3 / 3.5 mobile parts (Phoenix 780M = gfx1103, Strix 890M = gfx1150, Strix Halo = gfx1151)
if (gfx === 'gfx1103' || gfx === 'gfx1150' || gfx === 'gfx1151') {
return '11.0.0'
}
return '11.0.0'
} }
/** /**
@ -1688,10 +1728,25 @@ export class DockerService {
let finalEnv = baseEnv let finalEnv = baseEnv
if (updatedAmdGpuConfigured) { if (updatedAmdGpuConfigured) {
const hsaOverride = await this._resolveAmdHsaOverride() const hsaOverride = await this._resolveAmdHsaOverride()
finalEnv = baseEnv.filter((e: string) => !e.startsWith('HSA_OVERRIDE_GFX_VERSION=')) finalEnv = baseEnv.filter(
(e: string) =>
!e.startsWith('HSA_OVERRIDE_GFX_VERSION=') && !e.startsWith('OLLAMA_IGPU_ENABLE=')
)
if (hsaOverride) { if (hsaOverride) {
finalEnv.push(`HSA_OVERRIDE_GFX_VERSION=${hsaOverride}`) finalEnv.push(`HSA_OVERRIDE_GFX_VERSION=${hsaOverride}`)
} }
// Re-assert the iGPU flag so updates from a container provisioned before the
// issue #1056 fix (which wouldn't have it in the captured env) pick it up.
finalEnv.push('OLLAMA_IGPU_ENABLE=1')
}
// Heal a Homebox container that predates the #1043 fix: inject the persisted pepper on
// update if it's missing, so an update (not just a force-reinstall) fixes the crash loop.
if (
serviceName === SERVICE_NAMES.HOMEBOX &&
!finalEnv.some((e: string) => e.startsWith('HBOX_AUTH_API_KEY_PEPPER='))
) {
finalEnv = [...finalEnv, `HBOX_AUTH_API_KEY_PEPPER=${await this._resolveHomeboxPepper()}`]
} }
const newContainerConfig: any = { const newContainerConfig: any = {
@ -2141,6 +2196,17 @@ export class DockerService {
await this.pullImage(service.container_image) await this.pullImage(service.container_image)
} }
// Runtime-injected secrets (e.g. Homebox's API-key pepper, #1043) live in the KV store, not
// in container_config. This path rebuilds Env from container_config alone, so re-inject them
// here — otherwise an Edit or in-place recreate drops the pepper and Homebox crash-loops again.
let recreateEnv: string[] = containerConfig?.Env ?? []
if (
serviceName === SERVICE_NAMES.HOMEBOX &&
!recreateEnv.some((e: string) => e.startsWith('HBOX_AUTH_API_KEY_PEPPER='))
) {
recreateEnv = [...recreateEnv, `HBOX_AUTH_API_KEY_PEPPER=${await this._resolveHomeboxPepper()}`]
}
const newContainer = await this.docker.createContainer({ const newContainer = await this.docker.createContainer({
Image: service.container_image, Image: service.container_image,
name: serviceName, name: serviceName,
@ -2152,7 +2218,7 @@ export class DockerService {
...(containerConfig?.User && { User: containerConfig.User }), ...(containerConfig?.User && { User: containerConfig.User }),
HostConfig: containerConfig?.HostConfig ?? {}, HostConfig: containerConfig?.HostConfig ?? {},
...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }), ...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }),
...(containerConfig?.Env && { Env: containerConfig.Env }), ...(recreateEnv.length ? { Env: recreateEnv } : {}),
...(service.container_command ? { Cmd: service.container_command.split(' ') } : {}), ...(service.container_command ? { Cmd: service.container_command.split(' ') } : {}),
...(process.env.NODE_ENV === 'production' && { ...(process.env.NODE_ENV === 'production' && {
NetworkingConfig: { EndpointsConfig: { [DockerService.NOMAD_NETWORK]: {} } }, NetworkingConfig: { EndpointsConfig: { [DockerService.NOMAD_NETWORK]: {} } },

View File

@ -13,11 +13,12 @@ export class DocsService {
'getting-started': 2, 'getting-started': 2,
'use-cases': 3, 'use-cases': 3,
'supply-depot-apps': 4, 'supply-depot-apps': 4,
'community-add-ons': 5, 'drug-reference': 5,
'updates': 6, 'community-add-ons': 6,
'faq': 7, 'updates': 7,
'about': 8, 'faq': 8,
'release-notes': 9, 'about': 9,
'release-notes': 10,
} }
async getDocs() { async getDocs() {

View File

@ -4,7 +4,8 @@ import { RunDownloadJob } from '#jobs/run_download_job'
import { RunExtractPmtilesJob } from '#jobs/run_extract_pmtiles_job' import { RunExtractPmtilesJob } from '#jobs/run_extract_pmtiles_job'
import type { RunExtractPmtilesJobParams } from '#jobs/run_extract_pmtiles_job' import type { RunExtractPmtilesJobParams } from '#jobs/run_extract_pmtiles_job'
import { DownloadModelJob } from '#jobs/download_model_job' import { DownloadModelJob } from '#jobs/download_model_job'
import { DownloadJobWithProgress, DownloadProgressData } from '../../types/downloads.js' import { DownloadDrugDataJob } from '#jobs/download_drug_data_job'
import { DownloadJobWithProgress, DownloadProgressData, RunDownloadJobParams } from '../../types/downloads.js'
import type { Job, Queue } from 'bullmq' import type { Job, Queue } from 'bullmq'
import { normalize } from 'path' import { normalize } from 'path'
import { deleteFileIfExists } from '../utils/fs.js' import { deleteFileIfExists } from '../utils/fs.js'
@ -46,15 +47,21 @@ export class DownloadService {
...active.map((j) => ({ job: j, state: 'active' as const })), ...active.map((j) => ({ job: j, state: 'active' as const })),
...delayed.map((j) => ({ job: j, state: 'delayed' as const })), ...delayed.map((j) => ({ job: j, state: 'delayed' as const })),
...failed.map((j) => ({ job: j, state: 'failed' as const })), ...failed.map((j) => ({ job: j, state: 'failed' as const })),
] // A job id can outlive its payload hash — BullMQ still returns an entry for
// it, with `data` empty. One of those in the failed set used to throw on
// every poll of this endpoint (normalize(undefined)), and because failed
// jobs are never evicted the endpoint stayed broken until Redis was cleared
// by hand. Drop them: with no payload there is nothing to show anyway.
].filter(({ job }) => job?.id != null && job.data != null)
} }
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> { async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
const modelQueue = this.queueService.getQueue(DownloadModelJob.queue) const modelQueue = this.queueService.getQueue(DownloadModelJob.queue)
const [fileTagged, extractTagged, modelJobs] = await Promise.all([ const [fileTagged, extractTagged, modelJobs, drugTagged] = await Promise.all([
this.fetchJobsWithStates(RunDownloadJob.queue), this.fetchJobsWithStates(RunDownloadJob.queue),
this.fetchJobsWithStates(RunExtractPmtilesJob.queue), this.fetchJobsWithStates(RunExtractPmtilesJob.queue),
modelQueue.getJobs(['waiting', 'active', 'delayed', 'failed']), modelQueue.getJobs(['waiting', 'active', 'delayed', 'failed']),
this.fetchJobsWithStates(DownloadDrugDataJob.queue),
]) ])
const fileDownloads = fileTagged.map(({ job, state }) => { const fileDownloads = fileTagged.map(({ job, state }) => {
@ -63,7 +70,7 @@ export class DownloadService {
jobId: job.id!.toString(), jobId: job.id!.toString(),
url: job.data.url, url: job.data.url,
progress: parsed.percent, progress: parsed.percent,
filepath: normalize(job.data.filepath), filepath: job.data.filepath ? normalize(job.data.filepath) : '',
filetype: job.data.filetype, filetype: job.data.filetype,
title: job.data.title || undefined, title: job.data.title || undefined,
downloadedBytes: parsed.downloadedBytes, downloadedBytes: parsed.downloadedBytes,
@ -80,7 +87,7 @@ export class DownloadService {
jobId: job.id!.toString(), jobId: job.id!.toString(),
url: job.data.sourceUrl, url: job.data.sourceUrl,
progress: parsed.percent, progress: parsed.percent,
filepath: normalize(job.data.outputFilepath), filepath: job.data.outputFilepath ? normalize(job.data.outputFilepath) : '',
filetype: job.data.filetype || 'map', filetype: job.data.filetype || 'map',
title: job.data.title || undefined, title: job.data.title || undefined,
downloadedBytes: parsed.downloadedBytes, downloadedBytes: parsed.downloadedBytes,
@ -101,7 +108,40 @@ export class DownloadService {
failedReason: job.failedReason || undefined, failedReason: job.failedReason || undefined,
})) }))
const allDownloads = [...fileDownloads, ...extractDownloads, ...modelDownloads] // FDA drug dataset — DOWNLOAD phase only, collapsed to ONE card. The job fans
// the manifest's N partitions into continuations under AUTO-GENERATED jobIds
// (only part 0 runs under the deterministic jobId), and the queue is
// concurrency 1, so at most one part is ever in flight. Filtering to the
// deterministic jobId would track only part 0 and then drop the card while
// parts 2..N keep downloading. Instead represent the whole download with the
// single in-flight job's aggregate progress (the progress emit already spans
// all parts), and always report the deterministic jobId so the cancel/remove
// button routes to _cancelDrugDownloadJob whichever part is active. The heavy
// ingest is EXCLUDED here — it stays on the IngestStatus surface.
const drugInFlight =
drugTagged.find(({ state }) => state === 'active') ??
drugTagged.find(({ state }) => state === 'waiting' || state === 'delayed') ??
drugTagged.find(({ state }) => state === 'failed')
const drugDownloads = drugInFlight
? [drugInFlight].map(({ job, state }) => {
const parsed = this.parseProgress(job.progress)
return {
jobId: DownloadDrugDataJob.jobId,
url: 'https://api.fda.gov/download.json',
progress: parsed.percent,
filepath: job.data.currentPartName || 'FDA Drug Reference',
filetype: 'drug-data',
title: 'FDA Drug Reference',
downloadedBytes: parsed.downloadedBytes,
totalBytes: parsed.totalBytes,
lastProgressTime: parsed.lastProgressTime,
status: state,
failedReason: job.failedReason || undefined,
}
})
: []
const allDownloads = [...fileDownloads, ...extractDownloads, ...modelDownloads, ...drugDownloads]
const filtered = allDownloads.filter((job) => !filetype || job.filetype === filetype) const filtered = allDownloads.filter((job) => !filetype || job.filetype === filetype)
return filtered.sort((a, b) => { return filtered.sort((a, b) => {
@ -116,6 +156,7 @@ export class DownloadService {
RunDownloadJob.queue, RunDownloadJob.queue,
RunExtractPmtilesJob.queue, RunExtractPmtilesJob.queue,
DownloadModelJob.queue, DownloadModelJob.queue,
DownloadDrugDataJob.queue,
]) { ]) {
const queue = this.queueService.getQueue(queueName) const queue = this.queueService.getQueue(queueName)
const job = await queue.getJob(jobId) const job = await queue.getJob(jobId)
@ -137,6 +178,40 @@ export class DownloadService {
} }
} }
async retryFailedJob(jobId: string): Promise<{ success: boolean; message: string }> {
// Search both the file download queue and the model download queue
for (const queueName of [RunDownloadJob.queue, DownloadModelJob.queue]) {
const queue = this.queueService.getQueue(queueName)
const job = await queue.getJob(jobId)
if (job) {
// For Ollama model downloads, re-dispatch with the model name
if (queueName === DownloadModelJob.queue) {
const modelName = job.data.modelName
if (!modelName) {
return { success: false, message: 'Cannot retry: model name not found in job data' }
}
await DownloadModelJob.dispatch({ modelName })
await job.remove().catch(() => {})
return { success: true, message: `Retrying download for model ${modelName}` }
}
// For file downloads (zim, map, etc.), re-dispatch with original params
const params = job.data as RunDownloadJobParams
if (!params.url || !params.filepath) {
return { success: false, message: 'Cannot retry: missing URL or filepath in job data' }
}
// Remove the old failed job, then dispatch a fresh one
await job.remove().catch(() => {})
await RunDownloadJob.dispatch(params)
return { success: true, message: `Retrying download for ${params.url}` }
}
}
return { success: false, message: 'Failed job not found. It may have already been dismissed.' }
}
async cancelJob(jobId: string): Promise<{ success: boolean; message: string }> { async cancelJob(jobId: string): Promise<{ success: boolean; message: string }> {
const queue = this.queueService.getQueue(RunDownloadJob.queue) const queue = this.queueService.getQueue(RunDownloadJob.queue)
const job = await queue.getJob(jobId) const job = await queue.getJob(jobId)
@ -159,9 +234,42 @@ export class DownloadService {
return await this._cancelModelDownloadJob(jobId, modelJob, modelQueue) return await this._cancelModelDownloadJob(jobId, modelJob, modelQueue)
} }
// FDA drug dataset: cancel is matched on the deterministic jobId only (the
// single card the aggregator shows). The continuation parts run under
// auto-generated ids, so cancelling must stop the whole CHAIN, not just the
// current part — otherwise the next continuation fires after we remove one.
if (jobId === DownloadDrugDataJob.jobId) {
return await this._cancelDrugDownloadJob()
}
return { success: true, message: 'Job not found (may have already completed)' } return { success: true, message: 'Job not found (may have already completed)' }
} }
/**
* Cancel the FDA drug-data download.
*
* The drug job has no Redis cancel-signal / AbortController (unlike
* RunDownloadJob), and it self-continues into the next part under a fresh jobId.
* So a v1 cancel that only removed the current job would leave the next
* continuation to fire. Instead we obliterate the single-purpose drug-download
* queue (force = removes the active/locked job too), which drops the current
* part AND every queued continuation in one shot. Scoped to the drug-download
* queue only the ingest queue and everything else are untouched. The on-disk
* parts are intentionally LEFT in place: they're resumable, and a re-trigger
* picks up from the .tmp rather than re-downloading. (Tracked as the v1 choice
* for cancel depth full cross-process signal cancel is a follow-up.)
*/
private async _cancelDrugDownloadJob(): Promise<{ success: boolean; message: string }> {
const queue = this.queueService.getQueue(DownloadDrugDataJob.queue)
try {
await queue.obliterate({ force: true })
return { success: true, message: 'Drug data download cancelled' }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return { success: false, message: `Could not cancel drug data download: ${msg}` }
}
}
private async _cancelExtractJob( private async _cancelExtractJob(
jobId: string, jobId: string,
job: Job<RunExtractPmtilesJobParams>, job: Job<RunExtractPmtilesJobParams>,

View File

@ -0,0 +1,830 @@
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import { rm } from 'node:fs/promises'
import type { Job } from 'bullmq'
import { QueueService } from './queue_service.js'
import { DownloadDrugDataJob, STORAGE_BASE } from '#jobs/download_drug_data_job'
import { IngestDrugDataJob } from '#jobs/ingest_drug_data_job'
/**
* Manifest resource id for the FDA drug dataset the `installed_resources`
* row's `resource_id`, matching the `medicine-standard` tier manifest entry.
* Single source of truth so the install write-back, the tier-status math, and
* uninstall all agree on the same id.
*/
export const DRUG_DATASET_RESOURCE_ID = 'openfda-drug-labels'
import {
normalizeDrugName,
parseDownloadState,
deriveIngestPhase,
resolveExpectedTotal,
resolveIngestRecordsShown,
summarizeJobError,
isExportDateNewer,
} from '../../util/drug_labels.js'
import KVStore from '#models/kv_store'
import { parseCompareIds, MAX_COMPARE } from '../../util/compare_ids.js'
import type {
DrugSearchResult,
DrugLabelDetail,
DrugIngestStatus,
DrugPhaseState,
DrugDownloadStatus,
DrugIngestPhaseStatus,
DrugInteractionEntry,
} from '../../types/drug_reference.js'
/**
* Drug Reference v1 service layer.
*
* Exposes search (collapsed by brand+generic), detail fetch, ingest trigger,
* and ingest status. Mirrors the shape of DownloadService/ZimService.
*/
export class DrugReferenceService {
/**
* Search for drug labels, collapsed by (brand_name, generic_name).
*
* Each distinct (brand_name, generic_name) pair returns ONE result a
* representative row id (MIN(id)) and a labelCount of how many set_ids
* collapsed into it. This is the locked UX decision.
*
* Scope:
* 'name' (default) existing path: MATCH(searchable_name) on brand+generic.
* 'indication' new path: MATCH(searchable_name, indications) on the combined
* ft_drug_labels_name_indications index so users can search by
* what a drug treats ("heartburn", "high blood pressure").
*
* Strategy (both scopes):
* 1. FULLTEXT path: MATCH(cols) AGAINST(? IN NATURAL LANGUAGE MODE)
* relevance-ranked, requires >= 3 chars (innodb_ft_min_token_size = 3).
* 2. LIKE fallback: query < 3 chars OR FULLTEXT throws LIKE '%term%'.
* 3. Both paths apply the optional product_type filter and GROUP BY collapse.
*/
async search(
query: string,
options: {
productType?: string
route?: string
sort?: 'relevance' | 'name'
limit?: number
offset?: number
scope?: 'name' | 'indication'
}
): Promise<DrugSearchResult[]> {
const limit = options.limit ?? 50
const offset = options.offset ?? 0
const scope = options.scope ?? 'name'
const normalized = normalizeDrugName(query, null) ?? query.trim()
if (!normalized || normalized.length === 0) return []
const useLike = normalized.length < 3
if (scope === 'indication') {
if (!useLike) {
try {
return await this.searchIndicationFulltext(normalized, options.productType, limit, offset)
} catch (err) {
logger.warn(
`[DrugReferenceService] FULLTEXT indication search failed, falling back to LIKE: ${
err instanceof Error ? err.message : String(err)
}`
)
}
}
return await this.searchIndicationLike(normalized, options.productType, limit, offset)
}
if (!useLike) {
// FULLTEXT path
try {
return await this.searchFulltext(normalized, options, limit, offset)
} catch (err) {
logger.warn(
`[DrugReferenceService] FULLTEXT search failed, falling back to LIKE: ${
err instanceof Error ? err.message : String(err)
}`
)
}
}
// LIKE fallback
return await this.searchLike(normalized, options, limit, offset)
}
private async searchFulltext(
normalized: string,
opts: { productType?: string; route?: string; sort?: 'relevance' | 'name' },
limit: number,
offset: number
): Promise<DrugSearchResult[]> {
let sql = `
SELECT
MIN(id) AS id,
brand_name,
generic_name,
MIN(manufacturer) AS manufacturer,
MIN(route) AS route,
MIN(product_type) AS product_type,
COUNT(*) AS labelCount,
MAX(MATCH(searchable_name) AGAINST(? IN NATURAL LANGUAGE MODE)) AS relevance
FROM drug_labels
WHERE MATCH(searchable_name) AGAINST(? IN NATURAL LANGUAGE MODE)
`
const bindings: unknown[] = [normalized, normalized]
if (opts.productType) {
sql += ' AND product_type = ?'
bindings.push(opts.productType)
}
if (opts.route) {
// `route` is a comma-joined uppercase list (e.g. "ORAL, TOPICAL").
sql += ' AND route LIKE ?'
bindings.push(`%${opts.route.toUpperCase()}%`)
}
sql += `
GROUP BY brand_name, generic_name
ORDER BY ${opts.sort === 'name' ? 'COALESCE(brand_name, generic_name) ASC' : 'relevance DESC'}
LIMIT ? OFFSET ?
`
bindings.push(limit, offset)
const rows = await db.rawQuery(sql, bindings)
return this.mapSearchRows(rows[0])
}
private async searchLike(
normalized: string,
opts: { productType?: string; route?: string; sort?: 'relevance' | 'name' },
limit: number,
offset: number
): Promise<DrugSearchResult[]> {
const term = `%${normalized}%`
let sql = `
SELECT
MIN(id) AS id,
brand_name,
generic_name,
MIN(manufacturer) AS manufacturer,
MIN(route) AS route,
MIN(product_type) AS product_type,
COUNT(*) AS labelCount
FROM drug_labels
WHERE (searchable_name LIKE ? OR brand_name LIKE ?)
`
const bindings: unknown[] = [term, term]
if (opts.productType) {
sql += ' AND product_type = ?'
bindings.push(opts.productType)
}
if (opts.route) {
sql += ' AND route LIKE ?'
bindings.push(`%${opts.route.toUpperCase()}%`)
}
sql += `
GROUP BY brand_name, generic_name
ORDER BY brand_name ASC
LIMIT ? OFFSET ?
`
bindings.push(limit, offset)
const rows = await db.rawQuery(sql, bindings)
return this.mapSearchRows(rows[0])
}
/**
* FULLTEXT indication-scope search.
*
* MATCHes over (searchable_name, indications) must exactly match the
* ft_drug_labels_name_indications index column list. The MAX(MATCH ) pattern
* is LOAD-BEARING: MySQL 8.0 ONLY_FULL_GROUP_BY rejects a bare MATCH() in
* SELECT when GROUP BY is in effect; wrapping in MAX() makes it an aggregate
* and satisfies the mode constraint.
*/
private async searchIndicationFulltext(
normalized: string,
productType: string | undefined,
limit: number,
offset: number
): Promise<DrugSearchResult[]> {
let sql = `
SELECT
MIN(id) AS id,
brand_name,
generic_name,
MIN(manufacturer) AS manufacturer,
MIN(route) AS route,
MIN(product_type) AS product_type,
COUNT(*) AS labelCount,
MAX(MATCH(searchable_name, indications) AGAINST(? IN NATURAL LANGUAGE MODE)) AS relevance
FROM drug_labels
WHERE MATCH(searchable_name, indications) AGAINST(? IN NATURAL LANGUAGE MODE)
`
const bindings: unknown[] = [normalized, normalized]
if (productType) {
sql += ' AND product_type = ?'
bindings.push(productType)
}
sql += `
GROUP BY brand_name, generic_name
ORDER BY relevance DESC
LIMIT ? OFFSET ?
`
bindings.push(limit, offset)
const rows = await db.rawQuery(sql, bindings)
return this.mapSearchRows(rows[0])
}
/**
* LIKE indication-scope fallback (query < 3 chars or FULLTEXT unavailable).
*
* Searches searchable_name OR indications so short queries still return
* useful results without requiring the FULLTEXT index.
*/
private async searchIndicationLike(
normalized: string,
productType: string | undefined,
limit: number,
offset: number
): Promise<DrugSearchResult[]> {
const term = `%${normalized}%`
let sql = `
SELECT
MIN(id) AS id,
brand_name,
generic_name,
MIN(manufacturer) AS manufacturer,
MIN(route) AS route,
MIN(product_type) AS product_type,
COUNT(*) AS labelCount
FROM drug_labels
WHERE (searchable_name LIKE ? OR indications LIKE ?)
`
const bindings: unknown[] = [term, term]
if (productType) {
sql += ' AND product_type = ?'
bindings.push(productType)
}
sql += `
GROUP BY brand_name, generic_name
ORDER BY brand_name ASC
LIMIT ? OFFSET ?
`
bindings.push(limit, offset)
const rows = await db.rawQuery(sql, bindings)
return this.mapSearchRows(rows[0])
}
private mapSearchRows(rows: any[]): DrugSearchResult[] {
if (!Array.isArray(rows)) return []
return rows.map((row) => ({
id: Number(row.id),
brand_name: row.brand_name ?? null,
generic_name: row.generic_name ?? null,
manufacturer: row.manufacturer ?? null,
route: row.route ?? null,
product_type: row.product_type ?? null,
labelCount: Number(row.labelCount ?? row.labelcount ?? 1),
}))
}
/**
* Load the full detail for a single drug label row by its surrogate id.
* Returns null if the row doesn't exist.
*/
async find(id: number): Promise<DrugLabelDetail | null> {
const { default: DrugLabel } = await import('#models/drug_label')
const row = await DrugLabel.find(id)
if (!row) return null
return {
id: row.id,
set_id: row.set_id,
spl_id: row.spl_id,
version: row.version,
brand_name: row.brand_name,
generic_name: row.generic_name,
manufacturer: row.manufacturer,
product_ndc: row.product_ndc,
route: row.route,
product_type: row.product_type,
indications: row.indications,
dosage: row.dosage,
warnings: row.warnings,
boxed_warning: row.boxed_warning,
drug_interactions: row.drug_interactions,
contraindications: row.contraindications,
when_using: row.when_using,
stop_use: row.stop_use,
source_updated_at: row.source_updated_at,
ingested_at: row.ingested_at.toISO() ?? '',
}
}
/**
* Return the drug interaction text for a set of label ids.
*
* - Dedupes and caps the id list via parseCompareIds / MAX_COMPARE.
* - One query: SELECT id, brand_name, generic_name, product_type,
* drug_interactions FROM drug_labels WHERE id IN (?).
* - Re-orders the rows to match the requested id order so the caller's
* column positions are stable even if MySQL returns rows in a different
* order. Missing ids (non-existent in the table) are silently omitted.
* - Returns [] for an empty or entirely-invalid id list.
*/
async getInteractionsFor(ids: number[]): Promise<DrugInteractionEntry[]> {
if (ids.length === 0) return []
// Dedupe + cap (caller may already have done this, but be defensive).
const safeIds = parseCompareIds(ids.join(',')).slice(0, MAX_COMPARE)
if (safeIds.length === 0) return []
const placeholders = safeIds.map(() => '?').join(', ')
const sql = `
SELECT id, brand_name, generic_name, product_type, drug_interactions
FROM drug_labels
WHERE id IN (${placeholders})
`
const rows = await db.rawQuery(sql, safeIds)
const rawRows: Array<{
id: number | string
brand_name: string | null
generic_name: string | null
product_type: string | null
drug_interactions: string | null
}> = Array.isArray(rows[0]) ? rows[0] : []
// Build a lookup by id so we can re-order to match the request order.
const byId = new Map<number, DrugInteractionEntry>()
for (const row of rawRows) {
const entry: DrugInteractionEntry = {
id: Number(row.id),
brand_name: row.brand_name ?? null,
generic_name: row.generic_name ?? null,
product_type: row.product_type ?? null,
drug_interactions: row.drug_interactions ?? null,
}
byId.set(entry.id, entry)
}
// Re-order: iterate safeIds, include only ids that exist in the table.
const ordered: DrugInteractionEntry[] = []
for (const id of safeIds) {
const entry = byId.get(id)
if (entry) ordered.push(entry)
}
return ordered
}
/**
* Get current row count what's searchable right now.
*/
async rowCount(): Promise<number> {
try {
const result = await db.rawQuery('SELECT COUNT(*) AS cnt FROM drug_labels')
const rows = result[0] as Array<{ cnt: number | string }>
return Number(rows[0]?.cnt ?? 0)
} catch {
return 0
}
}
/**
* Dispatch the download phase (idempotent deduped by deterministic jobId).
* Auto-chains the ingest phase on completion. Returns "already running" if the
* download job is active/waiting.
*/
async triggerDownload() {
return DownloadDrugDataJob.dispatch(true)
}
/**
* Freshness check for the content-auto-update path: compare the live openFDA
* manifest `export_date` against the last-ingested one (KV
* `drugReference.lastUpdatedExportDate`). Reuses the job's single manifest
* fetch (Maxim 4 one openFDA call site) and the pure `isExportDateNewer`
* compare (defensive about the unconfirmed date format; see its TODO).
*
* Returns `{ updateAvailable, latestExportDate, currentExportDate }`. Never
* triggers a download itself `attemptAutoUpdate` decides whether to, after
* its own gating (only when installed, no active job).
*/
async checkForUpdate(): Promise<{
updateAvailable: boolean
latestExportDate: string | null
currentExportDate: string | null
}> {
const current = await KVStore.getValue('drugReference.lastUpdatedExportDate')
const manifest = await DownloadDrugDataJob.fetchManifest()
const latest = manifest.export_date ?? null
return {
updateAvailable: isExportDateNewer(latest ?? '', current),
latestExportDate: latest,
currentExportDate: current,
}
}
/**
* Freshness pass for the drug dataset, invoked from
* `ContentAutoUpdateService.attemptDrugDataset()` inside the hourly content
* loop so the dataset refreshes alongside ZIMs and maps under the shared
* `contentAutoUpdate.*` master switch and window, not on a rogue schedule.
*
* Gating (unchanged from the prior standalone job): only acts when the dataset
* is installed, and never while a drug download or ingest is already in flight,
* so it can't stack a refresh on a running install. A failed manifest fetch is
* a transient miss (offline), reported rather than thrown.
*/
async attemptAutoUpdate(): Promise<{
started: boolean
reason: string
latestExportDate?: string | null
}> {
// Only act when the dataset is installed.
const rowCount = await this.rowCount()
if (rowCount === 0) {
return { started: false, reason: 'not-installed' }
}
// Don't stack on top of an in-flight download/ingest.
const [dlJob, ingJob] = await Promise.all([
DownloadDrugDataJob.getJob(),
IngestDrugDataJob.getJob(),
])
const activeStates = ['active', 'waiting', 'delayed']
const dlState = dlJob ? await dlJob.getState() : undefined
const ingState = ingJob ? await ingJob.getState() : undefined
if (
(dlState && activeStates.includes(dlState)) ||
(ingState && activeStates.includes(ingState))
) {
return { started: false, reason: 'job-in-flight' }
}
let check: Awaited<ReturnType<DrugReferenceService['checkForUpdate']>>
try {
check = await this.checkForUpdate()
} catch (err) {
// Offline or manifest fetch failed — transient, retried next run.
logger.warn(
`[DrugReferenceService] Freshness check failed (will retry next run): ${
err instanceof Error ? err.message : String(err)
}`
)
return { started: false, reason: 'check-failed' }
}
if (!check.updateAvailable) {
return {
started: false,
reason: `up-to-date (current=${check.currentExportDate ?? 'none'}, latest=${
check.latestExportDate ?? 'unknown'
})`,
latestExportDate: check.latestExportDate,
}
}
logger.info(
`[DrugReferenceService] Newer export_date available ` +
`(current=${check.currentExportDate ?? 'none'} → latest=${check.latestExportDate}); ` +
'triggering re-download.'
)
const result = await this.triggerDownload()
return {
started: result.created,
reason: result.created ? 'update-dispatched' : result.message,
latestExportDate: check.latestExportDate,
}
}
/**
* Dispatch the ingest phase from the already-downloaded on-disk parts (the
* manual "Ingest into search" path). Guards on the KV download-state marker so
* it fails fast with a typed result when nothing has been downloaded, rather
* than dispatching a job that would immediately fail in the worker.
*/
async triggerIngestFromDisk() {
const marker = parseDownloadState(await KVStore.getValue('drugReference.downloadState'))
if (!marker) {
return {
job: undefined,
created: false,
message: 'Nothing downloaded — run Download FDA data first.',
nothingDownloaded: true,
}
}
const result = await IngestDrugDataJob.dispatch()
return { ...result, nothingDownloaded: false }
}
/**
* Force-clear a wedged ingest and restart it from the on-disk parts.
*
* A worker killed mid-ingest (e.g. during a `nomad upgrade`) leaves its job
* 'active' holding a lock BullMQ won't reclaim until lockDuration elapses, so
* the normal dispatch refuses to start a new ingest and the UI button stays
* disabled ("Indexing…") with no way out. Obliterating the single-purpose
* drug-ingest queue removes the stuck job (force = even active/locked); we then
* re-dispatch from disk. The downloaded parts and the download-state marker are
* left untouched, so this restarts ingest WITHOUT re-downloading.
*/
async resetAndReingest() {
const marker = parseDownloadState(await KVStore.getValue('drugReference.downloadState'))
if (!marker) {
return {
job: undefined,
created: false,
message: 'Nothing downloaded — run Download FDA data first.',
nothingDownloaded: true,
}
}
const queue = QueueService.getInstance().getQueue(IngestDrugDataJob.queue)
try {
// force: true removes the active/locked stuck job too. Scoped to the
// single-purpose ingest queue, so nothing else is affected.
await queue.obliterate({ force: true })
logger.info('[DrugReferenceService] drug-ingest queue obliterated for restart')
} catch (err) {
logger.warn(
`[DrugReferenceService] ingest queue obliterate failed (continuing to dispatch): ${
err instanceof Error ? err.message : String(err)
}`
)
}
const result = await IngestDrugDataJob.dispatch()
return { ...result, nothingDownloaded: false }
}
/**
* Uninstall the offline FDA drug dataset the curated-tier "remove" path.
*
* Mirrors how ZimService.delete() reverses a ZIM install: stop anything that
* could re-create the data, delete the on-disk artifacts, drop the data, and
* remove the install-state row so the tier reads not-installed and the home
* tiles auto-hide (the same cascade as a ZIM delete).
*
* Order matters:
* 1. Obliterate BOTH drug queues (force = removes active/locked jobs too) so a
* running download/ingest can't write rows back in after we clear them.
* Scoped to the two single-purpose drug queues nothing else is touched.
* 2. Delete the on-disk parts dir (STORAGE_BASE is a FIXED constant, never a
* client value no path-traversal surface). Usually empty after a full
* ingest; non-empty only when uninstalling a downloaded-not-yet-ingested
* state.
* 3. TRUNCATE drug_labels (not DROP) preserves the schema + FULLTEXT
* indexes so a later reinstall re-ingests without a migration.
* 4. Clear the KV markers (download-state + last-updated export_date).
* 5. Delete the `installed_resources` 'dataset' row.
*
* Every step is best-effort and logged: a partial failure still removes as much
* as it can and reports what it did, rather than leaving a half-uninstalled
* state with no signal.
*/
async uninstall(): Promise<{
success: boolean
rowsDropped: number
message: string
}> {
const rowsBefore = await this.rowCount()
const errors: string[] = []
// 1. Stop in-flight jobs on both drug queues (force removes locked/active).
for (const queueName of [DownloadDrugDataJob.queue, IngestDrugDataJob.queue]) {
try {
await QueueService.getInstance().getQueue(queueName).obliterate({ force: true })
logger.info(`[DrugReferenceService] obliterated ${queueName} for uninstall`)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.warn(`[DrugReferenceService] obliterate ${queueName} failed: ${msg}`)
errors.push(`queue ${queueName}: ${msg}`)
}
}
// 2. Delete the on-disk parts directory. STORAGE_BASE is a fixed module const
// (never a client filename), so this is not a path-traversal surface.
try {
await rm(STORAGE_BASE, { recursive: true, force: true })
logger.info(`[DrugReferenceService] removed on-disk parts dir ${STORAGE_BASE}`)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.warn(`[DrugReferenceService] could not remove ${STORAGE_BASE}: ${msg}`)
errors.push(`storage: ${msg}`)
}
// 3. Drop the searchable data. TRUNCATE keeps schema + the FULLTEXT indexes
// so reinstall re-ingests cleanly with no migration.
try {
await db.rawQuery('TRUNCATE TABLE drug_labels')
logger.info('[DrugReferenceService] truncated drug_labels')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.error(`[DrugReferenceService] TRUNCATE drug_labels failed: ${msg}`)
errors.push(`truncate: ${msg}`)
}
// 4. Clear KV markers.
try {
await KVStore.clearValue('drugReference.downloadState')
await KVStore.clearValue('drugReference.lastUpdatedExportDate')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.warn(`[DrugReferenceService] clearing KV markers failed: ${msg}`)
errors.push(`kv: ${msg}`)
}
// 5. Remove the install-state row so the tier reads not-installed.
try {
const { default: InstalledResource } = await import('#models/installed_resource')
await InstalledResource.query()
.where('resource_type', 'dataset')
.where('resource_id', DRUG_DATASET_RESOURCE_ID)
.delete()
logger.info('[DrugReferenceService] deleted installed_resources dataset row')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
logger.warn(`[DrugReferenceService] deleting install row failed: ${msg}`)
errors.push(`install-row: ${msg}`)
}
const rowsDropped = rowsBefore - (await this.rowCount())
const success = errors.length === 0
return {
success,
rowsDropped,
message: success
? `Uninstalled FDA drug reference (${rowsDropped} labels removed).`
: `Uninstall completed with ${errors.length} issue(s): ${errors.join('; ')}`,
}
}
/**
* Resolve the canonical deterministic job for a phase's queue, falling back to
* the most-progressed auto-id continuation when the deterministic job is
* absent or completed (passes > 0 use auto-generated ids). Each phase has its
* own queue + jobId, so this is called once per queue with the matching ids.
*/
private async resolvePhaseJob(
queueName: string,
deterministicJobId: string
): Promise<Job | undefined> {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(queueName)
let job = await queue.getJob(deterministicJobId)
if (!job || (await job.getState()) === 'completed') {
const activeJobs = await queue.getJobs(['active', 'waiting', 'delayed'])
const continuation = activeJobs
.filter((j) => j.id !== deterministicJobId)
.sort((a, b) => (b.data?.partIndex ?? 0) - (a.data?.partIndex ?? 0))[0]
if (continuation) {
job = continuation
} else {
// No live continuation. If the chain FAILED partway, surface the failed
// job so the status reads 'failed' instead of falsely 'ready' with only
// part 1's count. BUT removeOnFail keeps history: a STALE failure from a
// prior run must not mask a newer successful one (seen live: a fixed
// re-run completed, yet the panel stayed 'failed' on last night's
// duplicate-key job). Compare finish times and only surface the failed
// job when it is the most recent terminal outcome.
const [failedJobs, completedJobs] = await Promise.all([
queue.getJobs(['failed']),
queue.getJobs(['completed']),
])
const newestFailed = failedJobs.sort((a, b) => (b.finishedOn ?? 0) - (a.finishedOn ?? 0))[0]
const newestCompletedFinish = completedJobs.reduce(
(max, j) => Math.max(max, j.finishedOn ?? 0),
0
)
if (newestFailed && (newestFailed.finishedOn ?? 0) > newestCompletedFinish) {
job = newestFailed
}
}
}
return job
}
/** Map a BullMQ job state to the per-phase run state. */
private phaseStateFor(state: string | undefined): DrugPhaseState {
if (state === 'failed') return 'failed'
if (state === 'completed') return 'completed'
if (state === 'active' || state === 'waiting' || state === 'delayed') return 'running'
return 'idle'
}
/**
* Return the two-phase ingest status for the UI panel. Reads the download job
* (drug-download queue) and the ingest job (drug-ingest queue) independently,
* merges the KV download-state marker + last-updated marker + live row count,
* and derives the top-level phase from the two sub-phases.
*/
async getIngestStatus(): Promise<DrugIngestStatus> {
const [downloadJob, ingestJob] = await Promise.all([
this.resolvePhaseJob(DownloadDrugDataJob.queue, DownloadDrugDataJob.jobId),
this.resolvePhaseJob(IngestDrugDataJob.queue, IngestDrugDataJob.jobId),
])
const [lastUpdated, rawMarker, count] = await Promise.all([
KVStore.getValue('drugReference.lastUpdatedExportDate'),
KVStore.getValue('drugReference.downloadState'),
this.rowCount(),
])
const marker = parseDownloadState(rawMarker)
// ── Download sub-status ─────────────────────────────────────────────────
const dlState = downloadJob ? await downloadJob.getState() : undefined
const dlData = downloadJob?.data ?? {}
let downloadPhaseState = this.phaseStateFor(dlState)
// A finished download job is pruned (removeOnComplete) but the marker proves
// the parts are on disk — treat that as a completed download phase so the
// manual "Ingest into search" button stays available.
if (downloadPhaseState === 'idle' && marker) downloadPhaseState = 'completed'
const download: DrugDownloadStatus = {
state: downloadPhaseState,
partsDone: downloadPhaseState === 'completed' ? (marker?.totalParts ?? dlData.totalParts ?? 0) : (dlData.partIndex ?? 0),
totalParts: dlData.totalParts ?? marker?.totalParts ?? 0,
bytesDownloaded: dlData.bytesDownloaded,
currentPartName: dlData.currentPartName ?? null,
failedReason:
dlState === 'failed' ? summarizeJobError(downloadJob?.failedReason) : undefined,
}
// ── Ingest sub-status ───────────────────────────────────────────────────
const ingState = ingestJob ? await ingestJob.getState() : undefined
const ingData = ingestJob?.data ?? {}
let ingestPhaseState = this.phaseStateFor(ingState)
// A pruned-but-successful ingest leaves rows + the last-updated marker and
// clears the download marker; reflect that as a completed ingest phase.
if (ingestPhaseState === 'idle' && !marker && count > 0) ingestPhaseState = 'completed'
// total_records 0 means "unknown" (e.g. a manifest rebuilt from an older
// marker) — resolveExpectedTotal falls back to a parts estimate, then the
// live row count, so the counter/%/ETA never silently vanish.
const expectedTotal = resolveExpectedTotal(
ingData.manifest?.total_records,
ingData.totalParts,
count
)
const jobRecords = ingData.recordsIngested ?? 0
// While ingesting, the per-job recordsIngested lags across the per-part
// continuation handoff (continuations run under auto jobIds). Drive the shown
// count from max(jobRecords, live rowCount) so a first ingest tracks the table
// filling 0 → ~259k, while a re-ingest into a populated table still rides the
// per-job counter. Outside the running phase, trust the job's own total.
// Always reconcile against the live row count (the per-job recordsIngested can
// be a partial/stale total — a completed pass-0 job only counted part 1; a
// failed continuation stops mid-run). WHILE RUNNING, additionally subtract the
// run's start-row baseline so a re-ingest into a populated table shows THIS
// run's progress (0 → ~259k) instead of reading ~100% from second zero.
// Outside running, start=0 so 'ready'/'failed' reflect total searchable rows.
const records =
ingestPhaseState === 'running'
? resolveIngestRecordsShown(jobRecords, count, expectedTotal, ingData.startRowCount ?? 0)
: resolveIngestRecordsShown(jobRecords, count, expectedTotal)
const ingest: DrugIngestPhaseStatus = {
state: ingestPhaseState,
records,
expectedTotal,
partsDone: ingData.partIndex ?? 0,
totalParts: ingData.totalParts ?? marker?.totalParts ?? 0,
currentPartName: ingData.currentPartName ?? null,
failedReason: ingState === 'failed' ? summarizeJobError(ingestJob?.failedReason) : undefined,
}
const phase = deriveIngestPhase(download, ingest, count)
// The active phase drives elapsed/ETA: ingest start when ingesting, else the
// download start.
const startedAtMs =
phase === 'ingesting'
? (ingData.startedAt ?? null)
: phase === 'downloading'
? (dlData.startedAt ?? null)
: null
const error =
phase === 'failed' ? (ingest.failedReason ?? download.failedReason) : undefined
return {
phase,
download,
ingest,
startedAtMs,
lastUpdated: lastUpdated ?? null,
rowCount: count,
error,
}
}
}

View File

@ -404,6 +404,34 @@ export class MapService implements IMapService {
return true return true
} }
/**
* Whether the low-zoom world basemap is present on disk. Checked directly (rather than trusting
* the in-process `worldBasemapReady` flag) so callers get an accurate answer regardless of whether
* `ensureWorldBasemap()` has run yet this process. Used to warn the user instead of showing a
* silent grey map when the basemap was never provisioned (e.g. installed straight offline, #1030).
*/
async checkWorldBasemapExists(): Promise<boolean> {
const basePath = resolve(join(this.baseDirPath, 'pmtiles'))
const filepath = resolve(join(basePath, WORLD_BASEMAP_FILENAME))
if (!filepath.startsWith(basePath + sep)) return false
const stats = await getFileStatsIfExists(filepath)
const exists = !!stats && Number(stats.size) > 0
if (exists) this.worldBasemapReady = true
return exists
}
/**
* Explicitly (re)provision the world basemap on demand. Ensures base assets exist, then extracts
* the basemap if it isn't present yet. Requires internet surfaced to the user as a "download the
* base map" action so the one network dependency can be satisfied deliberately while online (#1030).
*/
async provisionWorldBasemap(): Promise<boolean> {
const baseAssetsExist = await this.ensureBaseAssets()
if (!baseAssetsExist) return false
await this.ensureWorldBasemap()
return this.checkWorldBasemapExists()
}
/** /**
* Extract a low-zoom global basemap once so the map isn't grey outside a * Extract a low-zoom global basemap once so the map isn't grey outside a
* regional extract's polygon. Cheap (~15 MB, a handful of HTTP range * regional extract's polygon. Cheap (~15 MB, a handful of HTTP range

View File

@ -0,0 +1,51 @@
import { rename, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { randomUUID } from 'node:crypto'
import app from '@adonisjs/core/services/app'
import { ensureDirectoryExists, getFile } from '../utils/fs.js'
/**
* Manages the user-editable `NOMAD.md` file that is injected as a system prompt
* during chat completions. The file lives in the admin storage directory
* (`/app/storage/NOMAD.md` in the container, `/opt/project-nomad/storage/NOMAD.md`
* on the host), so an end user can also edit it directly on disk. The file is
* created lazily on the first save a missing or empty file simply means no
* custom prompt is injected.
*/
export class NomadMdService {
static STORAGE_PATH = 'storage/NOMAD.md'
private get filePath(): string {
return app.makePath(NomadMdService.STORAGE_PATH)
}
/**
* Read the raw file contents. Returns an empty string when the file does not
* exist yet (the editor renders a template client-side in that case).
*/
async read(): Promise<string> {
const content = await getFile(this.filePath, 'string')
return content ?? ''
}
/**
* Persist the file. Written atomically (tmp file + rename) so a concurrent
* on-disk edit never observes a half-written file.
*/
async write(content: string): Promise<void> {
const filePath = this.filePath
await ensureDirectoryExists(dirname(filePath))
const tmpPath = `${filePath}.tmp.${randomUUID()}`
await writeFile(tmpPath, content, 'utf-8')
await rename(tmpPath, filePath)
}
/**
* The trimmed contents suitable for injection as a system message, or `null`
* when the file is missing or blank (so chat behaviour is unchanged).
*/
async getSystemPrompt(): Promise<string | null> {
const content = (await this.read()).trim()
return content.length > 0 ? content : null
}
}

View File

@ -43,8 +43,14 @@ type ChatInput = {
model: string model: string
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
think?: boolean | 'medium' think?: boolean | 'medium'
// Whether the target model supports thinking. Lets chat()/chatStream() tell "capable but
// disabled" (send reasoning_effort:'none') apart from "not capable" (send nothing).
thinkingCapable?: boolean
stream?: boolean stream?: boolean
numCtx?: number numCtx?: number
// Aborts the upstream request when the client disconnects, so an abandoned generation
// doesn't keep decoding server-side and block Ollama's single parallel slot (#1065).
signal?: AbortSignal
} }
@inject() @inject()
@ -54,6 +60,9 @@ export class OllamaService {
private initPromise: Promise<void> | null = null private initPromise: Promise<void> | null = null
private isOllamaNative: boolean | null = null private isOllamaNative: boolean | null = null
private activeDownloads: Map<string, Promise<{ success: boolean; message: string; retryable?: boolean }>> = new Map() private activeDownloads: Map<string, Promise<{ success: boolean; message: string; retryable?: boolean }>> = new Map()
// Memoized `thinking` capability per model name (see checkModelHasThinking). Only successful
// /api/show lookups are cached; transient failures are left uncached so they can be retried.
private thinkingCapabilityCache: Map<string, boolean> = new Map()
constructor() {} constructor() {}
@ -329,17 +338,28 @@ export class OllamaService {
if (chatRequest.think) { if (chatRequest.think) {
params.think = chatRequest.think params.think = chatRequest.think
} }
// The /v1 (OpenAI-compat) endpoint ignores `think`; `reasoning_effort` is the actual lever.
// Only touch it for thinking-capable models so non-Ollama backends never get an unexpected
// param. gpt-oss requires an explicit level; a capable-but-disabled model gets 'none' to
// suppress thinking (capable models default thinking ON otherwise, so think===true is a no-op).
if (chatRequest.think === 'medium') {
params.reasoning_effort = 'medium'
} else if (chatRequest.thinkingCapable && chatRequest.think === false) {
params.reasoning_effort = 'none'
}
if (chatRequest.numCtx) { if (chatRequest.numCtx) {
params.num_ctx = chatRequest.numCtx params.num_ctx = chatRequest.numCtx
} }
const response = await this.openai.chat.completions.create(params) const response = await this.openai.chat.completions.create(params, { signal: chatRequest.signal })
const choice = response.choices[0] const choice = response.choices[0]
return { return {
message: { message: {
content: choice.message.content ?? '', content: choice.message.content ?? '',
thinking: (choice.message as any).thinking ?? undefined, // Ollama's OpenAI-compat endpoint (/v1) emits thinking as `reasoning`; its native
// shape uses `thinking`. Read both so thinking is never silently dropped (#1065).
thinking: (choice.message as any).thinking ?? (choice.message as any).reasoning ?? undefined,
}, },
done: true, done: true,
model: response.model, model: response.model,
@ -360,11 +380,22 @@ export class OllamaService {
if (chatRequest.think) { if (chatRequest.think) {
params.think = chatRequest.think params.think = chatRequest.think
} }
// The /v1 (OpenAI-compat) endpoint ignores `think`; `reasoning_effort` is the actual lever.
// Only touch it for thinking-capable models so non-Ollama backends never get an unexpected
// param. gpt-oss requires an explicit level; a capable-but-disabled model gets 'none' to
// suppress thinking (capable models default thinking ON otherwise, so think===true is a no-op).
if (chatRequest.think === 'medium') {
params.reasoning_effort = 'medium'
} else if (chatRequest.thinkingCapable && chatRequest.think === false) {
params.reasoning_effort = 'none'
}
if (chatRequest.numCtx) { if (chatRequest.numCtx) {
params.num_ctx = chatRequest.numCtx params.num_ctx = chatRequest.numCtx
} }
const stream = (await this.openai.chat.completions.create(params)) as unknown as Stream<ChatCompletionChunk> const stream = (await this.openai.chat.completions.create(params, {
signal: chatRequest.signal,
})) as unknown as Stream<ChatCompletionChunk>
// Returns how many trailing chars of `text` could be the start of `tag` // Returns how many trailing chars of `text` could be the start of `tag`
function partialTagSuffix(tag: string, text: string): number { function partialTagSuffix(tag: string, text: string): number {
@ -383,7 +414,8 @@ export class OllamaService {
for await (const chunk of stream) { for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta const delta = chunk.choices[0]?.delta
const nativeThinking: string = (delta as any)?.thinking ?? '' // /v1 emits thinking as `reasoning`; native Ollama uses `thinking`. Read both (#1065).
const nativeThinking: string = (delta as any)?.thinking ?? (delta as any)?.reasoning ?? ''
const rawContent: string = delta?.content ?? '' const rawContent: string = delta?.content ?? ''
// Parse <think> tags out of the content stream // Parse <think> tags out of the content stream
@ -436,13 +468,22 @@ export class OllamaService {
await this._ensureDependencies() await this._ensureDependencies()
if (!this.baseUrl) return false if (!this.baseUrl) return false
// A model's capabilities don't change at runtime, so memoize the /api/show result. Without
// this, loading the chat picker fires one /api/show per installed model and every chat send
// fires another — this collapses those to a single call per model per process.
const cached = this.thinkingCapabilityCache.get(modelName)
if (cached !== undefined) return cached
try { try {
const response = await axios.post( const response = await axios.post(
`${this.baseUrl}/api/show`, `${this.baseUrl}/api/show`,
{ model: modelName }, { model: modelName },
{ timeout: 5000 } { timeout: 5000 }
) )
return Array.isArray(response.data?.capabilities) && response.data.capabilities.includes('thinking') const hasThinking =
Array.isArray(response.data?.capabilities) && response.data.capabilities.includes('thinking')
this.thinkingCapabilityCache.set(modelName, hasThinking)
return hasThinking
} catch { } catch {
// Non-Ollama backends don't expose /api/show — assume no thinking support // Non-Ollama backends don't expose /api/show — assume no thinking support
return false return false
@ -764,7 +805,7 @@ export class OllamaService {
): Promise<{ models: NomadOllamaModel[]; hasMore: boolean } | null> { ): Promise<{ models: NomadOllamaModel[]; hasMore: boolean } | null> {
try { try {
const models = await this.retrieveAndRefreshModels(sort, force) const models = await this.retrieveAndRefreshModels(sort, force)
if (!models) { if (!models || models.length === 0) {
logger.warn( logger.warn(
'[OllamaService] Returning fallback recommended models due to failure in fetching available models' '[OllamaService] Returning fallback recommended models due to failure in fetching available models'
) )
@ -819,7 +860,10 @@ export class OllamaService {
try { try {
if (!force) { if (!force) {
const cachedModels = await this.readModelsFromCache() const cachedModels = await this.readModelsFromCache()
if (cachedModels) { // An empty cached array (e.g. written from a transient empty upstream
// response) must not be treated as valid data — fall through to a
// fresh fetch and, failing that, the fallback list.
if (cachedModels && cachedModels.length > 0) {
logger.info('[OllamaService] Using cached available models data') logger.info('[OllamaService] Using cached available models data')
return this.sortModels(cachedModels, sort) return this.sortModels(cachedModels, sort)
} }
@ -832,7 +876,7 @@ export class OllamaService {
const baseUrl = env.get('NOMAD_API_URL') || NOMAD_API_DEFAULT_BASE_URL const baseUrl = env.get('NOMAD_API_URL') || NOMAD_API_DEFAULT_BASE_URL
const fullUrl = new URL(NOMAD_MODELS_API_PATH, baseUrl).toString() const fullUrl = new URL(NOMAD_MODELS_API_PATH, baseUrl).toString()
const response = await axios.get(fullUrl) const response = await axios.get(fullUrl, { timeout: 10000 })
if (!response.data || !Array.isArray(response.data.models)) { if (!response.data || !Array.isArray(response.data.models)) {
logger.warn( logger.warn(
`[OllamaService] Invalid response format when fetching available models: ${JSON.stringify(response.data)}` `[OllamaService] Invalid response format when fetching available models: ${JSON.stringify(response.data)}`
@ -849,6 +893,16 @@ export class OllamaService {
})) }))
.filter((model) => model.tags.length > 0) .filter((model) => model.tags.length > 0)
// A successful-but-empty upstream response (0 models, or all filtered out
// as cloud-only) is a soft failure: return null so the caller serves the
// fallback list, and don't poison the 24h cache with an empty array.
if (noCloud.length === 0) {
logger.warn(
'[OllamaService] Nomad API returned no usable (non-cloud) models; using fallback'
)
return null
}
await this.writeModelsToCache(noCloud) await this.writeModelsToCache(noCloud)
return this.sortModels(noCloud, sort) return this.sortModels(noCloud, sort)
} catch (error) { } catch (error) {

View File

@ -10,6 +10,7 @@ import { createWorker } from 'tesseract.js'
import { fromBuffer } from 'pdf2pic' import { fromBuffer } from 'pdf2pic'
import JSZip from 'jszip' import JSZip from 'jszip'
import * as cheerio from 'cheerio' import * as cheerio from 'cheerio'
import mammoth from 'mammoth'
import { OllamaService } from './ollama_service.js' import { OllamaService } from './ollama_service.js'
import { SERVICE_NAMES } from '../../constants/service_names.js' import { SERVICE_NAMES } from '../../constants/service_names.js'
import { removeStopwords } from 'stopword' import { removeStopwords } from 'stopword'
@ -44,6 +45,10 @@ export class RagService {
private qdrantInitPromise: Promise<void> | null = null private qdrantInitPromise: Promise<void> | null = null
private embeddingModelVerified = false private embeddingModelVerified = false
private resolvedEmbeddingModel: string | null = null private resolvedEmbeddingModel: string | null = null
// Collections already verified this session (created + payload indexes in place).
// Skips the getCollections/createPayloadIndex round-trips that otherwise run on
// every embed call — ~45% of per-document Qdrant time on large ingestions (#1129)
private ensuredCollections = new Set<string>()
public static UPLOADS_STORAGE_PATH = 'storage/kb_uploads' public static UPLOADS_STORAGE_PATH = 'storage/kb_uploads'
public static CONTENT_COLLECTION_NAME = 'nomad_knowledge_base' public static CONTENT_COLLECTION_NAME = 'nomad_knowledge_base'
public static EMBEDDING_DIMENSION = 768 // Nomic Embed Text v1.5 dimension is 768 public static EMBEDDING_DIMENSION = 768 // Nomic Embed Text v1.5 dimension is 768
@ -93,6 +98,8 @@ export class RagService {
} catch { } catch {
this.qdrant = null this.qdrant = null
this.qdrantInitPromise = null this.qdrantInitPromise = null
// Qdrant may have restarted (or been recreated) — re-verify collections on reconnect
this.ensuredCollections.clear()
return { return {
online: false, online: false,
message: 'Qdrant vector database is offline. Restart the AI Assistant service in Settings to restore the Knowledge Base.', message: 'Qdrant vector database is offline. Restart the AI Assistant service in Settings to restore the Knowledge Base.',
@ -112,6 +119,11 @@ export class RagService {
) { ) {
try { try {
await this._ensureDependencies() await this._ensureDependencies()
if (this.ensuredCollections.has(collectionName)) {
return
}
const collections = await this.qdrant!.getCollections() const collections = await this.qdrant!.getCollections()
const collectionExists = collections.collections.some((col) => col.name === collectionName) const collectionExists = collections.collections.some((col) => col.name === collectionName)
@ -133,6 +145,13 @@ export class RagService {
field_name: 'content_type', field_name: 'content_type',
field_schema: 'keyword', field_schema: 'keyword',
}) })
await this.qdrant!.createPayloadIndex(collectionName, {
field_name: 'collection',
field_schema: 'keyword',
})
// Only memoize after every step succeeded, so a partial failure is retried
this.ensuredCollections.add(collectionName)
} catch (error) { } catch (error) {
logger.error('Error ensuring Qdrant collection:', error) logger.error('Error ensuring Qdrant collection:', error)
throw error throw error
@ -510,7 +529,8 @@ export class RagService {
filepath: string, filepath: string,
deleteAfterEmbedding: boolean, deleteAfterEmbedding: boolean,
batchOffset?: number, batchOffset?: number,
onProgress?: (percent: number) => Promise<void> onProgress?: (percent: number) => Promise<void>,
collection?: string
): Promise<ProcessZIMFileResponse> { ): Promise<ProcessZIMFileResponse> {
const zimExtractionService = new ZIMExtractionService() const zimExtractionService = new ZIMExtractionService()
@ -537,6 +557,9 @@ export class RagService {
const result = await this.embedAndStoreText(zimChunk.text, { const result = await this.embedAndStoreText(zimChunk.text, {
source: filepath, source: filepath,
content_type: 'zim_article', content_type: 'zim_article',
// Without this the ZIM path writes points with no `collection` at all, so
// getKnowledgeCollections() (which facets on it) never sees them.
...(collection ? { collection } : {}),
// Article-level context // Article-level context
article_title: zimChunk.articleTitle, article_title: zimChunk.articleTitle,
@ -611,6 +634,16 @@ export class RagService {
return await this.extractTXTText(fileBuffer) return await this.extractTXTText(fileBuffer)
} }
/**
* Extract text content from a DOCX file using mammoth. DOCX is a ZIP-based
* XML format, so raw-text extraction (extractTXTText) would return garbage
* this parses the document XML properly and returns clean plain text.
*/
private async processDocxFile(fileBuffer: Buffer): Promise<string> {
const { value: text } = await mammoth.extractRawText({ buffer: fileBuffer })
return text
}
/** /**
* Extract text content from an EPUB file. * Extract text content from an EPUB file.
* EPUBs are ZIP archives containing XHTML content files. * EPUBs are ZIP archives containing XHTML content files.
@ -695,14 +728,16 @@ export class RagService {
extractedText: string, extractedText: string,
filepath: string, filepath: string,
deleteAfterEmbedding: boolean = false, deleteAfterEmbedding: boolean = false,
onProgress?: (percent: number) => Promise<void> onProgress?: (percent: number) => Promise<void>,
collection?: string
): Promise<{ success: boolean; message: string; chunks?: number }> { ): Promise<{ success: boolean; message: string; chunks?: number }> {
if (!extractedText || extractedText.trim().length === 0) { if (!extractedText || extractedText.trim().length === 0) {
return { success: false, message: 'Process completed succesfully, but no text was found to embed.' } return { success: false, message: 'Process completed succesfully, but no text was found to embed.' }
} }
const embedResult = await this.embedAndStoreText(extractedText, { const embedResult = await this.embedAndStoreText(extractedText, {
source: filepath source: filepath,
...(collection ? { collection } : {})
}, onProgress) }, onProgress)
if (!embedResult) { if (!embedResult) {
@ -732,7 +767,8 @@ export class RagService {
filepath: string, filepath: string,
deleteAfterEmbedding: boolean = false, deleteAfterEmbedding: boolean = false,
batchOffset?: number, batchOffset?: number,
onProgress?: (percent: number) => Promise<void> onProgress?: (percent: number) => Promise<void>,
collection?: string
): Promise<ProcessAndEmbedFileResponse> { ): Promise<ProcessAndEmbedFileResponse> {
try { try {
const fileType = determineFileType(filepath) const fileType = determineFileType(filepath)
@ -751,7 +787,7 @@ export class RagService {
// Process based on file type // Process based on file type
// ZIM files are handled specially since they have their own embedding workflow // ZIM files are handled specially since they have their own embedding workflow
if (fileType === 'zim') { if (fileType === 'zim') {
return await this.processZIMFile(filepath, deleteAfterEmbedding, batchOffset, onProgress) return await this.processZIMFile(filepath, deleteAfterEmbedding, batchOffset, onProgress, collection)
} }
// Extract text based on file type // Extract text based on file type
@ -765,6 +801,9 @@ export class RagService {
case 'pdf': case 'pdf':
extractedText = await this.processPDFFile(fileBuffer!) extractedText = await this.processPDFFile(fileBuffer!)
break break
case 'docx':
extractedText = await this.processDocxFile(fileBuffer!)
break
case 'epub': case 'epub':
extractedText = await this.processEPUBFile(fileBuffer!) extractedText = await this.processEPUBFile(fileBuffer!)
break break
@ -781,7 +820,7 @@ export class RagService {
: undefined : undefined
// Embed extracted text and cleanup // Embed extracted text and cleanup
return await this.embedTextAndCleanup(extractedText, filepath, deleteAfterEmbedding, scaledProgress) return await this.embedTextAndCleanup(extractedText, filepath, deleteAfterEmbedding, scaledProgress, collection)
} catch (error) { } catch (error) {
logger.error('[RAG] Error processing and embedding file:', error) logger.error('[RAG] Error processing and embedding file:', error)
return { success: false, message: 'Error processing and embedding file.' } return { success: false, message: 'Error processing and embedding file.' }
@ -800,7 +839,8 @@ export class RagService {
public async searchSimilarDocuments( public async searchSimilarDocuments(
query: string, query: string,
limit: number = 5, limit: number = 5,
scoreThreshold: number = 0.3 // Lower default threshold - was 0.7, now 0.3 scoreThreshold: number = 0.3, // Lower default threshold - was 0.7, now 0.3
collection?: string
): Promise<Array<{ text: string; score: number; metadata?: Record<string, any> }>> { ): Promise<Array<{ text: string; score: number; metadata?: Record<string, any> }>> {
try { try {
logger.debug(`[RAG] Starting similarity search for query: "${query}"`) logger.debug(`[RAG] Starting similarity search for query: "${query}"`)
@ -873,6 +913,7 @@ export class RagService {
limit: searchLimit, limit: searchLimit,
score_threshold: scoreThreshold, score_threshold: scoreThreshold,
with_payload: true, with_payload: true,
...(collection ? { filter: { must: [{ key: 'collection', match: { value: collection } }] } } : {}),
}) })
logger.debug(`[RAG] Found ${searchResults.length} results above threshold ${scoreThreshold}`) logger.debug(`[RAG] Found ${searchResults.length} results above threshold ${scoreThreshold}`)
@ -1121,14 +1162,15 @@ export class RagService {
// in particular) have no row to attach to. The state machine is the // in particular) have no row to attach to. The state machine is the
// authoritative "what's on disk?" view; Qdrant is "what made it into // authoritative "what's on disk?" view; Qdrant is "what made it into
// the vector store?". Both are needed to render the KB UI honestly. // the vector store?". Both are needed to render the KB UI honestly.
const stateByPath = new Map<string, { state: KbIngestStateValue; chunks_embedded: number }>() const stateByPath = new Map<string, { state: KbIngestStateValue; chunks_embedded: number; collection: string | null }>()
try { try {
const stateRows = await KbIngestState.query().select('file_path', 'state', 'chunks_embedded') const stateRows = await KbIngestState.query().select('file_path', 'state', 'chunks_embedded', 'collection')
for (const row of stateRows) { for (const row of stateRows) {
sources.add(row.file_path) sources.add(row.file_path)
stateByPath.set(row.file_path, { stateByPath.set(row.file_path, {
state: row.state, state: row.state,
chunks_embedded: row.chunks_embedded, chunks_embedded: row.chunks_embedded,
collection: row.collection,
}) })
} }
} catch (error) { } catch (error) {
@ -1155,6 +1197,7 @@ export class RagService {
size: stats?.size ?? null, size: stats?.size ?? null,
uploadedAt: stats?.modifiedTime.toISOString() ?? null, uploadedAt: stats?.modifiedTime.toISOString() ?? null,
isUserUpload, isUserUpload,
collection: row?.collection ?? null,
} }
}) })
) )
@ -1164,6 +1207,116 @@ export class RagService {
} }
} }
/**
* Enumerate distinct `collection` values currently in the knowledge base,
* for populating a subject-picker in the upload/chat UI. Mirrors the
* `source` facet pattern used elsewhere in this file (see getStoredFiles).
*/
public async getKnowledgeCollections(): Promise<string[]> {
await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION)
const facetResult = await this.qdrant!.facet(RagService.CONTENT_COLLECTION_NAME, {
key: 'collection',
limit: RagService.FACET_SOURCE_LIMIT,
exact: true,
})
const collections = new Set<string>()
for (const hit of facetResult.hits) {
if (typeof hit.value === 'string') collections.add(hit.value)
}
return Array.from(collections).sort()
}
/**
* Reassign a stored file's collection after the fact. Updates the `collection`
* payload field on every existing Qdrant point for this source in place (no
* re-chunking or re-embedding needed), then mirrors the change onto the
* KbIngestState row so getStoredFiles() reflects it immediately.
*/
public async updateFileCollection(
source: string,
collection: string | null
): Promise<{ success: boolean; message: string }> {
try {
await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION)
await this.qdrant!.setPayload(RagService.CONTENT_COLLECTION_NAME, {
payload: { collection },
filter: { must: [{ key: 'source', match: { value: source } }] },
})
// The setPayload above only reaches points that already exist, so for a file
// that has not been indexed yet it matches nothing. The row is therefore the
// only durable record of the choice until EmbedFileJob picks it up -- create
// it when absent rather than reporting success and storing the value nowhere.
const row = await KbIngestState.getOrCreate(source)
row.collection = collection
await row.save()
return { success: true, message: collection ? `Moved to "${collection}".` : 'Moved to Uncategorized.' }
} catch (error) {
logger.error('[RAG] Error updating file collection:', error)
return { success: false, message: 'Error updating file collection.' }
}
}
/**
* Rename a knowledge-base collection everywhere it's referenced: updates every
* Qdrant point tagged with the old name in place, and mirrors the change onto
* any matching KbIngestState rows so getStoredFiles() reflects it immediately.
*/
public async renameKnowledgeCollection(
oldName: string,
newName: string
): Promise<{ success: boolean; message: string }> {
try {
if (!oldName || !newName || oldName === newName) {
return { success: false, message: 'Invalid collection names.' }
}
await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION)
await this.qdrant!.setPayload(RagService.CONTENT_COLLECTION_NAME, {
payload: { collection: newName },
filter: { must: [{ key: 'collection', match: { value: oldName } }] },
})
await KbIngestState.query().where('collection', oldName).update({ collection: newName })
return { success: true, message: `Renamed "${oldName}" to "${newName}".` }
} catch (error) {
logger.error('[RAG] Error renaming knowledge collection:', error)
return { success: false, message: 'Error renaming collection.' }
}
}
/**
* Remove a collection by reassigning every file tagged with it back to
* Uncategorized (collection: null). Non-destructive no files or embeddings
* are deleted, only the grouping label is cleared so items can be
* recategorized later.
*/
public async deleteKnowledgeCollection(
name: string
): Promise<{ success: boolean; message: string }> {
try {
if (!name) {
return { success: false, message: 'Invalid collection name.' }
}
await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION)
await this.qdrant!.setPayload(RagService.CONTENT_COLLECTION_NAME, {
payload: { collection: null },
filter: { must: [{ key: 'collection', match: { value: name } }] },
})
await KbIngestState.query().where('collection', name).update({ collection: null })
return { success: true, message: `"${name}" removed. Files moved to Uncategorized.` }
} catch (error) {
logger.error('[RAG] Error deleting knowledge collection:', error)
return { success: false, message: 'Error deleting collection.' }
}
}
/** /**
* Resolve a stored-file `source` to an absolute disk path, but only if the * Resolve a stored-file `source` to an absolute disk path, but only if the
* path lives under the uploads directory. Mirrors the docs_service traversal * path lives under the uploads directory. Mirrors the docs_service traversal
@ -1971,6 +2124,10 @@ export class RagService {
logger.warn(`[RAG] deleteCollection failed (may not exist): ${(err as Error).message}`) logger.warn(`[RAG] deleteCollection failed (may not exist): ${(err as Error).message}`)
} }
// The collection is gone — drop it from the ensured cache so the
// _ensureCollection call below actually recreates it
this.ensuredCollections.delete(RagService.CONTENT_COLLECTION_NAME)
await this._ensureCollection( await this._ensureCollection(
RagService.CONTENT_COLLECTION_NAME, RagService.CONTENT_COLLECTION_NAME,
RagService.EMBEDDING_DIMENSION RagService.EMBEDDING_DIMENSION

View File

@ -22,6 +22,7 @@ import KVStore from '#models/kv_store'
import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js' import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js'
import { isNewerVersion } from '../utils/version.js' import { isNewerVersion } from '../utils/version.js'
import { invalidateAssistantNameCache } from '../../config/inertia.js' import { invalidateAssistantNameCache } from '../../config/inertia.js'
import { KiwixLibraryService } from '#services/kiwix_library_service'
@inject() @inject()
export class SystemService { export class SystemService {
@ -38,7 +39,7 @@ export class SystemService {
async getInternetStatus(): Promise<boolean> { async getInternetStatus(): Promise<boolean> {
// Primary endpoint stays Cloudflare's privacy-respecting utility endpoint. // Primary endpoint stays Cloudflare's privacy-respecting utility endpoint.
// The fallbacks are hosts the application already contacts elsewhere // The fallbacks are hosts the application already contacts elsewhere
// (GitHub API for update checks, the Project N.O.M.A.D. API for release-note // (GitHub API for update checks, the Project NOMAD API for release-note
// subscriptions), so no new third-party services are introduced. They exist // subscriptions), so no new third-party services are introduced. They exist
// to avoid false "offline" reports on networks that block 1.1.1.1. // to avoid false "offline" reports on networks that block 1.1.1.1.
const DEFAULT_TEST_URLS = [ const DEFAULT_TEST_URLS = [
@ -757,6 +758,28 @@ export class SystemService {
this.checkLatestVersion().catch(() => null), this.checkLatestVersion().catch(() => null),
]) ])
// Diagnostics tied to common support cases: storage relocation (#1050),
// container/updater issues (#858), GPU passthrough (#755/#878), and the
// auto-update trilogy. All best-effort so a single failure never blanks the
// whole bundle.
const [dockerVersion, hostStorageRoot, kiwixBookCount, gpuType] = await Promise.all([
this.dockerService.docker
.version()
.then((v: any) => v?.Version ?? null)
.catch(() => null),
this.dockerService.getHostStorageRoot().catch(() => null),
new KiwixLibraryService().getBookCount().catch(() => null),
KVStore.getValue('gpu.type').catch(() => null),
])
const [autoUpdateCore, autoUpdateApps, autoUpdateContent, autoDisabledReason] =
await Promise.all([
KVStore.getValue('autoUpdate.enabled').catch(() => null),
KVStore.getValue('appAutoUpdate.enabled').catch(() => null),
KVStore.getValue('contentAutoUpdate.enabled').catch(() => null),
KVStore.getValue('autoUpdate.autoDisabledReason').catch(() => null),
])
const isEnabled = (v: any) => v === true || v === 'true'
const lines: string[] = [ const lines: string[] = [
'Project NOMAD Debug Info', 'Project NOMAD Debug Info',
'========================', '========================',
@ -765,7 +788,7 @@ export class SystemService {
] ]
if (systemInfo) { if (systemInfo) {
const { cpu, mem, os, disk, fsSize, uptime, graphics } = systemInfo const { cpu, mem, os, disk, fsSize, uptime, graphics, gpuHealth } = systemInfo
lines.push('') lines.push('')
lines.push('System:') lines.push('System:')
@ -773,6 +796,7 @@ export class SystemService {
if (os.hostname) lines.push(` Hostname: ${os.hostname}`) if (os.hostname) lines.push(` Hostname: ${os.hostname}`)
if (os.kernel) lines.push(` Kernel: ${os.kernel}`) if (os.kernel) lines.push(` Kernel: ${os.kernel}`)
if (os.arch) lines.push(` Architecture: ${os.arch}`) if (os.arch) lines.push(` Architecture: ${os.arch}`)
if (dockerVersion) lines.push(` Docker Engine: ${dockerVersion}`)
if (uptime?.uptime) lines.push(` Uptime: ${this._formatUptime(uptime.uptime)}`) if (uptime?.uptime) lines.push(` Uptime: ${this._formatUptime(uptime.uptime)}`)
lines.push('') lines.push('')
@ -794,6 +818,10 @@ export class SystemService {
} else { } else {
lines.push(' GPU: None detected') lines.push(' GPU: None detected')
} }
if (gpuHealth?.status) {
const vendor = gpuType || gpuHealth.gpuVendor
lines.push(` GPU Passthrough: ${gpuHealth.status}${vendor ? ` (${vendor})` : ''}`)
}
// Disk info — try disk array first, fall back to fsSize // Disk info — try disk array first, fall back to fsSize
const diskEntries = disk.filter((d) => d.totalSize > 0) const diskEntries = disk.filter((d) => d.totalSize > 0)
@ -814,6 +842,20 @@ export class SystemService {
} }
} }
lines.push('')
lines.push('Storage:')
lines.push(` Host storage root: ${hostStorageRoot ?? 'unknown'}`)
lines.push(` Container path: ${DockerService.ADMIN_STORAGE_DEST}`)
const storageEnv = process.env.NOMAD_STORAGE_PATH
lines.push(
` NOMAD_STORAGE_PATH: ${storageEnv ? storageEnv : 'not set (auto-detected from admin mount)'}`
)
if (kiwixBookCount !== null) {
lines.push(
` Kiwix library: ${kiwixBookCount === 0 ? 'empty (0 books)' : `${kiwixBookCount} book(s)`}`
)
}
const installed = services.filter((s) => s.installed) const installed = services.filter((s) => s.installed)
lines.push('') lines.push('')
if (installed.length > 0) { if (installed.length > 0) {
@ -837,6 +879,15 @@ export class SystemService {
lines.push(`Update Available: ${updateMsg}`) lines.push(`Update Available: ${updateMsg}`)
} }
lines.push('')
lines.push('Auto-Update:')
lines.push(` Core: ${isEnabled(autoUpdateCore) ? 'Enabled' : 'Disabled'}`)
lines.push(` Apps: ${isEnabled(autoUpdateApps) ? 'Enabled' : 'Disabled'}`)
lines.push(` Content: ${isEnabled(autoUpdateContent) ? 'Enabled' : 'Disabled'}`)
if (autoDisabledReason) {
lines.push(` Auto-disabled reason: ${autoDisabledReason}`)
}
return lines.join('\n') return lines.join('\n')
} }

View File

@ -1,6 +1,7 @@
import { Archive, Entry } from '@openzim/libzim' import { Archive, Entry } from '@openzim/libzim'
import * as cheerio from 'cheerio' import * as cheerio from 'cheerio'
import { HTML_SELECTORS_TO_REMOVE, NON_CONTENT_HEADING_PATTERNS } from '../../constants/zim_extraction.js' import { HTML_SELECTORS_TO_REMOVE, NON_CONTENT_HEADING_PATTERNS } from '../../constants/zim_extraction.js'
import { extractStructuredContent } from '../utils/zim_html.js'
import logger from '@adonisjs/core/services/logger' import logger from '@adonisjs/core/services/logger'
import { ExtractZIMChunkingStrategy, ExtractZIMContentOptions, ZIMContentChunk, ZIMArchiveMetadata } from '../../types/zim.js' import { ExtractZIMChunkingStrategy, ExtractZIMContentOptions, ZIMContentChunk, ZIMArchiveMetadata } from '../../types/zim.js'
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
@ -115,7 +116,7 @@ export class ZIMExtractionService {
let chunks: ZIMContentChunk[] let chunks: ZIMContentChunk[]
if (strategy === 'structured') { if (strategy === 'structured') {
const structured = this.extractStructuredContent(html) const structured = extractStructuredContent(html)
chunks = structured.sections.map(s => ({ chunks = structured.sections.map(s => ({
text: s.text, text: s.text,
articleTitle, articleTitle,
@ -210,76 +211,6 @@ export class ZIMExtractionService {
} }
} }
private extractStructuredContent(html: string) {
const $ = cheerio.load(html);
const title = $('h1').first().text().trim() || $('title').text().trim();
// Extract sections with their headings and heading levels
const sections: Array<{ heading: string; text: string; level: number }> = [];
let currentSection = { heading: 'Introduction', content: [] as string[], level: 2 };
// Walk the full DOM rather than only direct children of <body>. Modern ZIMs (Devdocs,
// Wikipedia, FreeCodeCamp, etc.) wrap article content in a container div, which under
// .children() would be a single non-heading/non-paragraph element and yield zero sections.
$('body').find('h2, h3, h4, p, ul, ol, dl, table').each((_, element) => {
const $el = $(element);
const tagName = element.tagName?.toLowerCase();
if (['h2', 'h3', 'h4'].includes(tagName)) {
// Save current section if it has content
if (currentSection.content.length > 0) {
sections.push({
heading: currentSection.heading,
text: currentSection.content.join(' ').replace(/\s+/g, ' ').trim(),
level: currentSection.level,
});
}
// Start new section
const level = parseInt(tagName.substring(1)); // Extract number from h2, h3, h4
currentSection = {
heading: $el.text().replace(/\[edit\]/gi, '').trim(),
content: [],
level,
};
} else if (['p', 'ul', 'ol', 'dl', 'table'].includes(tagName)) {
const text = $el.text().trim();
if (text.length > 0) {
currentSection.content.push(text);
}
}
});
// Push the last section if it has content
if (currentSection.content.length > 0) {
sections.push({
heading: currentSection.heading,
text: currentSection.content.join(' ').replace(/\s+/g, ' ').trim(),
level: currentSection.level,
});
}
// Fallback: if the selector walk produced no sections but the body has meaningful
// text (unusual structure, minimal markup), emit one section with the full body text
// so the article still contributes to the knowledge base.
if (sections.length === 0) {
const bodyText = $('body').text().replace(/\s+/g, ' ').trim();
if (bodyText.length > 0) {
sections.push({
heading: title || 'Content',
text: bodyText,
level: 2,
});
}
}
return {
title,
sections,
fullText: sections.map(s => `${s.heading}\n${s.text}`).join('\n\n'),
};
}
private hasStructuredHeadings(html: string): boolean { private hasStructuredHeadings(html: string): boolean {
const $ = cheerio.load(html); const $ = cheerio.load(html);

View File

@ -27,12 +27,17 @@ import WikipediaSelection from '#models/wikipedia_selection'
import InstalledResource from '#models/installed_resource' import InstalledResource from '#models/installed_resource'
import CollectionManifest from '#models/collection_manifest' import CollectionManifest from '#models/collection_manifest'
import { RunDownloadJob } from '#jobs/run_download_job' import { RunDownloadJob } from '#jobs/run_download_job'
import { DownloadDrugDataJob } from '#jobs/download_drug_data_job'
import { DrugReferenceService } from './drug_reference_service.js'
import { SERVICE_NAMES } from '../../constants/service_names.js' import { SERVICE_NAMES } from '../../constants/service_names.js'
import { CollectionManifestService } from './collection_manifest_service.js' import { CollectionManifestService } from './collection_manifest_service.js'
import { KiwixCatalogService } from './kiwix_catalog_service.js'
import { KiwixLibraryService } from './kiwix_library_service.js' import { KiwixLibraryService } from './kiwix_library_service.js'
import type { CategoryWithStatus } from '../../types/collections.js' import type { CategoryWithStatus } from '../../types/collections.js'
import CustomLibrarySource from '#models/custom_library_source' import CustomLibrarySource from '#models/custom_library_source'
import { assertNotPrivateUrl } from '#validators/common' import { assertNotPrivateUrl } from '#validators/common'
import { resolveZimDownload } from '../utils/zim_download_resolution.js'
import { getHostedContentHeaders } from '../utils/hosted_content_auth.js'
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream'] const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
const WIKIPEDIA_OPTIONS_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json' const WIKIPEDIA_OPTIONS_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json'
@ -164,6 +169,13 @@ export class ZimService {
download_url, download_url,
author: entry.author.name, author: entry.author.name,
file_name, file_name,
language: entry.language,
category: entry.category,
tags: entry.tags,
article_count: entry.articleCount,
media_count: entry.mediaCount,
publisher: entry.publisher?.name,
issued: entry['dc:issued'],
}) })
} }
@ -210,7 +222,6 @@ export class ZimService {
filepath, filepath,
timeout: 30000, timeout: 30000,
allowedMimeTypes: ZIM_MIME_TYPES, allowedMimeTypes: ZIM_MIME_TYPES,
forceNew: true,
filetype: 'zim', filetype: 'zim',
title: metadata?.title, title: metadata?.title,
totalBytes: metadata?.size_bytes, totalBytes: metadata?.size_bytes,
@ -253,40 +264,80 @@ export class ZimService {
const allResources = CollectionManifestService.resolveTierResources(tier, category.tiers) const allResources = CollectionManifestService.resolveTierResources(tier, category.tiers)
// Filter out already installed // Filter out already installed. Includes 'dataset' rows (the FDA drug labels)
const installed = await InstalledResource.query().where('resource_type', 'zim') // alongside 'zim' so an installed dataset is filtered out here — without it,
// a re-select of an installed Medicine tier would re-dispatch the ~1.7 GB drug
// download every time (the dataset branch's own getIngestStatus guard is a
// second line of defence, but this keeps the filter symmetric with the row-
// driven tier-status math).
const installed = await InstalledResource.query().whereIn('resource_type', ['zim', 'dataset'])
const installedIds = new Set(installed.map((r) => r.resource_id)) const installedIds = new Set(installed.map((r) => r.resource_id))
const toDownload = allResources.filter((r) => !installedIds.has(r.id)) const toDownload = allResources.filter((r) => !installedIds.has(r.id))
if (toDownload.length === 0) return null if (toDownload.length === 0) return null
const latestByResource = await new KiwixCatalogService().getLatestForResources(
toDownload.map((resource) => ({ resource_id: resource.id, resource_type: 'zim' }))
)
const downloadFilenames: string[] = [] const downloadFilenames: string[] = []
for (const resource of toDownload) { for (const resource of toDownload) {
const existingJob = await RunDownloadJob.getActiveByUrl(resource.url) // A `dataset` resource (e.g. the FDA drug labels) is DB-ingested, not a
if (existingJob) { // ZIM file — route it to the drug download+ingest pipeline instead of
logger.warn(`[ZimService] Download already in progress for ${resource.url}, skipping.`) // RunDownloadJob. The install-state row written on ingest 'ready' (below,
// via resourceMeta) is what the installed-filter above and the tier-status
// math key off; until that row exists the getIngestStatus guard prevents
// re-dispatching the ~1.7 GB download on every tier select.
if (resource.type === 'dataset') {
const drugReferenceService = new DrugReferenceService()
const status = await drugReferenceService.getIngestStatus()
if (status.phase === 'ready' || status.rowCount > 0) {
logger.info('[ZimService] Drug dataset already ingested, skipping dispatch.')
continue
}
// DownloadDrugDataJob.dispatch() is idempotent on its deterministic
// jobId — a concurrent in-flight download returns "already running"
// without re-adding, so this is safe to call repeatedly. The resourceMeta
// is threaded through download → ingest so the final ingest pass writes
// the `installed_resources` 'dataset' row, making the tier read installed.
await DownloadDrugDataJob.dispatch(true, {
resourceId: resource.id,
version: resource.version,
collectionRef: categorySlug,
})
logger.info('[ZimService] Dispatched drug data download for dataset resource.')
continue continue
} }
const filename = resource.url.split('/').pop() const resolved = resolveZimDownload(
resource,
latestByResource.get(`zim:${resource.id}`) ?? null
)
const existingJob = await RunDownloadJob.getActiveByUrl(resolved.url)
if (existingJob) {
logger.warn(`[ZimService] Download already in progress for ${resolved.url}, skipping.`)
continue
}
const filename = resolved.url.split('/').pop()
if (!filename) continue if (!filename) continue
downloadFilenames.push(filename) downloadFilenames.push(filename)
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename) const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
await RunDownloadJob.dispatch({ await RunDownloadJob.dispatch({
url: resource.url, url: resolved.url,
filepath, filepath,
timeout: 30000, timeout: 30000,
allowedMimeTypes: ZIM_MIME_TYPES, allowedMimeTypes: ZIM_MIME_TYPES,
forceNew: true,
filetype: 'zim', filetype: 'zim',
title: (resource as any).title || undefined, title: (resource as any).title || undefined,
totalBytes: (resource as any).size_mb ? (resource as any).size_mb * 1024 * 1024 : undefined, totalBytes: resolved.sizeBytes,
// Undefined for every ungated resource, so the existing flow is untouched.
requestHeaders: getHostedContentHeaders(resource),
resourceMetadata: { resourceMetadata: {
resource_id: resource.id, resource_id: resource.id,
version: resource.version, version: resolved.version,
collection_ref: categorySlug, collection_ref: categorySlug,
}, },
}) })
@ -547,14 +598,40 @@ export class ZimService {
const ollamaUrl = await this.dockerService.getServiceURL('nomad_ollama') const ollamaUrl = await this.dockerService.getServiceURL('nomad_ollama')
if (ollamaUrl) { if (ollamaUrl) {
// Respect the global ingest policy, same as the post-download path (PR #919).
// This used to dispatch unconditionally, so a user who deliberately chose
// Manual still got sideloaded ZIMs embedded behind their back.
//
// Reuses decideScanAction rather than re-inlining the Always/Manual check,
// so an existing browse_only or pending_decision row is honored too instead
// of being overridden by the act of re-uploading the file.
const filePath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
try { try {
const { EmbedFileJob } = await import('#jobs/embed_file_job') const { default: KVStore } = await import('#models/kv_store')
await EmbedFileJob.dispatch({ const { default: KbIngestState } = await import('#models/kb_ingest_state')
fileName: filename, const { decideScanAction } = await import('../utils/kb_ingest_decision.js')
filePath: join(process.cwd(), ZIM_STORAGE_PATH, filename),
}) // Unset is treated as Always, preserving legacy behavior — mirrors
// rag_service.ts and run_download_job.ts.
const policyRaw = await KVStore.getValue('rag.defaultIngestPolicy')
const policy = policyRaw === 'Manual' ? 'Manual' : 'Always'
const existing = await KbIngestState.findBy('file_path', filePath)
const action = decideScanAction(existing, false, policy)
if (action.kind === 'dispatch') {
const { EmbedFileJob } = await import('#jobs/embed_file_job')
await EmbedFileJob.dispatch({ fileName: filename, filePath })
} else if (action.kind === 'create_pending') {
// firstOrCreate so the KB panel surfaces the per-file Index affordance
// without demoting a row that already exists.
await KbIngestState.getOrCreate(filePath)
}
// 'skip' and 'backfill_indexed' need no action here: the file was just
// written to disk, so there is nothing to backfill and a settled state
// row means the user has already decided about this file.
} catch (error) { } catch (error) {
logger.error(`[ZimService] EmbedFileJob dispatch failed after local upload:`, error) logger.error(`[ZimService] KB ingest decision failed after local upload:`, error)
} }
} }
@ -751,7 +828,6 @@ export class ZimService {
filepath, filepath,
timeout: 30000, timeout: 30000,
allowedMimeTypes: ZIM_MIME_TYPES, allowedMimeTypes: ZIM_MIME_TYPES,
forceNew: true,
filetype: 'zim', filetype: 'zim',
title: selectedOption.name, title: selectedOption.name,
totalBytes: selectedOption.size_mb ? selectedOption.size_mb * 1024 * 1024 : undefined, totalBytes: selectedOption.size_mb ? selectedOption.size_mb * 1024 * 1024 : undefined,

View File

@ -0,0 +1,23 @@
import KVStore from '#models/kv_store'
/**
* Affirmative-content gate for the drug-reference feature (upstream #1040).
*
* The verbatim FDA label search and the conditionOTC matching are grounded in
* regulated label text and ship with the tier. The hand-authored self-care and
* herbal REMEDY content is guidance we author, so it stays gated off by default
* until a clinician has done the content pass; the maintainer flips this on in a
* follow-up once that's signed off.
*
* Independent of the tier install-state (a `medicine-standard` install alone does
* NOT enable remedies). Defaults off a missing/null KV value reads as false
* and there is deliberately no user-facing toggle, so un-reviewed medical
* guidance can't be self-enabled.
*
* Read at every HTTP boundary that could emit remedy data (the drug-reference
* page prop and the conditions show / drugs API), so no affirmative content is
* serialized to the client while the gate is closed.
*/
export async function affirmativeRemediesEnabled(): Promise<boolean> {
return (await KVStore.getValue('drugReference.remediesEnabled')) === true
}

View File

@ -0,0 +1,52 @@
/**
* Map an AMD GPU's gfx target to the `HSA_OVERRIDE_GFX_VERSION` value the ollama:rocm
* container needs, or `null` when the card is discovered natively and no override should
* be applied.
*
* This is intentionally a pure function so the mapping is unit-testable without
* constructing the Docker service or touching the container runtime. `DockerService`
* delegates its private `_mapGfxToHsaOverride` to this.
*
* The bundled `ollama/ollama:rocm` rocblas ships kernels for a fixed allowlist as seen
* in ollama's own startup log:
* supported=[gfx1030, gfx1100/1101/1102, gfx1150/1151, gfx1200/1201, gfx908/90a/942/950]
* A target NOT in that list is dropped to CPU unless we coerce it onto a supported one via
* HSA_OVERRIDE_GFX_VERSION.
*
* Mapping:
* - gfx1030 / gfx1100 / gfx1101 / gfx1102 none. Discrete RDNA 2/3 on the allowlist;
* forcing an override here breaks GPU discovery.
* - gfx1150 / gfx1151 (Strix 890M, Strix Halo) none. RDNA 3.5 iGPUs that ARE on the
* allowlist under the bundled ROCm, so native discovery works. (#1076 got this right.)
* - gfx1103 (Phoenix/Hawk Point 780M/760M) '11.0.0'. RDNA 3 iGPU that is NOT on the
* allowlist, so it must be coerced onto gfx1100's kernels. #1076 wrongly grouped it with
* gfx1150/1151 and dropped the override, silently sending the 780M to CPU (the very
* common iGPU this regression hit). 11.0.0 is the value that worked on v1.33.0 and that
* restores full GPU offload in the field; #1076's "gfx1100 WMMA fault" theory did not
* hold up.
* - gfx1031..gfx1036 (RDNA 2 iGPUs, e.g. Rembrandt 680M) '10.3.0'. Not on the allowlist;
* coerce onto gfx1030.
* - anything else (unknown/newer target) none. Prefer native discovery over a coercion
* that's likely wrong; a hardcoded default gets more wrong as ROCm adds native targets.
*/
export function mapGfxToHsaOverride(gfx: string): string | null {
// Officially supported by the bundled ROCm — no override needed.
if (gfx === 'gfx1030' || gfx === 'gfx1100' || gfx === 'gfx1101' || gfx === 'gfx1102') {
return null
}
// RDNA 3.5 iGPUs (Strix 890M = gfx1150, Strix Halo = gfx1151) — natively supported.
if (gfx === 'gfx1150' || gfx === 'gfx1151') {
return null
}
// RDNA 3 Phoenix/Hawk Point (780M/760M = gfx1103) — NOT on the rocblas allowlist; coerce
// to gfx1100 kernels or ollama drops it to CPU.
if (gfx === 'gfx1103') {
return '11.0.0'
}
// RDNA 2 variants + iGPUs (gfx1031..gfx1036, e.g. Rembrandt 680M) — coerce to gfx1030.
if (/^gfx103[1-6]$/.test(gfx)) {
return '10.3.0'
}
// Unknown/newer target: prefer native discovery over a coercion that's likely wrong.
return null
}

View File

@ -8,6 +8,32 @@ import { deleteFileIfExists, ensureDirectoryExists, getFileStatsIfExists } from
import { createWriteStream } from 'fs' import { createWriteStream } from 'fs'
import { rename } from 'fs/promises' import { rename } from 'fs/promises'
import path from 'path' import path from 'path'
import logger from '@adonisjs/core/services/logger'
/**
* A gated source rejected this install's credentials (401/403).
*
* Permanent by nature: whether the entitlement key is baked in is a property of
* the build, so no amount of retrying changes the answer. Declared here rather
* than thrown as an UnrecoverableError directly so this module stays free of a
* BullMQ dependency RunDownloadJob translates it at the queue boundary.
*/
export class GatedContentAuthError extends Error {
constructor(message: string) {
super(message)
this.name = 'GatedContentAuthError'
}
}
// Some upstream mirrors reject requests with a missing or generic User-Agent.
// Notably, download.kiwix.org routes the large Wikimedia-family ZIMs (Wikipedia,
// Wikiversity, Wikibooks — including the flagship full Wikipedia) to
// dumps.wikimedia.org, which returns HTTP 403 for a default `axios/x` (or empty)
// User-Agent per Wikimedia's UA policy. Identify ourselves descriptively so
// those downloads succeed.
const DOWNLOAD_HEADERS: Record<string, string> = {
'User-Agent': 'ProjectNOMAD/1.0 (+https://projectnomad.us)',
}
/** /**
* Perform a resumable download with progress tracking * Perform a resumable download with progress tracking
@ -24,6 +50,7 @@ export async function doResumableDownload({
onComplete, onComplete,
forceNew = false, forceNew = false,
allowedMimeTypes, allowedMimeTypes,
requestHeaders,
}: DoResumableDownloadParams): Promise<string> { }: DoResumableDownloadParams): Promise<string> {
const dirname = path.dirname(filepath) const dirname = path.dirname(filepath)
await ensureDirectoryExists(dirname) await ensureDirectoryExists(dirname)
@ -41,11 +68,33 @@ export async function doResumableDownload({
appendMode = true appendMode = true
} }
// Get file info with HEAD request first // Merge default headers with any caller-supplied headers (e.g. Creator Packs' Authorization)
const headResponse = await axios.head(url, { const headers: Record<string, string> = { ...DOWNLOAD_HEADERS, ...requestHeaders }
signal,
timeout, // Get file info with HEAD request first. Gated sources (Creator Packs) require
}) // the auth header on the HEAD too, or the probe 401s before the GET is reached.
let headResponse
try {
headResponse = await axios.head(url, {
signal,
timeout,
headers,
})
} catch (error: any) {
// A 401/403 from a gated source is not a network problem and the raw axios
// message ("Request failed with status code 401") reads like our server is
// broken. Translate it, because the actual cause is almost always a build
// without the entitlement key baked in — i.e. not an official release.
// failedReason is surfaced verbatim on the downloads UI.
const status = error?.response?.status
if (status === 401 || status === 403) {
throw new GatedContentAuthError(
'This content is hosted by Project NOMAD and requires an official release build. ' +
`The download server rejected this install's credentials (HTTP ${status}).`
)
}
throw error
}
// Some upstream hosts (notably download.kiwix.org for .zim files) don't set a // Some upstream hosts (notably download.kiwix.org for .zim files) don't set a
// Content-Type header at all. Per RFC 7231 §3.1.1.5, "if no Content-Type is // Content-Type header at all. Per RFC 7231 §3.1.1.5, "if no Content-Type is
@ -88,13 +137,32 @@ export async function doResumableDownload({
appendMode = false appendMode = false
} }
const headers: Record<string, string> = {} // A .tmp bigger than the file now on the server cannot be a prefix of it — the
// publisher replaced the file under the same name (openZIM rolls builds forward,
// see #1189/#1187). Resuming would ask for a range past the end and get a 416 on
// every attempt, with nothing deleting the .tmp, so the download could never
// recover on its own. Discard and start clean.
if (startByte > totalBytes && totalBytes > 0) {
logger.warn(
`[Download] Discarding stale partial for ${filepath}: .tmp is ${startByte}B but the server reports ${totalBytes}B`
)
await deleteFileIfExists(tempPath)
startByte = 0
appendMode = false
}
// Add Range header if resuming
if (supportsRangeRequests && startByte > 0) { if (supportsRangeRequests && startByte > 0) {
headers.Range = `bytes=${startByte}-` headers.Range = `bytes=${startByte}-`
} }
const fetchStream = (hdrs: Record<string, string>) => const fetchStream = (headers: Record<string, string>) =>
axios.get(url, { responseType: 'stream', headers: hdrs, signal, timeout }) axios.get(url, {
responseType: 'stream',
headers,
signal,
timeout,
})
let response = await fetchStream(headers) let response = await fetchStream(headers)

View File

@ -190,13 +190,15 @@ export function matchesDevice(fsPath: string, deviceName: string): boolean {
return false return false
} }
export function determineFileType(filename: string): 'image' | 'pdf' | 'text' | 'epub' | 'zim' | 'unknown' { export function determineFileType(filename: string): 'image' | 'pdf' | 'text' | 'docx' | 'epub' | 'zim' | 'unknown' {
const ext = path.extname(filename).toLowerCase() const ext = path.extname(filename).toLowerCase()
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'].includes(ext)) { if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'].includes(ext)) {
return 'image' return 'image'
} else if (ext === '.pdf') { } else if (ext === '.pdf') {
return 'pdf' return 'pdf'
} else if (['.txt', '.md', '.docx', '.rtf'].includes(ext)) { } else if (ext === '.docx') {
return 'docx'
} else if (['.txt', '.md', '.rtf'].includes(ext)) {
return 'text' return 'text'
} else if (ext === '.epub') { } else if (ext === '.epub') {
return 'epub' return 'epub'

View File

@ -0,0 +1,17 @@
import type { SpecResource } from '../../types/collections.js'
/**
* Pure predicate for "is this a resource we host behind the entitlement Worker?"
*
* Deliberately kept free of any `#start/env` import. `zim_download_resolution` is
* a pure, unit-tested module, and importing the env-reading side of this (see
* hosted_content_auth.ts) would trigger env validation at module load and break
* those tests outside a configured app context.
*/
/** The only gating scheme we support today. See SpecResource.auth. */
export const NOMAD_APP_KEY_AUTH = 'nomad_app_key' as const
export function isGatedResource(resource: Pick<SpecResource, 'auth'>): boolean {
return resource.auth === NOMAD_APP_KEY_AUTH
}

View File

@ -0,0 +1,38 @@
import env from '#start/env'
import type { SpecResource } from '../../types/collections.js'
import { isGatedResource } from './hosted_content.js'
/**
* Auth for curated content that WE host and pay egress for.
*
* Content we host sits in a private R2 bucket behind the entitlement Worker,
* which requires a bearer key that only official release builds bake in (see the
* Dockerfile ARG/ENV pair, fed from the CI secret). That is the whole point: a
* fork rebuilt from source cannot point at our bucket and spend our bandwidth.
*
* A manifest resource opts in with `auth: 'nomad_app_key'`. Everything else keeps
* downloading unauthenticated exactly as before.
*
* Note on the key name: this deliberately reuses CREATOR_PACKS_APP_KEY rather
* than minting a second secret. The question it answers ("is this an official
* build?") is identical for Creator Packs and for our own hosted content, so a
* second CI secret plus a second Dockerfile ARG would be real cost for no
* security gain. The name is narrower than the use; this comment is cheaper than
* the churn of renaming it across CI, the Dockerfile and the Worker.
*
* The pure `isGatedResource` predicate lives in hosted_content.ts so that
* modules which must not pull in env validation can still use it.
*/
export function getHostedContentHeaders(
resource: Pick<SpecResource, 'auth'>
): Record<string, string> | undefined {
if (!isGatedResource(resource)) return undefined
const appKey = env.get('CREATOR_PACKS_APP_KEY')
if (!appKey) return undefined
// Deliberately still dispatches with no header when the key is absent: the
// Worker answers 401 and the download surfaces "official release build
// required", which is a more useful signal than a silent no-op.
return { Authorization: `Bearer ${appKey}` }
}

View File

@ -0,0 +1,68 @@
/**
* Pure helpers for turning the Docker daemon's platform strings into the fields
* the benchmark submission carries.
*
* These read the HOST's platform, which is the entire point: inside the admin
* container `os.arch()` and `si.osInfo()` describe the container, not the
* machine being benchmarked. `BenchmarkService` delegates to these so the string
* handling is unit-testable without a Docker daemon.
*
* Observed daemon output across the test fleet:
*
* Architecture 'x86_64' | 'aarch64'
* OSVersion '24.04' | '26.04'
* OperatingSystem 'Ubuntu 24.04.4 LTS' | 'Ubuntu 26.04 LTS'
*/
/**
* Canonicalise the daemon's architecture string to the OCI platform names used
* everywhere else in the project (image manifests, install docs, the
* leaderboard).
*
* Docker reports `x86_64` / `aarch64`; images and the board talk in `amd64` /
* `arm64`. A fixed two-way map rather than a general normalisation table: these
* are the only architectures NOMAD targets, and anything unrecognised passes
* through verbatim rather than being guessed at, so an unexpected platform shows
* up honestly instead of mislabelled.
*/
export function normalizeArchitecture(raw: string): string {
const map: Record<string, string> = {
x86_64: 'amd64',
amd64: 'amd64',
aarch64: 'arm64',
arm64: 'arm64',
}
const key = raw.trim().toLowerCase()
return map[key] ?? raw.trim()
}
/**
* Split the distro name out of the daemon's free-form OperatingSystem string.
*
* `OperatingSystem` is a description ('Ubuntu 24.04.4 LTS') while `OSVersion` is
* structured ('24.04'). Taking the text before the version yields the name
* without hand-maintaining a list of distributions:
*
* 'Ubuntu 24.04.4 LTS' + '24.04' -> 'Ubuntu'
* 'Ubuntu 26.04 LTS' + '26.04' -> 'Ubuntu'
* 'Debian GNU/Linux 12 (bookworm)' + '12' -> 'Debian GNU/Linux'
*
* Falls back to the full description whenever the version is missing, empty, or
* doesn't appear in the string. An over-long name is harmless; a wrong one is
* not, and silently truncating an unfamiliar distro would be worse than leaving
* it verbose.
*/
export function deriveOsName(operatingSystem: string, osVersion: string | null): string {
const description = operatingSystem.trim()
if (!osVersion) return description
const version = osVersion.trim()
if (version === '') return description
const idx = description.indexOf(version)
// idx === 0 means the string starts with the version and has no name to take.
if (idx <= 0) return description
const name = description.slice(0, idx).trim()
return name.length > 0 ? name : description
}

View File

@ -0,0 +1,64 @@
import type { CatalogResult } from '../services/kiwix_catalog_service.js'
import type { SpecResource } from '../../types/collections.js'
import { isGatedResource } from './hosted_content.js'
export type ResolvedZimDownload = {
url: string
version: string
sizeBytes: number | undefined
}
function compareZimVersions(left: string, right: string): number {
const parse = (value: string): [number, number] | null => {
const match = /^(\d{4})-(\d{1,2})$/.exec(value)
if (!match) return null
const month = Number.parseInt(match[2], 10)
if (month < 1 || month > 12) return null
return [Number.parseInt(match[1], 10), month]
}
const leftParts = parse(left)
const rightParts = parse(right)
if (!leftParts || !rightParts) return left.localeCompare(right)
return leftParts[0] - rightParts[0] || leftParts[1] - rightParts[1]
}
export function resolveZimDownload(
resource: SpecResource,
latest: CatalogResult | null
): ResolvedZimDownload {
const manifestSizeBytes = resource.size_mb > 0 ? resource.size_mb * 1024 * 1024 : undefined
// Content we host ourselves is pinned to the manifest URL, never the Kiwix
// catalog. It isn't in the openzim catalog at all, so this is normally a no-op
// — but a resource-id collision would otherwise silently redirect a gated
// download to a third-party mirror, losing both the auth header and any
// guarantee about what the bytes are.
//
// Consequence, stated rather than implied: gated content does NOT participate
// in catalog-driven auto-update. New versions ship by bumping the manifest.
if (isGatedResource(resource)) {
return {
url: resource.url,
version: resource.version,
sizeBytes: manifestSizeBytes,
}
}
if (!latest || compareZimVersions(latest.version, resource.version) < 0) {
return {
url: resource.url,
version: resource.version,
sizeBytes: manifestSizeBytes,
}
}
return {
url: latest.download_url,
version: latest.version,
sizeBytes: latest.size_bytes > 0 ? latest.size_bytes : manifestSizeBytes,
}
}

127
admin/app/utils/zim_html.ts Normal file
View File

@ -0,0 +1,127 @@
import * as cheerio from 'cheerio'
import { NON_CONTENT_HEADING_PATTERNS } from '../../constants/zim_extraction.js'
export interface ZIMSection {
heading: string
text: string
level: number
}
export interface StructuredContent {
title: string
sections: ZIMSection[]
fullText: string
}
/**
* True when a section heading is one of the low-signal boilerplate headings
* (See also / References / External links / etc.). Sections under these
* headings are reference apparatus, not article content, and shouldn't reach
* the embedder. (#902)
*/
export function isNonContentHeading(heading: string): boolean {
return NON_CONTENT_HEADING_PATTERNS.some((pattern) => pattern.test(heading))
}
/**
* Render an HTML <table> into delimited text. cheerio's `.text()` concatenates
* every cell with no separators ("AgeDoseAdult500mg" word salad), which is
* unsearchable and pollutes embeddings. Instead, join cells with " | " and rows
* with newlines so row/column structure survives into the chunk. (#902)
*/
export function tableToText($: cheerio.CheerioAPI, table: any): string {
const rows: string[] = []
$(table)
.find('tr')
.each((_, tr) => {
const cells = $(tr)
.find('th, td')
.map((__, cell) => $(cell).text().replace(/\s+/g, ' ').trim())
.get()
.filter((cell) => cell.length > 0)
if (cells.length > 0) {
rows.push(cells.join(' | '))
}
})
return rows.join('\n')
}
/**
* Break a cleaned article's HTML into heading-delimited sections for chunking.
* Skips non-content sections (References, See also, ...) at emit time and
* renders tables as delimited text rather than concatenated cell soup. (#902)
*/
export function extractStructuredContent(html: string): StructuredContent {
const $ = cheerio.load(html)
const title = $('h1').first().text().trim() || $('title').text().trim()
const sections: ZIMSection[] = []
let currentSection = { heading: 'Introduction', content: [] as string[], level: 2, skip: false }
const flushSection = () => {
if (!currentSection.skip && currentSection.content.length > 0) {
sections.push({
heading: currentSection.heading,
text: currentSection.content.join(' ').replace(/\s+/g, ' ').trim(),
level: currentSection.level,
})
}
}
// Walk the full DOM rather than only direct children of <body>. Modern ZIMs (Devdocs,
// Wikipedia, FreeCodeCamp, etc.) wrap article content in a container div, which under
// .children() would be a single non-heading/non-paragraph element and yield zero sections.
$('body')
.find('h2, h3, h4, p, ul, ol, dl, table')
.each((_, element) => {
const $el = $(element)
const tagName = element.tagName?.toLowerCase()
if (['h2', 'h3', 'h4'].includes(tagName)) {
// Save the section we just finished, then open the next one.
flushSection()
const heading = $el
.text()
.replace(/\[edit\]/gi, '')
.trim()
const level = Number.parseInt(tagName.substring(1)) // Extract number from h2, h3, h4
currentSection = {
heading,
content: [],
level,
skip: isNonContentHeading(heading),
}
} else if (['p', 'ul', 'ol', 'dl', 'table'].includes(tagName)) {
// Don't bother collecting content for a section we're going to drop.
if (currentSection.skip) return
const text = tagName === 'table' ? tableToText($, element) : $el.text().trim()
if (text.length > 0) {
currentSection.content.push(text)
}
}
})
// Push the last section if it has content
flushSection()
// Fallback: if the selector walk produced no sections but the body has meaningful
// text (unusual structure, minimal markup), emit one section with the full body text
// so the article still contributes to the knowledge base.
if (sections.length === 0) {
const bodyText = $('body').text().replace(/\s+/g, ' ').trim()
if (bodyText.length > 0) {
sections.push({
heading: title || 'Content',
text: bodyText,
level: 2,
})
}
}
return {
title,
sections,
fullText: sections.map((s) => `${s.heading}\n${s.text}`).join('\n\n'),
}
}

View File

@ -0,0 +1,25 @@
import vine from '@vinejs/vine'
/**
* "When to use what" request validators.
*
* Mirrors the drug_reference validators: vine, minimal, typed at the edge.
*/
/**
* GET /api/conditions/drugs
*
* Resolve OTC drugs for either a curated condition (`slug`) or a free-text
* situation (`q`). Both are optional at the schema level; the controller
* requires exactly one and 400s otherwise, so the error message is specific
* ("provide slug or q") rather than a generic vine union failure.
*/
export const conditionDrugsValidator = vine.compile(
vine.object({
slug: vine.string().trim().minLength(1).maxLength(80).optional(),
q: vine.string().trim().minLength(1).maxLength(200).optional(),
limit: vine.number().min(1).max(200).optional(),
route: vine.string().trim().minLength(1).maxLength(40).optional(),
sort: vine.enum(['relevance', 'name']).optional(),
})
)

View File

@ -9,6 +9,14 @@ export const specResourceValidator = vine.object({
description: vine.string(), description: vine.string(),
url: vine.string().url(), url: vine.string().url(),
size_mb: vine.number().min(0).optional(), size_mb: vine.number().min(0).optional(),
// Resource-type discriminator (absent == 'zim'). Required here because VineJS
// strips unknown keys, which would silently drop the field on manifest fetch.
type: vine.enum(['zim', 'dataset']).optional(),
// Gated-download discriminator (absent == unauthenticated). Declared here for
// the same reason as `type`: VineJS strips unknown keys, so omitting it would
// silently drop the field on manifest fetch and every gated download would go
// out with no Authorization header and 401.
auth: vine.enum(['nomad_app_key']).optional(),
}) })
// ---- ZIM Categories spec (versioned) ---- // ---- ZIM Categories spec (versioned) ----
@ -68,6 +76,31 @@ export const wikipediaSpecSchema = vine.object({
).minLength(1), ).minLength(1),
}) })
// ---- Creator Packs spec (versioned) ----
//
// Display metadata only — deliberately NO `url` field. The ZIM bytes are resolved
// through the entitlement Worker at install time (CreatorPackService), never from
// this public catalog. See project_content_creator_packs.
export const creatorPacksSpecSchema = vine.object({
spec_version: vine.string(),
packs: vine.array(
vine.object({
id: vine.string(),
name: vine.string(),
creator: vine.string(),
description: vine.string(),
version: vine.string(),
resource_id: vine.string(),
video_count: vine.number().min(0),
size_mb: vine.number().min(0),
license_id: vine.string(),
banner_url: vine.string().url().optional(),
poster_url: vine.string().url().optional(),
logo_url: vine.string().url().optional(),
})
).minLength(1),
})
// ---- Wikipedia validators (used by ZimService) ---- // ---- Wikipedia validators (used by ZimService) ----
export const wikipediaOptionSchema = vine.object({ export const wikipediaOptionSchema = vine.object({

View File

@ -0,0 +1,40 @@
import vine from '@vinejs/vine'
import { PRODUCT_TYPES } from '../../types/drug_reference.js'
/**
* Drug Reference v1 request validators.
*
* Mirrors the stl_library validators: vine, minimal, typed at the edge.
*/
const PRODUCT_TYPE_VALUES = Object.values(PRODUCT_TYPES) as [string, ...string[]]
/** GET /api/drug-reference/search */
export const searchDrugValidator = vine.compile(
vine.object({
q: vine.string().trim().minLength(1).maxLength(200),
product_type: vine.enum(PRODUCT_TYPE_VALUES).optional(),
// Administration-route filter (openFDA `route`, e.g. ORAL, TOPICAL). Matched
// with LIKE because the column holds a comma-joined list; the UI sends
// curated values, the cap just bounds free input.
route: vine.string().trim().minLength(2).maxLength(40).optional(),
sort: vine.enum(['relevance', 'name'] as const).optional(),
limit: vine.number().min(1).max(200).optional(),
offset: vine.number().min(0).optional(),
scope: vine.enum(['name', 'indication'] as const).optional(),
})
)
/**
* GET /api/drug-reference/interactions?ids=1,2,3
*
* Accepts `ids` as a comma-separated string. The controller parses the actual
* id values via parseCompareIds (dedupe, drop non-positive-int, cap at 5).
* Vine validates only that `ids` is a non-empty string; the pure helper does
* the real semantic validation so it can be unit-tested without a running app.
*/
export const interactionsValidator = vine.compile(
vine.object({
ids: vine.string().trim().minLength(1).maxLength(200).optional(),
})
)

View File

@ -0,0 +1,11 @@
import vine from '@vinejs/vine'
// Allow an empty/absent value so the user can clear their NOMAD.md — AdonisJS
// converts empty request strings to null, so `optional()` (coerced to '' in the
// controller) is what lets a "clear" through. The cap keeps a single system
// prompt from growing unbounded.
export const updateNomadMdSchema = vine.compile(
vine.object({
content: vine.string().maxLength(100_000).optional(),
})
)

View File

@ -11,6 +11,9 @@ export const chatSchema = vine.compile(
), ),
stream: vine.boolean().optional(), stream: vine.boolean().optional(),
sessionId: vine.number().positive().optional(), sessionId: vine.number().positive().optional(),
// Effective per-request thinking preference (per-model override or global default),
// resolved client-side. Omitted -> server falls back to the ai.autoThinking KV default.
think: vine.boolean().optional(),
}) })
) )

View File

@ -0,0 +1,33 @@
/*
|--------------------------------------------------------------------------
| Chat response schemas
|--------------------------------------------------------------------------
|
| Shapes mirror the serialized `ChatSession` / `ChatMessage` Lucid models
| (app/models/chat_session.ts, app/models/chat_message.ts). `messages` is only
| present when the relation is preloaded, so it is optional.
|
*/
import vine from '@vinejs/vine'
const chatMessage = vine.object({
id: vine.number(),
session_id: vine.number(),
role: vine.enum(['system', 'user', 'assistant'] as const),
content: vine.string(),
created_at: vine.string(),
updated_at: vine.string(),
})
const chatSession = vine.object({
id: vine.number(),
title: vine.string(),
model: vine.string().nullable(),
created_at: vine.string(),
updated_at: vine.string(),
messages: vine.array(chatMessage).optional(),
})
export const chatSessionResponse = vine.compile(chatSession)
export const chatSessionListResponse = vine.compile(vine.array(chatSession.clone()))
export const chatMessageResponse = vine.compile(chatMessage.clone())

View File

@ -0,0 +1,37 @@
/*
|--------------------------------------------------------------------------
| Shared response schemas
|--------------------------------------------------------------------------
|
| Response schemas are authored as VineJS validators purely so the OpenAPI
| generator can turn them into documented response bodies via `toJSONSchema()`.
| They are not (currently) used to validate outgoing responses though they
| could be asserted against in tests.
|
| Only add a schema here once its shape is verified against the controller /
| model it documents; a wrong response schema is worse than none.
|
*/
import vine from '@vinejs/vine'
/** `{ status: 'ok' }` health probes. */
export const healthResponse = vine.compile(
vine.object({
status: vine.string(),
})
)
/** The `{ error: '...' }` envelope returned on most failure paths. */
export const errorResponse = vine.compile(
vine.object({
error: vine.string(),
})
)
/** The `{ success, message? }` envelope used by many mutation endpoints. */
export const successMessageResponse = vine.compile(
vine.object({
success: vine.boolean(),
message: vine.string().optional(),
})
)

View File

@ -12,6 +12,8 @@ import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
import { AutoUpdateJob } from '#jobs/auto_update_job' import { AutoUpdateJob } from '#jobs/auto_update_job'
import { AppAutoUpdateJob } from '#jobs/app_auto_update_job' import { AppAutoUpdateJob } from '#jobs/app_auto_update_job'
import { ContentAutoUpdateJob } from '#jobs/content_auto_update_job' import { ContentAutoUpdateJob } from '#jobs/content_auto_update_job'
import { DownloadDrugDataJob } from '#jobs/download_drug_data_job'
import { IngestDrugDataJob } from '#jobs/ingest_drug_data_job'
export default class QueueWork extends BaseCommand { export default class QueueWork extends BaseCommand {
static commandName = 'queue:work' static commandName = 'queue:work'
@ -51,6 +53,7 @@ export default class QueueWork extends BaseCommand {
// Create a worker for each queue // Create a worker for each queue
for (const queueName of queuesToProcess) { for (const queueName of queuesToProcess) {
const stall = this.getStallOptionsForQueue(queueName)
const worker = new Worker( const worker = new Worker(
queueName, queueName,
async (job) => { async (job) => {
@ -65,7 +68,15 @@ export default class QueueWork extends BaseCommand {
{ {
connection: queueConfig.connection, connection: queueConfig.connection,
concurrency: this.getConcurrencyForQueue(queueName), concurrency: this.getConcurrencyForQueue(queueName),
lockDuration: 300000, // lockDuration/maxStalledCount are per-queue. Non-drug queues keep
// the existing default (300000, BullMQ's default maxStalledCount).
// The drug download/ingest queues are NEW per-queue overrides
// (1_800_000 / 3) — see getStallOptionsForQueue — not a change to
// the default applied to every other queue.
lockDuration: stall.lockDuration,
...(stall.maxStalledCount !== undefined
? { maxStalledCount: stall.maxStalledCount }
: {}),
autorun: true, autorun: true,
} }
) )
@ -172,6 +183,8 @@ export default class QueueWork extends BaseCommand {
handlers.set(AutoUpdateJob.key, new AutoUpdateJob()) handlers.set(AutoUpdateJob.key, new AutoUpdateJob())
handlers.set(AppAutoUpdateJob.key, new AppAutoUpdateJob()) handlers.set(AppAutoUpdateJob.key, new AppAutoUpdateJob())
handlers.set(ContentAutoUpdateJob.key, new ContentAutoUpdateJob()) handlers.set(ContentAutoUpdateJob.key, new ContentAutoUpdateJob())
handlers.set(DownloadDrugDataJob.key, new DownloadDrugDataJob())
handlers.set(IngestDrugDataJob.key, new IngestDrugDataJob())
queues.set(RunDownloadJob.key, RunDownloadJob.queue) queues.set(RunDownloadJob.key, RunDownloadJob.queue)
queues.set(RunExtractPmtilesJob.key, RunExtractPmtilesJob.queue) queues.set(RunExtractPmtilesJob.key, RunExtractPmtilesJob.queue)
@ -183,10 +196,35 @@ export default class QueueWork extends BaseCommand {
queues.set(AutoUpdateJob.key, AutoUpdateJob.queue) queues.set(AutoUpdateJob.key, AutoUpdateJob.queue)
queues.set(AppAutoUpdateJob.key, AppAutoUpdateJob.queue) queues.set(AppAutoUpdateJob.key, AppAutoUpdateJob.queue)
queues.set(ContentAutoUpdateJob.key, ContentAutoUpdateJob.queue) queues.set(ContentAutoUpdateJob.key, ContentAutoUpdateJob.queue)
queues.set(DownloadDrugDataJob.key, DownloadDrugDataJob.queue)
queues.set(IngestDrugDataJob.key, IngestDrugDataJob.queue)
return [handlers, queues] return [handlers, queues]
} }
/**
* Per-queue BullMQ stall-recovery options.
*
* Every queue except the two drug queues keeps the branch default
* (lockDuration 300000, and BullMQ's default maxStalledCount of 1 left
* unset). The drug download/ingest queues are the ONLY per-queue override:
* each part is a long single stream (a ~150 MB resumable HTTP pull, then an
* unzip + JSON-stream ingest at concurrency 1), so a longer lock plus a
* higher stalled tolerance keeps a transient lock-renewal miss from killing
* the continuation chain ("job stalled more than allowable limit").
*/
private getStallOptionsForQueue(
queueName: string
): { lockDuration: number; maxStalledCount?: number } {
if (
queueName === DownloadDrugDataJob.queue ||
queueName === IngestDrugDataJob.queue
) {
return { lockDuration: 1_800_000, maxStalledCount: 3 }
}
return { lockDuration: 300000 }
}
/** /**
* Get concurrency setting for a specific queue * Get concurrency setting for a specific queue
* Can be customized per queue based on workload characteristics * Can be customized per queue based on workload characteristics
@ -201,6 +239,12 @@ export default class QueueWork extends BaseCommand {
[RunBenchmarkJob.queue]: 1, // Run benchmarks one at a time for accurate results [RunBenchmarkJob.queue]: 1, // Run benchmarks one at a time for accurate results
[EmbedFileJob.queue]: 2, // Lower concurrency for embedding jobs, can be resource intensive [EmbedFileJob.queue]: 2, // Lower concurrency for embedding jobs, can be resource intensive
[CheckUpdateJob.queue]: 1, // No need to run more than one update check at a time [CheckUpdateJob.queue]: 1, // No need to run more than one update check at a time
// Drug download: one part at a time — a ~150 MB resumable HTTP pull per
// part, no benefit to parallelism and easier on the storage volume.
[DownloadDrugDataJob.queue]: 1,
// Drug ingest: one heavy stream at a time — unzipping + parsing ~150 MB
// of JSON into batched DB inserts; serial keeps memory bounded.
[IngestDrugDataJob.queue]: 1,
default: 3, default: 3,
} }

View File

@ -1,6 +1,7 @@
export const BROADCAST_CHANNELS = { export const BROADCAST_CHANNELS = {
BENCHMARK_PROGRESS: 'benchmark-progress', BENCHMARK_PROGRESS: 'benchmark-progress',
BENCHMARK_TELEMETRY: 'benchmark-telemetry',
OLLAMA_MODEL_DOWNLOAD: 'ollama-model-download', OLLAMA_MODEL_DOWNLOAD: 'ollama-model-download',
SERVICE_INSTALLATION: 'service-installation', SERVICE_INSTALLATION: 'service-installation',
SERVICE_UPDATES: 'service-updates', SERVICE_UPDATES: 'service-updates',

View File

@ -0,0 +1,40 @@
/**
* Curated starter tags shown in the collection picker. These are just
* suggested defaults the actual set of usable tags is open-ended, since
* `collection` is a free-form string and getKnowledgeCollections() returns
* whatever's actually in use (see RagService). Kept general-purpose rather
* than survival-specific so NOMAD's Knowledge Base reads well for home-lab,
* reference, and everyday use too.
*/
export const KB_COLLECTIONS = [
'recipes',
'diy',
'health',
'technology',
'finance',
'travel',
'hobbies',
'reference',
'survival',
'energy',
] as const
export type KbCollection = (typeof KB_COLLECTIONS)[number]
/** Hard cap on a user-created tag's length, enforced client- and server-side. */
export const KB_COLLECTION_NAME_MAX_LENGTH = 40
/**
* Normalize a user-entered collection name: trim whitespace, lowercase, cap
* length. Returns null for empty/whitespace-only input, meaning
* "uncategorized". Lowercasing is what makes de-dupe work "Medical" and
* "medical" normalize to the same tag rather than forking into two, so this
* must run on every write path (upload, reassignment, rename) both
* client-side for instant feedback and server-side as the actual guarantee.
*/
export function sanitizeCollectionName(raw: string | null | undefined): string | null {
if (!raw) return null
const trimmed = raw.trim().toLowerCase()
if (!trimmed) return null
return trimmed.slice(0, KB_COLLECTION_NAME_MAX_LENGTH)
}

View File

@ -1,3 +1,27 @@
import { KVStoreKey } from "../types/kv_store.js"; import { KVStoreKey } from "../types/kv_store.js";
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'system.internetStatusTestUrl', 'ai.assistantCustomName', 'ai.remoteOllamaUrl', 'ai.ollamaFlashAttention', 'rag.defaultIngestPolicy', 'autoUpdate.enabled', 'autoUpdate.windowStart', 'autoUpdate.windowEnd', 'autoUpdate.cooloffHours', 'appAutoUpdate.enabled', 'contentAutoUpdate.enabled', 'contentAutoUpdate.windowStart', 'contentAutoUpdate.windowEnd', 'contentAutoUpdate.cooloffHours', 'contentAutoUpdate.maxBytesPerWindow']; export const SETTINGS_KEYS: KVStoreKey[] = [
'chat.suggestionsEnabled',
'chat.lastModel',
'ui.hasVisitedEasySetup',
'ui.theme',
'system.earlyAccess',
'system.internetStatusTestUrl',
'ai.assistantCustomName',
'ai.remoteOllamaUrl',
'ai.ollamaFlashAttention',
'ai.autoThinking',
'rag.defaultIngestPolicy',
'autoUpdate.enabled',
'autoUpdate.windowStart',
'autoUpdate.windowEnd',
'autoUpdate.cooloffHours',
'appAutoUpdate.enabled',
'contentAutoUpdate.enabled',
'contentAutoUpdate.windowStart',
'contentAutoUpdate.windowEnd',
'contentAutoUpdate.cooloffHours',
'contentAutoUpdate.maxBytesPerWindow',
'benchmark.rerunBannerDismissed',
'apps.homebox.apiKeyPepper'
];

View File

@ -130,7 +130,7 @@ Do NOT use:
Return ONLY the 3 suggestions as a comma-separated list with no additional text, formatting, numbering, or quotation marks. Return ONLY the 3 suggestions as a comma-separated list with no additional text, formatting, numbering, or quotation marks.
The suggestions should be in title case. The suggestions should be in title case.
Ensure that your suggestions are comma-seperated with no conjunctions like "and" or "or". Ensure that your suggestions are comma-separated with no conjunctions like "and" or "or".
Do not use line breaks, new lines, or extra spacing to separate the suggestions. Do not use line breaks, new lines, or extra spacing to separate the suggestions.
Format: suggestion1, suggestion2, suggestion3 Format: suggestion1, suggestion2, suggestion3
`, `,

View File

@ -0,0 +1,22 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'benchmark_results'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// Forensic harness metadata (Score v2 Phase 1): which sysbench image digest produced the
// system scores, and which Ollama version served the AI benchmark. Both nullable —
// pre-existing rows and runs without AI simply leave them empty.
table.string('sysbench_digest').nullable()
table.string('ollama_version').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('sysbench_digest')
table.dropColumn('ollama_version')
})
}
}

View File

@ -0,0 +1,17 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'kb_ingest_state'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.string('collection').nullable().index()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('collection')
})
}
}

View File

@ -0,0 +1,37 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
import { SERVICE_NAMES } from '../../constants/service_names.js'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
// Sunset the Meshtastic Daemon catalog entry. It was removed from the seeder's DEFAULT_SERVICES
// because it can't work without hands-on setup (the user's radio MAC address, etc.), so leaving
// the card in the catalog only offers a broken install. The seeder is additive + sync-existing
// and never deletes, so every deployment seeded while it was in the catalog (all early-access
// boxes) keeps an orphaned `nomad_meshtasticd` row and still shows the non-functional card.
// Mirror the legacy-Kolibri sunset; the `is_deprecated` column already exists from that migration.
this.defer(async (db) => {
// Never installed → just an orphaned catalog row; drop it outright so the card disappears.
await db
.from(this.tableName)
.where('service_name', SERVICE_NAMES.MESHTASTICD)
.where('installed', false)
.delete()
// Currently installed (rare) → keep the row so it stays Nomad's handle to stop/uninstall the
// container, but flag it deprecated: it drops out of the catalog (see SystemService.getServices)
// and shows a "Legacy" badge. Honors the "we don't remove pre-installed apps" policy.
await db
.from(this.tableName)
.where('service_name', SERVICE_NAMES.MESHTASTICD)
.where('installed', true)
.update({ is_deprecated: true })
})
}
async down() {
// The orphaned-row deletion is a one-way data change and is not restored here. The is_deprecated
// column is owned by the legacy-Kolibri migration, so there is nothing schema-level to revert.
}
}

View File

@ -0,0 +1,49 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'benchmark_results'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// NOMAD Score v2 (Phase 4): raw channel values the leaderboard scores from,
// the frozen test parameters, the W6 consistency companions, the uncapped
// score, and best-effort run-environment metadata (#1016). All nullable —
// pre-v2 rows and system-only runs simply leave them empty.
// double (not float): Knex's MySQL float is float(8,2) — max 999999.99 and
// rounded to 2 decimals. memory_ops_per_sec / cpu_total_events run into the
// millions, and rounding raws would break byte-match with the leaderboard's
// server-side score recompute. double holds full-precision large values.
table.double('cpu_events_single').nullable()
table.double('cpu_events_multi').nullable()
table.integer('cpu_benchmark_threads').nullable()
table.double('cpu_total_events').nullable()
table.double('cpu_total_time').nullable()
table.double('memory_ops_per_sec').nullable()
table.integer('memory_threads').nullable()
table.double('disk_read_mb_per_sec').nullable()
table.double('disk_write_mb_per_sec').nullable()
table.double('nomad_score_v2').nullable()
table.string('run_environment').nullable()
table.string('storage_path_type').nullable()
table.boolean('gpu_compute_detected').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('cpu_events_single')
table.dropColumn('cpu_events_multi')
table.dropColumn('cpu_benchmark_threads')
table.dropColumn('cpu_total_events')
table.dropColumn('cpu_total_time')
table.dropColumn('memory_ops_per_sec')
table.dropColumn('memory_threads')
table.dropColumn('disk_read_mb_per_sec')
table.dropColumn('disk_write_mb_per_sec')
table.dropColumn('nomad_score_v2')
table.dropColumn('run_environment')
table.dropColumn('storage_path_type')
table.dropColumn('gpu_compute_detected')
})
}
}

View File

@ -0,0 +1,29 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'benchmark_results'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// Platform metadata (Score v2). The leaderboard is a single board across
// instruction sets by design, with disclosure as the fairness mechanism —
// without an architecture field an ARM result is indistinguishable from an
// x86 one, which is exactly what the disclosure is meant to prevent.
//
// All sourced from the Docker daemon rather than systeminformation, because
// si.osInfo()/os.arch() inside the admin container describe the CONTAINER,
// not the host. Nullable — pre-existing rows simply leave them empty.
table.string('cpu_architecture').nullable()
table.string('os_name').nullable()
table.string('os_version').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('cpu_architecture')
table.dropColumn('os_name')
table.dropColumn('os_version')
})
}
}

View File

@ -0,0 +1,119 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Drug Reference v1 openFDA drug label catalog.
*
* ~259k rows of FDA drug labels (Rx + OTC), downloaded from the openFDA bulk
* export and streamed into MySQL in 500-row batches. The idempotent upsert key
* is `set_id` a stable GUID for a labeling across all revisions. Re-running
* the ingest refreshes existing rows in place; no manual purge needed.
*
* Section text columns use mediumtext (up to 16 MB) so no openFDA section is
* truncated. The `searchable_name` varchar(768) is indexed (idx_drug_labels_searchable_name).
* Under utf8mb4 that column is 768 × 4 = 3072 bytes, which is exactly the InnoDB
* index-prefix limit for the DYNAMIC / COMPRESSED row formats (DYNAMIC is the
* MySQL 8.0 default). It would overflow the 767-byte limit of the older
* REDUNDANT / COMPACT row formats, so this depends on the 8.0 default row format.
* If a deployment forces an older row format, drop the column to varchar(191)
* (191 × 4 = 764 767) to stay within the 767-byte budget.
*
* The FULLTEXT index is created in a guarded try/catch so a non-InnoDB engine
* or an older MySQL version that doesn't support FULLTEXT doesn't break the
* migration. The search service degrades gracefully to LIKE when FULLTEXT is
* unavailable.
*/
export default class extends BaseSchema {
protected tableName = 'drug_labels'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.bigIncrements('id').primary()
// Idempotent upsert key. UNIQUE enforced in DB so re-ingest never dupes.
table.string('set_id', 64).notNullable().unique('uniq_drug_labels_set_id')
// Per-revision GUID for provenance (not the upsert key).
table.string('spl_id', 64).nullable()
table.string('version', 16).nullable()
// Identity fields — sourced from openfda sub-object.
table.string('brand_name', 255).nullable()
table.string('generic_name', 512).nullable()
table.string('manufacturer', 512).nullable()
table.string('product_ndc', 255).nullable()
table.string('route', 255).nullable()
// OTC vs Rx discriminator. Expected: 'HUMAN OTC DRUG' | 'HUMAN PRESCRIPTION DRUG'.
// Plain varchar — not a native enum — to allow future product type additions
// without ALTER TABLE (stl_files convention).
table.string('product_type', 32).nullable()
// Normalized brand+generic blob — computed once at ingest, never on read.
// 768 chars × 4 bytes (utf8mb4) = 3072 = the InnoDB DYNAMIC/COMPRESSED
// index-prefix limit (see header note).
table.string('searchable_name', 768).nullable()
// Section text — mediumtext so even the longest FDA label bodies are stored
// in full (indications/dosage/warnings can be multiple pages of text).
table.specificType('indications', 'mediumtext').nullable()
table.specificType('dosage', 'mediumtext').nullable()
table.specificType('warnings', 'mediumtext').nullable()
table.specificType('boxed_warning', 'mediumtext').nullable()
table.specificType('drug_interactions', 'mediumtext').nullable()
table.specificType('contraindications', 'mediumtext').nullable()
// when_using / stop_use are OTC-specific and typically shorter.
table.text('when_using').nullable()
table.text('stop_use').nullable()
// Label version date, parsed from effective_time (YYYYMMDD → 'YYYY-MM-DD').
// Stored as a fixed-width varchar, not a DATE column, so it round-trips as a
// plain string: the model declares it string|null, but mysql2 hands back a
// JS Date for a DATE column. v1 never range-queries this field, and
// 'YYYY-MM-DD' sorts chronologically as text.
table.string('source_updated_at', 10).nullable()
// Set on every upsert pass — tracks when this row was last refreshed.
table.timestamp('ingested_at').notNullable()
// ── Non-FULLTEXT indexes ────────────────────────────────────────────────
// OTC vs Rx filter pill.
table.index('product_type', 'idx_drug_labels_product_type')
// LIKE fallback + alpha sort on the brand name column.
table.index('brand_name', 'idx_drug_labels_brand')
// LIKE fallback on the normalized search blob.
table.index('searchable_name', 'idx_drug_labels_searchable_name')
})
// ── FULLTEXT index — guarded so a non-InnoDB engine doesn't block migration ──
//
// MySQL 8.0 InnoDB supports FULLTEXT natively (confirmed: repo uses mysql:8.0).
// The guard ensures a future engine change or a fresh install on an engine
// without FULLTEXT doesn't brick the migration runner — the search service
// degrades to LIKE on a MATCH() failure, so an absent index is non-fatal.
//
// Only the name index ships in v1; search MATCHes searchable_name. A combined
// name+indications index (search-by-what-it-treats) is deferred: FULLTEXT
// can't take a prefix length, and indexing the full mediumtext body adds heavy
// index weight v1 doesn't use.
//
// Deferred so it runs AFTER createTable executes — Lucid's schema builder
// is deferred, so a bare ALTER here would hit a not-yet-created table. The
// this.defer(db => …) pattern (see 1775100000001_create_custom_library_sources_table.ts)
// queues it to run on the live connection once the table exists.
this.defer(async (db) => {
try {
await db.rawQuery(
`ALTER TABLE drug_labels ADD FULLTEXT INDEX ft_drug_labels_name (searchable_name)`
)
} catch {
// Non-InnoDB or FULLTEXT unsupported — search falls back to LIKE.
}
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,44 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Drug Reference v2 combined name+indications FULLTEXT index.
*
* Adds a FULLTEXT index over (searchable_name, indications) so users can
* search by what a drug treats ("heartburn", "high blood pressure") in
* addition to the existing name-only search path.
*
* Design notes:
* - FULLTEXT indexes cannot take a column prefix length, so the full
* mediumtext body of `indications` is indexed. On ~259k rows this adds
* meaningful index weight.
* - The guard mirrors the existing ft_drug_labels_name guard in migration
* 1778600000004: a non-InnoDB engine or a MySQL version without FULLTEXT
* support must not block the migration runner. The indication-search path
* degrades gracefully to a LIKE fallback when the index is absent.
* - The MATCH() column list in DrugReferenceService MUST be exactly
* (searchable_name, indications) matching this index or MySQL will
* refuse the query with "Can't find FULLTEXT index matching the column list".
*/
export default class extends BaseSchema {
async up() {
// ── Combined name+indications FULLTEXT index — guarded ──────────────────
try {
await this.db.rawQuery(
`ALTER TABLE drug_labels ADD FULLTEXT INDEX ft_drug_labels_name_indications (searchable_name, indications)`
)
} catch {
// Non-InnoDB or FULLTEXT unsupported — indication search falls back to LIKE.
}
}
async down() {
// ── Drop guarded — index may not exist if up() guard caught an error ────
try {
await this.db.rawQuery(
`ALTER TABLE drug_labels DROP INDEX ft_drug_labels_name_indications`
)
} catch {
// Index never existed — nothing to drop.
}
}
}

View File

@ -0,0 +1,16 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'drug_labels'
async up() {
await this.db.rawQuery(
'ALTER TABLE drug_labels MODIFY COLUMN ingested_at timestamp NOT NULL ' +
'DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
)
}
async down() {
await this.db.rawQuery('ALTER TABLE drug_labels MODIFY COLUMN ingested_at timestamp NOT NULL')
}
}

View File

@ -1,12 +1,12 @@
# About Project N.O.M.A.D. # About Project NOMAD
Project N.O.M.A.D. (Node for Offline Media, Archives, and Data; "Nomad" for short) is a project started in 2025 by Chris Sherwood of [Crosstalk Solutions, LLC](https://crosstalksolutions.com). The goal of the project is not to create just another utility for storing offline resources, but rather to allow users to run their own ultimate "survival computer". Project NOMAD ("NOMAD" for short) is a project started in 2025 by Chris Sherwood of [Crosstalk Solutions, LLC](https://crosstalksolutions.com). The goal of the project is not to create just another utility for storing offline resources, but rather to allow users to run their own ultimate "survival computer". The name started as a backronym, Node for Offline Maps, Archives, and Data, but these days we just call it NOMAD.
While many similar offline survival computers are designed to be run on bare-minimum, lightweight hardware, Project N.O.M.A.D. is quite the opposite. To install and run the available AI tools, we highly encourage the use of a beefy, GPU-backed device to make the most of your install. See the [Hardware Guide](https://www.projectnomad.us/hardware) for detailed build recommendations at three price points. While many similar offline survival computers are designed to be run on bare-minimum, lightweight hardware, Project NOMAD is quite the opposite. To install and run the available AI tools, we highly encourage the use of a beefy, GPU-backed device to make the most of your install. See the [Hardware Guide](https://www.projectnomad.us/hardware) for detailed build recommendations at three price points.
Since its initial release, NOMAD has grown to include built-in AI chat with a Knowledge Base for document-aware responses, a System Benchmark with a community leaderboard, curated content collections with tiered options, and an Easy Setup Wizard to get new users up and running quickly. Since its initial release, NOMAD has grown to include built-in AI chat with a Knowledge Base for document-aware responses, a System Benchmark with a community leaderboard, curated content collections with tiered options, and an Easy Setup Wizard to get new users up and running quickly.
Project N.O.M.A.D. is open source, released under the [Apache License 2.0](https://github.com/Crosstalk-Solutions/project-nomad/blob/main/LICENSE). Project NOMAD is open source, released under the [Apache License 2.0](https://github.com/Crosstalk-Solutions/project-nomad/blob/main/LICENSE).
## Links ## Links

View File

@ -1,6 +1,19 @@
# API Reference # API Reference
N.O.M.A.D. exposes a REST API for all operations. All endpoints are under `/api/` and return JSON. NOMAD exposes a REST API for all operations. All endpoints are under `/api/` and return JSON.
---
## Interactive reference
The full, always-current endpoint reference is generated directly from the application's
routes and validators and served as an interactive [Scalar](https://scalar.com) UI:
- **[/reference](/reference)** — browse every endpoint, request/response schema, and try calls live
- **[/api/openapi.json](/api/openapi.json)** — the raw OpenAPI 3.1 document (import into Postman, Insomnia, codegen, etc.)
Because it is derived from the same VineJS validators the API validates against, it never drifts
from the implementation. Prefer it over any hand-written endpoint list.
--- ---
@ -14,196 +27,3 @@ N.O.M.A.D. exposes a REST API for all operations. All endpoints are under `/api/
- Long-running operations (downloads, benchmarks, embeddings) return 201 or 202 with a job/benchmark ID for polling - Long-running operations (downloads, benchmarks, embeddings) return 201 or 202 with a job/benchmark ID for polling
**Async pattern:** Submit a job → receive an ID → poll a status endpoint until complete. **Async pattern:** Submit a job → receive an ID → poll a status endpoint until complete.
---
## Health
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/health` | Returns `{ "status": "ok" }` |
---
## System
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/system/info` | CPU, memory, disk, and platform info |
| GET | `/api/system/internet-status` | Check internet connectivity |
| GET | `/api/system/debug-info` | Detailed debug information |
| GET | `/api/system/latest-version` | Check for the latest N.O.M.A.D. version |
| POST | `/api/system/update` | Trigger a system update |
| GET | `/api/system/update/status` | Get update progress |
| GET | `/api/system/update/logs` | Get update operation logs |
| GET | `/api/system/settings` | Get a setting value (query param: `key`) |
| PATCH | `/api/system/settings` | Update a setting (`{ key, value }`) |
| POST | `/api/system/subscribe-release-notes` | Subscribe an email to release notes |
### Services
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/system/services` | List all services with status |
| POST | `/api/system/services/install` | Install a service |
| POST | `/api/system/services/force-reinstall` | Force reinstall a service |
| POST | `/api/system/services/affect` | Start, stop, or restart a service (body: `{ name, action }`) |
| POST | `/api/system/services/check-updates` | Check for available service updates |
| POST | `/api/system/services/update` | Update a service to a specific version |
| GET | `/api/system/services/:name/available-versions` | List available versions for a service |
---
## AI Chat
### Models
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/ollama/models` | List available models (supports filtering, sorting, pagination) |
| GET | `/api/ollama/installed-models` | List locally installed models |
| POST | `/api/ollama/models` | Download a model (async, returns job) |
| DELETE | `/api/ollama/models` | Delete an installed model |
### Chat
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/ollama/chat` | Send a chat message. Supports streaming (SSE) and RAG context injection. Body: `{ model, messages, stream?, useRag? }` |
| GET | `/api/chat/suggestions` | Get suggested chat prompts |
### Remote Ollama
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/ollama/configure-remote` | Configure a remote Ollama or LM Studio instance |
| GET | `/api/ollama/remote-status` | Check remote Ollama connection status |
### Chat Sessions
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/chat/sessions` | List all chat sessions |
| POST | `/api/chat/sessions` | Create a new session |
| GET | `/api/chat/sessions/:id` | Get a session with its messages |
| PUT | `/api/chat/sessions/:id` | Update session metadata (title, etc.) |
| DELETE | `/api/chat/sessions/:id` | Delete a session |
| DELETE | `/api/chat/sessions/all` | Delete all sessions |
| POST | `/api/chat/sessions/:id/messages` | Add a message to a session |
**Streaming:** The `/api/ollama/chat` endpoint supports Server-Sent Events (SSE) when `stream: true` is passed. Connect using `EventSource` or `fetch` with a streaming reader.
---
## Knowledge Base (RAG)
Upload documents to enable AI-powered retrieval during chat.
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/rag/upload` | Upload a file for embedding (async, 202 response) |
| GET | `/api/rag/files` | List stored RAG files |
| DELETE | `/api/rag/files` | Delete a file (query param: `source`) |
| GET | `/api/rag/active-jobs` | List active embedding jobs |
| GET | `/api/rag/job-status` | Get status for a specific file embedding job |
| GET | `/api/rag/failed-jobs` | List failed embedding jobs |
| DELETE | `/api/rag/failed-jobs` | Clean up failed jobs and delete associated files |
| POST | `/api/rag/sync` | Scan storage and sync database with filesystem |
---
## ZIM Files (Offline Content)
ZIM files provide offline Wikipedia, books, and other content via Kiwix.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/zim/list` | List locally stored ZIM files |
| GET | `/api/zim/list-remote` | List remote ZIM files (paginated, supports search) |
| GET | `/api/zim/curated-categories` | List curated categories with Essential/Standard/Comprehensive tiers |
| POST | `/api/zim/download-remote` | Download a remote ZIM file (async) |
| POST | `/api/zim/download-category-tier` | Download a full category tier |
| DELETE | `/api/zim/:filename` | Delete a local ZIM file |
### Wikipedia
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/zim/wikipedia` | Get current Wikipedia selection state |
| POST | `/api/zim/wikipedia/select` | Select a Wikipedia edition and tier |
---
## Maps
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/maps/regions` | List available map regions |
| GET | `/api/maps/styles` | Get map styles JSON |
| GET | `/api/maps/curated-collections` | List curated map collections |
| POST | `/api/maps/fetch-latest-collections` | Fetch latest collection metadata from source |
| POST | `/api/maps/download-base-assets` | Download base map assets |
| POST | `/api/maps/download-remote` | Download a remote map file (async) |
| POST | `/api/maps/download-remote-preflight` | Check download size/info before starting |
| POST | `/api/maps/download-collection` | Download an entire collection by slug (async) |
| DELETE | `/api/maps/:filename` | Delete a local map file |
### Map Markers
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/maps/markers` | List map markers |
| POST | `/api/maps/markers` | Add map marker (body: {"name": "Test Marker", "notes": "Example note", "longitude": 0.0, "latitude": 0.0, "color": "yellow", "marker_type": "pin"} ) |
| PATCH | `/api/maps/markers/{id}` | Update a map marker (body: {"name": "Test Marker", "notes": "Example note", "longitude": 0.0, "latitude": 0.0, "color": "yellow", "marker_type": "pin"} ) fields that don't change can be omitted|
| DELETE | `/api/maps/markers/{id}` | Delete a map marker |
---
## Downloads
Manage background download jobs for maps, ZIM files, and models.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/downloads/jobs` | List all download jobs |
| GET | `/api/downloads/jobs/:filetype` | List jobs filtered by type (`zim`, `map`, etc.) |
| DELETE | `/api/downloads/jobs/:jobId` | Cancel and remove a download job |
---
## Benchmarks
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/benchmark/run` | Run a benchmark (`full`, `system`, or `ai`; can be async) |
| POST | `/api/benchmark/run/system` | Run system-only benchmark |
| POST | `/api/benchmark/run/ai` | Run AI-only benchmark |
| GET | `/api/benchmark/status` | Get current benchmark status (`idle` or `running`) |
| GET | `/api/benchmark/results` | Get all benchmark results |
| GET | `/api/benchmark/results/latest` | Get the most recent result |
| GET | `/api/benchmark/results/:id` | Get a specific result |
| POST | `/api/benchmark/submit` | Submit a result to the central repository |
| POST | `/api/benchmark/builder-tag` | Update builder tag metadata for a result |
| GET | `/api/benchmark/comparison` | Get comparison stats from the repository |
| GET | `/api/benchmark/settings` | Get benchmark settings |
| POST | `/api/benchmark/settings` | Update benchmark settings |
---
## Easy Setup & Content Updates
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/easy-setup/curated-categories` | List curated content categories for setup wizard |
| POST | `/api/manifests/refresh` | Refresh manifest caches (`zim_categories`, `maps`, `wikipedia`) |
| POST | `/api/content-updates/check` | Check for available collection updates |
| POST | `/api/content-updates/apply` | Apply a single content update |
| POST | `/api/content-updates/apply-all` | Apply multiple content updates |
---
## Documentation
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/docs/list` | List all available documentation files |

View File

@ -1,8 +1,8 @@
# Community Add-Ons # Community Add-Ons
Project N.O.M.A.D. ships with a curated set of built-in tools and content, but the community has started building add-ons that extend the platform with specialized offline content packs. These are third-party projects, not maintained by the N.O.M.A.D. team. Install them at your own discretion, and please direct any bugs or feature requests to the add-on's own repository. Project NOMAD ships with a curated set of built-in tools and content, but the community has started building add-ons that extend the platform with specialized offline content packs. These are third-party projects, not maintained by the NOMAD team. Install them at your own discretion, and please direct any bugs or feature requests to the add-on's own repository.
Have you built a NOMAD add-on? Open an issue on the [Project N.O.M.A.D. GitHub repository](https://github.com/Crosstalk-Solutions/project-nomad/issues/new) or send us a note through the [contact form on projectnomad.us](https://www.projectnomad.us/contact), and we'll review it for inclusion on this page. Have you built a NOMAD add-on? Open an issue on the [Project NOMAD GitHub repository](https://github.com/Crosstalk-Solutions/project-nomad/issues/new) or send us a note through the [contact form on projectnomad.us](https://www.projectnomad.us/contact), and we'll review it for inclusion on this page.
--- ---
@ -45,4 +45,4 @@ Expect the initial build to take anywhere from a few minutes to an hour or more
## A Note on Support ## A Note on Support
These add-ons are community-built and community-maintained. If something goes wrong with an install script or the content inside a ZIM, please open an issue on the add-on's own repository rather than Project N.O.M.A.D.'s. We're happy to help if the issue is with NOMAD itself, for example if Kiwix isn't picking up a new ZIM after an install, but we can't maintain or support third-party content. These add-ons are community-built and community-maintained. If something goes wrong with an install script or the content inside a ZIM, please open an issue on the add-on's own repository rather than Project NOMAD's. We're happy to help if the issue is with NOMAD itself, for example if Kiwix isn't picking up a new ZIM after an install, but we can't maintain or support third-party content.

View File

@ -0,0 +1,77 @@
# Drug Reference
The Drug Reference is an offline, searchable database of **FDA drug labels**, the official information that comes with over-the-counter and prescription medicines. Once it is installed you can look up a medicine by name, work backwards from a situation to the medicines that treat it, and put two labels side by side, all with no internet connection.
It is an optional add-on. A fresh NOMAD does not have it until you choose to install it, because the dataset is large.
> **This is health information, not medical advice.** The Drug Reference shows you the manufacturer's FDA label text and matches situations to over-the-counter options. It cannot replace a doctor, pharmacist, or nurse. Always follow the directions on the actual product you have, and in a real emergency get professional help if you can.
The first time you open the Drug Reference in a browser, you will see this warning as a dialog you have to acknowledge before the page will load. That acknowledgement is remembered per browser, so a different browser or device will show it again.
---
## Installing it
There are two ways to get the data, and they end up in the same place.
**From the Content Explorer**, as part of a collection:
1. From the home screen, open **Content Explorer**.
2. Choose the **Medicine** category.
3. Select the **Standard** tier. Its contents are listed on the card, and you will see **FDA Drug Reference** among them.
4. Confirm the download.
**From the Drug Reference page itself.** Open **Drug Reference** from the home screen. If no data is installed you will get a "No FDA drug data yet" panel with a **Download FDA drug data** button, which starts the same process.
Either way it runs in two stages in the background:
- **Download** — NOMAD pulls the openFDA drug-label dataset, about **1.7 GB** compressed, in several parts. If your connection drops it picks up where it left off.
- **Indexing** — NOMAD ingests those labels into a fast offline search database. This is the longer stage, and the data expands to roughly **8 to 10 GB** on disk.
You do not have to sit and watch. Leave the page and it keeps going, and search switches on by itself once indexing finishes. The page shows progress for both stages while they run.
---
## Finding your way around
Everything lives behind a single **Drug Reference** tile on the home screen. Once data is installed, the page has three tabs.
### Search by drug
Type a drug name, brand or generic, and NOMAD shows matching FDA labels: what the medicine is for, dosing, warnings, and ingredients, straight from the manufacturer's official label.
Results are **grouped by active ingredient** rather than listed as hundreds of near-identical products. A search for a common painkiller returns one group per ingredient instead of every store brand separately, so you can see what you are actually choosing between.
### By situation
Start from the problem instead of the product. Pick one or more situations, such as burn, fever, or diarrhea, and NOMAD lists the medicines whose FDA labels cover them.
Selecting more than one situation looks for medicines that cover **all** of them first, then falls back to showing results for each situation on its own. That is useful when you are dealing with more than one symptom at once and want a single product if one exists.
### FDA data
Shows where the data came from and its current state: whether it is downloaded, indexed, and how many labels are loaded. This is also where you go to re-run a download or restart indexing if something needs attention.
---
## Comparing two medicines
From a drug's detail page, use **Compare label warnings** to put two labels side by side and read what each one says.
This puts the two manufacturers' warning sections next to each other. It does **not** calculate drug interactions, and it will not tell you whether a combination is safe. Deciding whether two medicines can be taken together is exactly the kind of question to put to a pharmacist or doctor.
---
## Keeping it current
FDA labels change over time. If you have turned on **automatic content updates** (Settings → Updates), NOMAD periodically checks whether openFDA has published a newer dataset and refreshes the Drug Reference on its own, the same way it handles your other offline content.
With automatic updates off, the data stays exactly as it was when you installed it, which is fine for offline use. You can always re-run the download from the **FDA data** tab to pull the latest.
---
## A note on storage
The Drug Reference is the largest single item in the Medicine → Standard collection. Budget around **8 to 10 GB** of disk for it after indexing, on top of the 1.7 GB download.
If storage is tight, the Content Explorer shows the full size of a tier before you commit, so you can see what you are taking on.

View File

@ -2,17 +2,26 @@
## General Questions ## General Questions
### What is N.O.M.A.D.? ### What is NOMAD?
N.O.M.A.D. (Node for Offline Media, Archives, and Data) is a personal server that gives you access to knowledge, education, and AI assistance without requiring an internet connection. It runs on your own hardware, keeping your data private and accessible anytime. NOMAD is a personal server that gives you access to knowledge, education, and AI assistance without requiring an internet connection. It runs on your own hardware, keeping your data private and accessible anytime.
### Do I need internet to use N.O.M.A.D.? ### Do I need internet to use NOMAD?
No — that's the whole point. Once your content is downloaded, everything works offline. You only need internet to: No — that's the whole point. Once your content is downloaded, everything works offline. You only need internet to:
- Download new content - Download new content
- Update the software - Update the software
- Sync the latest versions of Wikipedia, maps, etc. - Sync the latest versions of Wikipedia, maps, etc.
### What operating system does NOMAD need?
Debian-based Linux. **Ubuntu 26.04 LTS is what we recommend and test on** for new installs.
Ubuntu 24.04 LTS and Debian 12 are also supported, so there is no need to reinstall if you are already on one of those. Windows users can follow the [WSL2 guide](https://www.projectnomad.us/install/wsl2), which is community-supported.
macOS and non-Debian distributions like Fedora or Arch are not officially supported. NOMAD does not need a desktop environment, so Ubuntu Server is a fine choice if you are comfortable at the terminal.
For a full walkthrough including the Ubuntu install itself, see the [Installation Guide](https://www.projectnomad.us/install).
### What hardware do I need? ### What hardware do I need?
N.O.M.A.D. is designed for capable hardware, especially if you want to use the AI features. Recommended: NOMAD is designed for capable hardware, especially if you want to use the AI features. Recommended:
- Modern multi-core CPU (AMD Ryzen 7 with Radeon graphics is the community sweet spot) - Modern multi-core CPU (AMD Ryzen 7 with Radeon graphics is the community sweet spot)
- 16GB+ RAM (32GB+ for best AI performance) - 16GB+ RAM (32GB+ for best AI performance)
- SSD storage (size depends on content — 500GB minimum, 1TB+ recommended) - SSD storage (size depends on content — 500GB minimum, 1TB+ recommended)
@ -54,7 +63,7 @@ Content is as current as when it was last downloaded. Wikipedia snapshots are ty
### Can I add my own files? ### Can I add my own files?
Yes — with the Knowledge Base. Upload PDFs, text files, and other documents to the [Knowledge Base](/knowledge-base), and the AI can reference them when answering your questions. This uses semantic search to find relevant information from your uploaded files. Yes — with the Knowledge Base. Upload PDFs, text files, and other documents to the [Knowledge Base](/knowledge-base), and the AI can reference them when answering your questions. This uses semantic search to find relevant information from your uploaded files.
For Kiwix content, N.O.M.A.D. uses standard ZIM files. For educational content, Kolibri uses its own channel format. For Kiwix content, NOMAD uses standard ZIM files. For educational content, Kolibri uses its own channel format.
### What are curated collection tiers? ### What are curated collection tiers?
When selecting content in the Easy Setup wizard or Content Explorer, collections are organized into three tiers: When selecting content in the Easy Setup wizard or Content Explorer, collections are organized into three tiers:
@ -136,19 +145,19 @@ Local AI requires significant computing power. To improve speed:
### How do I enable GPU acceleration for AI? ### How do I enable GPU acceleration for AI?
N.O.M.A.D. automatically detects NVIDIA GPUs when the NVIDIA Container Toolkit is installed on the host system. To set up GPU acceleration: NOMAD automatically detects NVIDIA GPUs when the NVIDIA Container Toolkit is installed on the host system. To set up GPU acceleration:
1. **Install an NVIDIA GPU** in your server (if not already present) 1. **Install an NVIDIA GPU** in your server (if not already present)
2. **Install the NVIDIA Container Toolkit** on the host — follow the [official installation guide](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) 2. **Install the NVIDIA Container Toolkit** on the host — follow the [official installation guide](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)
3. **Reinstall the AI Assistant** — Go to [Supply Depot](/supply-depot), find AI Assistant, and click **Force Reinstall** 3. **Reinstall the AI Assistant** — Go to [Supply Depot](/supply-depot), find AI Assistant, and click **Force Reinstall**
N.O.M.A.D. will detect the GPU during installation and configure the AI to use it automatically. You'll see "NVIDIA container runtime detected" in the installation progress. NOMAD will detect the GPU during installation and configure the AI to use it automatically. You'll see "NVIDIA container runtime detected" in the installation progress.
**Tip:** Run a [System Benchmark](/settings/benchmark) before and after to see the difference. GPU-accelerated systems typically see 100+ tokens per second vs 10-15 on CPU only. **Tip:** Run a [System Benchmark](/settings/benchmark) before and after to see the difference. GPU-accelerated systems typically see 100+ tokens per second vs 10-15 on CPU only.
### I added/changed my GPU but AI is still slow ### I added/changed my GPU but AI is still slow
When you add or swap a GPU, N.O.M.A.D. needs to reconfigure the AI container to use it: When you add or swap a GPU, NOMAD needs to reconfigure the AI container to use it:
1. Make sure the **NVIDIA Container Toolkit** is installed on the host 1. Make sure the **NVIDIA Container Toolkit** is installed on the host
2. Go to **[Supply Depot](/supply-depot)** 2. Go to **[Supply Depot](/supply-depot)**
@ -158,7 +167,7 @@ Force Reinstall recreates the AI container with GPU support enabled. Without thi
### I see a "GPU passthrough not working" warning ### I see a "GPU passthrough not working" warning
N.O.M.A.D. checks whether your GPU is actually accessible inside the AI container. If a GPU is detected on the host but isn't working inside the container, you'll see a warning banner on the System Information and AI Settings pages. Click the **"Fix: Reinstall AI Assistant"** button to recreate the container with proper GPU access. This preserves your downloaded AI models. NOMAD checks whether your GPU is actually accessible inside the AI container. If a GPU is detected on the host but isn't working inside the container, you'll see a warning banner on the System Information and AI Settings pages. Click the **"Fix: Reinstall AI Assistant"** button to recreate the container with proper GPU access. This preserves your downloaded AI models.
### AI Chat not available ### AI Chat not available
@ -220,7 +229,7 @@ Kolibri passwords are managed separately:
## Updates and Maintenance ## Updates and Maintenance
### How do I update N.O.M.A.D.? ### How do I update NOMAD?
1. Go to **Settings → Check for Updates** 1. Go to **Settings → Check for Updates**
2. If an update is available, click to install 2. If an update is available, click to install
3. The system will download updates and restart automatically 3. The system will download updates and restart automatically
@ -233,8 +242,8 @@ Yes, while you have internet access. Updates include:
- Security improvements - Security improvements
- Performance enhancements - Performance enhancements
### Can N.O.M.A.D. update itself automatically? ### Can NOMAD update itself automatically?
Yes. N.O.M.A.D. can keep its software, its installed apps, and its content current on its own. Automatic updates are **opt-in and off by default** — you turn on what you want from **Settings → Updates** (and, for apps, a per-app toggle in the Supply Depot). They only run inside a time window you choose, after safety checks, and never apply major version jumps automatically. See the **[Updates guide](/docs/updates)** for a full walkthrough. Yes. NOMAD can keep its software, its installed apps, and its content current on its own. Automatic updates are **opt-in and off by default** — you turn on what you want from **Settings → Updates** (and, for apps, a per-app toggle in the Supply Depot). They only run inside a time window you choose, after safety checks, and never apply major version jumps automatically. See the **[Updates guide](/docs/updates)** for a full walkthrough.
### How do I update content (Wikipedia, etc.)? ### How do I update content (Wikipedia, etc.)?
Content updates are separate from software updates: Content updates are separate from software updates:
@ -254,7 +263,7 @@ The system is designed to recover gracefully. If an update fails:
### Command-Line Maintenance ### Command-Line Maintenance
For advanced troubleshooting or when you can't access the web interface, N.O.M.A.D. includes helper scripts in `/opt/project-nomad`: For advanced troubleshooting or when you can't access the web interface, NOMAD includes helper scripts in `/opt/project-nomad`:
**Start all services:** **Start all services:**
```bash ```bash
@ -272,7 +281,7 @@ sudo bash /opt/project-nomad/update_nomad.sh
``` ```
*Note: This updates the Command Center only, not individual apps. Update apps through the web interface.* *Note: This updates the Command Center only, not individual apps. Update apps through the web interface.*
**Uninstall N.O.M.A.D.:** **Uninstall NOMAD:**
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/uninstall_nomad.sh -o uninstall_nomad.sh curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/uninstall_nomad.sh -o uninstall_nomad.sh
sudo bash uninstall_nomad.sh sudo bash uninstall_nomad.sh
@ -284,10 +293,10 @@ sudo bash uninstall_nomad.sh
## Privacy and Security ## Privacy and Security
### Is my data private? ### Is my data private?
Yes. N.O.M.A.D. runs entirely on your hardware. Your searches, AI conversations, and usage data never leave your server. Yes. NOMAD runs entirely on your hardware. Your searches, AI conversations, and usage data never leave your server.
### Can others access my server? ### Can others access my server?
By default, N.O.M.A.D. is accessible on your local network. Anyone on the same network can access it. For public networks, consider additional security measures. By default, NOMAD is accessible on your local network. Anyone on the same network can access it. For public networks, consider additional security measures.
### Does the AI send data anywhere? ### Does the AI send data anywhere?
No. The AI runs completely locally. Your conversations are not sent to any external service. The AI chat is built into the Command Center — there's no separate service to configure. No. The AI runs completely locally. Your conversations are not sent to any external service. The AI chat is built into the Command Center — there's no separate service to configure.

View File

@ -1,12 +1,61 @@
# Getting Started with N.O.M.A.D. # Getting Started with NOMAD
This guide will help you get the most out of your N.O.M.A.D. server. This guide will help you get the most out of your NOMAD server.
---
## System Requirements
If you already have NOMAD running, you can skip this section. It is here for when you are planning a second server, moving to different hardware, or helping someone else get set up.
### Operating System
NOMAD runs on Debian-based Linux.
| Support level | Operating system |
|---|---|
| **Recommended** | Ubuntu 26.04 LTS |
| **Also supported** | Ubuntu 24.04 LTS, Debian 12 |
| **Community-supported** | Windows via WSL2, other Debian derivatives |
Ubuntu 26.04 LTS is the version we test on and the one we recommend for new installs. If you are already running 24.04 LTS or Debian 12, there is no need to reinstall, both are still supported.
Ubuntu Desktop is the friendlier choice if you are coming from Windows or macOS. Ubuntu Server works just as well if you are comfortable at the terminal, and NOMAD does not need a desktop environment either way since everything is accessed through a browser.
macOS and non-Debian distributions like Fedora or Arch are not officially supported.
### Hardware
NOMAD itself is lightweight. What drives your requirements is the content and tools you choose to install, and whether you want to run AI locally.
**Minimum, without local AI:**
- 2 GHz dual-core processor
- 4 GB RAM
- 5 GB free disk space, plus room for whatever content you download
**Recommended, with local AI:**
- AMD Ryzen 7 or Intel Core i7 or better
- 32 GB RAM
- NVIDIA RTX 3060 or AMD equivalent, more VRAM lets you run larger models
- 250 GB or more of free disk space, preferably an SSD
A stable internet connection is required during installation only. After that, NOMAD is designed to run fully offline.
### A note on GPU drivers
The installer sets up Docker and the NVIDIA Container Toolkit for you, but it does **not** install the GPU driver itself. You need that on the host beforehand.
On Ubuntu, the easiest way is to check **"Install third-party drivers for graphics and Wi-Fi hardware"** during setup. If you skipped that, or you added the GPU later, install the driver first and then use **Force Reinstall** on the AI Assistant in the [Supply Depot](/supply-depot) to pick it up.
Without a GPU, the AI Assistant still works. It just runs on the CPU, which is considerably slower.
--- ---
## Easy Setup Wizard ## Easy Setup Wizard
If this is your first time using N.O.M.A.D., the Easy Setup wizard will help you get everything configured. If this is your first time using NOMAD, the Easy Setup wizard will help you get everything configured.
**[Launch Easy Setup →](/easy-setup)** **[Launch Easy Setup →](/easy-setup)**
@ -66,7 +115,7 @@ The Education Platform provides complete educational courses that work offline.
![AI Chat interface](/docs/ai-chat.webp) ![AI Chat interface](/docs/ai-chat.webp)
N.O.M.A.D. includes a built-in AI chat interface powered by Ollama. It runs entirely on your server — no internet needed, no data sent anywhere. NOMAD includes a built-in AI chat interface powered by Ollama. It runs entirely on your server — no internet needed, no data sent anywhere.
**What can it do:** **What can it do:**
- Answer questions on any topic - Answer questions on any topic
@ -84,7 +133,7 @@ N.O.M.A.D. includes a built-in AI chat interface powered by Ollama. It runs enti
**Note:** The AI Assistant must be installed first. Enable it during Easy Setup or install it from the [Supply Depot](/supply-depot). **Note:** The AI Assistant must be installed first. Enable it during Easy Setup or install it from the [Supply Depot](/supply-depot).
**GPU Acceleration:** If your server has an NVIDIA GPU with the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) installed, N.O.M.A.D. will automatically use it for AI — dramatically faster responses (10-20x improvement). If you add a GPU later, go to the [Supply Depot](/supply-depot) and **Force Reinstall** the AI Assistant to enable it. **GPU Acceleration:** If your server has an NVIDIA GPU, NOMAD's installer sets up GPU support for you (it installs the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) and configures Docker automatically). You only need the NVIDIA driver present on the host, which on Ubuntu you get by enabling "Install third-party drivers" during setup. With a GPU, AI responses are dramatically faster (10-20x improvement). If you add a GPU later, go to the [Supply Depot](/supply-depot) and **Force Reinstall** the AI Assistant to enable it.
--- ---
@ -150,7 +199,7 @@ As your needs change, you can add more content anytime:
![Content Explorer — browse and download Wikipedia packages and curated collections](/docs/content-explorer.webp) ![Content Explorer — browse and download Wikipedia packages and curated collections](/docs/content-explorer.webp)
N.O.M.A.D. includes a dedicated Wikipedia content management tool for browsing and downloading Wikipedia packages. NOMAD includes a dedicated Wikipedia content management tool for browsing and downloading Wikipedia packages.
**How to use it:** **How to use it:**
1. Go to **[Content Explorer →](/settings/zim/remote-explorer)** 1. Go to **[Content Explorer →](/settings/zim/remote-explorer)**
@ -184,7 +233,7 @@ While you have internet, periodically check for updates:
Content updates (Wikipedia, maps, etc.) can be managed separately from software updates. Content updates (Wikipedia, maps, etc.) can be managed separately from software updates.
**Automatic updates:** N.O.M.A.D. can also keep itself current without you having to check. Software, installed apps, and content can each be set to update automatically on an opt-in basis, with safety checks and a time window you control. See the **[Updates guide](/docs/updates)** for the full picture. **Automatic updates:** NOMAD can also keep itself current without you having to check. Software, installed apps, and content can each be set to update automatically on an opt-in basis, with safety checks and a time window you control. See the **[Updates guide](/docs/updates)** for the full picture.
**Early Access Channel:** Want the latest features before they hit stable? Enable the Early Access Channel from the Check for Updates page to receive release candidate builds. You can switch back to stable anytime. **Early Access Channel:** Want the latest features before they hit stable? Enable the Early Access Channel from the Check for Updates page to receive release candidate builds. You can switch back to stable anytime.
@ -226,7 +275,7 @@ Check storage usage in **Settings → System**.
## Next Steps ## Next Steps
You're ready to use N.O.M.A.D. Here are some things to try: You're ready to use NOMAD Here are some things to try:
1. **Look something up** — Search for a topic in the Information Library 1. **Look something up** — Search for a topic in the Information Library
2. **Learn something** — Start a Khan Academy course in the Education Platform 2. **Learn something** — Start a Khan Academy course in the Education Platform

View File

@ -1,10 +1,10 @@
# Welcome to Project N.O.M.A.D. # Welcome to Project NOMAD
Your personal offline knowledge server is ready to use. Your personal offline knowledge server is ready to use.
## What is N.O.M.A.D.? ## What is NOMAD?
**N.O.M.A.D.** stands for **Node for Offline Media, Archives, and Data**. It's your personal server for accessing knowledge, education, and AI assistance — even when you have no internet connection. **NOMAD** is an offline-first knowledge and education server. It's your personal server for accessing knowledge, education, and AI assistance — even when you have no internet connection.
Think of it as having Wikipedia, Khan Academy, an AI assistant, and offline maps all in one place, running on hardware you control. Think of it as having Wikipedia, Khan Academy, an AI assistant, and offline maps all in one place, running on hardware you control.
@ -46,7 +46,7 @@ Run a System Benchmark to see how your hardware performs and compare your NOMAD
## Getting Started ## Getting Started
**New to N.O.M.A.D.?** Use the Easy Setup wizard to configure your server and download content collections. **New to NOMAD?** Use the Easy Setup wizard to configure your server and download content collections.
**[Run Easy Setup →](/easy-setup)** **[Run Easy Setup →](/easy-setup)**
@ -72,7 +72,7 @@ Or explore the **[Getting Started Guide](/docs/getting-started)** for a walkthro
## Keeping Your Server Updated ## Keeping Your Server Updated
N.O.M.A.D. works best when kept up to date while you have internet access. This ensures you have the latest: NOMAD works best when kept up to date while you have internet access. This ensures you have the latest:
- Software features and bug fixes - Software features and bug fixes
- Wikipedia and reference content - Wikipedia and reference content
- Educational materials - Educational materials
@ -80,6 +80,6 @@ N.O.M.A.D. works best when kept up to date while you have internet access. This
When you go offline, you'll have everything you need — the last synced versions of all your content. When you go offline, you'll have everything you need — the last synced versions of all your content.
You can update on demand, or turn on **automatic updates** so N.O.M.A.D. keeps its software, apps, and content current on its own while you have internet. See the **[Updates guide](/docs/updates)** for how it works. You can update on demand, or turn on **automatic updates** so NOMAD keeps its software, apps, and content current on its own while you have internet. See the **[Updates guide](/docs/updates)** for how it works.
**[Check for Updates →](/settings/update)** **[Check for Updates →](/settings/update)**

View File

@ -1,13 +1,91 @@
# Release Notes # Release Notes
## Unreleased ## Version 1.34.0 - August 4, 2026
### Features
- **AI**: nomad.md for custom instructions (#1127). Thanks @jakeaturner for the contribution!
- **AI**: per-model thinking toggle with global default (off) (#1079). Thanks @chriscrosstalk for the contribution!
- **API Documentation**: Auto-generating OpenAPI docs with Scalar UI (#1128). Thanks @jakeaturner for the contribution!
- **Benchmark**: official multi-arch sysbench, resolved digest, platform metadata (#1158). Thanks @chriscrosstalk for the contribution!
- **Benchmark**: lock Score v2 AI reference to 13.2 (measured, was placeholder) (#1097). Thanks @chriscrosstalk for the contribution!
- **Benchmark**: dashboard re-run banner prompting a Score v2 re-run (#1096). Thanks @chriscrosstalk for the contribution!
- **Benchmark**: Score v2 app client — raws, uncapped score, v2 payload + UI. Thanks @chriscrosstalk for the contribution!
- **Benchmark**: harness hardening — fail loudly + pin sysbench + record provenance (#1089). Thanks @chriscrosstalk for the contribution!
- **Benchmark**: end-of-run score reveal + NVIDIA GPU-util overlay (#1087). Thanks @chriscrosstalk for the contribution!
- **Benchmark**: authoritative in-test sysbench numbers + results strip (#1085). Thanks @chriscrosstalk for the contribution!
- **Benchmark**: live telemetry during benchmark runs (#1082) (#1084). Thanks @chriscrosstalk for the contribution!
- **Collections**: support gated downloads for self-hosted curated content (#1172). Thanks @chriscrosstalk for the contribution!
- **Creator Packs**: gated per-creator video packs, offline via Kiwix (#1106). Thanks @chriscrosstalk for the contribution!
- **Dashboard**: add dismissable "What's new" banner for v1.34 (#1112). Thanks @chriscrosstalk for the contribution!
- **Dashboard**: round out the v1.34 What's new highlights (#1197). Thanks @chriscrosstalk for the contribution!
- **Debug Info**: add storage, docker, GPU health, and auto-update diagnostics (#1102). Thanks @chriscrosstalk for the contribution!
- **Drug Reference**: Add offline FDA drug reference (labels, interaction view, conditions, remedies) (#1040). Thanks @caweis for the contribution!
- **Kiwix Library**: Expandable rows in Kiwix Library browser (#1060). Thanks @jarvisxyz for the contribution!
- **Maps**: add notes input to map pin placement popup (#926). Thanks @chriscrosstalk for the contribution!
- **RAG**: add subject/collection organization to knowledge base (#1063). Thanks @just-jbc for the contribution!
### Bug Fixes
- **AI**: stream thinking from /v1 reasoning field + abort on client disconnect (#1078). Thanks @chriscrosstalk for the contribution!
- **AI**: stop forcing HSA_OVERRIDE=11.0.0 on natively-supported AMD iGPUs (#1076). Thanks @chriscrosstalk for the contribution!
- **AI**: set OLLAMA_IGPU_ENABLE on AMD provisioning so iGPUs are used (#1074). Thanks @chriscrosstalk for the contribution!
- **AI**: coerce gfx1103 (780M) to HSA_OVERRIDE 11.0.0 so it stays on GPU (#1134). Thanks @jakeaturner for the contribution!
- **Benchmark**: fix various typescript errors. Thanks @jakeaturner for the contribution!
- **Benchmark**: partial runs are not the NOMAD Score (relabel + renormalize) (#1088). Thanks @chriscrosstalk for the contribution!
- **Benchmark**: remove stale progress setter (#1136). Thanks @NgoQuocViet2001 for the contribution!
- **Benchmark**: surface a clear reason when leaderboard submission fails (#1138). Thanks @chriscrosstalk for the contribution!
- **Benchmark**: warm the AI model before timed runs for reproducible scores (#1140). Thanks @chriscrosstalk for the contribution!
- **Benchmark**: block leaderboard submission when AI runs on a remote host (#1157). Thanks @chriscrosstalk for the contribution!
- **Benchmark**: don't block submission when the AI host is this machine (#1166). Thanks @chriscrosstalk for the contribution!
- **Chat**: make conversation layout responsive (#1090). Thanks @Bortlesboat for the contribution!
- **Chat**: open full chat in place instead of a new window (#1181). Thanks @chriscrosstalk for the contribution!
- **Content**: resolve current ZIM URL before download (#1091). Thanks @NgoQuocViet2001 for the contribution!
- **Content**: refresh installed ZIMs when a download completes to prune ghost entries (#1099). Thanks @chriscrosstalk for the contribution!
- **Creator Packs**: add missing Modern Rogue banner asset (#1147). Thanks @chriscrosstalk for the contribution!
- **Downloads**: send a descriptive User-Agent so Wikimedia mirrors don't 403 (#1114). Thanks @chriscrosstalk for the contribution!
- **Downloads**: add retry button and resource download link for failed downloads (#1059). Thanks @jarvisxyz for the contribution!
- **Downloads**: don't 500 the jobs endpoint on an orphaned BullMQ job (#1191). Thanks @chriscrosstalk for the contribution!
- **Downloads**: don't retry a rejected entitlement for four hours (#1205). Thanks @chriscrosstalk for the contribution!
- **Downloads**: let interrupted content downloads resume (#1202). Thanks @chriscrosstalk for the contribution!
- **Easy Setup**: streamline wizard + robust model recommendations (#1110). Thanks @chriscrosstalk for the contribution!
- **Install**: define missing header_red + colors in uninstall/update scripts (#1098). Thanks @chriscrosstalk for the contribution!
- **KB**: stop the collection dropdown in from being clipped, widen the modal (#1198). Thanks @chriscrosstalk for the contribution!
- **KB**: keep the collection when a file is indexed after assignment (#1200). Thanks @chriscrosstalk for the contribution!
- **KVStore**: fix missing apps.homebox key. Thanks @jakeaturner for the contribution!
- **Maps**: warn when world basemap missing instead of silent grey map (#1104). Thanks @not-knope for the contribution!
- **RAG**: add proper .docx text extraction via mammoth (#1100). Thanks @just-jbc for the contribution!
- **RAG**: stop re-creating payload indexes on every embedded document (#1135). Thanks @bragaus for the contribution!
- **Content**: respect ingest policy when a ZIM is uploaded locally (#1184). Thanks @chriscrosstalk for the contribution!
- **Supply Depot**: generate Homebox API key pepper so it stops crash-looping (#1077). Thanks @chriscrosstalk for the contribution!
- **UI**: add API reference link to the Settings sidebar. Thanks @jakeaturner for the contribution!
- **Updater**: prune superseded images after update to reclaim disk (#1101). Thanks @chriscrosstalk for the contribution!
### Improvements
- **Brand**: add ™ to Project NOMAD wordmark on prominent surfaces. Thanks @chriscrosstalk for the contribution!
- **Brand**: standardize brand name to Project NOMAD, retire backronym. Thanks @chriscrosstalk for the contribution!
- **Build**: run drug reference codegen step (#1132). Thanks @jakeaturner for the contribution!
- **Supply Depot**: sunset orphaned Meshtastic Daemon card (#1049). Thanks @chriscrosstalk for the contribution!
- **Catalog**: add The Modern Rogue creator pack (dev) (#1146). Thanks @chriscrosstalk for the contribution!
- **CI**: fix collection URL validation. Thanks @jakeaturner for the contribution!
- **Collections**: update stale URLs (#1148). Thanks @jakeaturner for the contribution!
- **Collections**: fix four dead Wikipedia download URLs (#1189). Thanks @chriscrosstalk for the contribution!
- **Content Manager**: filter non-content sections + render tables in ZIM extraction (#1044). Thanks @chriscrosstalk for the contribution!
- **Dependencies**: bump tar, vite, and dockerode in admin. Thanks @jakeaturner for the contribution!
- **Dependencies**: bump axios and systeminformation in admin. Thanks @jakeaturner for the contribution!
- **Docs**: make storage-relocation guidance accurate and consistent (#1103). Thanks @chriscrosstalk for the contribution!
- **Docs**: add UI Consistency section (#1080). Thanks @chriscrosstalk for the contribution!
- **Docs**: recommend Ubuntu 26.04 LTS as the default base (#1141). Thanks @chriscrosstalk for the contribution!
- **Docs**: point MeshCore Web to the official meshcore.io site (#1142). Thanks @chriscrosstalk for the contribution!
- **Drug Reference**: make collections JSON the single source for curated data (#1130). Thanks @caweis for the contribution!
- **Drug Reference**: tabbed redesign with grouped search, multi-select situations, and a disclaimer gate (#1137). Thanks @chriscrosstalk for the contribution!
## Version 1.33.0 - June 23, 2026
### Features ### Features
- **Supply Depot — Custom Apps**: The Supply Depot is NOMAD's new home for installable apps, and it now lets you run your *own* custom Docker containers — not just the curated catalog. Specify an image, port mappings, volume binds, environment variables, and memory/CPU limits, and NOMAD spins it up as a managed sibling container. A live, debounced pre-flight check warns about port conflicts and resource limits as you type and hard-blocks unsafe configurations, with an "install anyway" override for warning-only cases (e.g. an untrusted registry or a `:latest` tag). Installed custom apps can be edited, updated (re-pull latest + recreate with a safe rollback if the new container fails), and removed (optionally deleting the image), and every installed app — curated or custom — gets per-container **Logs** and **Stats** modals. Host-path binds are hardened against escapes and logs/stats are scoped to NOMAD-managed containers only. Thanks @jakeaturner for the contribution! - **Supply Depot — Custom Apps**: The Supply Depot is NOMAD's new home for installable apps, and it now lets you run your *own* custom Docker containers — not just the curated catalog. Specify an image, port mappings, volume binds, environment variables, and memory/CPU limits, and NOMAD spins it up as a managed sibling container. A live, debounced pre-flight check warns about port conflicts and resource limits as you type and hard-blocks unsafe configurations, with an "install anyway" override for warning-only cases (e.g. an untrusted registry or a `:latest` tag). Installed custom apps can be edited, updated (re-pull latest + recreate with a safe rollback if the new container fails), and removed (optionally deleting the image), and every installed app — curated or custom — gets per-container **Logs** and **Stats** modals. Host-path binds are hardened against escapes and logs/stats are scoped to NOMAD-managed containers only. Thanks @jakeaturner for the contribution!
- **Supply Depot — Curated App Onboarding & Fixes**: Each curated app now ships with NOMAD-specific getting-started docs (first run, default logins, where data lives, what does and doesn't work offline), deep-linked from a **Manage Docs** item on the card. Alongside it is a round of install fixes so the nine documented apps — Stirling PDF, File Browser, Calibre-Web, IT Tools, Excalidraw, Homebox, Vaultwarden, Jellyfin, Meshtastic Web — work out of the box: seeded logins instead of random passwords buried in logs, a bundled Calibre library, HTTPS-by-default where the app requires a secure context (Vaultwarden), corrected internal ports (Meshtastic Web), pre-created media folders (Jellyfin), and a swap to a maintained image (Homebox). You can now also **edit curated apps**, not just custom ones — edits are merged into the app's existing config (preserving advanced settings like GPU device requests) and flag the app so the seeder stops overwriting it, while untouched apps still receive catalog updates. Thanks @chriscrosstalk for the contribution! - **Supply Depot — Curated App Onboarding & Fixes**: Each curated app now ships with NOMAD-specific getting-started docs (first run, default logins, where data lives, what does and doesn't work offline), deep-linked from a **Manage Docs** item on the card. Alongside it is a round of install fixes so the nine documented apps — Stirling PDF, File Browser, Calibre-Web, IT Tools, Excalidraw, Homebox, Vaultwarden, Jellyfin, Meshtastic Web — work out of the box: seeded logins instead of random passwords buried in logs, a bundled Calibre library, HTTPS-by-default where the app requires a secure context (Vaultwarden), corrected internal ports (Meshtastic Web), pre-created media folders (Jellyfin), and a swap to a maintained image (Homebox). You can now also **edit curated apps**, not just custom ones — edits are merged into the app's existing config (preserving advanced settings like GPU device requests) and flag the app so the seeder stops overwriting it, while untouched apps still receive catalog updates. Thanks @chriscrosstalk for the contribution!
- **Automatic Core Updates**: NOMAD's own admin/core image can now update itself hands-off, gated by layered safety checks. It's opt-in and off by default, runs only inside a user-configured time window, and applies only same-major, strictly-newer GA releases (major bumps stay manual) past a configurable cool-off — behind pre-flight checks for the update sidecar, no in-flight updates/downloads/installs, and sufficient host disk. It auto-disables after repeated genuine failures, while transient offline release lookups are treated as harmless skips. Settings → Updates exposes the toggle, window, cool-off, and live status. This is the first leg of the auto-update trilogy. Thanks @jakeaturner for the contribution! - **Automatic Core Updates**: NOMAD's own admin/core image can now update itself hands-off, gated by layered safety checks. It's opt-in and off by default, runs only inside a user-configured time window, and applies only same-major, strictly-newer GA releases (major bumps stay manual) past a configurable cool-off — behind pre-flight checks for the update sidecar, no in-flight updates/downloads/installs, and sufficient host disk. It auto-disables after repeated genuine failures, while transient offline release lookups are treated as harmless skips. Settings → Updates exposes the toggle, window, cool-off, and live status. This is the first leg of the auto-update trilogy. Thanks @jakeaturner for the contribution!
- **Automatic App Updates**: Installed apps (the "Supply Depot" sibling containers) can now keep themselves up to date with opt-in, hands-off minor/patch updates, mirroring the core auto-update feature. Updates are gated behind a two-level opt-in — a global master switch in Settings → Updates **and** a per-app toggle in the Supply Depot — and respect the shared update window, cool-off period, disk/in-progress pre-flight checks, and per-app failure backoff. Major versions are never auto-applied. Thanks @jakeaturner for the contribution! - **Automatic App Updates**: Installed apps (the "Supply Depot" sibling containers) can now keep themselves up to date with opt-in, hands-off minor/patch updates, mirroring the core auto-update feature. Updates are gated behind a two-level opt-in — a global master switch in Settings → Updates **and** a per-app toggle in the Supply Depot — and respect the shared update window, cool-off period, disk/in-progress pre-flight checks, and per-app failure backoff. Major versions are never auto-applied. Thanks @jakeaturner for the contribution!
- **Automatic Content Updates**: Completing the auto-update trilogy, installed Kiwix ZIM files and PMTiles maps can now update themselves on an opt-in basis. Content updates run on their own dedicated overnight window and bandwidth cap (separate from the app/core schedule, since content downloads are multi-GB), check the upstream Kiwix and PMTiles catalogs directly (no more reliance on external Project N.O.M.A.D. API), and keep the AI Knowledge Base in sync when a ZIM is replaced. Thanks @jakeaturner for the contribution! - **Automatic Content Updates**: Completing the auto-update trilogy, installed Kiwix ZIM files and PMTiles maps can now update themselves on an opt-in basis. Content updates run on their own dedicated overnight window and bandwidth cap (separate from the app/core schedule, since content downloads are multi-GB), check the upstream Kiwix and PMTiles catalogs directly (no more reliance on external Project NOMAD API), and keep the AI Knowledge Base in sync when a ZIM is replaced. Thanks @jakeaturner for the contribution!
- **Supply Depot — Custom Launch URLs**: You can now override an app's "Open" link with a reverse-proxy or local-DNS address (e.g. `https://jellyfin.myhomelab.net`). The override is stored separately so the default link is always recoverable, survives reseeds/upgrades, and is validated on both client and server. Thanks @jakeaturner for the contribution! - **Supply Depot — Custom Launch URLs**: You can now override an app's "Open" link with a reverse-proxy or local-DNS address (e.g. `https://jellyfin.myhomelab.net`). The override is stored separately so the default link is always recoverable, survives reseeds/upgrades, and is validated on both client and server. Thanks @jakeaturner for the contribution!
- **Supply Depot — Version & Update visibility**: App cards now show the installed version next to the app name (e.g. `Kiwix · 3.7.0`), and the "Update available" pill now stands out with a solid desert-orange fill so available updates actually draw the eye. Thanks @chriscrosstalk for the contribution! - **Supply Depot — Version & Update visibility**: App cards now show the installed version next to the app name (e.g. `Kiwix · 3.7.0`), and the "Update available" pill now stands out with a solid desert-orange fill so available updates actually draw the eye. Thanks @chriscrosstalk for the contribution!
- **Maps — Persistent View**: The Maps page now remembers your position and zoom across refreshes instead of resetting to the default US-wide view. The saved view is bounds-checked, so a corrupt value safely falls back to the default. Thanks @chriscrosstalk for the contribution! - **Maps — Persistent View**: The Maps page now remembers your position and zoom across refreshes instead of resetting to the default US-wide view. The saved view is bounds-checked, so a corrupt value safely falls back to the default. Thanks @chriscrosstalk for the contribution!
@ -22,7 +100,7 @@
- **System**: Service install failures caused by a host port conflict (commonly a native Ollama install already on port 11434) now show a clear, actionable message with the exact commands to resolve it, instead of a raw Docker error. Thanks @chriscrosstalk for the fix! - **System**: Service install failures caused by a host port conflict (commonly a native Ollama install already on port 11434) now show a clear, actionable message with the exact commands to resolve it, instead of a raw Docker error. Thanks @chriscrosstalk for the fix!
- **System**: The per-service Update button is now disabled and shows "Updating..." while an update is in flight, preventing double-clicks that previously raced into Docker errors. The in-progress state is durable, so it survives a page reload during a multi-GB pull. Thanks @chriscrosstalk for the fix! - **System**: The per-service Update button is now disabled and shows "Updating..." while an update is in flight, preventing double-clicks that previously raced into Docker errors. The in-progress state is durable, so it survives a page reload during a multi-GB pull. Thanks @chriscrosstalk for the fix!
- **System Updates**: Update checks no longer crash for images with more than 1,000 tags (e.g Ollama). Registry pagination URLs are now resolved correctly, so the "Check for Updates" flow returns versions instead of failing silently — fixing Ollama appearing pinned at an old version. Thanks @chriscrosstalk for the fix! - **System Updates**: Update checks no longer crash for images with more than 1,000 tags (e.g Ollama). Registry pagination URLs are now resolved correctly, so the "Check for Updates" flow returns versions instead of failing silently — fixing Ollama appearing pinned at an old version. Thanks @chriscrosstalk for the fix!
- **System**: Internet status checks no longer report "No internet connection" on networks that block or hijack Cloudflare's 1.1.1.1. The check now probes additional hosts the app already contacts (GitHub and the Project N.O.M.A.D. API) in parallel and accepts any HTTP response as "online." Thanks @akashsalan for the fix! - **System**: Internet status checks no longer report "No internet connection" on networks that block or hijack Cloudflare's 1.1.1.1. The check now probes additional hosts the app already contacts (GitHub and the Project NOMAD API) in parallel and accepts any HTTP response as "online." Thanks @akashsalan for the fix!
- **AI Assistant**: Oversized embedding chunks are now truncated and retried instead of being silently dropped, ending the retry storm that could peg the GPU and flood logs (the "api/embed for weeks" issue). The OpenAI-compatible fallback path now also passes context and truncation settings. Thanks @chriscrosstalk for the fix! - **AI Assistant**: Oversized embedding chunks are now truncated and retried instead of being silently dropped, ending the retry storm that could peg the GPU and flood logs (the "api/embed for weeks" issue). The OpenAI-compatible fallback path now also passes context and truncation settings. Thanks @chriscrosstalk for the fix!
- **AI Assistant**: Chat suggestions now use your selected model (falling back to the *smallest* installed model) instead of the largest. This prevents a flagship model that exceeds available VRAM from hanging the chat page and returning a 500 error. Thanks @johno10661 for the fix! - **AI Assistant**: Chat suggestions now use your selected model (falling back to the *smallest* installed model) instead of the largest. This prevents a flagship model that exceeds available VRAM from hanging the chat page and returning a 500 error. Thanks @johno10661 for the fix!
- **AI Assistant**: The assistant no longer disclaims "Sorry, I couldn't find specific context regarding X..." when relevant material was actually retrieved. The RAG prompt now treats retrieved context as the authoritative source and falls back to general knowledge silently, the model-visible relevance scores that primed smaller models to distrust correct context were replaced with neutral source-title labels, and a conservative heading-match boost improves the ranking of already-retrieved chunks. Thanks @jakeaturner for the fix! - **AI Assistant**: The assistant no longer disclaims "Sorry, I couldn't find specific context regarding X..." when relevant material was actually retrieved. The RAG prompt now treats retrieved context as the authoritative source and falls back to general knowledge silently, the model-visible relevance scores that primed smaller models to distrust correct context were replaced with neutral source-title labels, and a conservative heading-match boost improves the ranking of already-retrieved chunks. Thanks @jakeaturner for the fix!
@ -335,7 +413,7 @@
- **Night Ops**: Added our most requested feature — a dark mode theme for the Command Center interface! Activate it from the footer and enjoy the sleek new look during your late-night missions. Thanks @chriscrosstalk for the contribution! - **Night Ops**: Added our most requested feature — a dark mode theme for the Command Center interface! Activate it from the footer and enjoy the sleek new look during your late-night missions. Thanks @chriscrosstalk for the contribution!
- **Debug Info**: Added a new "Debug Info" modal accessible from the footer that provides detailed system and application information for troubleshooting and support. Thanks @chriscrosstalk for the contribution! - **Debug Info**: Added a new "Debug Info" modal accessible from the footer that provides detailed system and application information for troubleshooting and support. Thanks @chriscrosstalk for the contribution!
- **Support the Project**: Added a new "Support the Project" page in settings with links to community resources, donation options, and ways to contribute. - **Support the Project**: Added a new "Support the Project" page in settings with links to community resources, donation options, and ways to contribute.
- **Install**: The main Nomad image is now fully self-contained and directly usable with Docker Compose, allowing for more flexible and customizable installations without relying on external scripts. The image remains fully backwards compatible with existing installations, and the install script has been updated to reflect the simpler deployment process. - **Install**: The main NOMAD image is now fully self-contained and directly usable with Docker Compose, allowing for more flexible and customizable installations without relying on external scripts. The image remains fully backwards compatible with existing installations, and the install script has been updated to reflect the simpler deployment process.
### Bug Fixes ### Bug Fixes
- **Settings**: Storage usage display now prefers real block devices over tempfs. Thanks @Bortlesboat for the fix! - **Settings**: Storage usage display now prefers real block devices over tempfs. Thanks @Bortlesboat for the fix!
@ -353,7 +431,7 @@
- **Ollama**: The detected GPU type is now persisted in the database for more reliable configuration and troubleshooting across updates and restarts. Thanks @chriscrosstalk for the contribution! - **Ollama**: The detected GPU type is now persisted in the database for more reliable configuration and troubleshooting across updates and restarts. Thanks @chriscrosstalk for the contribution!
- **Downloads**: Users can now dismiss failed download notifications to reduce clutter in the UI. Thanks @chriscrosstalk for the contribution! - **Downloads**: Users can now dismiss failed download notifications to reduce clutter in the UI. Thanks @chriscrosstalk for the contribution!
- **Logging**: Changed the default log level to "info" to reduce noise and focus on important messages. Thanks @traxeon for the suggestion! - **Logging**: Changed the default log level to "info" to reduce noise and focus on important messages. Thanks @traxeon for the suggestion!
- **Logging**: Nomad's internal logger now creates it's own log directory on startup if it doesn't already exist to prevent errors on fresh installs where the logs directory hasn't been created yet. - **Logging**: NOMAD's internal logger now creates it's own log directory on startup if it doesn't already exist to prevent errors on fresh installs where the logs directory hasn't been created yet.
- **Dozzle**: Dozzle shell access and container actions are now disabled by default. Thanks @traxeon for the recommendation! - **Dozzle**: Dozzle shell access and container actions are now disabled by default. Thanks @traxeon for the recommendation!
- **MySQL & Redis**: Removed port exposure to host by default for improved security. Ports can still be exposed manually if needed. Thanks @traxeon for the recommendation! - **MySQL & Redis**: Removed port exposure to host by default for improved security. Ports can still be exposed manually if needed. Thanks @traxeon for the recommendation!
- **Dependencies**: Various dependency updates to close security vulnerabilities and improve stability - **Dependencies**: Various dependency updates to close security vulnerabilities and improve stability
@ -367,7 +445,7 @@
### Features ### Features
- **AI Assistant**: Added improved user guidance for troubleshooting GPU pass-through issues - **AI Assistant**: Added improved user guidance for troubleshooting GPU pass-through issues
- **AI Assistant**: The last used model is now automatically selected when a new chat is started - **AI Assistant**: The last used model is now automatically selected when a new chat is started
- **Settings**: Nomad now automatically performs nightly checks for available app updates, and users can select and apply updates from the Apps page in Settings - **Settings**: NOMAD now automatically performs nightly checks for available app updates, and users can select and apply updates from the Apps page in Settings
### Bug Fixes ### Bug Fixes
- **Settings**: Fixed an issue where the AI Assistant settings page would be shown in navigation even if the AI Assistant was not installed, thus causing 404 errors when clicked - **Settings**: Fixed an issue where the AI Assistant settings page would be shown in navigation even if the AI Assistant was not installed, thus causing 404 errors when clicked
@ -819,7 +897,7 @@
### 🚀 New Features ### 🚀 New Features
- Uninstall script now removes non-management Nomad app containers - Uninstall script now removes non-management NOMAD app containers
### ✨ Improvements ### ✨ Improvements
@ -844,7 +922,7 @@
- Fixed renderer file permissions - Fixed renderer file permissions
- Fixed absolute host path issue - Fixed absolute host path issue
- **ZIM Manager**: - **ZIM Manager**:
- Initial ZIM download now hosted in Project Nomad GitHub repo for better availability - Initial ZIM download now hosted in Project NOMAD GitHub repo for better availability
--- ---
@ -868,7 +946,7 @@
### ⚠️ Breaking Changes ### ⚠️ Breaking Changes
- **Container Naming**: As a result of standardized container naming, it is recommend that you do a fresh install of Project N.O.M.A.D. and any apps to avoid potential conflicts/duplication of containers - **Container Naming**: As a result of standardized container naming, it is recommend that you do a fresh install of Project NOMAD and any apps to avoid potential conflicts/duplication of containers
### 📚 Documentation ### 📚 Documentation

View File

@ -265,9 +265,9 @@ A complete offline learning platform from Learning Equality. Kolibri pulls toget
**Works offline:** Fully offline once content is imported, that's what Kolibri is for. The only step that uses the internet is importing channels from Kolibri Studio; everything after that, browsing lessons, doing exercises, tracking progress, runs entirely on your NOMAD. **Works offline:** Fully offline once content is imported, that's what Kolibri is for. The only step that uses the internet is importing channels from Kolibri Studio; everything after that, browsing lessons, doing exercises, tracking progress, runs entirely on your NOMAD.
## MeshCore Web {% #meshcore-web %} ## MeshCore Web {% #meshcore-web %}
A browser-based client for [MeshCore](https://meshcore.co.uk) radios. MeshCore is another take on off-grid, long-range LoRa mesh messaging, a sibling to Meshtastic: small radios that form their own network and pass text and location for miles with no cell service, no internet, and no fees. This app is how you configure a MeshCore radio and read and send messages from a full-size screen. If you're not already running MeshCore gear, the Meshtastic client above is the more common starting point. This one is here for people who use MeshCore. A browser-based client for [MeshCore](https://meshcore.io) radios. MeshCore is another take on off-grid, long-range LoRa mesh messaging, a sibling to Meshtastic: small radios that form their own network and pass text and location for miles with no cell service, no internet, and no fees. This app is how you configure a MeshCore radio and read and send messages from a full-size screen. If you're not already running MeshCore gear, the Meshtastic client above is the more common starting point. This one is here for people who use MeshCore.
**Official site:** [meshcore.co.uk](https://meshcore.co.uk) · **Source:** [github.com/aXistem-dev/meshcore-web](https://github.com/aXistem-dev/meshcore-web) (a packaged build of Liam Cottle's MeshCore client) **Official site:** [meshcore.io](https://meshcore.io) · **Source:** [github.com/aXistem-dev/meshcore-web](https://github.com/aXistem-dev/meshcore-web) (a packaged build of Liam Cottle's MeshCore client)
**You need a MeshCore radio to use this.** Like the Meshtastic client, this is just the control panel. With no radio connected, there's nothing for it to talk to. **You need a MeshCore radio to use this.** Like the Meshtastic client, this is just the control panel. With no radio connected, there's nothing for it to talk to.

View File

@ -1,6 +1,6 @@
# Keeping N.O.M.A.D. Updated # Keeping NOMAD Updated
N.O.M.A.D. works best when it's kept current while you have internet, so it's ready with the latest software and content the next time you go offline. This page explains what can be updated, how to do it on demand, and how to let N.O.M.A.D. handle it for you automatically. NOMAD works best when it's kept current while you have internet, so it's ready with the latest software and content the next time you go offline. This page explains what can be updated, how to do it on demand, and how to let NOMAD handle it for you automatically.
--- ---
@ -8,7 +8,7 @@ N.O.M.A.D. works best when it's kept current while you have internet, so it's re
There are three separate things that can be updated, and you control each one independently: There are three separate things that can be updated, and you control each one independently:
1. **Software (the core)** — N.O.M.A.D. itself: the Command Center, new features, bug fixes, and security improvements. 1. **Software (the core)** — NOMAD itself: the Command Center, new features, bug fixes, and security improvements.
2. **Apps** — the installable apps from the [Supply Depot](/supply-depot) (Kiwix, the AI Assistant, and any others you've added). 2. **Apps** — the installable apps from the [Supply Depot](/supply-depot) (Kiwix, the AI Assistant, and any others you've added).
3. **Content** — your offline material: Wikipedia and other Kiwix libraries, and downloaded map regions. 3. **Content** — your offline material: Wikipedia and other Kiwix libraries, and downloaded map regions.
@ -21,28 +21,28 @@ You can update any of these on demand, or set any of them to update automaticall
To check for and install updates yourself: To check for and install updates yourself:
1. Go to **[Settings → Check for Updates](/settings/update)**. 1. Go to **[Settings → Check for Updates](/settings/update)**.
2. If a software update is available, click to install it. N.O.M.A.D. downloads the update and restarts (usually 25 minutes). 2. If a software update is available, click to install it. NOMAD downloads the update and restarts (usually 25 minutes).
3. Apps can be updated from their card in the [Supply Depot](/supply-depot) using **Manage Update**. 3. Apps can be updated from their card in the [Supply Depot](/supply-depot) using **Manage Update**.
4. Content is managed from **Settings → Content Manager** and **Content Explorer**, where you can download newer versions of installed libraries and maps. 4. Content is managed from **Settings → Content Manager** and **Content Explorer**, where you can download newer versions of installed libraries and maps.
If a software or app update ever fails, N.O.M.A.D. is designed to recover gracefully — the previous working version keeps running, so your server stays up. If a software or app update ever fails, NOMAD is designed to recover gracefully — the previous working version keeps running, so your server stays up.
--- ---
## Automatic updates ## Automatic updates
N.O.M.A.D. can keep itself current without you having to remember to check. **Automatic updates are opt-in and off by default** — nothing updates on its own until you turn it on. You manage all of it from **Settings → Updates**. NOMAD can keep itself current without you having to remember to check. **Automatic updates are opt-in and off by default** — nothing updates on its own until you turn it on. You manage all of it from **Settings → Updates**.
A few things are true across all three: A few things are true across all three:
- **You choose a time window.** Automatic updates only run during the hours you set, so they never interrupt you mid-use. - **You choose a time window.** Automatic updates only run during the hours you set, so they never interrupt you mid-use.
- **Major versions are never automatic.** Only minor and patch updates apply on their own; a big version jump always waits for you to do it manually, on purpose. - **Major versions are never automatic.** Only minor and patch updates apply on their own; a big version jump always waits for you to do it manually, on purpose.
- **Safety checks come first.** Before applying anything, N.O.M.A.D. confirms there's enough disk space and that no other update, download, or install is already in progress. - **Safety checks come first.** Before applying anything, NOMAD confirms there's enough disk space and that no other update, download, or install is already in progress.
- **Being offline is harmless.** If N.O.M.A.D. can't reach the internet to check, it simply skips that round and tries again later. - **Being offline is harmless.** If NOMAD can't reach the internet to check, it simply skips that round and tries again later.
### Automatic software (core) updates ### Automatic software (core) updates
Turn this on from **Settings → Updates**. When enabled, N.O.M.A.D. updates its own core to newer releases within the same major version, during your chosen window, after a configurable **cool-off** period (so a brand-new release has time to prove itself before your server takes it). The same page shows the toggle, the window, the cool-off setting, and live status. If updates fail repeatedly for a real reason, N.O.M.A.D. turns the feature back off and lets you know rather than retrying forever. Turn this on from **Settings → Updates**. When enabled, NOMAD updates its own core to newer releases within the same major version, during your chosen window, after a configurable **cool-off** period (so a brand-new release has time to prove itself before your server takes it). The same page shows the toggle, the window, the cool-off setting, and live status. If updates fail repeatedly for a real reason, NOMAD turns the feature back off and lets you know rather than retrying forever.
### Automatic app updates ### Automatic app updates
@ -50,7 +50,7 @@ App auto-updates are opt-in at **two levels**: a master switch in **Settings →
### Automatic content updates ### Automatic content updates
Installed Wikipedia/ZIM libraries and map regions can refresh themselves too. Because content downloads are large (often many gigabytes), content updates run on their **own dedicated overnight window** with a **bandwidth cap**, separate from the software and app schedule. N.O.M.A.D. checks the upstream Kiwix and map catalogs directly, and when a Wikipedia library is replaced with a newer version, it keeps the AI Knowledge Base in sync automatically. Installed Wikipedia/ZIM libraries and map regions can refresh themselves too. Because content downloads are large (often many gigabytes), content updates run on their **own dedicated overnight window** with a **bandwidth cap**, separate from the software and app schedule. NOMAD checks the upstream Kiwix and map catalogs directly, and when a Wikipedia library is replaced with a newer version, it keeps the AI Knowledge Base in sync automatically.
--- ---

View File

@ -1,12 +1,12 @@
# What Can You Do With N.O.M.A.D.? # What Can You Do With NOMAD?
N.O.M.A.D. is designed to be your information lifeline when internet isn't available. Here's how different people use it. NOMAD is designed to be your information lifeline when internet isn't available. Here's how different people use it.
--- ---
## Emergency Preparedness ## Emergency Preparedness
When disasters strike, internet and cell service often go down first. N.O.M.A.D. keeps critical information at your fingertips. When disasters strike, internet and cell service often go down first. NOMAD keeps critical information at your fingertips.
**What you can do:** **What you can do:**
- Look up first aid and emergency medical procedures - Look up first aid and emergency medical procedures
@ -184,13 +184,13 @@ Add your own documents to the [Knowledge Base](/knowledge-base) — emergency pl
Keep your server updated while you have internet. You never know when you'll need to go offline. Keep your server updated while you have internet. You never know when you'll need to go offline.
### Step 5: Practice ### Step 5: Practice
Try using N.O.M.A.D. before you need it. Familiarity with the tools makes them more useful in a crisis. Try using NOMAD before you need it. Familiarity with the tools makes them more useful in a crisis.
--- ---
## Need Something Specific? ## Need Something Specific?
N.O.M.A.D. content is customizable. If you don't see what you need: NOMAD content is customizable. If you don't see what you need:
1. **Browse [Content Explorer](/settings/zim/remote-explorer)** — Thousands of ZIM files including Wikipedia packages 1. **Browse [Content Explorer](/settings/zim/remote-explorer)** — Thousands of ZIM files including Wikipedia packages
2. **Check [Content Manager](/settings/zim)** — Manage your installed content 2. **Check [Content Manager](/settings/zim)** — Manage your installed content

View File

@ -14,7 +14,7 @@ import NotificationsProvider from '~/providers/NotificationProvider'
import { ThemeProvider } from '~/providers/ThemeProvider' import { ThemeProvider } from '~/providers/ThemeProvider'
import { UsePageProps } from '../../types/system' import { UsePageProps } from '../../types/system'
const appName = import.meta.env.VITE_APP_NAME || 'Project N.O.M.A.D.' const appName = import.meta.env.VITE_APP_NAME || 'Project NOMAD'
const queryClient = new QueryClient() const queryClient = new QueryClient()
// Patch the global crypto object for non-HTTPS/localhost contexts // Patch the global crypto object for non-HTTPS/localhost contexts

View File

@ -2,7 +2,7 @@ import { useRef, useState, useCallback } from 'react'
import useDownloads, { useDownloadsProps } from '~/hooks/useDownloads' import useDownloads, { useDownloadsProps } from '~/hooks/useDownloads'
import { extractFileName, formatBytes } from '~/lib/util' import { extractFileName, formatBytes } from '~/lib/util'
import StyledSectionHeader from './StyledSectionHeader' import StyledSectionHeader from './StyledSectionHeader'
import { IconAlertTriangle, IconX, IconLoader2 } from '@tabler/icons-react' import { IconAlertTriangle, IconX, IconLoader2, IconRefresh, IconExternalLink } from '@tabler/icons-react'
import api from '~/lib/api' import api from '~/lib/api'
interface ActiveDownloadProps { interface ActiveDownloadProps {
@ -39,6 +39,7 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
const { data: downloads, invalidate } = useDownloads({ filetype }) const { data: downloads, invalidate } = useDownloads({ filetype })
const [cancellingJobs, setCancellingJobs] = useState<Set<string>>(new Set()) const [cancellingJobs, setCancellingJobs] = useState<Set<string>>(new Set())
const [confirmingCancel, setConfirmingCancel] = useState<string | null>(null) const [confirmingCancel, setConfirmingCancel] = useState<string | null>(null)
const [retryingJobs, setRetryingJobs] = useState<Set<string>>(new Set())
// Track previous downloadedBytes for speed calculation // Track previous downloadedBytes for speed calculation
const prevBytesRef = useRef<Map<string, { bytes: number; time: number }>>(new Map()) const prevBytesRef = useRef<Map<string, { bytes: number; time: number }>>(new Map())
@ -83,6 +84,20 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
invalidate() invalidate()
} }
const handleRetry = async (jobId: string) => {
setRetryingJobs((prev) => new Set(prev).add(jobId))
try {
await api.retryDownloadJob(jobId)
} finally {
setRetryingJobs((prev) => {
const next = new Set(prev)
next.delete(jobId)
return next
})
invalidate()
}
}
const handleCancel = async (jobId: string) => { const handleCancel = async (jobId: string) => {
setCancellingJobs((prev) => new Set(prev).add(jobId)) setCancellingJobs((prev) => new Set(prev).add(jobId))
setConfirmingCancel(null) setConfirmingCancel(null)
@ -113,6 +128,8 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
const isCancelling = cancellingJobs.has(download.jobId) const isCancelling = cancellingJobs.has(download.jobId)
const isConfirming = confirmingCancel === download.jobId const isConfirming = confirmingCancel === download.jobId
const isRetrying = retryingJobs.has(download.jobId)
return ( return (
<div <div
key={download.jobId} key={download.jobId}
@ -123,26 +140,55 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
}`} }`}
> >
{status === 'failed' ? ( {status === 'failed' ? (
<div className="flex items-center gap-2"> <div className="space-y-3">
<IconAlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0" /> <div className="flex items-start gap-2">
<div className="flex-1 min-w-0"> <IconAlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm font-medium text-text-primary truncate"> <div className="flex-1 min-w-0">
{download.title || filename} <p className="text-sm font-medium text-text-primary truncate">
</p> {download.title || filename}
{download.title && ( </p>
<p className="text-xs text-text-muted truncate">{filename}</p> {download.title && (
)} <p className="text-xs text-text-muted truncate">{filename}</p>
<p className="text-xs text-red-600 mt-0.5"> )}
Download failed{download.failedReason ? `: ${download.failedReason}` : ''} <p className="text-xs text-red-600 mt-0.5">
</p> Download failed{download.failedReason ? `: ${download.failedReason}` : ''}
</p>
</div>
<button
onClick={() => handleDismiss(download.jobId)}
className="flex-shrink-0 p-1 rounded hover:bg-red-100 transition-colors"
title="Dismiss failed download"
>
<IconX className="w-4 h-4 text-red-400 hover:text-red-600" />
</button>
</div>
<div className="flex items-center gap-2 pl-7">
<button
onClick={() => handleRetry(download.jobId)}
disabled={isRetrying}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-desert-green text-white hover:bg-desert-green-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Retry download"
>
{isRetrying ? (
<IconLoader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<IconRefresh className="w-3.5 h-3.5" />
)}
{isRetrying ? 'Retrying...' : 'Retry'}
</button>
{download.url && download.url.startsWith('http') && (
<a
href={download.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-desert-stone-lighter text-text-secondary hover:bg-desert-stone-light transition-colors"
title="Open resource download page"
>
<IconExternalLink className="w-3.5 h-3.5" />
Download page
</a>
)}
</div> </div>
<button
onClick={() => handleDismiss(download.jobId)}
className="flex-shrink-0 p-1 rounded hover:bg-red-100 transition-colors"
title="Dismiss failed download"
>
<IconX className="w-4 h-4 text-red-400 hover:text-red-600" />
</button>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">

View File

@ -75,7 +75,8 @@ const CategoryCard: React.FC<CategoryCardProps> = ({ category, selectedTier, onC
)} )}
<span className="text-lime-400 text-sm ml-1"> <span className="text-lime-400 text-sm ml-1">
{badgeTier.name} {badgeTier.name}
{badgeStatus === 'downloading' && ' (downloading)'} {badgeStatus === 'downloading' &&
(category.downloadingTierIndexing ? ' (indexing)' : ' (downloading)')}
</span> </span>
</div> </div>
) : ( ) : (

View File

@ -0,0 +1,119 @@
import { useState } from 'react'
import { formatBytes } from '~/lib/util'
import type { CreatorPackWithStatus } from '../../types/collections'
import classNames from 'classnames'
import {
IconChevronRight,
IconCircleCheck,
IconLoader2,
IconMovie,
IconTrash,
} from '@tabler/icons-react'
export interface CreatorPackCardProps {
pack: CreatorPackWithStatus
/** In-session wizard selection highlight (before anything is installed). */
selected?: boolean
onClick?: (pack: CreatorPackWithStatus) => void
/** When set, an installed pack shows an uninstall control (settings surface only). */
onUninstall?: (pack: CreatorPackWithStatus) => void
}
const CreatorPackCard: React.FC<CreatorPackCardProps> = ({ pack, selected, onClick, onUninstall }) => {
const isInstalled = pack.status === 'installed'
const isDownloading = pack.status === 'downloading'
const hasUpdate = !!pack.available_update_version
const sizeBytes = pack.size_mb * 1024 * 1024
// Installed packs are inert unless a newer version is available (click = update).
const clickable = selected || !isInstalled || hasUpdate
const highlighted = selected || isDownloading || (isInstalled && !hasUpdate)
// Prefer a catalog-supplied banner (future remote creators); otherwise the
// banner bundled with the app by pack id. Both are the branded 1060x175 art we
// build into the ZIM. Fall back to a simple header only if the image is absent.
const [bannerFailed, setBannerFailed] = useState(false)
const bannerSrc = pack.banner_url || `/creator-packs/${pack.id}.webp`
const statusBadge = selected ? (
<span className="flex items-center text-lime-600 dark:text-lime-400 text-sm font-medium">
<IconCircleCheck className="w-5 h-5 mr-1" />
Selected
</span>
) : isDownloading ? (
<span className="flex items-center text-lime-600 dark:text-lime-400 text-sm font-medium">
<IconLoader2 className="w-5 h-5 mr-1 animate-spin" />
Downloading
</span>
) : isInstalled ? (
<span className="flex items-center text-lime-600 dark:text-lime-400 text-sm font-medium">
<IconCircleCheck className="w-5 h-5 mr-1" />
Installed
</span>
) : (
<span className="flex items-center text-text-muted text-sm font-medium">
Install
<IconChevronRight className="w-5 h-5 ml-1" />
</span>
)
return (
<div
className={classNames(
'flex flex-col rounded-lg overflow-hidden bg-surface-primary border shadow-sm transition-shadow',
highlighted ? 'border-lime-400 border-2' : 'border-border-subtle',
clickable ? 'cursor-pointer hover:shadow-lg' : 'opacity-70 cursor-not-allowed'
)}
onClick={() => {
if (!clickable) return
onClick?.(pack)
}}
>
{!bannerFailed ? (
<img
src={bannerSrc}
alt={pack.name}
className="w-full block aspect-[1060/175] object-cover"
onError={() => setBannerFailed(true)}
/>
) : (
<div className="flex items-center gap-2 bg-desert-green text-white px-5 py-6">
<IconMovie className="w-6 h-6 shrink-0" />
<h3 className="text-lg font-semibold truncate">{pack.name}</h3>
</div>
)}
<div className="flex items-center justify-between gap-3 px-5 py-3">
<div className="min-w-0">
<p className="text-sm text-text-secondary truncate">
{pack.video_count} videos &middot; {formatBytes(sizeBytes, 0)}
</p>
{hasUpdate && (
<span className="inline-block mt-1 text-xs px-2 py-0.5 rounded bg-lime-500/20 text-lime-700 dark:text-lime-300">
Update available
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{statusBadge}
{onUninstall && isInstalled && (
<button
type="button"
title="Uninstall pack"
aria-label="Uninstall pack"
className="p-1 rounded text-text-muted hover:text-red-500 hover:bg-red-500/10 transition-colors"
onClick={(e) => {
e.stopPropagation()
onUninstall(pack)
}}
>
<IconTrash className="w-5 h-5" />
</button>
)}
</div>
</div>
</div>
)
}
export default CreatorPackCard

View File

@ -0,0 +1,163 @@
import { useState } from 'react'
import { IconMovie } from '@tabler/icons-react'
import api from '~/lib/api'
import useCreatorPacks from '~/hooks/useCreatorPacks'
import useDownloads from '~/hooks/useDownloads'
import { useNotifications } from '~/context/NotificationContext'
import CreatorPackCard from '~/components/CreatorPackCard'
import StyledModal from '~/components/StyledModal'
import { formatBytes } from '~/lib/util'
import type { CreatorPackWithStatus } from '../../types/collections'
// Canonical Creator Pack License (one license across the seed packs). Opened in a
// new tab from the install modal; install is an online action so an external link
// is fine. A per-pack catalog `license_url` can supersede this later if needed.
const LICENSE_URL =
'https://github.com/Crosstalk-Solutions/project-nomad/blob/main/collections/creator-pack-license.md'
export interface CreatorPacksSectionProps {
/** Show uninstall controls on installed packs (the settings "manage" surface). */
allowUninstall?: boolean
}
/**
* Install-on-click grid of Creator Packs + confirm modals. Shared by the Content
* Explorer block and the /settings/creator-packs page. Renders NOTHING when the
* build isn't configured (fork / key unset) a fork never sees a broken install
* button. The Easy Setup wizard does NOT use this (it needs selection semantics,
* not install-on-click) and drives CreatorPackCard itself.
*/
const CreatorPacksSection: React.FC<CreatorPacksSectionProps> = ({ allowUninstall }) => {
const { configured, packs, invalidate: invalidateCreatorPacks } = useCreatorPacks()
const { invalidate: invalidateDownloads } = useDownloads({ filetype: 'zim' })
const { addNotification } = useNotifications()
const [packToInstall, setPackToInstall] = useState<CreatorPackWithStatus | null>(null)
const [installing, setInstalling] = useState(false)
const [packToUninstall, setPackToUninstall] = useState<CreatorPackWithStatus | null>(null)
const [uninstalling, setUninstalling] = useState(false)
if (!configured) return null
const handleConfirmInstall = async () => {
if (!packToInstall) return
setInstalling(true)
try {
await api.installCreatorPack(packToInstall.id)
addNotification({ message: `Started installing "${packToInstall.name}"`, type: 'success' })
invalidateCreatorPacks()
invalidateDownloads()
setPackToInstall(null)
} catch (error) {
console.error('Error installing creator pack:', error)
addNotification({ message: 'An error occurred while starting the install.', type: 'error' })
} finally {
setInstalling(false)
}
}
const handleConfirmUninstall = async () => {
if (!packToUninstall) return
setUninstalling(true)
try {
await api.uninstallCreatorPack(packToUninstall.id)
addNotification({ message: `Uninstalled "${packToUninstall.name}"`, type: 'success' })
invalidateCreatorPacks()
invalidateDownloads()
setPackToUninstall(null)
} catch (error) {
console.error('Error uninstalling creator pack:', error)
addNotification({ message: 'An error occurred while uninstalling.', type: 'error' })
} finally {
setUninstalling(false)
}
}
return (
<>
<div className="flex items-center gap-3 mt-8 mb-4">
<div className="w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm">
<IconMovie className="w-6 h-6 text-text-primary" />
</div>
<div>
<h3 className="text-xl font-semibold text-text-primary">Creator Packs</h3>
<p className="text-sm text-text-muted">
Branded video collections from creators, for offline viewing
</p>
</div>
</div>
{packs.length > 0 ? (
<div className="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-6">
{packs.map((pack) => (
<CreatorPackCard
key={pack.id}
pack={pack}
onClick={setPackToInstall}
onUninstall={allowUninstall ? setPackToUninstall : undefined}
/>
))}
</div>
) : (
<p className="text-text-muted mt-4">No creator packs available.</p>
)}
<StyledModal
open={!!packToInstall}
title={packToInstall ? `Install ${packToInstall.name}?` : 'Install Creator Pack'}
onClose={() => !installing && setPackToInstall(null)}
onCancel={() => setPackToInstall(null)}
onConfirm={handleConfirmInstall}
confirmText={packToInstall?.available_update_version ? 'Update pack' : 'Install pack'}
confirmIcon="IconDownload"
confirmLoading={installing}
icon={<IconMovie className="w-6 h-6" />}
>
{packToInstall && (
<div className="space-y-3 text-text-secondary">
<p>
{packToInstall.video_count} videos from {packToInstall.creator}, about{' '}
{formatBytes(packToInstall.size_mb * 1024 * 1024, 0)}. It will download in the
background and appear in Kiwix when ready.
</p>
<p className="text-sm text-text-muted">
Licensed content personal use, not for redistribution.{' '}
<a
href={LICENSE_URL}
target="_blank"
rel="noreferrer"
className="text-desert-green underline hover:no-underline"
onClick={(e) => e.stopPropagation()}
>
View license
</a>
</p>
</div>
)}
</StyledModal>
<StyledModal
open={!!packToUninstall}
title={packToUninstall ? `Uninstall ${packToUninstall.name}?` : 'Uninstall Creator Pack'}
onClose={() => !uninstalling && setPackToUninstall(null)}
onCancel={() => setPackToUninstall(null)}
onConfirm={handleConfirmUninstall}
confirmText="Uninstall pack"
confirmIcon="IconTrash"
confirmVariant="danger"
confirmLoading={uninstalling}
icon={<IconMovie className="w-6 h-6" />}
>
{packToUninstall && (
<p className="text-text-secondary">
This removes the downloaded videos (
{formatBytes(packToUninstall.size_mb * 1024 * 1024, 0)}) from this NOMAD. You can
reinstall the pack anytime.
</p>
)}
</StyledModal>
</>
)
}
export default CreatorPacksSection

View File

@ -13,7 +13,7 @@ export default function Footer() {
<footer> <footer>
<div className="flex items-center justify-center gap-3 border-t border-border-subtle py-4"> <div className="flex items-center justify-center gap-3 border-t border-border-subtle py-4">
<p className="text-sm/6 text-text-secondary"> <p className="text-sm/6 text-text-secondary">
Project N.O.M.A.D. Command Center v{appVersion} Project NOMAD&trade; Command Center v{appVersion}
</p> </p>
<span className="text-gray-300">|</span> <span className="text-gray-300">|</span>
<button <button

View File

@ -4,9 +4,21 @@ import { useState } from 'react'
interface InfoTooltipProps { interface InfoTooltipProps {
text: string text: string
className?: string className?: string
// Which side of the icon the tooltip pops toward. Defaults to 'top' (existing behavior);
// use 'bottom' when the icon sits near the top of the viewport so it isn't clipped.
position?: 'top' | 'bottom'
// Horizontal anchoring. 'center' (default) centers the bubble on the icon. Use 'right' when
// the icon sits near the right edge so the bubble expands leftward into open space instead of
// being squeezed against the viewport edge (which forces one-word-per-line wrapping).
align?: 'center' | 'right'
} }
export default function InfoTooltip({ text, className = '' }: InfoTooltipProps) { export default function InfoTooltip({
text,
className = '',
position = 'top',
align = 'center',
}: InfoTooltipProps) {
const [isVisible, setIsVisible] = useState(false) const [isVisible, setIsVisible] = useState(false)
return ( return (
@ -23,10 +35,24 @@ export default function InfoTooltip({ text, className = '' }: InfoTooltipProps)
<IconInfoCircle className="w-4 h-4" /> <IconInfoCircle className="w-4 h-4" />
</button> </button>
{isVisible && ( {isVisible && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50"> <div
<div className="bg-desert-stone-dark text-white text-xs rounded-lg px-3 py-2 max-w-xs whitespace-normal shadow-lg"> className={`absolute z-50 ${position === 'bottom' ? 'top-full mt-2' : 'bottom-full mb-2'} ${
align === 'right' ? 'right-0' : 'left-1/2 -translate-x-1/2'
}`}
>
<div
className={`bg-desert-stone-dark text-white text-xs rounded-lg px-3 py-2 whitespace-normal shadow-lg ${
align === 'right' ? 'w-64' : 'max-w-xs'
}`}
>
{text} {text}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-desert-stone-dark" /> <div
className={`absolute border-4 border-transparent ${
position === 'bottom'
? 'bottom-full border-b-desert-stone-dark'
: 'top-full border-t-desert-stone-dark'
} ${align === 'right' ? 'right-3' : 'left-1/2 -translate-x-1/2'}`}
/>
</div> </div>
</div> </div>
)} )}

View File

@ -0,0 +1,72 @@
import { useEffect, useRef } from 'react'
import { Compartment, EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { markdown } from '@codemirror/lang-markdown'
import { oneDark } from '@codemirror/theme-one-dark'
import { basicSetup } from 'codemirror'
import { useTheme } from '~/hooks/useTheme'
interface MarkdownEditorProps {
/** Initial document contents. Read once on mount; later edits are reported via onChange. */
initialValue: string
onChange: (value: string) => void
className?: string
}
/**
* A thin React wrapper that mounts a CodeMirror 6 editor tuned for Markdown.
* The editor is uncontrolled after mount (its state lives in CodeMirror); the
* current value is surfaced to the parent through onChange. The one-dark theme
* is applied via a Compartment so light/dark toggles reconfigure in place
* without rebuilding the document.
*/
export default function MarkdownEditor({ initialValue, onChange, className }: MarkdownEditorProps) {
const hostRef = useRef<HTMLDivElement | null>(null)
const viewRef = useRef<EditorView | null>(null)
const themeCompartment = useRef(new Compartment())
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const { theme } = useTheme()
useEffect(() => {
if (!hostRef.current) return
const state = EditorState.create({
doc: initialValue,
extensions: [
basicSetup,
markdown(),
EditorView.lineWrapping,
// Fill the host element and scroll internally rather than growing the page.
EditorView.theme({
'&': { height: '100%' },
'.cm-scroller': { overflow: 'auto' },
}),
themeCompartment.current.of(theme === 'dark' ? oneDark : []),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString())
}
}),
],
})
const view = new EditorView({ state, parent: hostRef.current })
viewRef.current = view
return () => {
view.destroy()
viewRef.current = null
}
// Mount once; initialValue/theme changes are handled below or by remounting.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
viewRef.current?.dispatch({
effects: themeCompartment.current.reconfigure(theme === 'dark' ? oneDark : []),
})
}, [theme])
return <div ref={hostRef} className={className} />
}

Some files were not shown because too many files have changed in this diff Show More