Compare commits

..

118 Commits

Author SHA1 Message Date
cosmistack-bot
8dcbf7dbcf docs(release): finalize v1.31.0 release notes [skip ci] 2026-04-03 21:27:57 +00:00
cosmistack-bot
37abad33c9 chore(release): 1.31.0 [skip ci] 2026-04-03 21:27:36 +00:00
Jake Turner
d666b24598 docs: update release notes 2026-04-03 14:26:50 -07:00
chriscrosstalk
a813468949 feat(maps): add imperial/metric toggle for scale bar (#641)
Defaults to metric for global audience. Persists choice in localStorage.
Segmented button styled to match MapLibre controls.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:26:50 -07:00
cosmistack-bot
e72268bb1c chore(release): 1.31.0-rc.3 [skip ci] 2026-04-03 14:26:50 -07:00
chriscrosstalk
0183b42d71 feat(maps): add scale bar and location markers (#636)
Add distance scale bar and user-placed location pins to the offline maps viewer.

- Scale bar (bottom-left) shows distance reference that updates with zoom level
- Click anywhere on map to place a named pin with color selection (6 colors)
- Collapsible "Saved Locations" panel lists all pins with fly-to navigation
- Full dark mode support for popups and panel via CSS overrides
- New `map_markers` table with future-proofed columns for routing (marker_type,
  route_id, route_order, notes) to avoid a migration when routes are added later
- CRUD endpoints: GET/POST /api/maps/markers, PATCH/DELETE /api/maps/markers/:id
- VineJS validation on create/update
- MapMarker Lucid model

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:26:50 -07:00
Jake Turner
6287755946 fix(Maps): ensure proper parsing of hostnames (#640) 2026-04-03 14:26:50 -07:00
cosmistack-bot
0f9be7c215 chore(release): 1.31.0-rc.2 [skip ci] 2026-04-03 14:26:50 -07:00
Jake Turner
afbe4c42b1 docs: update release notes 2026-04-03 14:26:50 -07:00
0xGlitch
d7e3d9246b fix(downloads): improved handling for large file downloads and user-initiated cancellation (#632)
* fix(downloads): increase retry attempts and backoff for large file downloads
* fix download retry config and abort handling
* use abort reason to detect user-initiated cancels
2026-04-03 14:26:50 -07:00
Jake Turner
cb4fa003a4 fix: cache docker list requests, aiAssistantName fetching, and ensure inertia used properly 2026-04-03 14:26:50 -07:00
Jake Turner
877fb1276a feat: gzip compression by default for all registered routes 2026-04-03 14:26:50 -07:00
Jake Turner
1e4b7aea82 fix(UI): manual import map for DynamicIcon to avoid huge bundle of Tabler icons 2026-04-03 14:26:50 -07:00
Jake Turner
a14dd688fa feat(KnowledgeBase): support up to 5 files upload of 100mb each per req 2026-04-03 14:26:50 -07:00
cosmistack-bot
1bd1811498 chore(release): 1.31.0-rc.1 [skip ci] 2026-04-03 14:26:50 -07:00
Jake Turner
3e922877d2 docs: update release notes 2026-04-03 14:26:50 -07:00
Jake Turner
9964a82240 docs: update CONTRIBUTING.md 2026-04-03 14:26:50 -07:00
Jake Turner
91a0b8bad5 docs: update FAQ 2026-04-03 14:26:50 -07:00
Jake Turner
9e3828bcba feat(Kiwix): migrate to Kiwix library mode for improved stability (#622) 2026-04-03 14:26:50 -07:00
Henry Estela
43c8876f19 feat(docs): add simple API reference (#615)
Adds tables with method,path and description in /docs/api-reference/
2026-04-03 14:26:50 -07:00
Jake Turner
31986d7319 chore(deps): bump yaml, fast-xml-parser, pmtiles, tailwindcss, @types/dockerode 2026-04-03 14:26:50 -07:00
Henry Estela
0edfdead90 feat(AI): enable flash_attn by default and disable ollama cloud (#616)
New defaults:
OLLAMA_NO_CLOUD=1 - "Ollama can run in local only mode by disabling
Ollama’s cloud features. By turning off Ollama’s cloud features, you
will lose the ability to use Ollama’s cloud models and web search."
https://ollama.com/blog/web-search
https://docs.ollama.com/faq#how-do-i-disable-ollama%E2%80%99s-cloud-features
example output:
```
ollama run minimax-m2.7:cloud
Error: ollama cloud is disabled: remote model details are unavailable
```
This setting can be safely disabled as you have to click on a link to
login to ollama cloud and theres no real way to do that in nomad outside
of looking at the nomad_ollama logs.

This one can be disabled in settings in case theres a model out there
that doesn't play nice. but that doesnt seem necessary so far.
OLLAMA_FLASH_ATTENTION=1 - "Flash Attention is a feature of most modern
models that can significantly reduce memory usage as the context size
grows. "

Tested with llama3.2:
```
docker logs nomad_ollama --tail 1000 2>&1 |grep --color -i flash_attn
llama_context: flash_attn    = enabled
```

And with second_constantine/deepseek-coder-v2 with is based on
https://huggingface.co/lmstudio-community/DeepSeek-Coder-V2-Lite-Instruct-GGUF
which is a model that specifically calls out that you should disable
flash attention, but during testing it seems ollama can do this for you
automatically:
```
docker logs nomad_ollama --tail 1000 2>&1 |grep --color -i flash_attn
llama_context: flash_attn    = disabled
```
2026-04-03 14:26:50 -07:00
Jake Turner
2e3253b1ac fix(Jobs): improved error handling and robustness 2026-04-03 14:26:50 -07:00
chriscrosstalk
a6c257ab27 feat(UI): add Installed Models section to AI Assistant settings (#612)
Surfaces all installed AI models in a dedicated table between Settings
and Active Model Downloads, so users can quickly see what's installed
and delete models without hunting through the expandable model catalog.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:26:50 -07:00
Jake Turner
f4beb9a18a fix(Maps): remove unused import 2026-04-03 14:26:50 -07:00
chriscrosstalk
bac53e28dc feat(downloads): rich progress, friendly names, cancel, and live status (#554)
* feat(downloads): rich progress, friendly names, cancel, and live status

Redesign the Active Downloads UI with four improvements:

- Rich progress: BullMQ jobs now report downloadedBytes/totalBytes instead
  of just a percentage, showing "2.3 GB / 5.1 GB" instead of "78% / 100%"
- Friendly names: dispatch title metadata from curated categories, Content
  Explorer library, Wikipedia selector, and map collections
- Cancel button: Redis-based cross-process abort signal lets users cancel
  active downloads with file cleanup. Confirmation step prevents accidents.
- Live status indicator: green pulsing dot with transfer speed for active
  downloads, orange stall warning after 60s of no data, gray dot for queued

Backward compatible with in-flight jobs that have integer-only progress.

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

* fix(downloads): fix cancel, dismiss, speed, and retry bugs

- Speed indicator: only set prevBytesRef on first observation to prevent
  intermediate re-renders from inflating the calculated speed
- Cancel: throw UnrecoverableError on abort to prevent BullMQ retries
- Dismiss: remove stale BullMQ lock before job.remove() so cancelled
  jobs can actually be dismissed
- Retry: add getActiveByUrl() helper that checks job state before
  blocking re-download, auto-cleans terminal jobs
- Wikipedia: reset selection status to failed on cancel so the
  "downloading" state doesn't persist

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

* feat(downloads): improve cancellation logic and surface true BullMQ job states

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Jake Turner <jturner@cosmistack.com>
2026-04-03 14:26:50 -07:00
0xGlitch
2609530d25 fix(queue): increase BullMQ lockDuration to prevent download stalls (#604) 2026-04-03 14:26:50 -07:00
David Gross
b65b6d6b35 fix(Maps): add x-forwarded-proto support to handle https termination (#600) 2026-04-03 14:26:50 -07:00
Henry Estela
7711b5f0e8 feat: switch all PNG images to WEBP (#575)
* feat(web): Switch all png except favicon to webp format
* fix(docs): use relative path for README project logo
2026-04-03 14:26:50 -07:00
Sebastion
e9af7a555b fix: block IPv4-mapped IPv6 and IPv6 all-zeros in SSRF check (#520)
The assertNotPrivateUrl() function blocked standard loopback and link-local
addresses but could be bypassed using IPv4-mapped IPv6 representations:

  - http://[::ffff:127.0.0.1]:8080/ → loopback bypass
  - http://[::ffff:169.254.169.254]:8080/ → metadata endpoint bypass
  - http://[::]:8080/ → all-interfaces bypass

Node.js normalises these to [::ffff:7f00:1], [::ffff:a9fe:a9fe], and [::]
respectively, none of which matched the existing regex patterns.

Add two patterns to close the gap:
  - /^\[::ffff:/i catches all IPv4-mapped IPv6 addresses
  - /^\[::\]$/ catches the IPv6 all-zeros address

Legitimate RFC1918 LAN URLs (192.168.x, 10.x, 172.16-31.x) remain allowed.
2026-04-03 14:26:50 -07:00
Luís Miguel
b183bc6745 fix(security): validate key parameter on settings read endpoint#517
Co-authored-by: Jake Turner <52841588+jakeaturner@users.noreply.github.com>
2026-04-03 14:26:50 -07:00
Jake Turner
fc6152c908 feat: support adding labels on dynamic container creation (#620)
Co-authored-by: Benjamin Sanders <ben@benjaminsanders.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:26:50 -07:00
chriscrosstalk
6a0195b9fc fix(UI): constrain install activity feed height with auto-scroll (#611)
The App Installation Activity list on the Easy Setup complete page grew
unboundedly, pushing Active Downloads off-screen. Caps the list at ~8
visible items with overflow scrolling, auto-scrolling to keep the latest
activity visible.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:26:50 -07:00
0xGlitch
789fdfe95d feat(maps): add global map download from Protomaps (#525)
* feat(maps): add global map download from Protomaps
* fix: add path traversal check to global map download
2026-04-03 14:26:50 -07:00
Sam
1def8c0991 docs(readme): format Quick Install command as multiline bash (#569) 2026-04-03 14:26:50 -07:00
chriscrosstalk
9ba1bbf715 fix(install): add gpg as a required dependency (#574)
The NVIDIA container toolkit setup requires gpg to dearmor the GPG key,
but minimal Debian/Ubuntu installs may not have it present. Adds gpg to
the dependency check alongside curl so it gets installed automatically.

Closes #522

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:26:50 -07:00
Jake Turner
328453c4cf build: regen lockfile 2026-04-03 14:26:50 -07:00
arn6694
ed8918f2e9 feat(rag): add EPUB file support for Knowledge Base uploads (#257) 2026-04-03 14:26:50 -07:00
Salman Chishti
d474c142a1 ci: upgrade GitHub Actions to latest versions (#362) 2026-04-03 14:26:50 -07:00
dependabot[bot]
32f8b0ff98 build(deps): bump file-type from 21.3.0 to 21.3.2 in /admin (#283)
Bumps [file-type](https://github.com/sindresorhus/file-type) from 21.3.0 to 21.3.2.
- [Release notes](https://github.com/sindresorhus/file-type/releases)
- [Commits](https://github.com/sindresorhus/file-type/compare/v21.3.0...v21.3.2)

---
updated-dependencies:
- dependency-name: file-type
  dependency-version: 21.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 14:26:50 -07:00
Henry Estela
69c15b8b1e feat(AI): enable remote AI chat host 2026-04-03 14:26:50 -07:00
Jake Turner
d25292a713
Revert "feat: support adding labels on dynamic container creation (#610)" (#619)
This reverts commit f32ba3bb51.
2026-04-01 11:04:11 -07:00
Benjamin Sanders
f32ba3bb51
feat: support adding labels on dynamic container creation (#610)
Co-authored-by: Jake Turner <jturner@cosmistack.com>
2026-04-01 11:03:44 -07:00
Henry Estela
44ecf41ca6 Add model download to FAQ.md 2026-03-26 23:12:49 -07:00
cosmistack-bot
5c92c89813 docs(release): finalize v1.30.3 release notes [skip ci] 2026-03-25 23:40:34 +00:00
cosmistack-bot
f9e3773ec3 chore(release): 1.30.3 [skip ci] 2026-03-25 23:39:41 +00:00
cosmistack-bot
e5a7edca03 chore(release): 1.30.3-rc.2 [skip ci] 2026-03-25 16:30:35 -07:00
Jake Turner
bd015f4c56 fix(UI): improve version display in Settings sidebar (#547) 2026-03-25 16:30:35 -07:00
Jake Turner
0e60e246e1 ops: remove deprecated sidecar-updater files from install script (#546) 2026-03-25 16:30:35 -07:00
Jake Turner
c67653b87a fix(UI): use StyledButton in TierSelectionModal for consistency (#543) 2026-03-25 16:30:35 -07:00
cosmistack-bot
643eaea84b chore(release): 1.30.3-rc.1 [skip ci] 2026-03-25 16:30:35 -07:00
Jake Turner
150134a9fa docs: update release notes 2026-03-25 16:30:35 -07:00
Tom Boucher
6b558531be fix: surface actual error message when service installation fails
Backend returned { error: message } on 400 but frontend expected { message }.
catchInternal swallowed Axios errors and returned undefined, causing a
generic 'An internal error occurred' message instead of the real reason
(already installed, already in progress, not found).

- Fix 400 response shape to { success: false, message } in controller
- Replace catchInternal with direct error handling in installService,
  affectService, and forceReinstallService API methods
- Extract error.response.data.message from Axios errors so callers
  see the actual server message
2026-03-25 16:30:35 -07:00
Bortlesboat
4642dee6ce fix: benchmark scores clamped to 0% for below-average hardware
The log2 normalization formula `50 * (1 + log2(ratio))` produces negative
values (clamped to 0) whenever the measured value is less than half the
reference. For example, a CPU scoring 1993 events/sec against a 5000
reference gives ratio=0.4, log2(0.4)=-1.32, score=-16 -> 0%.

Fix by dividing log2 by 3 to widen the usable range. This preserves the
50% score at the reference value while allowing below-average hardware
to receive proportional non-zero scores (e.g., 28% for the CPU above).

Also adds debug logging for CPU sysbench output parsing to aid future
diagnosis of parsing issues.

Fixes #415
2026-03-25 16:30:35 -07:00
Chris Sherwood
78c0b1d24d fix(ai): surface model download errors and prevent silent retry loops
Model downloads that fail (e.g., when Ollama is too old for a model)
were silently retrying 40 times with no UI feedback. Now errors are
broadcast via SSE and shown in the Active Model Downloads section.
Version mismatch errors use UnrecoverableError to fail immediately
instead of retrying. Stale failed jobs are cleared on retry so users
aren't permanently blocked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:30:35 -07:00
Jake Turner
0226e651c7 fix: bump default ollama and cyberchef versions 2026-03-25 16:30:35 -07:00
LuisMIguelFurlanettoSousa
7ab5e65826 fix(zim): adicionar método deleteZimFile ausente no API client
O Content Manager chamava api.deleteZimFile() para deletar arquivos
ZIM, mas esse método nunca foi implementado na classe API, causando
"TypeError: deleteZimFile is not a function".

O backend (DELETE /api/zim/:filename → ZimController.delete) já
existia e funcionava corretamente — só faltava o método no client
frontend que faz a ponte.

Closes #372
2026-03-25 16:30:35 -07:00
Chris Sherwood
b7ed8b6694 docs: add installation guide link to README
Link to the full step-by-step install walkthrough on projectnomad.us/install,
placed below the Quick Install command for users who need Ubuntu setup help.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:30:35 -07:00
builder555
4443799cc9 fix(Collections): update ZIM files to latest versions (#332)
* fix: update data sources to newer versions
* fix: bump spec version for wikipedia
2026-03-25 16:30:35 -07:00
Divyank Singh
4219e753da build: increase mysql healthcheck retries to avoid race condition on lower-end hardware (#480) 2026-03-25 16:30:35 -07:00
Chris Sherwood
f00bfff77c fix(install): prevent MySQL credential mismatch on reinstall
When the install script runs a second time (e.g., after a failed first
attempt), it generates new random database passwords and writes them to
compose.yml. However, MySQL only initializes credentials on first startup
when its data directory is empty. If /opt/project-nomad/mysql/ persists
from the previous attempt, MySQL skips initialization and keeps the old
passwords, causing "Access denied" errors for nomad_admin.

Fix: remove the MySQL data directory before generating new credentials
so MySQL reinitializes with the correct passwords.

Closes #404

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:30:35 -07:00
chriscrosstalk
5e93f2661b fix: correct Rogue Support URL on Support the Project page (#472)
roguesupport.com changed to rogue.support (the actual domain).
Updates href and display text in two places.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:30:35 -07:00
Brenex
9a8378d63a
build: fix grep command in install script for NVIDIA runtime detection (#526) 2026-03-25 14:36:19 -07:00
Salman Chishti
982dceb949
ci: upgrade checkout action version (#361) 2026-03-25 14:31:53 -07:00
Jake Turner
6a1d0e83f9
ci: bump checkout action veon build-sidecar and validate-collections 2026-03-25 21:29:51 +00:00
Jake Turner
edcfd937e2
ci: add build check for PRs 2026-03-25 20:41:53 +00:00
Jake Turner
f9062616b8
docs: add FAQ 2026-03-25 06:04:17 +00:00
Jake Turner
efe6af9b24
ci: add collection URLs validation check 2026-03-24 05:31:53 +00:00
Jake Turner
8b96793c4d
build: add latest initial zim file 2026-03-24 02:08:18 +00:00
cosmistack-bot
735b9e8ae6 chore(release): 1.30.2 [skip ci] 2026-03-23 19:47:10 +00:00
Chris Sherwood
c409896718 fix(collections): update Full Wikipedia URL to current Kiwix mirror
The 2024-01 all_maxi ZIM was removed from Kiwix mirrors, causing
silent 404 failures for users selecting "Complete Wikipedia (Full)".
Updated to 2026-02 release (115 GB).

Closes #216

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:46:26 -07:00
Philip L. Welch
f004c002a7 docs: fix typo in README url 2026-03-20 17:15:56 -07:00
cosmistack-bot
d501d2dc7e chore(release): 1.30.1 [skip ci] 2026-03-20 19:29:55 +00:00
Jake Turner
8e84ece2ef
fix(ui): ref issue in benchmark page 2026-03-20 19:29:13 +00:00
cosmistack-bot
a4de8d05f7 docs(release): finalize v1.30.0 release notes [skip ci] 2026-03-20 18:48:42 +00:00
cosmistack-bot
28df8e6b23 chore(release): 1.30.0 [skip ci] 2026-03-20 18:46:55 +00:00
Jake Turner
baeb96b863 fix(ui): support proper size override of LoadingSpinner 2026-03-20 11:46:10 -07:00
Jake Turner
d645fc161b fix(ui): reduce SSE reconnect churn and polling overhead on navigation 2026-03-20 11:46:10 -07:00
Jake Turner
b8cf1b6127 fix(disk): correct storage display by fixing device matching and dedup mount entries 2026-03-20 11:46:10 -07:00
cosmistack-bot
f5a181b09f chore(release): 1.30.0-rc.2 [skip ci] 2026-03-20 11:46:10 -07:00
Jake Turner
4784cd6e43 docs: update release notes 2026-03-20 11:46:10 -07:00
Jake Turner
467299b231 docs: update port mapping guidance in compose file 2026-03-20 11:46:10 -07:00
Jake Turner
5dfa6d7810 docs: update release notes 2026-03-20 11:46:10 -07:00
Chris Sherwood
571f6bb5a2 fix(GPU): persist GPU type to KV store for reliable passthrough
GPU detection results were only applied at container creation time and
never persisted. If live detection failed transiently (Docker daemon
hiccup, runtime temporarily unavailable), Ollama would silently fall
back to CPU-only mode with no way to recover short of force-reinstall.

Now _detectGPUType() persists successful detections to the KV store
(gpu.type = 'nvidia' | 'amd') and uses the saved value as a fallback
when live detection returns nothing. This ensures GPU config survives
across container recreations regardless of transient detection failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
Chris Sherwood
023e3f30af fix(downloads): allow users to dismiss failed downloads
Failed download jobs persist in BullMQ forever with no way to clear
them, leaving stale error notifications in Content Explorer and Easy
Setup. Adds a dismiss button (X) on failed download cards that removes
the job from the queue via a new DELETE endpoint.

- Backend: DELETE /api/downloads/jobs/:jobId endpoint
- Frontend: X button on failed download cards with immediate refresh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
Chris Sherwood
d6c6cb66fa fix(docs): remove internal security audit from public documentation
The security audit report was an internal pre-launch document that
shouldn't be exposed in the user-facing documentation sidebar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
Chris Sherwood
b8d36da9e1 fix(UI): hide 'Start here!' badge after Easy Setup is completed
The KV store returns ui.hasVisitedEasySetup as boolean true, but the
comparison checked against string 'true'. Since true !== 'true', the
badge was always shown even after completing Easy Setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
Chris Sherwood
6b41ebbd45 fix(UI): clear stale update banner after successful update
After an update completes, the page reloads but the KV store still has
updateAvailable=true from the pre-update check. This causes the banner
to show "Current 1.30.0-rc.1 → New 1.30.0-rc.1" until the user
manually clicks Check Again.

Now triggers a version re-check before the post-update reload so the
KV store is updated and the banner reflects the correct state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
cosmistack-bot
85492454a5 chore(release): 1.30.0-rc.1 [skip ci] 2026-03-20 11:46:10 -07:00
Jake Turner
77e83085d6 docs: updated release notes with latest changes 2026-03-20 11:46:10 -07:00
Jake Turner
0ec5334e0d docs: additional comments in management_compose about storage config 2026-03-20 11:46:10 -07:00
Jake Turner
6cb2a0d944 ops: added additional warning about possible overwrites of existing custom installs 2026-03-20 11:46:10 -07:00
Jake Turner
6934e8b4d1 ops: added a check for docker-compose version in Nomad utility scripts 2026-03-20 11:46:10 -07:00
Jake Turner
bb0c4d19d8 docs: add note about Dozzle optionality 2026-03-20 11:46:10 -07:00
Jake Turner
1c179efde2 docs: improve docs for advanced install 2026-03-20 11:46:10 -07:00
Jake Turner
5dc48477f6 fix(Docker): ensure fresh GPU detection when Ollama ctr updated 2026-03-20 11:46:10 -07:00
Chris Sherwood
b0b8f07661 fix: improve download reliability with stall detection, failure visibility, and Wikipedia status tracking
Three bugs caused downloads to hang, disappear, or leave stuck spinners:
1. Wikipedia downloads that failed never updated the DB status from 'downloading',
   leaving the spinner stuck forever. Now the worker's failed handler marks them as failed.
2. No stall detection on streaming downloads - if data stopped flowing mid-download,
   the job hung indefinitely. Added a 5-minute stall timer that triggers retry.
3. Failed jobs were invisible to users since only waiting/active/delayed states were
   queried. Now failed jobs appear with error indicators in the download list.

Closes #364, closes #216

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
Jake Turner
5e290119ab fix(maps): remove DC from South Atlantic until generated 2026-03-20 11:46:10 -07:00
Chris Sherwood
ab5a7cb178 fix(maps): split combined Indiana/Michigan entry into separate states
The East North Central region had a single "indianamichigan" entry pointing
to a pmtiles file that doesn't exist. Indiana and Michigan are separate
files in the maps repo.

Closes #350

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
Chris Sherwood
5b990b7323 fix(collections): update stale React devdocs ZIM URL
Kiwix skipped the January 2026 build of devdocs_en_react — the
2026-01 URL returns 404. Updated to 2026-02 which exists.

Closes #269

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
Jake Turner
92ce7400e7 feat: make Nomad fully composable 2026-03-20 11:46:10 -07:00
Andrew Barnes
d53ccd2dc8 fix: prefer real block devices over tmpfs for storage display
The disk-collector could produce an empty fsSize array when
/host/proc/1/mounts is unreadable, causing the admin UI to fall back
to systeminformation's fsSize which includes tmpfs mounts. This led to
the storage display showing ~1.5 GB (tmpfs /run) instead of the actual
storage capacity.

Two changes:
- disk-collector: fall back to df on /storage when host mount table
  yields no real filesystems, since /storage is always bind-mounted
  from the host and reflects the actual backing device.
- easy-setup UI: when falling back to systeminformation fsSize, filter
  for /dev/ block devices and prefer the largest one instead of blindly
  taking the first entry.

Fixes #373
2026-03-20 11:46:10 -07:00
Jake Turner
c0b1980bbc build: change compose to use prebuilt sidecar-updater image 2026-03-20 11:46:10 -07:00
Jake Turner
9b74c71f29 fix(UI): minor styling fixes for Night Ops 2026-03-20 11:46:10 -07:00
orbisai0security
9802dd7c70 fix: upgrade systeminformation to 5.31.0 (CVE-2026-26318)
systeminformation: systeminformation: Arbitrary code execution via unsanitized `locate` output
Resolves CVE-2026-26318
2026-03-20 11:46:10 -07:00
dependabot[bot]
138ad84286 build(deps): bump fast-xml-parser from 5.3.8 to 5.5.6 in /admin
Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) from 5.3.8 to 5.5.6.
- [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases)
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.3.8...v5.5.6)

---
updated-dependencies:
- dependency-name: fast-xml-parser
  dependency-version: 5.5.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 11:46:10 -07:00
Chris Sherwood
34076b107b fix: prevent embedding retry storm when Ollama is not installed
When Ollama isn't installed, every ZIM download dispatches embedding jobs
that fail and retry 30x with 60s backoff. With many ZIM files downloading
in parallel, this exhausts Redis connections with EPIPE/ECONNRESET errors.

Two changes:
1. Don't dispatch embedding jobs when Ollama isn't installed (belt)
2. Use BullMQ UnrecoverableError for "not installed" so jobs fail
   immediately without retrying (suspenders)

Closes #351

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
dependabot[bot]
5e0fba29ca build(deps): bump undici in /admin
Bumps  and [undici](https://github.com/nodejs/undici). These dependencies needed to be updated together.

Updates `undici` from 6.23.0 to 6.24.1
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.23.0...v6.24.1)

Updates `undici` from 7.20.0 to 7.24.3
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.23.0...v6.24.1)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.24.1
  dependency-type: indirect
- dependency-name: undici
  dependency-version: 7.24.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 11:46:10 -07:00
dependabot[bot]
06e1c4f4f2 build(deps): bump tar from 7.5.10 to 7.5.11 in /admin
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.10 to 7.5.11.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.10...v7.5.11)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.11
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 11:46:10 -07:00
Chris Sherwood
fbc48dd115 fix: default LOG_LEVEL to info in production
Debug logging in production is unnecessarily noisy. Users who need
debug output can still set LOG_LEVEL=debug in their compose.yml.

Closes #285

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
Chris Sherwood
e4fde22dd9 feat(UI): add Debug Info modal for bug reporting
Add a "Debug Info" link to the footer and settings sidebar that opens a
modal with non-sensitive system information (version, OS, hardware, GPU,
installed services, internet status, update availability). Users can copy
the formatted text and paste it into GitHub issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
Chris Sherwood
826c819b4a docs: update hardware price ranges to reflect 2026 market
Updated hardware guide price references from $200–$800+ to $150–$1,000+
based on community leaderboard data (41 submissions) and current market
pricing. DDR5 RAM and GPU prices are significantly inflated — budget DDR4
refurbs start at $150, recommended AMD APU builds run $500–$800, and
dedicated GPU builds start at $1,000+. Also noted AMD Ryzen 7 with
Radeon graphics as the community sweet spot in the FAQ.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
Chris Sherwood
fe0c2afe60 fix(security): remove MySQL and Redis port exposure to host
MySQL (3306) and Redis (6379) were published to all host interfaces
despite only being accessed by the admin container via Docker's internal
network. Redis has no authentication, so anyone on the LAN could connect.

Removes the port mappings — containers still communicate internally via
Docker service names.

Closes #279

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
Jake Turner
9220b4b83d fix(maps): respect request protocol for reverse proxy HTTPS support 2026-03-20 11:46:10 -07:00
Chris Sherwood
6120e257e8 fix(security): also disable Dozzle container actions
Dozzle runs on port 9999 with no authentication. DOZZLE_ENABLE_ACTIONS
allows anyone on the LAN to stop/restart containers. NOMAD already
handles container management through its own admin UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
Chris Sherwood
bd642ac1e8 fix(security): disable Dozzle web shell access
Dozzle's DOZZLE_ENABLE_SHELL=true on an unauthenticated port allows
anyone on the LAN to open a shell into containers, including nomad_admin
which has the Docker socket mounted — creating a path to host root.

Disables shell access while keeping log viewing and container actions
(restart/stop) enabled.

Closes #278

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
Chris Sherwood
6a737ed83f feat(UI): add Support the Project settings page
Adds a new settings page with Ko-fi donation link, Rogue Support
banner, and community contribution options (GitHub, Discord).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
Chris Sherwood
b1edef27e8 feat(UI): add Night Ops dark mode with theme toggle
Add a warm charcoal dark mode ("Night Ops") using CSS variable swapping
under [data-theme="dark"]. All 23 desert palette variables are overridden
with dark-mode counterparts, and ~313 generic Tailwind classes (bg-white,
text-gray-*, border-gray-*) are replaced with semantic tokens.

Infrastructure:
- CSS variable overrides in app.css for both themes
- ThemeProvider + useTheme hook (localStorage + KV store sync)
- ThemeToggle component (moon/sun icons, "Night Ops"/"Day Ops" labels)
- FOUC prevention script in inertia_layout.edge
- Toggle placed in StyledSidebar and Footer for access on every page

Color replacements across 50 files:
- bg-white → bg-surface-primary
- bg-gray-50/100 → bg-surface-secondary
- text-gray-900/800 → text-text-primary
- text-gray-600/500 → text-text-secondary/text-text-muted
- border-gray-200/300 → border-border-subtle/border-border-default
- text-desert-white → text-white (fixes invisible text on colored bg)
- Button hover/active states use dedicated btn-green-hover/active vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00
127 changed files with 4795 additions and 941 deletions

25
.github/workflows/build-admin-on-pr.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: Build Admin
on: pull_request
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
- name: Install dependencies
run: npm ci
working-directory: ./admin
- name: Run build
run: npm run build
working-directory: ./admin

View File

@ -33,15 +33,15 @@ jobs:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: install/sidecar-disk-collector
push: true

View File

@ -33,15 +33,15 @@ jobs:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
push: true
tags: |

View File

@ -33,7 +33,7 @@ jobs:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:

View File

@ -22,12 +22,12 @@ jobs:
newVersion: ${{ steps.semver.outputs.new_release_version }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- name: semantic-release
uses: cycjimmy/semantic-release-action@v3
uses: cycjimmy/semantic-release-action@v6
id: semver
env:
GITHUB_TOKEN: ${{ secrets.COSMISTACKBOT_ACCESS_TOKEN }}

View File

@ -0,0 +1,58 @@
name: Validate Collection URLs
on:
push:
paths:
- 'collections/**.json'
pull_request:
paths:
- 'collections/**.json'
jobs:
validate-urls:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Extract and validate URLs
run: |
FAILED=0
CHECKED=0
FAILED_URLS=""
# Recursively extract all non-null string URLs from every JSON file in collections/
URLS=$(jq -r '.. | .url? | select(type == "string")' collections/*.json | sort -u)
while IFS= read -r url; do
[ -z "$url" ] && continue
CHECKED=$((CHECKED + 1))
printf "Checking: %s ... " "$url"
# Use Range: bytes=0-0 to avoid downloading the full file.
# --max-filesize 1 aborts early if the server ignores the Range header
# and returns 200 with the full body. The HTTP status is still captured.
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--range 0-0 \
--max-filesize 1 \
--max-time 30 \
--location \
"$url")
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "206" ]; then
echo "OK ($HTTP_CODE)"
else
echo "FAILED ($HTTP_CODE)"
FAILED=$((FAILED + 1))
FAILED_URLS="$FAILED_URLS\n - $url (HTTP $HTTP_CODE)"
fi
done <<< "$URLS"
echo ""
echo "Checked $CHECKED URLs, $FAILED failed."
if [ "$FAILED" -gt 0 ]; then
echo ""
echo "Broken URLs:"
printf "%b\n" "$FAILED_URLS"
exit 1
fi

View File

@ -74,11 +74,11 @@ Because Nomad relies heavily on Docker, we actually recommend against installing
1. **Sync with upstream** before starting any new work. We prefer rebasing over merge commits to keep a clean, linear git history as much as possible (this also makes it easier for maintainers to review and merge your changes). To sync with upstream:
```bash
git fetch upstream
git checkout main
git rebase upstream/main
git checkout dev
git rebase upstream/dev
```
2. **Create a feature branch** off `main` with a descriptive name:
2. **Create a feature branch** off `dev` with a descriptive name:
```bash
git checkout -b fix/issue-123
# or
@ -130,26 +130,7 @@ chore(deps): bump docker-compose to v2.24
Human-readable release notes live in [`admin/docs/release-notes.md`](admin/docs/release-notes.md) and are displayed directly in the Command Center UI.
When your changes include anything user-facing, **add a summary to the `## Unreleased` section** at the top of that file under the appropriate heading:
- **Features** — new user-facing capabilities
- **Bug Fixes** — corrections to existing behavior
- **Improvements** — enhancements, refactors, docs, or dependency updates
Use the format `- **Area**: Description` to stay consistent with existing entries.
**Example:**
```markdown
## Unreleased
### Features
- **Maps**: Added support for downloading South America regional maps
### Bug Fixes
- **AI Chat**: Fixed document upload failing on filenames with special characters
```
> When a release is triggered, CI automatically stamps the version and date, commits the update, and publishes the content to the GitHub release. You do not need to do this manually.
If your PR is merged in, the maintainers will update the release notes with a summary of your contribution and credit you as the author. You do not need to add this yourself in the PR (please don't, as it may cause merge conflicts), but you can include a suggested note in the PR description if you like.
---
@ -165,7 +146,7 @@ This project uses [Semantic Versioning](https://semver.org/). Versions are manag
```bash
git push origin your-branch-name
```
2. Open a pull request against the `main` branch of this repository
2. Open a pull request against the `dev` branch of this repository
3. In the PR description:
- Summarize what your changes do and why
- Reference the related issue (e.g., `Closes #123`)

106
FAQ.md Normal file
View File

@ -0,0 +1,106 @@
# Frequently Asked Questions (FAQ)
Find answers to some of the most common questions about Project N.O.M.A.D.
## Can I customize the port(s) that NOMAD uses?
Yes, you can customize the ports that NOMAD's core services (Command Center, MySQL, Redis) use. Please refer to the [Advanced Installation](README.md#advanced-installation) section of the README for more details on how to do this.
Note: As of 3/24/2026, only the core services defined in the `docker-compose.yml` file currently support port customization - the installable applications (e.g. Ollama, Kiwix, etc.) do not yet support this, but we have multiple PR's in the works to add this feature for all installable applications in a future release.
## Can I customize the storage location for NOMAD's data?
Yes, you can customize the storage location for NOMAD's content by modifying the `docker-compose.yml` file to adjust the appropriate bind mounts to point to your desired storage location on your host machine. Please refer to the [Advanced Installation](README.md#advanced-installation) section of the README for more details on how to do this.
## Can I store NOMAD's data on an external drive or network storage?
Short answer: yes, but we can't do it for you (and we recommend a local drive for best performance).
Long answer: Custom storage paths, mount points, and external drives (like iSCSI or SMB/NFS volumes) **are possible**, but this will be up to your individual configuration on the host before NOMAD starts, and then passed in via the compose.yml as this is a *host-level concern*, not a NOMAD-level concern (see above for details). NOMAD itself can't configure this for you, nor could we support all possible configurations in the install script.
## Can I run NOMAD on MAC, WSL2, or a non-Debian-based Distro?
See [Why does NOMAD require a Debian-based OS?](#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.
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.
Community members have provided guides for running N.O.M.A.D. on other platforms (e.g. WSL2, Mac, etc.) 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.
## 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).
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.
## 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.
## 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.
## What technologies is NOMAD built with?
Project N.O.M.A.D. is built using a combination of technologies, including:
- **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
- **React:** for the frontend of the Command Center, utilizing [Vite](https://vitejs.dev/) and [Inertia.js](https://inertiajs.com/) under the hood
- **MySQL:** for the Command Center's database
- **Redis:** for various caching, background jobs, "cron" tasks, and other internal processes within the Command Center
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?
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.
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.
## Why does NOMAD require access to the Docker socket?
See [What technologies is NOMAD built with?](#what-technologies-is-nomad-built-with)
## Can I use any AI models?
NOMAD by default uses Ollama inside of a docker container to run LLM Models for the AI Assistant. So if you find a model on HuggingFace for example, you won't be able to use that model in NOMAD. The list of available models in the AI Assistant settings (/settings/models) may not show all of the models you are looking for. If you found a model from https://ollama.com/search that you'd like to try and its not in the settings page, you can use a curl command to download the model.
`curl -X POST -H "Content-Type: application/json" -d '{"model":"MODEL_NAME_HERE"}' http://localhost:8080/api/ollama/models` replacing MODEL_NAME_HERE with the model name from whats in the ollama website.
## Do I have to install the AI features in 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?
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.
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?
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.
## 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 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.
## How often are new features added or updates released?
We aim to release updates and new features on a regular basis, but the exact timing can vary based on the complexity of the features being developed, the resources available to our volunteer development team, and the feedback and needs of our community. We typically release smaller "patch" versions more frequently to address bugs and make minor improvements, while larger feature releases may take more time to develop and test before they're ready for release.
## I opened a PR to contribute a new feature or fix a bug. How long does it usually take for PRs to be reviewed and merged?
We appreciate all contributions to the project and strive to review and merge pull requests (PRs) as quickly as possible. The time it takes for a PR to be reviewed and merged can vary based on several factors, including the complexity of the changes, the current workload of our maintainers, and the need for any additional testing or revisions.
Because NOMAD is still a young project, some PRs (particularly those for new features) may take longer to review and merge as we prioritize building out the core functionality and ensuring stability before adding new features. However, we do our best to provide timely feedback on all PRs and keep contributors informed about the status of their contributions.
## I have a question that isn't answered here. Where can I ask for help?
If you have a question that isn't answered in this FAQ, please feel free to ask for help in our Discord community (https://discord.com/invite/crosstalksolutions) or on our Github Discussions page (https://github.com/Crosstalk-Solutions/project-nomad/discussions).
## I have a suggestion for a new feature or improvement. How can I share it?
We welcome and encourage suggestions for new features and improvements! We highly encourage sharing your ideas (or upvoting existing suggestions) on our public roadmap at https://roadmap.projectnomad.us, where we track new feature requests. This is the best way to ensure that your suggestion is seen by the development team and the community, and it also allows other community members to upvote and show support for your idea, which can help prioritize it for future development.

View File

@ -1,5 +1,5 @@
<div align="center">
<img src="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/admin/public/project_nomad_logo.png" width="200" height="200"/>
<img src="admin/public/project_nomad_logo.webp" width="200" height="200"/>
# Project N.O.M.A.D.
### Node for Offline Media, Archives, and Data
@ -21,21 +21,27 @@ Project N.O.M.A.D. can be installed on any Debian-based operating system (we rec
*Note: sudo/root privileges are required to run the install script*
#### Quick Install (Debian-based OS Only)
### Quick Install (Debian-based OS Only)
```bash
sudo apt-get update && sudo apt-get install -y curl && curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/install_nomad.sh -o install_nomad.sh && sudo bash install_nomad.sh
sudo apt-get update && \
sudo apt-get install -y curl && \
curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/install_nomad.sh \
-o 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!
For a complete step-by-step walkthrough (including Ubuntu installation), see the [Installation Guide](https://www.projectnomad.us/install).
### Advanced Installation
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.yml) 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
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.
**Built-in capabilities include:**
- **AI Chat with Knowledge Base** — local AI chat powered by [Ollama](https://ollama.com/), 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/))
- **Information Library** — offline Wikipedia, medical references, ebooks, and more via [Kiwix](https://kiwix.org/)
- **Education Platform** — Khan Academy courses with progress tracking via [Kolibri](https://learningequality.org/kolibri/)
- **Offline Maps** — downloadable regional maps via [ProtoMaps](https://protomaps.com)
@ -87,6 +93,15 @@ To run LLM's and other included AI tools:
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
#### 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 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`.
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.
## Frequently Asked Questions (FAQ)
For answers to common questions about Project N.O.M.A.D., please see our [FAQ](FAQ.md) page.
## 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.
@ -95,49 +110,20 @@ To test internet connectivity, N.O.M.A.D. attempts to make a request to Cloudfla
## 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 it's 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 uses 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.). 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.
**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 uses 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.
## Contributing
Contributions are welcome and appreciated! Please read this section fully to understand how to contribute to the project.
### General Guidelines
- **Open an issue first**: Before starting work on a new feature or bug fix, please open an issue to discuss your proposed changes. This helps ensure that your contribution aligns with the project's goals and avoids duplicate work. Title the issue clearly and provide a detailed description of the problem or feature you want to work on.
- **Fork the repository**: Click the "Fork" button at the top right of the repository page to create a copy of the project under your GitHub account.
- **Create a new branch**: In your forked repository, create a new branch for your work. Use a descriptive name for the branch that reflects the purpose of your changes (e.g., `fix/issue-123` or `feature/add-new-tool`).
- **Make your changes**: Implement your changes in the new branch. Follow the existing code style and conventions used in the project. Be sure to test your changes locally to ensure they work as expected.
- **Add Release Notes**: If your changes include new features, bug fixes, or improvements, please see the "Release Notes" section below to properly document your contribution for the next release.
- **Conventional Commits**: When committing your changes, please use conventional commit messages to provide clear and consistent commit history. The format is `<type>(<scope>): <description>`, where:
- `type` is the type of change (e.g., `feat` for new features, `fix` for bug fixes, `docs` for documentation changes, etc.)
- `scope` is an optional area of the codebase that your change affects (e.g., `api`, `ui`, `docs`, etc.)
- `description` is a brief summary of the change
- **Submit a pull request**: Once your changes are ready, submit a pull request to the main repository. Provide a clear description of your changes and reference any related issues. The project maintainers will review your pull request and may provide feedback or request changes before it can be merged.
- **Be responsive to feedback**: If the maintainers request changes or provide feedback on your pull request, please respond in a timely manner. Stale pull requests may be closed if there is no activity for an extended period.
- **Follow the project's code of conduct**: Please adhere to the project's code of conduct when interacting with maintainers and other contributors. Be respectful and considerate in your communications.
- **No guarantee of acceptance**: The project is community-driven, and all contributions are appreciated, but acceptance is not guaranteed. The maintainers will evaluate each contribution based on its quality, relevance, and alignment with the project's goals.
- **Thank you for contributing to Project N.O.M.A.D.!** Your efforts help make this project better for everyone.
### Versioning
This project uses semantic versioning. The version is managed in the root `package.json`
and automatically updated by semantic-release. For simplicity's sake, the "project-nomad" image
uses the same version defined there instead of the version in `admin/package.json` (stays at 0.0.0), as it's the only published image derived from the code.
### Release Notes
Human-readable release notes live in [`admin/docs/release-notes.md`](admin/docs/release-notes.md) and are displayed in the Command Center's built-in documentation.
When working on changes, add a summary to the `## Unreleased` section at the top of that file under the appropriate heading:
- **Features** — new user-facing capabilities
- **Bug Fixes** — corrections to existing behavior
- **Improvements** — enhancements, refactors, docs, or dependency updates
Use the format `- **Area**: Description` to stay consistent with existing entries. When a release is triggered, CI automatically stamps the version and date, commits the update, and pushes the content to the GitHub release.
Contributions are welcome and appreciated! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute to the project.
## Community & Resources
- **Website:** [www.projectnomad.us](https://www.projectnomad.us) - Learn more about the project
- **Discord:** [Join the Community](https://discord.com/invite/crosstalksolutions) - Get help, share your builds, and connect with other NOMAD users
- **Benchmark Leaderboard:** [benchmark.projectnomad.us](https://benchmark.projectnomad.us) - See how your hardware stacks up against other NOMAD builds
- **Troubleshooting Guide:** [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Find solutions to common issues
- **FAQ:** [FAQ.md](FAQ.md) - Find answers to frequently asked questions
## License
@ -168,4 +154,4 @@ sudo bash /opt/project-nomad/update_nomad.sh
###### Uninstall Script - Need to start fresh? Use the uninstall script to make your life easy. Note: this cannot be undone!
```bash
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
```
```

View File

@ -53,7 +53,8 @@ export default defineConfig({
() => import('@adonisjs/lucid/database_provider'),
() => import('@adonisjs/inertia/inertia_provider'),
() => import('@adonisjs/transmit/transmit_provider'),
() => import('#providers/map_static_provider')
() => import('#providers/map_static_provider'),
() => import('#providers/kiwix_migration_provider'),
],
/*

View File

@ -20,4 +20,8 @@ export default class DownloadsController {
await this.downloadService.removeFailedJob(params.jobId)
return { success: true }
}
async cancelJob({ params }: HttpContext) {
return this.downloadService.cancelJob(params.jobId)
}
}

View File

@ -1,6 +1,7 @@
import { SystemService } from '#services/system_service'
import { ZimService } from '#services/zim_service'
import { CollectionManifestService } from '#services/collection_manifest_service'
import KVStore from '#models/kv_store'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
@ -12,10 +13,14 @@ export default class EasySetupController {
) {}
async index({ inertia }: HttpContext) {
const services = await this.systemService.getServices({ installedOnly: false })
const [services, remoteOllamaUrl] = await Promise.all([
this.systemService.getServices({ installedOnly: false }),
KVStore.getValue('ai.remoteOllamaUrl'),
])
return inertia.render('easy-setup/index', {
system: {
services: services,
remoteOllamaUrl: remoteOllamaUrl ?? '',
},
})
}

View File

@ -1,4 +1,5 @@
import { MapService } from '#services/map_service'
import MapMarker from '#models/map_marker'
import {
assertNotPrivateUrl,
downloadCollectionValidator,
@ -8,6 +9,7 @@ import {
} from '#validators/common'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import vine from '@vinejs/vine'
@inject()
export default class MapsController {
@ -73,6 +75,18 @@ export default class MapsController {
return await this.mapService.listRegions()
}
async globalMapInfo({}: HttpContext) {
return await this.mapService.getGlobalMapInfo()
}
async downloadGlobalMap({}: HttpContext) {
const result = await this.mapService.downloadGlobalMap()
return {
message: 'Download started successfully',
...result,
}
}
async styles({ request, response }: HttpContext) {
// Automatically ensure base assets are present before generating styles
const baseAssetsExist = await this.mapService.ensureBaseAssets()
@ -83,7 +97,13 @@ export default class MapsController {
})
}
const styles = await this.mapService.generateStylesJSON(request.host(), request.protocol())
const forwardedProto = request.headers()['x-forwarded-proto'];
const protocol: string = forwardedProto
? (typeof forwardedProto === 'string' ? forwardedProto : request.protocol())
: request.protocol();
const styles = await this.mapService.generateStylesJSON(request.host(), protocol)
return response.json(styles)
}
@ -105,4 +125,60 @@ export default class MapsController {
message: 'Map file deleted successfully',
}
}
// --- Map Markers ---
async listMarkers({}: HttpContext) {
return await MapMarker.query().orderBy('created_at', 'asc')
}
async createMarker({ request }: HttpContext) {
const payload = await request.validateUsing(
vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(255),
longitude: vine.number(),
latitude: vine.number(),
color: vine.string().trim().maxLength(20).optional(),
})
)
)
const marker = await MapMarker.create({
name: payload.name,
longitude: payload.longitude,
latitude: payload.latitude,
color: payload.color ?? 'orange',
})
return marker
}
async updateMarker({ request, response }: HttpContext) {
const { id } = request.params()
const marker = await MapMarker.find(id)
if (!marker) {
return response.status(404).send({ message: 'Marker not found' })
}
const payload = await request.validateUsing(
vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(255).optional(),
color: vine.string().trim().maxLength(20).optional(),
})
)
)
if (payload.name !== undefined) marker.name = payload.name
if (payload.color !== undefined) marker.color = payload.color
await marker.save()
return marker
}
async deleteMarker({ request, response }: HttpContext) {
const { id } = request.params()
const marker = await MapMarker.find(id)
if (!marker) {
return response.status(404).send({ message: 'Marker not found' })
}
await marker.delete()
return { message: 'Marker deleted' }
}
}

View File

@ -1,18 +1,23 @@
import { ChatService } from '#services/chat_service'
import { DockerService } from '#services/docker_service'
import { OllamaService } from '#services/ollama_service'
import { RagService } from '#services/rag_service'
import Service from '#models/service'
import KVStore from '#models/kv_store'
import { modelNameSchema } from '#validators/download'
import { chatSchema, getAvailableModelsSchema } from '#validators/ollama'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import { DEFAULT_QUERY_REWRITE_MODEL, RAG_CONTEXT_LIMITS, SYSTEM_PROMPTS } from '../../constants/ollama.js'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import logger from '@adonisjs/core/services/logger'
import type { Message } from 'ollama'
type Message = { role: 'system' | 'user' | 'assistant'; content: string }
@inject()
export default class OllamaController {
constructor(
private chatService: ChatService,
private dockerService: DockerService,
private ollamaService: OllamaService,
private ragService: RagService
) { }
@ -72,10 +77,10 @@ export default class OllamaController {
const { maxResults, maxTokens } = this.getContextLimitsForModel(reqData.model)
let trimmedDocs = relevantDocs.slice(0, maxResults)
// Apply token cap if set (estimate ~4 chars per token)
// Apply token cap if set (estimate ~3.5 chars per token)
// Always include the first (most relevant) result — the cap only gates subsequent results
if (maxTokens > 0) {
const charCap = maxTokens * 4
const charCap = maxTokens * 3.5
let totalChars = 0
trimmedDocs = trimmedDocs.filter((doc, idx) => {
totalChars += doc.text.length
@ -103,6 +108,19 @@ export default class OllamaController {
}
}
// If system messages are large (e.g. due to RAG context), request a context window big
// enough to fit them. Ollama respects num_ctx per-request; LM Studio ignores it gracefully.
const systemChars = reqData.messages
.filter((m) => m.role === 'system')
.reduce((sum, m) => sum + m.content.length, 0)
const estimatedSystemTokens = Math.ceil(systemChars / 3.5)
let numCtx: number | undefined
if (estimatedSystemTokens > 3000) {
const needed = estimatedSystemTokens + 2048 // leave room for conversation + response
numCtx = [8192, 16384, 32768, 65536].find((n) => n >= needed) ?? 65536
logger.debug(`[OllamaController] Large system prompt (~${estimatedSystemTokens} tokens), requesting num_ctx: ${numCtx}`)
}
// Check if the model supports "thinking" capability for enhanced response generation
// 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)
@ -124,7 +142,7 @@ export default class OllamaController {
if (reqData.stream) {
logger.debug(`[OllamaController] Initiating streaming response for model: "${reqData.model}" with think: ${think}`)
// Headers already flushed above
const stream = await this.ollamaService.chatStream({ ...ollamaRequest, think })
const stream = await this.ollamaService.chatStream({ ...ollamaRequest, think, numCtx })
let fullContent = ''
for await (const chunk of stream) {
if (chunk.message?.content) {
@ -148,7 +166,7 @@ export default class OllamaController {
}
// Non-streaming (legacy) path
const result = await this.ollamaService.chat({ ...ollamaRequest, think })
const result = await this.ollamaService.chat({ ...ollamaRequest, think, numCtx })
if (sessionId && result?.message?.content) {
await this.chatService.addMessage(sessionId, 'assistant', result.message.content)
@ -171,6 +189,87 @@ export default class OllamaController {
}
}
async remoteStatus() {
const remoteUrl = await KVStore.getValue('ai.remoteOllamaUrl')
if (!remoteUrl) {
return { configured: false, connected: false }
}
try {
const testResponse = await fetch(`${remoteUrl.replace(/\/$/, '')}/v1/models`, {
signal: AbortSignal.timeout(3000),
})
return { configured: true, connected: testResponse.ok }
} catch {
return { configured: true, connected: false }
}
}
async configureRemote({ request, response }: HttpContext) {
const remoteUrl: string | null = request.input('remoteUrl', null)
const ollamaService = await Service.query().where('service_name', SERVICE_NAMES.OLLAMA).first()
if (!ollamaService) {
return response.status(404).send({ success: false, message: 'Ollama service record not found.' })
}
// Clear path: null or empty URL removes remote config and marks service as not installed
if (!remoteUrl || remoteUrl.trim() === '') {
await KVStore.clearValue('ai.remoteOllamaUrl')
ollamaService.installed = false
ollamaService.installation_status = 'idle'
await ollamaService.save()
return { success: true, message: 'Remote Ollama configuration cleared.' }
}
// Validate URL format
if (!remoteUrl.startsWith('http')) {
return response.status(400).send({
success: false,
message: 'Invalid URL. Must start with http:// or https://',
})
}
// Test connectivity via OpenAI-compatible /v1/models endpoint (works with Ollama, LM Studio, llama.cpp, etc.)
try {
const testResponse = await fetch(`${remoteUrl.replace(/\/$/, '')}/v1/models`, {
signal: AbortSignal.timeout(5000),
})
if (!testResponse.ok) {
return response.status(400).send({
success: false,
message: `Could not connect to ${remoteUrl} (HTTP ${testResponse.status}). Make sure the server is running and accessible. For Ollama, start it with OLLAMA_HOST=0.0.0.0.`,
})
}
} catch (error) {
return response.status(400).send({
success: false,
message: `Could not connect to ${remoteUrl}. Make sure the server is running and reachable. For Ollama, start it with OLLAMA_HOST=0.0.0.0.`,
})
}
// Save remote URL and mark service as installed
await KVStore.setValue('ai.remoteOllamaUrl', remoteUrl.trim())
ollamaService.installed = true
ollamaService.installation_status = 'idle'
await ollamaService.save()
// Install Qdrant if not already installed (fire-and-forget)
const qdrantService = await Service.query().where('service_name', SERVICE_NAMES.QDRANT).first()
if (qdrantService && !qdrantService.installed) {
this.dockerService.createContainerPreflight(SERVICE_NAMES.QDRANT).catch((error) => {
logger.error('[OllamaController] Failed to start Qdrant preflight:', error)
})
}
// Mirror post-install side effects: disable suggestions, trigger docs discovery
await KVStore.setValue('chat.suggestionsEnabled', false)
this.ragService.discoverNomadDocs().catch((error) => {
logger.error('[OllamaController] Failed to discover Nomad docs:', error)
})
return { success: true, message: 'Remote Ollama configured.' }
}
async deleteModel({ request }: HttpContext) {
const reqData = await request.validateUsing(modelNameSchema)
await this.ollamaService.deleteModel(reqData.model)

View File

@ -74,6 +74,19 @@ export default class RagController {
return response.status(200).json({ message: result.message })
}
public async getFailedJobs({ response }: HttpContext) {
const jobs = await EmbedFileJob.listFailedJobs()
return response.status(200).json(jobs)
}
public async cleanupFailedJobs({ response }: HttpContext) {
const result = await EmbedFileJob.cleanupFailedJobs()
return response.status(200).json({
message: `Cleaned up ${result.cleaned} failed job${result.cleaned !== 1 ? 's' : ''}${result.filesDeleted > 0 ? `, deleted ${result.filesDeleted} file${result.filesDeleted !== 1 ? 's' : ''}` : ''}.`,
...result,
})
}
public async scanAndSync({ response }: HttpContext) {
try {
const syncResult = await this.ragService.scanAndSyncStorage()

View File

@ -1,116 +1,124 @@
import KVStore from '#models/kv_store';
import { BenchmarkService } from '#services/benchmark_service';
import { MapService } from '#services/map_service';
import { OllamaService } from '#services/ollama_service';
import { SystemService } from '#services/system_service';
import { updateSettingSchema } from '#validators/settings';
import { inject } from '@adonisjs/core';
import KVStore from '#models/kv_store'
import { BenchmarkService } from '#services/benchmark_service'
import { MapService } from '#services/map_service'
import { OllamaService } from '#services/ollama_service'
import { SystemService } from '#services/system_service'
import { getSettingSchema, updateSettingSchema } from '#validators/settings'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import type { KVStoreKey } from '../../types/kv_store.js';
@inject()
export default class SettingsController {
constructor(
private systemService: SystemService,
private mapService: MapService,
private benchmarkService: BenchmarkService,
private ollamaService: OllamaService
) { }
constructor(
private systemService: SystemService,
private mapService: MapService,
private benchmarkService: BenchmarkService,
private ollamaService: OllamaService
) {}
async system({ inertia }: HttpContext) {
const systemInfo = await this.systemService.getSystemInfo();
return inertia.render('settings/system', {
system: {
info: systemInfo
}
});
}
async system({ inertia }: HttpContext) {
const systemInfo = await this.systemService.getSystemInfo()
return inertia.render('settings/system', {
system: {
info: systemInfo,
},
})
}
async apps({ inertia }: HttpContext) {
const services = await this.systemService.getServices({ installedOnly: false });
return inertia.render('settings/apps', {
system: {
services
}
});
}
async legal({ inertia }: HttpContext) {
return inertia.render('settings/legal');
}
async apps({ inertia }: HttpContext) {
const services = await this.systemService.getServices({ installedOnly: false })
return inertia.render('settings/apps', {
system: {
services,
},
})
}
async support({ inertia }: HttpContext) {
return inertia.render('settings/support');
}
async legal({ inertia }: HttpContext) {
return inertia.render('settings/legal')
}
async maps({ inertia }: HttpContext) {
const baseAssetsCheck = await this.mapService.ensureBaseAssets();
const regionFiles = await this.mapService.listRegions();
return inertia.render('settings/maps', {
maps: {
baseAssetsExist: baseAssetsCheck,
regionFiles: regionFiles.files
}
});
}
async support({ inertia }: HttpContext) {
return inertia.render('settings/support')
}
async models({ inertia }: HttpContext) {
const availableModels = await this.ollamaService.getAvailableModels({ sort: 'pulls', recommendedOnly: false, query: null, limit: 15 });
const installedModels = await this.ollamaService.getModels();
const chatSuggestionsEnabled = await KVStore.getValue('chat.suggestionsEnabled')
const aiAssistantCustomName = await KVStore.getValue('ai.assistantCustomName')
return inertia.render('settings/models', {
models: {
availableModels: availableModels?.models || [],
installedModels: installedModels || [],
settings: {
chatSuggestionsEnabled: chatSuggestionsEnabled ?? false,
aiAssistantCustomName: aiAssistantCustomName ?? '',
}
}
});
}
async maps({ inertia }: HttpContext) {
const baseAssetsCheck = await this.mapService.ensureBaseAssets()
const regionFiles = await this.mapService.listRegions()
return inertia.render('settings/maps', {
maps: {
baseAssetsExist: baseAssetsCheck,
regionFiles: regionFiles.files,
},
})
}
async update({ inertia }: HttpContext) {
const updateInfo = await this.systemService.checkLatestVersion();
return inertia.render('settings/update', {
system: {
updateAvailable: updateInfo.updateAvailable,
latestVersion: updateInfo.latestVersion,
currentVersion: updateInfo.currentVersion
}
});
}
async models({ inertia }: HttpContext) {
const availableModels = await this.ollamaService.getAvailableModels({
sort: 'pulls',
recommendedOnly: false,
query: null,
limit: 15,
})
const installedModels = await this.ollamaService.getModels().catch(() => [])
const chatSuggestionsEnabled = await KVStore.getValue('chat.suggestionsEnabled')
const aiAssistantCustomName = await KVStore.getValue('ai.assistantCustomName')
const remoteOllamaUrl = await KVStore.getValue('ai.remoteOllamaUrl')
const ollamaFlashAttention = await KVStore.getValue('ai.ollamaFlashAttention')
return inertia.render('settings/models', {
models: {
availableModels: availableModels?.models || [],
installedModels: installedModels || [],
settings: {
chatSuggestionsEnabled: chatSuggestionsEnabled ?? false,
aiAssistantCustomName: aiAssistantCustomName ?? '',
remoteOllamaUrl: remoteOllamaUrl ?? '',
ollamaFlashAttention: ollamaFlashAttention ?? true,
},
},
})
}
async zim({ inertia }: HttpContext) {
return inertia.render('settings/zim/index')
}
async update({ inertia }: HttpContext) {
const updateInfo = await this.systemService.checkLatestVersion()
return inertia.render('settings/update', {
system: {
updateAvailable: updateInfo.updateAvailable,
latestVersion: updateInfo.latestVersion,
currentVersion: updateInfo.currentVersion,
},
})
}
async zimRemote({ inertia }: HttpContext) {
return inertia.render('settings/zim/remote-explorer');
}
async zim({ inertia }: HttpContext) {
return inertia.render('settings/zim/index')
}
async benchmark({ inertia }: HttpContext) {
const latestResult = await this.benchmarkService.getLatestResult();
const status = this.benchmarkService.getStatus();
return inertia.render('settings/benchmark', {
benchmark: {
latestResult,
status: status.status,
currentBenchmarkId: status.benchmarkId
}
});
}
async zimRemote({ inertia }: HttpContext) {
return inertia.render('settings/zim/remote-explorer')
}
async getSetting({ request, response }: HttpContext) {
const key = request.qs().key;
const value = await KVStore.getValue(key as KVStoreKey);
return response.status(200).send({ key, value });
}
async benchmark({ inertia }: HttpContext) {
const latestResult = await this.benchmarkService.getLatestResult()
const status = this.benchmarkService.getStatus()
return inertia.render('settings/benchmark', {
benchmark: {
latestResult,
status: status.status,
currentBenchmarkId: status.benchmarkId,
},
})
}
async updateSetting({ request, response }: HttpContext) {
const reqData = await request.validateUsing(updateSettingSchema);
await this.systemService.updateSetting(reqData.key, reqData.value);
return response.status(200).send({ success: true, message: 'Setting updated successfully' });
}
}
async getSetting({ request, response }: HttpContext) {
const { key } = await getSettingSchema.validate({ key: request.qs().key });
const value = await KVStore.getValue(key);
return response.status(200).send({ key, value });
}
async updateSetting({ request, response }: HttpContext) {
const reqData = await request.validateUsing(updateSettingSchema)
await this.systemService.updateSetting(reqData.key, reqData.value)
return response.status(200).send({ success: true, message: 'Setting updated successfully' })
}
}

View File

@ -35,7 +35,7 @@ export default class SystemController {
if (result.success) {
response.send({ success: true, message: result.message });
} else {
response.status(400).send({ error: result.message });
response.status(400).send({ success: false, message: result.message });
}
}

View File

@ -27,7 +27,7 @@ export default class ZimController {
async downloadRemote({ request }: HttpContext) {
const payload = await request.validateUsing(remoteDownloadWithMetadataValidator)
assertNotPrivateUrl(payload.url)
const { filename, jobId } = await this.zimService.downloadRemote(payload.url)
const { filename, jobId } = await this.zimService.downloadRemote(payload.url, payload.metadata)
return {
message: 'Download started successfully',

View File

@ -1,4 +1,4 @@
import { Job } from 'bullmq'
import { Job, UnrecoverableError } from 'bullmq'
import { QueueService } from '#services/queue_service'
import { createHash } from 'crypto'
import logger from '@adonisjs/core/services/logger'
@ -44,7 +44,9 @@ export class DownloadModelJob {
// Services are ready, initiate the download with progress tracking
const result = await ollamaService.downloadModel(modelName, (progressPercent) => {
if (progressPercent) {
job.updateProgress(Math.floor(progressPercent))
job.updateProgress(Math.floor(progressPercent)).catch((err) => {
if (err?.code !== -1) throw err
})
logger.info(
`[DownloadModelJob] Model ${modelName}: ${progressPercent}%`
)
@ -56,6 +58,8 @@ export class DownloadModelJob {
status: 'downloading',
progress: progressPercent,
progress_timestamp: new Date().toISOString(),
}).catch((err) => {
if (err?.code !== -1) throw err
})
})
@ -63,6 +67,10 @@ export class DownloadModelJob {
logger.error(
`[DownloadModelJob] Failed to initiate download for model ${modelName}: ${result.message}`
)
// Don't retry errors that will never succeed (e.g., Ollama version too old)
if (result.retryable === false) {
throw new UnrecoverableError(result.message)
}
throw new Error(`Failed to initiate download for model: ${result.message}`)
}
@ -85,6 +93,15 @@ export class DownloadModelJob {
const queue = queueService.getQueue(this.queue)
const jobId = this.getJobId(params.modelName)
// Clear any previous failed job so a fresh attempt can be dispatched
const existing = await queue.getJob(jobId)
if (existing) {
const state = await existing.getState()
if (state === 'failed') {
await existing.remove()
}
}
try {
const job = await queue.add(this.key, params, {
jobId,
@ -104,9 +121,9 @@ export class DownloadModelJob {
}
} catch (error) {
if (error.message.includes('job already exists')) {
const existing = await queue.getJob(jobId)
const active = await queue.getJob(jobId)
return {
job: existing,
job: active,
created: false,
message: `Job already exists for model ${params.modelName}`,
}

View File

@ -6,6 +6,7 @@ import { DockerService } from '#services/docker_service'
import { OllamaService } from '#services/ollama_service'
import { createHash } from 'crypto'
import logger from '@adonisjs/core/services/logger'
import fs from 'node:fs/promises'
export interface EmbedFileJobParams {
filePath: string
@ -30,6 +31,17 @@ export class EmbedFileJob {
return createHash('sha256').update(filePath).digest('hex').slice(0, 16)
}
/** Calls job.updateProgress but silently ignores "Missing key" errors (code -1),
* which occur when the job has been removed from Redis (e.g. cancelled externally)
* between the time the await was issued and the Redis write completed. */
private async safeUpdateProgress(job: Job, progress: number): Promise<void> {
try {
await job.updateProgress(progress)
} catch (err: any) {
if (err?.code !== -1) throw err
}
}
async handle(job: Job) {
const { filePath, fileName, batchOffset, totalArticles } = job.data as EmbedFileJobParams
@ -66,7 +78,7 @@ export class EmbedFileJob {
logger.info(`[EmbedFileJob] Services ready. Processing file: ${fileName}`)
// Update progress starting
await job.updateProgress(5)
await this.safeUpdateProgress(job, 5)
await job.updateData({
...job.data,
status: 'processing',
@ -77,7 +89,7 @@ export class EmbedFileJob {
// Progress callback: maps service-reported 0-100% into the 5-95% job range
const onProgress = async (percent: number) => {
await job.updateProgress(Math.min(95, Math.round(5 + percent * 0.9)))
await this.safeUpdateProgress(job, Math.min(95, Math.round(5 + percent * 0.9)))
}
// Process and embed the file
@ -116,7 +128,7 @@ export class EmbedFileJob {
? Math.round((nextOffset / totalArticles) * 100)
: 50
await job.updateProgress(progress)
await this.safeUpdateProgress(job, progress)
await job.updateData({
...job.data,
status: 'batch_completed',
@ -137,7 +149,7 @@ export class EmbedFileJob {
// Final batch or non-batched file - mark as complete
const totalChunks = (job.data.chunks || 0) + (result.chunks || 0)
await job.updateProgress(100)
await this.safeUpdateProgress(job, 100)
await job.updateData({
...job.data,
status: 'completed',
@ -232,6 +244,52 @@ export class EmbedFileJob {
}
}
static async listFailedJobs(): Promise<EmbedJobWithProgress[]> {
const queueService = new QueueService()
const queue = queueService.getQueue(this.queue)
// Jobs that have failed at least once are in 'delayed' (retrying) or terminal 'failed' state.
// We identify them by job.data.status === 'failed' set in the catch block of handle().
const jobs = await queue.getJobs(['waiting', 'delayed', 'failed'])
return jobs
.filter((job) => (job.data as any).status === 'failed')
.map((job) => ({
jobId: job.id!.toString(),
fileName: (job.data as EmbedFileJobParams).fileName,
filePath: (job.data as EmbedFileJobParams).filePath,
progress: 0,
status: 'failed',
error: (job.data as any).error,
}))
}
static async cleanupFailedJobs(): Promise<{ cleaned: number; filesDeleted: number }> {
const queueService = new QueueService()
const queue = queueService.getQueue(this.queue)
const allJobs = await queue.getJobs(['waiting', 'delayed', 'failed'])
const failedJobs = allJobs.filter((job) => (job.data as any).status === 'failed')
let cleaned = 0
let filesDeleted = 0
for (const job of failedJobs) {
const filePath = (job.data as EmbedFileJobParams).filePath
if (filePath && filePath.includes(RagService.UPLOADS_STORAGE_PATH)) {
try {
await fs.unlink(filePath)
filesDeleted++
} catch {
// File may already be deleted — that's fine
}
}
await job.remove()
cleaned++
}
logger.info(`[EmbedFileJob] Cleaned up ${cleaned} failed jobs, deleted ${filesDeleted} files`)
return { cleaned, filesDeleted }
}
static async getStatus(filePath: string): Promise<{
exists: boolean
status?: string

View File

@ -1,5 +1,5 @@
import { Job } from 'bullmq'
import { RunDownloadJobParams } from '../../types/downloads.js'
import { Job, UnrecoverableError } from 'bullmq'
import { RunDownloadJobParams, DownloadProgressData } from '../../types/downloads.js'
import { QueueService } from '#services/queue_service'
import { doResumableDownload } from '../utils/downloads.js'
import { createHash } from 'crypto'
@ -17,100 +17,184 @@ export class RunDownloadJob {
return 'run-download'
}
/** In-memory registry of abort controllers for active download jobs */
static abortControllers: Map<string, AbortController> = new Map()
static getJobId(url: string): string {
return createHash('sha256').update(url).digest('hex').slice(0, 16)
}
/** Redis key used to signal cancellation across processes */
static cancelKey(jobId: string): string {
return `nomad:download:cancel:${jobId}`
}
/** Signal cancellation via Redis so the worker process can pick it up */
static async signalCancel(jobId: string): Promise<void> {
const queueService = new QueueService()
const queue = queueService.getQueue(this.queue)
const client = await queue.client
await client.set(this.cancelKey(jobId), '1', 'EX', 300) // 5 min TTL
}
async handle(job: Job) {
const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype, resourceMetadata } =
job.data as RunDownloadJobParams
await doResumableDownload({
url,
filepath,
timeout,
allowedMimeTypes,
forceNew,
onProgress(progress) {
const progressPercent = (progress.downloadedBytes / (progress.totalBytes || 1)) * 100
job.updateProgress(Math.floor(progressPercent))
},
async onComplete(url) {
try {
// Create InstalledResource entry if metadata was provided
if (resourceMetadata) {
const { default: InstalledResource } = await import('#models/installed_resource')
const { DateTime } = await import('luxon')
const { getFileStatsIfExists, deleteFileIfExists } = await import('../utils/fs.js')
const stats = await getFileStatsIfExists(filepath)
// Register abort controller for this job
const abortController = new AbortController()
RunDownloadJob.abortControllers.set(job.id!, abortController)
// Look up the old entry so we can clean up the previous file after updating
const oldEntry = await InstalledResource.query()
.where('resource_id', resourceMetadata.resource_id)
.where('resource_type', filetype as 'zim' | 'map')
.first()
const oldFilePath = oldEntry?.file_path ?? null
// Get Redis client for checking cancel signals from the API process
const queueService = new QueueService()
const cancelRedis = await queueService.getQueue(RunDownloadJob.queue).client
await InstalledResource.updateOrCreate(
{ resource_id: resourceMetadata.resource_id, resource_type: filetype as 'zim' | 'map' },
{
version: resourceMetadata.version,
collection_ref: resourceMetadata.collection_ref,
url: url,
file_path: filepath,
file_size_bytes: stats ? Number(stats.size) : null,
installed_at: DateTime.now(),
}
)
let lastKnownProgress: Pick<DownloadProgressData, 'downloadedBytes' | 'totalBytes'> = {
downloadedBytes: 0,
totalBytes: 0,
}
// Delete the old file if it differs from the new one
if (oldFilePath && oldFilePath !== filepath) {
try {
await deleteFileIfExists(oldFilePath)
console.log(`[RunDownloadJob] Deleted old file: ${oldFilePath}`)
} catch (deleteError) {
console.warn(
`[RunDownloadJob] Failed to delete old file ${oldFilePath}:`,
deleteError
)
}
}
}
// Track whether cancellation was explicitly requested by the user (via Redis signal
// or in-process AbortController). BullMQ lock mismatches can also abort the download
// stream, but those should be retried — only user-initiated cancels are unrecoverable.
let userCancelled = false
if (filetype === 'zim') {
const dockerService = new DockerService()
const zimService = new ZimService(dockerService)
await zimService.downloadRemoteSuccessCallback([url], true)
// Only dispatch embedding job if AI Assistant (Ollama) is installed
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
if (ollamaUrl) {
try {
await EmbedFileJob.dispatch({
fileName: url.split('/').pop() || '',
filePath: filepath,
})
} catch (error) {
console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)
}
}
} else if (filetype === 'map') {
const mapsService = new MapService()
await mapsService.downloadRemoteSuccessCallback([url], false)
}
} catch (error) {
console.error(
`[RunDownloadJob] Error in download success callback for URL ${url}:`,
error
)
// Poll Redis for cancel signal every 2s — independent of progress events so cancellation
// works even when the stream is stalled and no onProgress ticks are firing.
let cancelPollInterval: ReturnType<typeof setInterval> | null = setInterval(async () => {
try {
const val = await cancelRedis.get(RunDownloadJob.cancelKey(job.id!))
if (val) {
await cancelRedis.del(RunDownloadJob.cancelKey(job.id!))
userCancelled = true
abortController.abort('user-cancel')
}
job.updateProgress(100)
},
})
} catch {
// Redis errors are non-fatal; in-process AbortController covers same-process cancels
}
}, 2000)
return {
url,
filepath,
try {
await doResumableDownload({
url,
filepath,
timeout,
allowedMimeTypes,
forceNew,
signal: abortController.signal,
onProgress(progress) {
const progressPercent = (progress.downloadedBytes / (progress.totalBytes || 1)) * 100
const progressData: DownloadProgressData = {
percent: Math.floor(progressPercent),
downloadedBytes: progress.downloadedBytes,
totalBytes: progress.totalBytes,
lastProgressTime: Date.now(),
}
job.updateProgress(progressData).catch((err) => {
// Job was removed from Redis (e.g. cancelled) between the callback firing
// and the Redis write completing — this is expected and safe to ignore.
if (err?.code !== -1) throw err
})
lastKnownProgress = { downloadedBytes: progress.downloadedBytes, totalBytes: progress.totalBytes }
},
async onComplete(url) {
try {
// Create InstalledResource entry if metadata was provided
if (resourceMetadata) {
const { default: InstalledResource } = await import('#models/installed_resource')
const { DateTime } = await import('luxon')
const { getFileStatsIfExists, deleteFileIfExists } = await import('../utils/fs.js')
const stats = await getFileStatsIfExists(filepath)
// Look up the old entry so we can clean up the previous file after updating
const oldEntry = await InstalledResource.query()
.where('resource_id', resourceMetadata.resource_id)
.where('resource_type', filetype as 'zim' | 'map')
.first()
const oldFilePath = oldEntry?.file_path ?? null
await InstalledResource.updateOrCreate(
{ resource_id: resourceMetadata.resource_id, resource_type: filetype as 'zim' | 'map' },
{
version: resourceMetadata.version,
collection_ref: resourceMetadata.collection_ref,
url: url,
file_path: filepath,
file_size_bytes: stats ? Number(stats.size) : null,
installed_at: DateTime.now(),
}
)
// Delete the old file if it differs from the new one
if (oldFilePath && oldFilePath !== filepath) {
try {
await deleteFileIfExists(oldFilePath)
console.log(`[RunDownloadJob] Deleted old file: ${oldFilePath}`)
} catch (deleteError) {
console.warn(
`[RunDownloadJob] Failed to delete old file ${oldFilePath}:`,
deleteError
)
}
}
}
if (filetype === 'zim') {
const dockerService = new DockerService()
const zimService = new ZimService(dockerService)
await zimService.downloadRemoteSuccessCallback([url], true)
// Only dispatch embedding job if AI Assistant (Ollama) is installed
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
if (ollamaUrl) {
try {
await EmbedFileJob.dispatch({
fileName: url.split('/').pop() || '',
filePath: filepath,
})
} catch (error) {
console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)
}
}
} else if (filetype === 'map') {
const mapsService = new MapService()
await mapsService.downloadRemoteSuccessCallback([url], false)
}
} catch (error) {
console.error(
`[RunDownloadJob] Error in download success callback for URL ${url}:`,
error
)
}
job.updateProgress({
percent: 100,
downloadedBytes: lastKnownProgress.downloadedBytes,
totalBytes: lastKnownProgress.totalBytes,
lastProgressTime: Date.now(),
} as DownloadProgressData).catch((err) => {
if (err?.code !== -1) throw err
})
},
})
return {
url,
filepath,
}
} catch (error: any) {
// Only prevent retries for user-initiated cancellations. BullMQ lock mismatches
// can also abort the stream, and those should be retried with backoff.
// Check both the flag (Redis poll) and abort reason (in-process cancel).
if (userCancelled || abortController.signal.reason === 'user-cancel') {
throw new UnrecoverableError(`Download cancelled: ${error.message}`)
}
throw error
} finally {
if (cancelPollInterval !== null) {
clearInterval(cancelPollInterval)
cancelPollInterval = null
}
RunDownloadJob.abortControllers.delete(job.id!)
}
}
@ -121,6 +205,29 @@ export class RunDownloadJob {
return await queue.getJob(jobId)
}
/**
* Check if a download is actively in progress for the given URL.
* Returns the job only if it's in an active state (active, waiting, delayed).
* If the job exists in a terminal state (failed, completed), removes it and returns undefined.
*/
static async getActiveByUrl(url: string): Promise<Job | undefined> {
const job = await this.getByUrl(url)
if (!job) return undefined
const state = await job.getState()
if (state === 'active' || state === 'waiting' || state === 'delayed') {
return job
}
// Terminal state -- clean up stale job so it doesn't block re-download
try {
await job.remove()
} catch {
// May already be gone
}
return undefined
}
static async dispatch(params: RunDownloadJobParams) {
const queueService = new QueueService()
const queue = queueService.getQueue(this.queue)
@ -129,8 +236,8 @@ export class RunDownloadJob {
try {
const job = await queue.add(this.key, params, {
jobId,
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
attempts: 10,
backoff: { type: 'exponential', delay: 30000 },
removeOnComplete: true,
})

View File

@ -0,0 +1,21 @@
import env from '#start/env'
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import compression from 'compression'
const compress = env.get('DISABLE_COMPRESSION') ? null : compression()
export default class CompressionMiddleware {
async handle({ request, response }: HttpContext, next: NextFn) {
if (!compress) return await next()
await new Promise<void>((resolve, reject) => {
compress(request.request as any, response.response as any, (err?: any) => {
if (err) reject(err)
else resolve()
})
})
await next()
}
}

View File

@ -0,0 +1,43 @@
import { DateTime } from 'luxon'
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
export default class MapMarker extends BaseModel {
static namingStrategy = new SnakeCaseNamingStrategy()
@column({ isPrimary: true })
declare id: number
@column()
declare name: string
@column()
declare longitude: number
@column()
declare latitude: number
@column()
declare color: string
// 'pin' for user-placed markers, 'waypoint' for route points (future)
@column()
declare marker_type: string
// Groups markers into a route (future)
@column()
declare route_id: string | null
// Order within a route (future)
@column()
declare route_order: number | null
// Optional user notes for a location
@column()
declare notes: string | null
@column.dateTime({ autoCreate: true })
declare created_at: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updated_at: DateTime
}

View File

@ -571,10 +571,10 @@ export class BenchmarkService {
*/
private _normalizeScore(value: number, reference: number): number {
if (value <= 0) return 0
// Log scale: score = 50 * (1 + log2(value/reference))
// This gives 50 at reference value, scales logarithmically
// Log scale with widened range: dividing log2 by 3 prevents scores from
// clamping to 0% for below-average hardware. Gives 50% at reference value.
const ratio = value / reference
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)))
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)) / 3)
return Math.min(100, Math.max(0, score)) / 100
}
@ -583,9 +583,9 @@ export class BenchmarkService {
*/
private _normalizeScoreInverse(value: number, reference: number): number {
if (value <= 0) return 1
// Inverse: lower values = higher scores
// Inverse: lower values = higher scores, with widened log range
const ratio = reference / value
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)))
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)) / 3)
return Math.min(100, Math.max(0, score)) / 100
}
@ -619,6 +619,7 @@ export class BenchmarkService {
const eventsMatch = output.match(/events per second:\s*([\d.]+)/i)
const totalTimeMatch = output.match(/total time:\s*([\d.]+)s/i)
const totalEventsMatch = output.match(/total number of events:\s*(\d+)/i)
logger.debug(`[BenchmarkService] CPU output parsing - events/s: ${eventsMatch?.[1]}, total_time: ${totalTimeMatch?.[1]}, total_events: ${totalEventsMatch?.[1]}`)
return {
events_per_second: eventsMatch ? parseFloat(eventsMatch[1]) : 0,

View File

@ -6,12 +6,14 @@ import transmit from '@adonisjs/transmit/services/main'
import { doResumableDownloadWithRetry } from '../utils/downloads.js'
import { join } from 'path'
import { ZIM_STORAGE_PATH } from '../utils/fs.js'
import { KiwixLibraryService } from './kiwix_library_service.js'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { exec } from 'child_process'
import { promisify } from 'util'
// import { readdir } from 'fs/promises'
import KVStore from '#models/kv_store'
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
@inject()
export class DockerService {
@ -19,6 +21,9 @@ export class DockerService {
private activeInstallations: Set<string> = new Set()
public static NOMAD_NETWORK = 'project-nomad_default'
private _servicesStatusCache: { data: { service_name: string; status: string }[]; expiresAt: number } | null = null
private _servicesStatusInflight: Promise<{ service_name: string; status: string }[]> | null = null
constructor() {
// Support both Linux (production) and Windows (development with Docker Desktop)
const isWindows = process.platform === 'win32'
@ -56,6 +61,7 @@ export class DockerService {
const dockerContainer = this.docker.getContainer(container.Id)
if (action === 'stop') {
await dockerContainer.stop()
this.invalidateServicesStatusCache()
return {
success: true,
message: `Service ${serviceName} stopped successfully`,
@ -63,7 +69,18 @@ export class DockerService {
}
if (action === 'restart') {
if (serviceName === SERVICE_NAMES.KIWIX) {
const isLegacy = await this.isKiwixOnLegacyConfig()
if (isLegacy) {
logger.info('[DockerService] Kiwix on legacy glob config — running migration instead of restart.')
await this.migrateKiwixToLibraryMode()
this.invalidateServicesStatusCache()
return { success: true, message: 'Kiwix migrated to library mode successfully.' }
}
}
await dockerContainer.restart()
this.invalidateServicesStatusCache()
return {
success: true,
@ -80,6 +97,7 @@ export class DockerService {
}
await dockerContainer.start()
this.invalidateServicesStatusCache()
return {
success: true,
@ -91,7 +109,7 @@ export class DockerService {
success: false,
message: `Invalid action: ${action}. Use 'start', 'stop', or 'restart'.`,
}
} catch (error) {
} catch (error: any) {
logger.error(`Error starting service ${serviceName}: ${error.message}`)
return {
success: false,
@ -102,13 +120,37 @@ export class DockerService {
/**
* Fetches the status of all Docker containers related to Nomad services. (those prefixed with 'nomad_')
* Results are cached for 5 seconds and concurrent callers share a single in-flight request,
* preventing Docker socket congestion during rapid page navigation.
*/
async getServicesStatus(): Promise<
{
service_name: string
status: string
}[]
> {
async getServicesStatus(): Promise<{ service_name: string; status: string }[]> {
const now = Date.now()
if (this._servicesStatusCache && now < this._servicesStatusCache.expiresAt) {
return this._servicesStatusCache.data
}
if (this._servicesStatusInflight) return this._servicesStatusInflight
this._servicesStatusInflight = this._fetchServicesStatus().then((data) => {
this._servicesStatusCache = { data, expiresAt: Date.now() + 5000 }
this._servicesStatusInflight = null
return data
}).catch((err) => {
this._servicesStatusInflight = null
throw err
})
return this._servicesStatusInflight
}
/**
* Invalidates the services status cache. Call this after any container state change
* (start, stop, restart, install, uninstall) so the next read reflects reality.
*/
invalidateServicesStatusCache() {
this._servicesStatusCache = null
this._servicesStatusInflight = null
}
private async _fetchServicesStatus(): Promise<{ service_name: string; status: string }[]> {
try {
const containers = await this.docker.listContainers({ all: true })
const containerMap = new Map<string, Docker.ContainerInfo>()
@ -123,7 +165,7 @@ export class DockerService {
service_name: name,
status: container.State,
}))
} catch (error) {
} catch (error: any) {
logger.error(`Error fetching services status: ${error.message}`)
return []
}
@ -140,6 +182,11 @@ export class DockerService {
return null
}
if (serviceName === SERVICE_NAMES.OLLAMA) {
const remoteUrl = await KVStore.getValue('ai.remoteOllamaUrl')
if (remoteUrl) return remoteUrl
}
const service = await Service.query()
.where('service_name', serviceName)
.andWhere('installed', true)
@ -307,7 +354,7 @@ export class DockerService {
`No existing container found, proceeding with installation...`
)
}
} catch (error) {
} catch (error: any) {
logger.warn(`Error during container cleanup: ${error.message}`)
this._broadcast(serviceName, 'cleanup-warning', `Warning during cleanup: ${error.message}`)
}
@ -326,7 +373,7 @@ export class DockerService {
const volume = this.docker.getVolume(vol.Name)
await volume.remove({ force: true })
this._broadcast(serviceName, 'volume-removed', `Removed volume: ${vol.Name}`)
} catch (error) {
} catch (error: any) {
logger.warn(`Failed to remove volume ${vol.Name}: ${error.message}`)
}
}
@ -334,7 +381,7 @@ export class DockerService {
if (serviceVolumes.length === 0) {
this._broadcast(serviceName, 'no-volumes', `No volumes found to clear`)
}
} catch (error) {
} catch (error: any) {
logger.warn(`Error during volume cleanup: ${error.message}`)
this._broadcast(
serviceName,
@ -347,6 +394,7 @@ export class DockerService {
service.installed = false
service.installation_status = 'installing'
await service.save()
this.invalidateServicesStatusCache()
// Step 5: Recreate the container
this._broadcast(serviceName, 'recreating', `Recreating container...`)
@ -362,7 +410,7 @@ export class DockerService {
success: true,
message: `Service ${serviceName} force reinstall initiated successfully. You can receive updates via server-sent events.`,
}
} catch (error) {
} catch (error: any) {
logger.error(`Force reinstall failed for ${serviceName}: ${error.message}`)
await this._cleanupFailedInstallation(serviceName)
return {
@ -500,6 +548,15 @@ export class DockerService {
}
}
const ollamaEnv: string[] = []
if (service.service_name === SERVICE_NAMES.OLLAMA) {
ollamaEnv.push('OLLAMA_NO_CLOUD=1')
const flashAttentionEnabled = await KVStore.getValue('ai.ollamaFlashAttention')
if (flashAttentionEnabled !== false) {
ollamaEnv.push('OLLAMA_FLASH_ATTENTION=1')
}
}
this._broadcast(
service.service_name,
'creating',
@ -508,11 +565,16 @@ export class DockerService {
const container = await this.docker.createContainer({
Image: finalImage,
name: service.service_name,
Labels: {
...(containerConfig?.Labels ?? {}),
'com.docker.compose.project': 'project-nomad-managed',
'io.project-nomad.managed': 'true',
},
...(containerConfig?.User && { User: containerConfig.User }),
HostConfig: gpuHostConfig,
...(containerConfig?.WorkingDir && { WorkingDir: containerConfig.WorkingDir }),
...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }),
...(containerConfig?.Env && { Env: containerConfig.Env }),
Env: [...(containerConfig?.Env ?? []), ...ollamaEnv],
...(service.container_command ? { Cmd: service.container_command.split(' ') } : {}),
// Ensure container is attached to the Nomad docker network in production
...(process.env.NODE_ENV === 'production' && {
@ -539,6 +601,7 @@ export class DockerService {
service.installed = true
service.installation_status = 'idle'
await service.save()
this.invalidateServicesStatusCache()
// Remove from active installs tracking
this.activeInstallations.delete(service.service_name)
@ -564,7 +627,7 @@ export class DockerService {
'completed',
`Service ${service.service_name} installation completed successfully.`
)
} catch (error) {
} catch (error: any) {
this._broadcast(
service.service_name,
'error',
@ -580,7 +643,7 @@ export class DockerService {
try {
const containers = await this.docker.listContainers({ all: true })
return containers.some((container) => container.Names.includes(`/${serviceName}`))
} catch (error) {
} catch (error: any) {
logger.error(`Error checking if service container exists: ${error.message}`)
return false
}
@ -600,7 +663,7 @@ export class DockerService {
await dockerContainer.remove({ force: true })
return { success: true, message: `Service ${serviceName} container removed successfully` }
} catch (error) {
} catch (error: any) {
logger.error(`Error removing service container: ${error.message}`)
return {
success: false,
@ -615,8 +678,8 @@ export class DockerService {
* We'll download the lightweight mini Wikipedia Top 100 zim file for this purpose.
**/
const WIKIPEDIA_ZIM_URL =
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/install/wikipedia_en_100_mini_2025-06.zim'
const filename = 'wikipedia_en_100_mini_2025-06.zim'
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/install/wikipedia_en_100_mini_2026-01.zim'
const filename = 'wikipedia_en_100_mini_2026-01.zim'
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
logger.info(`[DockerService] Kiwix Serve pre-install: Downloading ZIM file to ${filepath}`)
@ -648,7 +711,12 @@ export class DockerService {
'preinstall',
`Downloaded Wikipedia ZIM file to ${filepath}`
)
} catch (error) {
// Generate the initial kiwix library XML before the container is created
const kiwixLibraryService = new KiwixLibraryService()
await kiwixLibraryService.rebuildFromDisk()
this._broadcast(SERVICE_NAMES.KIWIX, 'preinstall', 'Generated kiwix library XML.')
} catch (error: any) {
this._broadcast(
SERVICE_NAMES.KIWIX,
'preinstall-error',
@ -671,13 +739,121 @@ export class DockerService {
await this._removeServiceContainer(serviceName)
logger.info(`[DockerService] Cleaned up failed installation for ${serviceName}`)
} catch (error) {
} catch (error: any) {
logger.error(
`[DockerService] Failed to cleanup installation for ${serviceName}: ${error.message}`
)
}
}
/**
* Checks whether the running kiwix container is using the legacy glob-pattern command
* (`*.zim --address=all`) rather than the library-file command. Used to detect containers
* that need to be migrated to library mode.
*/
async isKiwixOnLegacyConfig(): Promise<boolean> {
try {
const containers = await this.docker.listContainers({ all: true })
const info = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.KIWIX}`))
if (!info) return false
const inspected = await this.docker.getContainer(info.Id).inspect()
const cmd: string[] = inspected.Config?.Cmd ?? []
return cmd.some((arg) => arg.includes('*.zim'))
} catch (err: any) {
logger.warn(`[DockerService] Could not inspect kiwix container: ${err.message}`)
return false
}
}
/**
* Migrates the kiwix container from legacy glob mode (`*.zim`) to library mode
* (`--library /data/kiwix-library.xml --monitorLibrary`).
*
* This is a non-destructive recreation: ZIM files and volumes are preserved.
* The container is stopped, removed, and recreated with the correct library-mode command.
* This function is authoritative: it writes the correct command to the DB itself rather than
* trusting the DB to have been pre-updated by a separate migration.
*/
async migrateKiwixToLibraryMode(): Promise<void> {
if (this.activeInstallations.has(SERVICE_NAMES.KIWIX)) {
logger.warn('[DockerService] Kiwix migration already in progress, skipping duplicate call.')
return
}
this.activeInstallations.add(SERVICE_NAMES.KIWIX)
try {
// Step 1: Build/update the XML from current disk state
this._broadcast(SERVICE_NAMES.KIWIX, 'migrating', 'Migrating kiwix to library mode...')
const kiwixLibraryService = new KiwixLibraryService()
await kiwixLibraryService.rebuildFromDisk()
this._broadcast(SERVICE_NAMES.KIWIX, 'migrating', 'Built kiwix library XML from existing ZIM files.')
// Step 2: Stop and remove old container (leave ZIM volumes intact)
const containers = await this.docker.listContainers({ all: true })
const containerInfo = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.KIWIX}`))
if (containerInfo) {
const oldContainer = this.docker.getContainer(containerInfo.Id)
if (containerInfo.State === 'running') {
await oldContainer.stop({ t: 10 }).catch((e: any) =>
logger.warn(`[DockerService] Kiwix stop warning during migration: ${e.message}`)
)
}
await oldContainer.remove({ force: true }).catch((e: any) =>
logger.warn(`[DockerService] Kiwix remove warning during migration: ${e.message}`)
)
}
// Step 3: Read the service record and authoritatively set the correct command.
// Do NOT rely on prior DB state — we write container_command here so the record
// stays consistent regardless of whether the DB migration ran.
const service = await Service.query().where('service_name', SERVICE_NAMES.KIWIX).first()
if (!service) {
throw new Error('Kiwix service record not found in DB during migration')
}
service.container_command = KIWIX_LIBRARY_CMD
service.installed = false
service.installation_status = 'installing'
await service.save()
const containerConfig = this._parseContainerConfig(service.container_config)
// Step 4: Recreate container directly (skipping _createContainer to avoid re-downloading
// the bootstrap ZIM — ZIM files already exist on disk)
this._broadcast(SERVICE_NAMES.KIWIX, 'migrating', 'Recreating kiwix container with library mode config...')
const newContainer = await this.docker.createContainer({
Image: service.container_image,
name: service.service_name,
HostConfig: containerConfig?.HostConfig ?? {},
...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }),
Cmd: KIWIX_LIBRARY_CMD.split(' '),
...(process.env.NODE_ENV === 'production' && {
NetworkingConfig: {
EndpointsConfig: {
[DockerService.NOMAD_NETWORK]: {},
},
},
}),
})
await newContainer.start()
service.installed = true
service.installation_status = 'idle'
await service.save()
this.activeInstallations.delete(SERVICE_NAMES.KIWIX)
this._broadcast(SERVICE_NAMES.KIWIX, 'migrated', 'Kiwix successfully migrated to library mode.')
logger.info('[DockerService] Kiwix migration to library mode complete.')
} catch (error: any) {
logger.error(`[DockerService] Kiwix migration failed: ${error.message}`)
await this._cleanupFailedInstallation(SERVICE_NAMES.KIWIX)
throw error
}
}
/**
* Detect GPU type and toolkit availability.
* Primary: Check Docker runtimes via docker.info() (works from inside containers).
@ -694,7 +870,7 @@ export class DockerService {
await this._persistGPUType('nvidia')
return { type: 'nvidia' }
}
} catch (error) {
} catch (error: any) {
logger.warn(`[DockerService] Could not query Docker info for GPU runtimes: ${error.message}`)
}
@ -711,7 +887,7 @@ export class DockerService {
logger.warn('[DockerService] NVIDIA GPU detected via lspci but NVIDIA Container Toolkit is not installed')
return { type: 'none', toolkitMissing: true }
}
} catch (error) {
} catch (error: any) {
// lspci not available (likely inside Docker container), continue
}
@ -726,7 +902,7 @@ export class DockerService {
await this._persistGPUType('amd')
return { type: 'amd' }
}
} catch (error) {
} catch (error: any) {
// lspci not available, continue
}
@ -745,7 +921,7 @@ export class DockerService {
logger.info('[DockerService] No GPU detected')
return { type: 'none' }
} catch (error) {
} catch (error: any) {
logger.warn(`[DockerService] Error detecting GPU type: ${error.message}`)
return { type: 'none' }
}
@ -755,7 +931,7 @@ export class DockerService {
try {
await KVStore.setValue('gpu.type', type)
logger.info(`[DockerService] Persisted GPU type '${type}' to KV store`)
} catch (error) {
} catch (error: any) {
logger.warn(`[DockerService] Failed to persist GPU type: ${error.message}`)
}
}
@ -950,7 +1126,7 @@ export class DockerService {
let newContainer: any
try {
newContainer = await this.docker.createContainer(newContainerConfig)
} catch (createError) {
} catch (createError: any) {
// Rollback: rename old container back
this._broadcast(serviceName, 'update-rollback', `Failed to create new container: ${createError.message}. Rolling back...`)
const rollbackContainer = this.docker.getContainer((await this.docker.listContainers({ all: true })).find((c) => c.Names.includes(`/${oldName}`))!.Id)
@ -1023,7 +1199,7 @@ export class DockerService {
message: `Update failed: new container did not stay running. Rolled back to previous version.`,
}
}
} catch (error) {
} catch (error: any) {
this.activeInstallations.delete(serviceName)
this._broadcast(
serviceName,
@ -1058,7 +1234,7 @@ export class DockerService {
}
return JSON.parse(toParse)
} catch (error) {
} catch (error: any) {
logger.error(`Failed to parse container configuration: ${error.message}`)
throw new Error(`Invalid container configuration: ${error.message}`)
}
@ -1075,7 +1251,7 @@ export class DockerService {
// Check if any image has a RepoTag that matches the requested image
return images.some((image) => image.RepoTags && image.RepoTags.includes(imageName))
} catch (error) {
} catch (error: any) {
logger.warn(`Error checking if image exists: ${error.message}`)
// If run into an error, assume the image does not exist
return false

View File

@ -2,27 +2,64 @@ import { inject } from '@adonisjs/core'
import { QueueService } from './queue_service.js'
import { RunDownloadJob } from '#jobs/run_download_job'
import { DownloadModelJob } from '#jobs/download_model_job'
import { DownloadJobWithProgress } from '../../types/downloads.js'
import { DownloadJobWithProgress, DownloadProgressData } from '../../types/downloads.js'
import { normalize } from 'path'
import { deleteFileIfExists } from '../utils/fs.js'
@inject()
export class DownloadService {
constructor(private queueService: QueueService) {}
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
// Get regular file download jobs (zim, map, etc.)
const queue = this.queueService.getQueue(RunDownloadJob.queue)
const fileJobs = await queue.getJobs(['waiting', 'active', 'delayed', 'failed'])
private parseProgress(progress: any): { percent: number; downloadedBytes?: number; totalBytes?: number; lastProgressTime?: number } {
if (typeof progress === 'object' && progress !== null && 'percent' in progress) {
const p = progress as DownloadProgressData
return {
percent: p.percent,
downloadedBytes: p.downloadedBytes,
totalBytes: p.totalBytes,
lastProgressTime: p.lastProgressTime,
}
}
// Backward compat: plain integer from in-flight jobs during upgrade
return { percent: parseInt(String(progress), 10) || 0 }
}
const fileDownloads = fileJobs.map((job) => ({
jobId: job.id!.toString(),
url: job.data.url,
progress: parseInt(job.progress.toString(), 10),
filepath: normalize(job.data.filepath),
filetype: job.data.filetype,
status: (job.failedReason ? 'failed' : 'active') as 'active' | 'failed',
failedReason: job.failedReason || undefined,
}))
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
// Get regular file download jobs (zim, map, etc.) — query each state separately so we can
// tag each job with its actual BullMQ state rather than guessing from progress data.
const queue = this.queueService.getQueue(RunDownloadJob.queue)
type FileJobState = 'waiting' | 'active' | 'delayed' | 'failed'
const [waitingJobs, activeJobs, delayedJobs, failedJobs] = await Promise.all([
queue.getJobs(['waiting']),
queue.getJobs(['active']),
queue.getJobs(['delayed']),
queue.getJobs(['failed']),
])
const taggedFileJobs: Array<{ job: (typeof waitingJobs)[0]; state: FileJobState }> = [
...waitingJobs.map((j) => ({ job: j, state: 'waiting' as const })),
...activeJobs.map((j) => ({ job: j, state: 'active' as const })),
...delayedJobs.map((j) => ({ job: j, state: 'delayed' as const })),
...failedJobs.map((j) => ({ job: j, state: 'failed' as const })),
]
const fileDownloads = taggedFileJobs.map(({ job, state }) => {
const parsed = this.parseProgress(job.progress)
return {
jobId: job.id!.toString(),
url: job.data.url,
progress: parsed.percent,
filepath: normalize(job.data.filepath),
filetype: job.data.filetype,
title: job.data.title || undefined,
downloadedBytes: parsed.downloadedBytes,
totalBytes: parsed.totalBytes || job.data.totalBytes || undefined,
lastProgressTime: parsed.lastProgressTime,
status: state,
failedReason: job.failedReason || undefined,
}
})
// Get Ollama model download jobs
const modelQueue = this.queueService.getQueue(DownloadModelJob.queue)
@ -56,9 +93,106 @@ export class DownloadService {
const queue = this.queueService.getQueue(queueName)
const job = await queue.getJob(jobId)
if (job) {
await job.remove()
try {
await job.remove()
} catch {
// Job may be locked by the worker after cancel. Remove the stale lock and retry.
try {
const client = await queue.client
await client.del(`bull:${queueName}:${jobId}:lock`)
await job.remove()
} catch {
// Last resort: already removed or truly stuck
}
}
return
}
}
}
async cancelJob(jobId: string): Promise<{ success: boolean; message: string }> {
const queue = this.queueService.getQueue(RunDownloadJob.queue)
const job = await queue.getJob(jobId)
if (!job) {
// Job already completed (removeOnComplete: true) or doesn't exist
return { success: true, message: 'Job not found (may have already completed)' }
}
const filepath = job.data.filepath
// Signal the worker process to abort the download via Redis
await RunDownloadJob.signalCancel(jobId)
// Also try in-memory abort (works if worker is in same process)
RunDownloadJob.abortControllers.get(jobId)?.abort('user-cancel')
RunDownloadJob.abortControllers.delete(jobId)
// Poll for terminal state (up to 4s at 250ms intervals) — cooperates with BullMQ's lifecycle
// instead of force-removing an active job and losing the worker's failure/cleanup path.
const POLL_INTERVAL_MS = 250
const POLL_TIMEOUT_MS = 4000
const deadline = Date.now() + POLL_TIMEOUT_MS
let reachedTerminal = false
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
try {
const state = await job.getState()
if (state === 'failed' || state === 'completed' || state === 'unknown') {
reachedTerminal = true
break
}
} catch {
reachedTerminal = true // getState() throws if job is already gone
break
}
}
if (!reachedTerminal) {
console.warn(`[DownloadService] cancelJob: job ${jobId} did not reach terminal state within timeout, removing anyway`)
}
// Remove the BullMQ job
try {
await job.remove()
} catch {
// Lock contention fallback: clear lock and retry once
try {
const client = await queue.client
await client.del(`bull:${RunDownloadJob.queue}:${jobId}:lock`)
const updatedJob = await queue.getJob(jobId)
if (updatedJob) await updatedJob.remove()
} catch {
// Best effort - job will be cleaned up on next dismiss attempt
}
}
// Delete the partial file from disk
if (filepath) {
try {
await deleteFileIfExists(filepath)
// Also try .tmp in case PR #448 staging is merged
await deleteFileIfExists(filepath + '.tmp')
} catch {
// File may not exist yet (waiting job)
}
}
// If this was a Wikipedia download, update selection status to failed
// (the worker's failed event may not fire if we removed the job first)
if (job.data.filetype === 'zim' && job.data.url?.includes('wikipedia_en_')) {
try {
const { DockerService } = await import('#services/docker_service')
const { ZimService } = await import('#services/zim_service')
const dockerService = new DockerService()
const zimService = new ZimService(dockerService)
await zimService.onWikipediaDownloadComplete(job.data.url, false)
} catch {
// Best effort
}
}
return { success: true, message: 'Download cancelled and partial file deleted' }
}
}

View File

@ -0,0 +1,285 @@
import { XMLBuilder, XMLParser } from 'fast-xml-parser'
import { readFile, writeFile, rename, readdir } from 'fs/promises'
import { join } from 'path'
import { Archive } from '@openzim/libzim'
import { KIWIX_LIBRARY_XML_PATH, ZIM_STORAGE_PATH, ensureDirectoryExists } from '../utils/fs.js'
import logger from '@adonisjs/core/services/logger'
import { randomUUID } from 'node:crypto'
const CONTAINER_DATA_PATH = '/data'
const XML_DECLARATION = '<?xml version="1.0" encoding="UTF-8"?>\n'
interface KiwixBook {
id: string
path: string
title: string
description?: string
language?: string
creator?: string
publisher?: string
name?: string
flavour?: string
tags?: string
faviconMimeType?: string
favicon?: string
date?: string
articleCount?: number
mediaCount?: number
size?: number
}
export class KiwixLibraryService {
getLibraryFilePath(): string {
return join(process.cwd(), KIWIX_LIBRARY_XML_PATH)
}
containerLibraryPath(): string {
return '/data/kiwix-library.xml'
}
private _filenameToTitle(filename: string): string {
const withoutExt = filename.endsWith('.zim') ? filename.slice(0, -4) : filename
const parts = withoutExt.split('_')
// Drop last segment if it looks like a date (YYYY-MM)
const lastPart = parts[parts.length - 1]
const isDate = /^\d{4}-\d{2}$/.test(lastPart)
const titleParts = isDate && parts.length > 1 ? parts.slice(0, -1) : parts
return titleParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(' ')
}
/**
* Reads all kiwix-manage-compatible metadata from a ZIM file, including the internal UUID,
* rich text fields, and the base64-encoded favicon. Kiwix-serve uses the UUID for OPDS
* catalog entries and illustration URLs (/catalog/v2/illustration/{uuid}).
*
* Returns null on any error so callers can fall back gracefully.
*/
private _readZimMetadata(zimFilePath: string): Partial<KiwixBook> | null {
try {
const archive = new Archive(zimFilePath)
const getMeta = (key: string): string | undefined => {
try {
return archive.getMetadata(key) || undefined
} catch {
return undefined
}
}
let favicon: string | undefined
let faviconMimeType: string | undefined
try {
if (archive.illustrationSizes.size > 0) {
const size = archive.illustrationSizes.has(48)
? 48
: ([...archive.illustrationSizes][0] as number)
const item = archive.getIllustrationItem(size)
favicon = item.data.data.toString('base64')
faviconMimeType = item.mimetype || undefined
}
} catch {
// ZIM has no illustration — that's fine
}
const rawFilesize =
typeof archive.filesize === 'bigint' ? Number(archive.filesize) : archive.filesize
return {
id: archive.uuid || undefined,
title: getMeta('Title'),
description: getMeta('Description'),
language: getMeta('Language'),
creator: getMeta('Creator'),
publisher: getMeta('Publisher'),
name: getMeta('Name'),
flavour: getMeta('Flavour'),
tags: getMeta('Tags'),
date: getMeta('Date'),
articleCount: archive.articleCount,
mediaCount: archive.mediaCount,
size: Math.floor(rawFilesize / 1024),
favicon,
faviconMimeType,
}
} catch {
return null
}
}
private _buildXml(books: KiwixBook[]): string {
const builder = new XMLBuilder({
ignoreAttributes: false,
attributeNamePrefix: '@_',
format: true,
suppressEmptyNode: false,
})
const obj: Record<string, any> = {
library: {
'@_version': '20110515',
...(books.length > 0 && {
book: books.map((b) => ({
'@_id': b.id,
'@_path': b.path,
'@_title': b.title,
...(b.description !== undefined && { '@_description': b.description }),
...(b.language !== undefined && { '@_language': b.language }),
...(b.creator !== undefined && { '@_creator': b.creator }),
...(b.publisher !== undefined && { '@_publisher': b.publisher }),
...(b.name !== undefined && { '@_name': b.name }),
...(b.flavour !== undefined && { '@_flavour': b.flavour }),
...(b.tags !== undefined && { '@_tags': b.tags }),
...(b.faviconMimeType !== undefined && { '@_faviconMimeType': b.faviconMimeType }),
...(b.favicon !== undefined && { '@_favicon': b.favicon }),
...(b.date !== undefined && { '@_date': b.date }),
...(b.articleCount !== undefined && { '@_articleCount': b.articleCount }),
...(b.mediaCount !== undefined && { '@_mediaCount': b.mediaCount }),
...(b.size !== undefined && { '@_size': b.size }),
})),
}),
},
}
return XML_DECLARATION + builder.build(obj)
}
private async _atomicWrite(content: string): Promise<void> {
const filePath = this.getLibraryFilePath()
const tmpPath = `${filePath}.tmp.${randomUUID()}`
await writeFile(tmpPath, content, 'utf-8')
await rename(tmpPath, filePath)
}
private _parseExistingBooks(xmlContent: string): KiwixBook[] {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
isArray: (name) => name === 'book',
})
const parsed = parser.parse(xmlContent)
const books: any[] = parsed?.library?.book ?? []
return books
.map((b) => ({
id: b['@_id'] ?? '',
path: b['@_path'] ?? '',
title: b['@_title'] ?? '',
description: b['@_description'],
language: b['@_language'],
creator: b['@_creator'],
publisher: b['@_publisher'],
name: b['@_name'],
flavour: b['@_flavour'],
tags: b['@_tags'],
faviconMimeType: b['@_faviconMimeType'],
favicon: b['@_favicon'],
date: b['@_date'],
articleCount:
b['@_articleCount'] !== undefined ? Number(b['@_articleCount']) : undefined,
mediaCount: b['@_mediaCount'] !== undefined ? Number(b['@_mediaCount']) : undefined,
size: b['@_size'] !== undefined ? Number(b['@_size']) : undefined,
}))
.filter((b) => b.id && b.path)
}
async rebuildFromDisk(opts?: { excludeFilenames?: string[] }): Promise<void> {
const dirPath = join(process.cwd(), ZIM_STORAGE_PATH)
await ensureDirectoryExists(dirPath)
let entries: string[] = []
try {
entries = await readdir(dirPath)
} catch {
entries = []
}
const excludeSet = new Set(opts?.excludeFilenames ?? [])
const zimFiles = entries.filter((name) => name.endsWith('.zim') && !excludeSet.has(name))
const books: KiwixBook[] = zimFiles.map((filename) => {
const meta = this._readZimMetadata(join(dirPath, filename))
const containerPath = `${CONTAINER_DATA_PATH}/${filename}`
return {
...meta,
// Override fields that must be derived locally, not from ZIM metadata
id: meta?.id ?? filename.slice(0, -4),
path: containerPath,
title: meta?.title ?? this._filenameToTitle(filename),
}
})
const xml = this._buildXml(books)
await this._atomicWrite(xml)
logger.info(`[KiwixLibraryService] Rebuilt library XML with ${books.length} book(s).`)
}
async addBook(filename: string): Promise<void> {
const zimFilename = filename.endsWith('.zim') ? filename : `${filename}.zim`
const containerPath = `${CONTAINER_DATA_PATH}/${zimFilename}`
const filePath = this.getLibraryFilePath()
let existingBooks: KiwixBook[] = []
try {
const content = await readFile(filePath, 'utf-8')
existingBooks = this._parseExistingBooks(content)
} catch (err: any) {
if (err.code === 'ENOENT') {
// XML doesn't exist yet — rebuild from disk; the completed download is already there
await this.rebuildFromDisk()
return
}
throw err
}
if (existingBooks.some((b) => b.path === containerPath)) {
logger.info(`[KiwixLibraryService] ${zimFilename} already in library, skipping.`)
return
}
const fullPath = join(process.cwd(), ZIM_STORAGE_PATH, zimFilename)
const meta = this._readZimMetadata(fullPath)
existingBooks.push({
...meta,
id: meta?.id ?? zimFilename.slice(0, -4),
path: containerPath,
title: meta?.title ?? this._filenameToTitle(zimFilename),
})
const xml = this._buildXml(existingBooks)
await this._atomicWrite(xml)
logger.info(`[KiwixLibraryService] Added ${zimFilename} to library XML.`)
}
async removeBook(filename: string): Promise<void> {
const zimFilename = filename.endsWith('.zim') ? filename : `${filename}.zim`
const containerPath = `${CONTAINER_DATA_PATH}/${zimFilename}`
const filePath = this.getLibraryFilePath()
let existingBooks: KiwixBook[] = []
try {
const content = await readFile(filePath, 'utf-8')
existingBooks = this._parseExistingBooks(content)
} catch (err: any) {
if (err.code === 'ENOENT') {
logger.warn(`[KiwixLibraryService] Library XML not found, nothing to remove.`)
return
}
throw err
}
const filtered = existingBooks.filter((b) => b.path !== containerPath)
if (filtered.length === existingBooks.length) {
logger.info(`[KiwixLibraryService] ${zimFilename} not found in library, nothing to remove.`)
return
}
const xml = this._buildXml(filtered)
await this._atomicWrite(xml)
logger.info(`[KiwixLibraryService] Removed ${zimFilename} from library XML.`)
}
}

View File

@ -21,6 +21,16 @@ import InstalledResource from '#models/installed_resource'
import { CollectionManifestService } from './collection_manifest_service.js'
import type { CollectionWithStatus, MapsSpec } from '../../types/collections.js'
const PROTOMAPS_BUILDS_METADATA_URL = 'https://build-metadata.protomaps.dev/builds.json'
const PROTOMAPS_BUILD_BASE_URL = 'https://build.protomaps.com'
export interface ProtomapsBuildInfo {
url: string
date: string
size: number
key: string
}
const BASE_ASSETS_MIME_TYPES = [
'application/gzip',
'application/x-gzip',
@ -109,7 +119,7 @@ export class MapService implements IMapService {
const downloadFilenames: string[] = []
for (const resource of toDownload) {
const existing = await RunDownloadJob.getByUrl(resource.url)
const existing = await RunDownloadJob.getActiveByUrl(resource.url)
if (existing) {
logger.warn(`[MapService] Download already in progress for URL ${resource.url}, skipping.`)
continue
@ -131,6 +141,7 @@ export class MapService implements IMapService {
allowedMimeTypes: PMTILES_MIME_TYPES,
forceNew: true,
filetype: 'map',
title: (resource as any).title || undefined,
resourceMetadata: {
resource_id: resource.id,
version: resource.version,
@ -179,7 +190,7 @@ export class MapService implements IMapService {
throw new Error(`Invalid PMTiles file URL: ${url}. URL must end with .pmtiles`)
}
const existing = await RunDownloadJob.getByUrl(url)
const existing = await RunDownloadJob.getActiveByUrl(url)
if (existing) {
throw new Error(`Download already in progress for URL ${url}`)
}
@ -398,6 +409,76 @@ export class MapService implements IMapService {
return template
}
async getGlobalMapInfo(): Promise<ProtomapsBuildInfo> {
const { default: axios } = await import('axios')
const response = await axios.get(PROTOMAPS_BUILDS_METADATA_URL, { timeout: 15000 })
const builds = response.data as Array<{ key: string; size: number }>
if (!builds || builds.length === 0) {
throw new Error('No protomaps builds found')
}
// Latest build first
const sorted = builds.sort((a, b) => b.key.localeCompare(a.key))
const latest = sorted[0]
const dateStr = latest.key.replace('.pmtiles', '')
const date = `${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}-${dateStr.slice(6, 8)}`
return {
url: `${PROTOMAPS_BUILD_BASE_URL}/${latest.key}`,
date,
size: latest.size,
key: latest.key,
}
}
async downloadGlobalMap(): Promise<{ filename: string; jobId?: string }> {
const info = await this.getGlobalMapInfo()
const existing = await RunDownloadJob.getByUrl(info.url)
if (existing) {
throw new Error(`Download already in progress for URL ${info.url}`)
}
const basePath = resolve(join(this.baseDirPath, 'pmtiles'))
const filepath = resolve(join(basePath, info.key))
// Prevent path traversal — resolved path must stay within the storage directory
if (!filepath.startsWith(basePath + sep)) {
throw new Error('Invalid filename')
}
// First, ensure base assets are present - the global map depends on them
const baseAssetsExist = await this.ensureBaseAssets()
if (!baseAssetsExist) {
throw new Error(
'Base map assets are missing and could not be downloaded. Please check your connection and try again.'
)
}
// forceNew: false so retries resume partial downloads
const result = await RunDownloadJob.dispatch({
url: info.url,
filepath,
timeout: 30000,
allowedMimeTypes: PMTILES_MIME_TYPES,
forceNew: false,
filetype: 'map',
})
if (!result.job) {
throw new Error('Failed to dispatch download job')
}
logger.info(`[MapService] Dispatched global map download job ${result.job.id}`)
return {
filename: info.key,
jobId: result.job?.id,
}
}
async delete(file: string): Promise<void> {
let fileName = file
if (!fileName.endsWith('.pmtiles')) {
@ -430,8 +511,18 @@ export class MapService implements IMapService {
}
}
/*
* Gets the appropriate public URL for a map asset depending on environment
/**
* Gets the appropriate public URL for a map asset depending on environment. The host and protocol that the user
* is accessing the maps from must match the host and protocol used in the generated URLs, otherwise maps will fail to load.
* If you make changes to this function, you need to ensure it handles all the following cases correctly:
* - No host provided (should default to localhost or env URL)
* - Host provided as full URL (e.g. "http://example.com:8080")
* - Host provided as host:port (e.g. "example.com:8080")
* - Host provided as bare hostname (e.g. "example.com")
* @param specifiedHost - the host as provided by the user/request, can be null or in various formats (full URL, host:port, bare hostname)
* @param childPath - the path to append to the base URL (e.g. "basemaps-assets", "pmtiles")
* @param protocol - the protocol to use in the generated URL (e.g. "http", "https"), defaults to "http"
* @returns the public URL for the map asset
*/
private getPublicFileBaseUrl(specifiedHost: string | null, childPath: string, protocol: string = 'http'): string {
function getHost() {
@ -446,8 +537,25 @@ export class MapService implements IMapService {
}
}
const host = specifiedHost || getHost()
const withProtocol = host.startsWith('http') ? host : `${protocol}://${host}`
function specifiedHostOrDefault() {
if (specifiedHost === null) {
return getHost()
}
// Try as a full URL first (e.g. "http://example.com:8080")
try {
const specifiedUrl = new URL(specifiedHost)
if (specifiedUrl.host) return specifiedUrl.host
} catch {}
// Try as a bare host or host:port (e.g. "nomad-box:8080", "192.168.1.1:8080", "example.com")
try {
const specifiedUrl = new URL(`http://${specifiedHost}`)
if (specifiedUrl.host) return specifiedUrl.host
} catch {}
return getHost()
}
const host = specifiedHostOrDefault();
const withProtocol = `${protocol}://${host}`
const baseUrlPath =
process.env.NODE_ENV === 'production' ? childPath : urlJoin(this.mapStoragePath, childPath)

View File

@ -1,5 +1,7 @@
import { inject } from '@adonisjs/core'
import { ChatRequest, Ollama } from 'ollama'
import OpenAI from 'openai'
import type { ChatCompletionChunk, ChatCompletionMessageParam } from 'openai/resources/chat/completions.js'
import type { Stream } from 'openai/streaming.js'
import { NomadOllamaModel } from '../../types/ollama.js'
import { FALLBACK_RECOMMENDED_OLLAMA_MODELS } from '../../constants/ollama.js'
import fs from 'node:fs/promises'
@ -13,51 +15,93 @@ import Fuse, { IFuseOptions } from 'fuse.js'
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
import env from '#start/env'
import { NOMAD_API_DEFAULT_BASE_URL } from '../../constants/misc.js'
import KVStore from '#models/kv_store'
const NOMAD_MODELS_API_PATH = '/api/v1/ollama/models'
const MODELS_CACHE_FILE = path.join(process.cwd(), 'storage', 'ollama-models-cache.json')
const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours
export type NomadInstalledModel = {
name: string
size: number
digest?: string
details?: Record<string, any>
}
export type NomadChatResponse = {
message: { content: string; thinking?: string }
done: boolean
model: string
}
export type NomadChatStreamChunk = {
message: { content: string; thinking?: string }
done: boolean
}
type ChatInput = {
model: string
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
think?: boolean | 'medium'
stream?: boolean
numCtx?: number
}
@inject()
export class OllamaService {
private ollama: Ollama | null = null
private ollamaInitPromise: Promise<void> | null = null
private openai: OpenAI | null = null
private baseUrl: string | null = null
private initPromise: Promise<void> | null = null
private isOllamaNative: boolean | null = null
constructor() { }
constructor() {}
private async _initializeOllamaClient() {
if (!this.ollamaInitPromise) {
this.ollamaInitPromise = (async () => {
const dockerService = new (await import('./docker_service.js')).DockerService()
const qdrantUrl = await dockerService.getServiceURL(SERVICE_NAMES.OLLAMA)
if (!qdrantUrl) {
throw new Error('Ollama service is not installed or running.')
private async _initialize() {
if (!this.initPromise) {
this.initPromise = (async () => {
// Check KVStore for a custom base URL (remote Ollama, LM Studio, llama.cpp, etc.)
const customUrl = (await KVStore.getValue('ai.remoteOllamaUrl')) as string | null
if (customUrl && customUrl.trim()) {
this.baseUrl = customUrl.trim().replace(/\/$/, '')
} else {
// Fall back to the local Ollama container managed by Docker
const dockerService = new (await import('./docker_service.js')).DockerService()
const ollamaUrl = await dockerService.getServiceURL(SERVICE_NAMES.OLLAMA)
if (!ollamaUrl) {
throw new Error('Ollama service is not installed or running.')
}
this.baseUrl = ollamaUrl.trim().replace(/\/$/, '')
}
this.ollama = new Ollama({ host: qdrantUrl })
this.openai = new OpenAI({
apiKey: 'nomad', // Required by SDK; not validated by Ollama/LM Studio/llama.cpp
baseURL: `${this.baseUrl}/v1`,
})
})()
}
return this.ollamaInitPromise
return this.initPromise
}
private async _ensureDependencies() {
if (!this.ollama) {
await this._initializeOllamaClient()
if (!this.openai) {
await this._initialize()
}
}
/**
* Downloads a model from the Ollama service with progress tracking. Where possible,
* one should dispatch a background job instead of calling this method directly to avoid long blocking.
* @param model Model name to download
* @returns Success status and message
* Downloads a model from Ollama with progress tracking. Only works with Ollama backends.
* Use dispatchModelDownload() for background job processing where possible.
*/
async downloadModel(model: string, progressCallback?: (percent: number) => void): Promise<{ success: boolean; message: string }> {
try {
await this._ensureDependencies()
if (!this.ollama) {
throw new Error('Ollama client is not initialized.')
}
async downloadModel(
model: string,
progressCallback?: (percent: number) => void
): Promise<{ success: boolean; message: string; retryable?: boolean }> {
await this._ensureDependencies()
if (!this.baseUrl) {
return { success: false, message: 'AI service is not initialized.' }
}
try {
// See if model is already installed
const installedModels = await this.getModels()
if (installedModels && installedModels.some((m) => m.name === model)) {
@ -65,32 +109,67 @@ export class OllamaService {
return { success: true, message: 'Model is already installed.' }
}
// Returns AbortableAsyncIterator<ProgressResponse>
const downloadStream = await this.ollama.pull({
model,
stream: true,
})
for await (const chunk of downloadStream) {
if (chunk.completed && chunk.total) {
const percent = ((chunk.completed / chunk.total) * 100).toFixed(2)
const percentNum = parseFloat(percent)
this.broadcastDownloadProgress(model, percentNum)
if (progressCallback) {
progressCallback(percentNum)
}
// Model pulling is an Ollama-only operation. Non-Ollama backends (LM Studio, llama.cpp, etc.)
// return HTTP 200 for unknown endpoints, so the pull would appear to succeed but do nothing.
if (this.isOllamaNative === false) {
logger.warn(
`[OllamaService] Non-Ollama backend detected — skipping model pull for "${model}". Load the model manually in your AI host.`
)
return {
success: false,
message: `Model "${model}" is not available in your AI host. Please load it manually (model pulling is only supported for Ollama backends).`,
}
}
// Stream pull via Ollama native API
const pullResponse = await axios.post(
`${this.baseUrl}/api/pull`,
{ model, stream: true },
{ responseType: 'stream', timeout: 0 }
)
await new Promise<void>((resolve, reject) => {
let buffer = ''
pullResponse.data.on('data', (chunk: Buffer) => {
buffer += chunk.toString()
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.trim()) continue
try {
const parsed = JSON.parse(line)
if (parsed.completed && parsed.total) {
const percent = parseFloat(((parsed.completed / parsed.total) * 100).toFixed(2))
this.broadcastDownloadProgress(model, percent)
if (progressCallback) progressCallback(percent)
}
} catch {
// ignore parse errors on partial lines
}
}
})
pullResponse.data.on('end', resolve)
pullResponse.data.on('error', reject)
})
logger.info(`[OllamaService] Model "${model}" downloaded successfully.`)
return { success: true, message: 'Model downloaded successfully.' }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logger.error(
`[OllamaService] Failed to download model "${model}": ${error instanceof Error ? error.message : error
}`
`[OllamaService] Failed to download model "${model}": ${errorMessage}`
)
return { success: false, message: 'Failed to download model.' }
// Check for version mismatch (Ollama 412 response)
const isVersionMismatch = errorMessage.includes('newer version of Ollama')
const userMessage = isVersionMismatch
? 'This model requires a newer version of Ollama. Please update AI Assistant from the Apps page.'
: `Failed to download model: ${errorMessage}`
// Broadcast failure to connected clients so UI can show the error
this.broadcastDownloadError(model, userMessage)
return { success: false, message: userMessage, retryable: !isVersionMismatch }
}
}
@ -118,88 +197,257 @@ export class OllamaService {
}
}
public async getClient() {
public async chat(chatRequest: ChatInput): Promise<NomadChatResponse> {
await this._ensureDependencies()
return this.ollama!
}
public async chat(chatRequest: ChatRequest & { stream?: boolean }) {
await this._ensureDependencies()
if (!this.ollama) {
throw new Error('Ollama client is not initialized.')
if (!this.openai) {
throw new Error('AI client is not initialized.')
}
return await this.ollama.chat({
...chatRequest,
const params: any = {
model: chatRequest.model,
messages: chatRequest.messages as ChatCompletionMessageParam[],
stream: false,
})
}
if (chatRequest.think) {
params.think = chatRequest.think
}
if (chatRequest.numCtx) {
params.num_ctx = chatRequest.numCtx
}
const response = await this.openai.chat.completions.create(params)
const choice = response.choices[0]
return {
message: {
content: choice.message.content ?? '',
thinking: (choice.message as any).thinking ?? undefined,
},
done: true,
model: response.model,
}
}
public async chatStream(chatRequest: ChatRequest) {
public async chatStream(chatRequest: ChatInput): Promise<AsyncIterable<NomadChatStreamChunk>> {
await this._ensureDependencies()
if (!this.ollama) {
throw new Error('Ollama client is not initialized.')
if (!this.openai) {
throw new Error('AI client is not initialized.')
}
return await this.ollama.chat({
...chatRequest,
const params: any = {
model: chatRequest.model,
messages: chatRequest.messages as ChatCompletionMessageParam[],
stream: true,
})
}
if (chatRequest.think) {
params.think = chatRequest.think
}
if (chatRequest.numCtx) {
params.num_ctx = chatRequest.numCtx
}
const stream = (await this.openai.chat.completions.create(params)) as unknown as Stream<ChatCompletionChunk>
// Returns how many trailing chars of `text` could be the start of `tag`
function partialTagSuffix(tag: string, text: string): number {
for (let len = Math.min(tag.length - 1, text.length); len >= 1; len--) {
if (text.endsWith(tag.slice(0, len))) return len
}
return 0
}
async function* normalize(): AsyncGenerator<NomadChatStreamChunk> {
// Stateful parser for <think>...</think> tags that may be split across chunks.
// Ollama provides thinking natively via delta.thinking; OpenAI-compatible backends
// (LM Studio, llama.cpp, etc.) embed them inline in delta.content.
let tagBuffer = ''
let inThink = false
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
const nativeThinking: string = (delta as any)?.thinking ?? ''
const rawContent: string = delta?.content ?? ''
// Parse <think> tags out of the content stream
tagBuffer += rawContent
let parsedContent = ''
let parsedThinking = ''
while (tagBuffer.length > 0) {
if (inThink) {
const closeIdx = tagBuffer.indexOf('</think>')
if (closeIdx !== -1) {
parsedThinking += tagBuffer.slice(0, closeIdx)
tagBuffer = tagBuffer.slice(closeIdx + 8)
inThink = false
} else {
const hold = partialTagSuffix('</think>', tagBuffer)
parsedThinking += tagBuffer.slice(0, tagBuffer.length - hold)
tagBuffer = tagBuffer.slice(tagBuffer.length - hold)
break
}
} else {
const openIdx = tagBuffer.indexOf('<think>')
if (openIdx !== -1) {
parsedContent += tagBuffer.slice(0, openIdx)
tagBuffer = tagBuffer.slice(openIdx + 7)
inThink = true
} else {
const hold = partialTagSuffix('<think>', tagBuffer)
parsedContent += tagBuffer.slice(0, tagBuffer.length - hold)
tagBuffer = tagBuffer.slice(tagBuffer.length - hold)
break
}
}
}
yield {
message: {
content: parsedContent,
thinking: nativeThinking + parsedThinking,
},
done: chunk.choices[0]?.finish_reason !== null && chunk.choices[0]?.finish_reason !== undefined,
}
}
}
return normalize()
}
public async checkModelHasThinking(modelName: string): Promise<boolean> {
await this._ensureDependencies()
if (!this.ollama) {
throw new Error('Ollama client is not initialized.')
if (!this.baseUrl) return false
try {
const response = await axios.post(
`${this.baseUrl}/api/show`,
{ model: modelName },
{ timeout: 5000 }
)
return Array.isArray(response.data?.capabilities) && response.data.capabilities.includes('thinking')
} catch {
// Non-Ollama backends don't expose /api/show — assume no thinking support
return false
}
const modelInfo = await this.ollama.show({
model: modelName,
})
return modelInfo.capabilities.includes('thinking')
}
public async deleteModel(modelName: string) {
public async deleteModel(modelName: string): Promise<{ success: boolean; message: string }> {
await this._ensureDependencies()
if (!this.ollama) {
throw new Error('Ollama client is not initialized.')
if (!this.baseUrl) {
return { success: false, message: 'AI service is not initialized.' }
}
return await this.ollama.delete({
model: modelName,
})
try {
await axios.delete(`${this.baseUrl}/api/delete`, {
data: { model: modelName },
timeout: 10000,
})
return { success: true, message: `Model "${modelName}" deleted.` }
} catch (error) {
logger.error(
`[OllamaService] Failed to delete model "${modelName}": ${error instanceof Error ? error.message : error}`
)
return { success: false, message: 'Failed to delete model. This may not be an Ollama backend.' }
}
}
public async getModels(includeEmbeddings = false) {
/**
* Generate embeddings for the given input strings.
* Tries the Ollama native /api/embed endpoint first, falls back to /v1/embeddings.
*/
public async embed(model: string, input: string[]): Promise<{ embeddings: number[][] }> {
await this._ensureDependencies()
if (!this.ollama) {
throw new Error('Ollama client is not initialized.')
if (!this.baseUrl || !this.openai) {
throw new Error('AI service is not initialized.')
}
const response = await this.ollama.list()
if (includeEmbeddings) {
return response.models
try {
// Prefer Ollama native endpoint (supports batch input natively)
const response = await axios.post(
`${this.baseUrl}/api/embed`,
{ model, input },
{ timeout: 60000 }
)
// Some backends (e.g. LM Studio) return HTTP 200 for unknown endpoints with an incompatible
// body — validate explicitly before accepting the result.
if (!Array.isArray(response.data?.embeddings)) {
throw new Error('Invalid /api/embed response — missing embeddings array')
}
return { embeddings: response.data.embeddings }
} catch {
// Fall back to OpenAI-compatible /v1/embeddings
// Explicitly request float format — some backends (e.g. LM Studio) don't reliably
// implement the base64 encoding the OpenAI SDK requests by default.
logger.info('[OllamaService] /api/embed unavailable, falling back to /v1/embeddings')
const results = await this.openai.embeddings.create({ model, input, encoding_format: 'float' })
return { embeddings: results.data.map((e) => e.embedding as number[]) }
}
}
public async getModels(includeEmbeddings = false): Promise<NomadInstalledModel[]> {
await this._ensureDependencies()
if (!this.baseUrl) {
throw new Error('AI service is not initialized.')
}
try {
// Prefer the Ollama native endpoint which includes size and metadata
const response = await axios.get(`${this.baseUrl}/api/tags`, { timeout: 5000 })
// LM Studio returns HTTP 200 for unknown endpoints with an incompatible body — validate explicitly
if (!Array.isArray(response.data?.models)) {
throw new Error('Not an Ollama-compatible /api/tags response')
}
this.isOllamaNative = true
const models: NomadInstalledModel[] = response.data.models
if (includeEmbeddings) return models
return models.filter((m) => !m.name.includes('embed'))
} catch {
// Fall back to the OpenAI-compatible /v1/models endpoint (LM Studio, llama.cpp, etc.)
this.isOllamaNative = false
logger.info('[OllamaService] /api/tags unavailable, falling back to /v1/models')
try {
const modelList = await this.openai!.models.list()
const models: NomadInstalledModel[] = modelList.data.map((m) => ({ name: m.id, size: 0 }))
if (includeEmbeddings) return models
return models.filter((m) => !m.name.includes('embed'))
} catch (err) {
logger.error(
`[OllamaService] Failed to list models: ${err instanceof Error ? err.message : err}`
)
return []
}
}
// Filter out embedding models
return response.models.filter((model) => !model.name.includes('embed'))
}
async getAvailableModels(
{ sort, recommendedOnly, query, limit, force }: { sort?: 'pulls' | 'name'; recommendedOnly?: boolean, query: string | null, limit?: number, force?: boolean } = {
{
sort,
recommendedOnly,
query,
limit,
force,
}: {
sort?: 'pulls' | 'name'
recommendedOnly?: boolean
query: string | null
limit?: number
force?: boolean
} = {
sort: 'pulls',
recommendedOnly: false,
query: null,
limit: 15,
}
): Promise<{ models: NomadOllamaModel[], hasMore: boolean } | null> {
): Promise<{ models: NomadOllamaModel[]; hasMore: boolean } | null> {
try {
const models = await this.retrieveAndRefreshModels(sort, force)
if (!models) {
// If we fail to get models from the API, return the fallback recommended models
logger.warn(
'[OllamaService] Returning fallback recommended models due to failure in fetching available models'
)
return {
models: FALLBACK_RECOMMENDED_OLLAMA_MODELS,
hasMore: false
hasMore: false,
}
}
@ -207,15 +455,13 @@ export class OllamaService {
const filteredModels = query ? this.fuseSearchModels(models, query) : models
return {
models: filteredModels.slice(0, limit || 15),
hasMore: filteredModels.length > (limit || 15)
hasMore: filteredModels.length > (limit || 15),
}
}
// If recommendedOnly is true, only return the first three models (if sorted by pulls, these will be the top 3)
const sortedByPulls = sort === 'pulls' ? models : this.sortModels(models, 'pulls')
const firstThree = sortedByPulls.slice(0, 3)
// Only return the first tag of each of these models (should be the most lightweight variant)
const recommendedModels = firstThree.map((model) => {
return {
...model,
@ -227,13 +473,13 @@ export class OllamaService {
const filteredRecommendedModels = this.fuseSearchModels(recommendedModels, query)
return {
models: filteredRecommendedModels,
hasMore: filteredRecommendedModels.length > (limit || 15)
hasMore: filteredRecommendedModels.length > (limit || 15),
}
}
return {
models: recommendedModels,
hasMore: recommendedModels.length > (limit || 15)
hasMore: recommendedModels.length > (limit || 15),
}
} catch (error) {
logger.error(
@ -273,7 +519,6 @@ export class OllamaService {
const rawModels = response.data.models as NomadOllamaModel[]
// Filter out tags where cloud is truthy, then remove models with no remaining tags
const noCloud = rawModels
.map((model) => ({
...model,
@ -285,8 +530,7 @@ export class OllamaService {
return this.sortModels(noCloud, sort)
} catch (error) {
logger.error(
`[OllamaService] Failed to retrieve models from Nomad API: ${error instanceof Error ? error.message : error
}`
`[OllamaService] Failed to retrieve models from Nomad API: ${error instanceof Error ? error.message : error}`
)
return null
}
@ -312,7 +556,6 @@ export class OllamaService {
return models
} catch (error) {
// Cache doesn't exist or is invalid
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.warn(
`[OllamaService] Error reading cache: ${error instanceof Error ? error.message : error}`
@ -336,7 +579,6 @@ export class OllamaService {
private sortModels(models: NomadOllamaModel[], sort?: 'pulls' | 'name'): NomadOllamaModel[] {
if (sort === 'pulls') {
// Sort by estimated pulls (it should be a string like "1.2K", "500", "4M" etc.)
models.sort((a, b) => {
const parsePulls = (pulls: string) => {
const multiplier = pulls.endsWith('K')
@ -354,8 +596,6 @@ export class OllamaService {
models.sort((a, b) => a.name.localeCompare(b.name))
}
// Always sort model.tags by the size field in descending order
// Size is a string like '75GB', '8.5GB', '2GB' etc. Smaller models first
models.forEach((model) => {
if (model.tags && Array.isArray(model.tags)) {
model.tags.sort((a, b) => {
@ -368,7 +608,7 @@ export class OllamaService {
? 1
: size.endsWith('TB')
? 1_000
: 0 // Unknown size format
: 0
return parseFloat(size) * multiplier
}
return parseSize(a.size) - parseSize(b.size)
@ -379,6 +619,15 @@ export class OllamaService {
return models
}
private broadcastDownloadError(model: string, error: string) {
transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {
model,
percent: -1,
error,
timestamp: new Date().toISOString(),
})
}
private broadcastDownloadProgress(model: string, percent: number) {
transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {
model,
@ -392,11 +641,11 @@ export class OllamaService {
const options: IFuseOptions<NomadOllamaModel> = {
ignoreDiacritics: true,
keys: ['name', 'description', 'tags.name'],
threshold: 0.3, // lower threshold for stricter matching
threshold: 0.3,
}
const fuse = new Fuse(models, options)
return fuse.search(query).map(result => result.item)
return fuse.search(query).map((result) => result.item)
}
}

View File

@ -8,6 +8,8 @@ import { deleteFileIfExists, determineFileType, getFile, getFileStatsIfExists, l
import { PDFParse } from 'pdf-parse'
import { createWorker } from 'tesseract.js'
import { fromBuffer } from 'pdf2pic'
import JSZip from 'jszip'
import * as cheerio from 'cheerio'
import { OllamaService } from './ollama_service.js'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { removeStopwords } from 'stopword'
@ -23,15 +25,18 @@ export class RagService {
private qdrant: QdrantClient | null = null
private qdrantInitPromise: Promise<void> | null = null
private embeddingModelVerified = false
private resolvedEmbeddingModel: string | null = null
public static UPLOADS_STORAGE_PATH = 'storage/kb_uploads'
public static CONTENT_COLLECTION_NAME = 'nomad_knowledge_base'
public static EMBEDDING_MODEL = 'nomic-embed-text:v1.5'
public static EMBEDDING_DIMENSION = 768 // Nomic Embed Text v1.5 dimension is 768
public static MODEL_CONTEXT_LENGTH = 2048 // nomic-embed-text has 2K token context
public static MAX_SAFE_TOKENS = 1800 // Leave buffer for prefix and tokenization variance
public static TARGET_TOKENS_PER_CHUNK = 1700 // Target 1700 tokens per chunk for embedding
public static MAX_SAFE_TOKENS = 1600 // Leave buffer for prefix and tokenization variance
public static TARGET_TOKENS_PER_CHUNK = 1500 // Target 1500 tokens per chunk for embedding
public static PREFIX_TOKEN_BUDGET = 10 // Reserve ~10 tokens for prefixes
public static CHAR_TO_TOKEN_RATIO = 3 // Approximate chars per token
public static CHAR_TO_TOKEN_RATIO = 2 // Conservative chars-per-token estimate; technical docs
// (numbers, symbols, abbreviations) tokenize denser
// than plain prose (~3), so 2 avoids context overflows
// Nomic Embed Text v1.5 uses task-specific prefixes for optimal performance
public static SEARCH_DOCUMENT_PREFIX = 'search_document: '
public static SEARCH_QUERY_PREFIX = 'search_query: '
@ -245,7 +250,9 @@ export class RagService {
if (!this.embeddingModelVerified) {
const allModels = await this.ollamaService.getModels(true)
const embeddingModel = allModels.find((model) => model.name === RagService.EMBEDDING_MODEL)
const embeddingModel =
allModels.find((model) => model.name === RagService.EMBEDDING_MODEL) ??
allModels.find((model) => model.name.toLowerCase().includes('nomic-embed-text'))
if (!embeddingModel) {
try {
@ -262,6 +269,7 @@ export class RagService {
return null
}
}
this.resolvedEmbeddingModel = embeddingModel?.name ?? RagService.EMBEDDING_MODEL
this.embeddingModelVerified = true
}
@ -285,8 +293,6 @@ export class RagService {
// Extract text from chunk results
const chunks = chunkResults.map((chunk) => chunk.text)
const ollamaClient = await this.ollamaService.getClient()
// Prepare all chunk texts with prefix and truncation
const prefixedChunks: string[] = []
for (let i = 0; i < chunks.length; i++) {
@ -320,10 +326,7 @@ export class RagService {
logger.debug(`[RAG] Embedding batch ${batchIdx + 1}/${totalBatches} (${batch.length} chunks)`)
const response = await ollamaClient.embed({
model: RagService.EMBEDDING_MODEL,
input: batch,
})
const response = await this.ollamaService.embed(this.resolvedEmbeddingModel ?? RagService.EMBEDDING_MODEL, batch)
embeddings.push(...response.embeddings)
@ -564,6 +567,86 @@ export class RagService {
return await this.extractTXTText(fileBuffer)
}
/**
* Extract text content from an EPUB file.
* EPUBs are ZIP archives containing XHTML content files.
* Reads the OPF manifest to determine reading order, then extracts
* text from each content document in sequence.
*/
private async processEPUBFile(fileBuffer: Buffer): Promise<string> {
const zip = await JSZip.loadAsync(fileBuffer)
// Read container.xml to find the OPF file path
const containerXml = await zip.file('META-INF/container.xml')?.async('text')
if (!containerXml) {
throw new Error('Invalid EPUB: missing META-INF/container.xml')
}
// Parse container.xml to get the OPF rootfile path
const $container = cheerio.load(containerXml, { xml: true })
const opfPath = $container('rootfile').attr('full-path')
if (!opfPath) {
throw new Error('Invalid EPUB: no rootfile found in container.xml')
}
// Determine the base directory of the OPF file for resolving relative paths
const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : ''
// Read and parse the OPF file
const opfContent = await zip.file(opfPath)?.async('text')
if (!opfContent) {
throw new Error(`Invalid EPUB: OPF file not found at ${opfPath}`)
}
const $opf = cheerio.load(opfContent, { xml: true })
// Build a map of manifest items (id -> href)
const manifestItems = new Map<string, string>()
$opf('manifest item').each((_, el) => {
const id = $opf(el).attr('id')
const href = $opf(el).attr('href')
const mediaType = $opf(el).attr('media-type') || ''
// Only include XHTML/HTML content documents
if (id && href && (mediaType.includes('html') || mediaType.includes('xml'))) {
manifestItems.set(id, href)
}
})
// Get the reading order from the spine
const spineOrder: string[] = []
$opf('spine itemref').each((_, el) => {
const idref = $opf(el).attr('idref')
if (idref && manifestItems.has(idref)) {
spineOrder.push(manifestItems.get(idref)!)
}
})
// If no spine found, fall back to all manifest items
const contentFiles = spineOrder.length > 0
? spineOrder
: Array.from(manifestItems.values())
// Extract text from each content file in order
const textParts: string[] = []
for (const href of contentFiles) {
const fullPath = opfDir + href
const content = await zip.file(fullPath)?.async('text')
if (content) {
const $ = cheerio.load(content)
// Remove script and style elements
$('script, style').remove()
const text = $('body').text().trim()
if (text) {
textParts.push(text)
}
}
}
const fullText = textParts.join('\n\n')
logger.debug(`[RAG] EPUB extracted ${textParts.length} chapters, ${fullText.length} characters total`)
return fullText
}
private async embedTextAndCleanup(
extractedText: string,
filepath: string,
@ -638,6 +721,9 @@ export class RagService {
case 'pdf':
extractedText = await this.processPDFFile(fileBuffer!)
break
case 'epub':
extractedText = await this.processEPUBFile(fileBuffer!)
break
case 'text':
default:
extractedText = await this.processTextFile(fileBuffer!)
@ -692,7 +778,9 @@ export class RagService {
if (!this.embeddingModelVerified) {
const allModels = await this.ollamaService.getModels(true)
const embeddingModel = allModels.find((model) => model.name === RagService.EMBEDDING_MODEL)
const embeddingModel =
allModels.find((model) => model.name === RagService.EMBEDDING_MODEL) ??
allModels.find((model) => model.name.toLowerCase().includes('nomic-embed-text'))
if (!embeddingModel) {
logger.warn(
@ -701,6 +789,7 @@ export class RagService {
this.embeddingModelVerified = false
return []
}
this.resolvedEmbeddingModel = embeddingModel.name
this.embeddingModelVerified = true
}
@ -710,8 +799,6 @@ export class RagService {
logger.debug(`[RAG] Extracted keywords: [${keywords.join(', ')}]`)
// Generate embedding for the query with search_query prefix
const ollamaClient = await this.ollamaService.getClient()
// Ensure query doesn't exceed token limit
const prefixTokens = this.estimateTokenCount(RagService.SEARCH_QUERY_PREFIX)
const maxQueryTokens = RagService.MAX_SAFE_TOKENS - prefixTokens
@ -729,10 +816,7 @@ export class RagService {
return []
}
const response = await ollamaClient.embed({
model: RagService.EMBEDDING_MODEL,
input: [prefixedQuery],
})
const response = await this.ollamaService.embed(this.resolvedEmbeddingModel ?? RagService.EMBEDDING_MODEL, [prefixedQuery])
// Perform semantic search with a higher limit to enable reranking
const searchLimit = limit * 3 // Get more results for reranking

View File

@ -4,28 +4,33 @@ import { DockerService } from '#services/docker_service'
import { ServiceSlim } from '../../types/services.js'
import logger from '@adonisjs/core/services/logger'
import si from 'systeminformation'
import { GpuHealthStatus, NomadDiskInfo, NomadDiskInfoRaw, SystemInformationResponse } from '../../types/system.js'
import {
GpuHealthStatus,
NomadDiskInfo,
NomadDiskInfoRaw,
SystemInformationResponse,
} from '../../types/system.js'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { readFileSync } from 'fs'
import path, { join } from 'path'
import { readFileSync } from 'node:fs'
import path, { join } from 'node:path'
import { getAllFilesystems, getFile } from '../utils/fs.js'
import axios from 'axios'
import env from '#start/env'
import KVStore from '#models/kv_store'
import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js'
import { isNewerVersion } from '../utils/version.js'
import { invalidateAssistantNameCache } from '../../config/inertia.js'
@inject()
export class SystemService {
private static appVersion: string | null = null
private static diskInfoFile = '/storage/nomad-disk-info.json'
constructor(private dockerService: DockerService) { }
constructor(private dockerService: DockerService) {}
async checkServiceInstalled(serviceName: string): Promise<boolean> {
const services = await this.getServices({ installedOnly: true });
return services.some(service => service.service_name === serviceName);
const services = await this.getServices({ installedOnly: true })
return services.some((service) => service.service_name === serviceName)
}
async getInternetStatus(): Promise<boolean> {
@ -67,14 +72,20 @@ export class SystemService {
return false
}
async getNvidiaSmiInfo(): Promise<Array<{ vendor: string; model: string; vram: number; }> | { error: string } | 'OLLAMA_NOT_FOUND' | 'BAD_RESPONSE' | 'UNKNOWN_ERROR'> {
async getNvidiaSmiInfo(): Promise<
| Array<{ vendor: string; model: string; vram: number }>
| { error: string }
| 'OLLAMA_NOT_FOUND'
| 'BAD_RESPONSE'
| 'UNKNOWN_ERROR'
> {
try {
const containers = await this.dockerService.docker.listContainers({ all: false })
const ollamaContainer = containers.find((c) =>
c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`)
)
const ollamaContainer = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`))
if (!ollamaContainer) {
logger.info('Ollama container not found for nvidia-smi info retrieval. This is expected if Ollama is not installed.')
logger.info(
'Ollama container not found for nvidia-smi info retrieval. This is expected if Ollama is not installed.'
)
return 'OLLAMA_NOT_FOUND'
}
@ -92,23 +103,35 @@ export class SystemService {
const output = await new Promise<string>((resolve) => {
let data = ''
const timeout = setTimeout(() => resolve(data), 5000)
stream.on('data', (chunk: Buffer) => { data += chunk.toString() })
stream.on('end', () => { clearTimeout(timeout); resolve(data) })
stream.on('data', (chunk: Buffer) => {
data += chunk.toString()
})
stream.on('end', () => {
clearTimeout(timeout)
resolve(data)
})
})
// Remove any non-printable characters and trim the output
const cleaned = output.replace(/[\x00-\x08]/g, '').trim()
if (cleaned && !cleaned.toLowerCase().includes('error') && !cleaned.toLowerCase().includes('not found')) {
const cleaned = Array.from(output)
.filter((character) => character.charCodeAt(0) > 8)
.join('')
.trim()
if (
cleaned &&
!cleaned.toLowerCase().includes('error') &&
!cleaned.toLowerCase().includes('not found')
) {
// Split by newlines to handle multiple GPUs installed
const lines = cleaned.split('\n').filter(line => line.trim())
const lines = cleaned.split('\n').filter((line) => line.trim())
// Map each line out to a useful structure for us
const gpus = lines.map(line => {
const gpus = lines.map((line) => {
const parts = line.split(',').map((s) => s.trim())
return {
vendor: 'NVIDIA',
model: parts[0] || 'NVIDIA GPU',
vram: parts[1] ? parseInt(parts[1], 10) : 0,
vram: parts[1] ? Number.parseInt(parts[1], 10) : 0,
}
})
@ -117,8 +140,7 @@ export class SystemService {
// If we got output but looks like an error, consider it a bad response from nvidia-smi
return 'BAD_RESPONSE'
}
catch (error) {
} catch (error) {
logger.error('Error getting nvidia-smi info:', error)
if (error instanceof Error && error.message) {
return { error: error.message }
@ -127,8 +149,65 @@ export class SystemService {
}
}
async getExternalOllamaGpuInfo(): Promise<Array<{
vendor: string
model: string
vram: number
}> | null> {
try {
// If a remote Ollama URL is configured, use it directly without requiring a local container
const remoteOllamaUrl = await KVStore.getValue('ai.remoteOllamaUrl')
if (!remoteOllamaUrl) {
const containers = await this.dockerService.docker.listContainers({ all: false })
const ollamaContainer = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`))
if (!ollamaContainer) {
return null
}
const actualImage = (ollamaContainer.Image || '').toLowerCase()
if (actualImage.includes('ollama/ollama') || actualImage.startsWith('ollama:')) {
return null
}
}
const ollamaUrl = remoteOllamaUrl || (await this.dockerService.getServiceURL(SERVICE_NAMES.OLLAMA))
if (!ollamaUrl) {
return null
}
await axios.get(new URL('/api/tags', ollamaUrl).toString(), { timeout: 3000 })
let vramMb = 0
try {
const psResponse = await axios.get(new URL('/api/ps', ollamaUrl).toString(), {
timeout: 3000,
})
const loadedModels = Array.isArray(psResponse.data?.models) ? psResponse.data.models : []
const largestAllocation = loadedModels.reduce(
(max: number, model: { size_vram?: number | string }) =>
Math.max(max, Number(model.size_vram) || 0),
0
)
vramMb = largestAllocation > 0 ? Math.round(largestAllocation / (1024 * 1024)) : 0
} catch {}
return [
{
vendor: 'NVIDIA',
model: 'NVIDIA GPU (external Ollama)',
vram: vramMb,
},
]
} catch (error) {
logger.info(
`[SystemService] External Ollama GPU probe failed: ${error instanceof Error ? error.message : error}`
)
return null
}
}
async getServices({ installedOnly = true }: { installedOnly?: boolean }): Promise<ServiceSlim[]> {
await this._syncContainersWithDatabase() // Sync up before fetching to ensure we have the latest status
const statuses = await this._syncContainersWithDatabase() // Sync and reuse the fetched status list
const query = Service.query()
.orderBy('display_order', 'asc')
@ -157,8 +236,6 @@ export class SystemService {
return []
}
const statuses = await this.dockerService.getServicesStatus()
const toReturn: ServiceSlim[] = []
for (const service of services) {
@ -273,17 +350,46 @@ export class SystemService {
graphics.controllers = nvidiaInfo.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: "",
bus: '',
vram: gpu.vram,
vramDynamic: false, // assume false here, we don't actually use this field for our purposes.
}))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else if (nvidiaInfo === 'OLLAMA_NOT_FOUND') {
gpuHealth.status = 'ollama_not_installed'
// No local Ollama container — check if a remote Ollama URL is configured
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
if (externalOllamaGpu) {
graphics.controllers = externalOllamaGpu.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false,
}))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else {
gpuHealth.status = 'ollama_not_installed'
}
} else {
gpuHealth.status = 'passthrough_failed'
logger.warn(`NVIDIA runtime detected but GPU passthrough failed: ${typeof nvidiaInfo === 'string' ? nvidiaInfo : JSON.stringify(nvidiaInfo)}`)
const externalOllamaGpu = await this.getExternalOllamaGpuInfo()
if (externalOllamaGpu) {
graphics.controllers = externalOllamaGpu.map((gpu) => ({
model: gpu.model,
vendor: gpu.vendor,
bus: '',
vram: gpu.vram,
vramDynamic: false,
}))
gpuHealth.status = 'ok'
gpuHealth.ollamaGpuAccessible = true
} else {
gpuHealth.status = 'passthrough_failed'
logger.warn(
`NVIDIA runtime detected but GPU passthrough failed: ${typeof nvidiaInfo === 'string' ? nvidiaInfo : JSON.stringify(nvidiaInfo)}`
)
}
}
}
} else {
@ -356,9 +462,10 @@ export class SystemService {
logger.info(`Current version: ${currentVersion}, Latest version: ${latestVersion}`)
const updateAvailable = process.env.NODE_ENV === 'development'
? false
: isNewerVersion(latestVersion, currentVersion.trim(), earlyAccess)
const updateAvailable =
process.env.NODE_ENV === 'development'
? false
: isNewerVersion(latestVersion, currentVersion.trim(), earlyAccess)
// Cache the results in KVStore for frontend checks
await KVStore.setValue('system.updateAvailable', updateAvailable)
@ -518,15 +625,21 @@ export class SystemService {
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]
}
async updateSetting(key: KVStoreKey, value: any): Promise<void> {
if ((value === '' || value === undefined || value === null) && KV_STORE_SCHEMA[key] === 'string') {
if (
(value === '' || value === undefined || value === null) &&
KV_STORE_SCHEMA[key] === 'string'
) {
await KVStore.clearValue(key)
} else {
await KVStore.setValue(key, value)
}
if (key === 'ai.assistantCustomName') {
invalidateAssistantNameCache()
}
}
/**
@ -534,8 +647,9 @@ export class SystemService {
* It will mark services as not installed if their corresponding containers do not exist, regardless of their running state.
* Handles cases where a container might have been manually removed, ensuring the database reflects the actual existence of containers.
* Containers that exist but are stopped, paused, or restarting will still be considered installed.
* Returns the fetched service status list so callers can reuse it without a second Docker API call.
*/
private async _syncContainersWithDatabase() {
private async _syncContainersWithDatabase(): Promise<{ service_name: string; status: string }[]> {
try {
const allServices = await Service.all()
const serviceStatusList = await this.dockerService.getServicesStatus()
@ -548,6 +662,11 @@ export class SystemService {
if (service.installed) {
// If marked as installed but container doesn't exist, mark as not installed
if (!containerExists) {
// Exception: remote Ollama is configured without a local container — don't reset it
if (service.service_name === SERVICE_NAMES.OLLAMA) {
const remoteUrl = await KVStore.getValue('ai.remoteOllamaUrl')
if (remoteUrl) continue
}
logger.warn(
`Service ${service.service_name} is marked as installed but container does not exist. Marking as not installed.`
)
@ -567,8 +686,11 @@ export class SystemService {
}
}
}
return serviceStatusList
} catch (error) {
logger.error('Error syncing containers with database:', error)
return []
}
}
@ -579,10 +701,21 @@ export class SystemService {
return []
}
// Deduplicate: same device path mounted in multiple places (Docker bind-mounts)
// Keep the entry with the largest size — that's the real partition
const deduped = new Map<string, NomadDiskInfoRaw['fsSize'][0]>()
for (const entry of fsSize) {
const existing = deduped.get(entry.fs)
if (!existing || entry.size > existing.size) {
deduped.set(entry.fs, entry)
}
}
const dedupedFsSize = Array.from(deduped.values())
return diskLayout.blockdevices
.filter((disk) => disk.type === 'disk') // Only physical disks
.map((disk) => {
const filesystems = getAllFilesystems(disk, fsSize)
const filesystems = getAllFilesystems(disk, dedupedFsSize)
// Across all partitions
const totalUsed = filesystems.reduce((sum, p) => sum + (p.used || 0), 0)
@ -609,5 +742,4 @@ export class SystemService {
}
})
}
}

View File

@ -25,6 +25,7 @@ import InstalledResource from '#models/installed_resource'
import { RunDownloadJob } from '#jobs/run_download_job'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { CollectionManifestService } from './collection_manifest_service.js'
import { KiwixLibraryService } from './kiwix_library_service.js'
import type { CategoryWithStatus } from '../../types/collections.js'
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
@ -137,13 +138,13 @@ export class ZimService {
}
}
async downloadRemote(url: string): Promise<{ filename: string; jobId?: string }> {
async downloadRemote(url: string, metadata?: { title?: string; summary?: string; author?: string; size_bytes?: number }): Promise<{ filename: string; jobId?: string }> {
const parsed = new URL(url)
if (!parsed.pathname.endsWith('.zim')) {
throw new Error(`Invalid ZIM file URL: ${url}. URL must end with .zim`)
}
const existing = await RunDownloadJob.getByUrl(url)
const existing = await RunDownloadJob.getActiveByUrl(url)
if (existing) {
throw new Error('A download for this URL is already in progress')
}
@ -170,6 +171,8 @@ export class ZimService {
allowedMimeTypes: ZIM_MIME_TYPES,
forceNew: true,
filetype: 'zim',
title: metadata?.title,
totalBytes: metadata?.size_bytes,
resourceMetadata,
})
@ -219,7 +222,7 @@ export class ZimService {
const downloadFilenames: string[] = []
for (const resource of toDownload) {
const existingJob = await RunDownloadJob.getByUrl(resource.url)
const existingJob = await RunDownloadJob.getActiveByUrl(resource.url)
if (existingJob) {
logger.warn(`[ZimService] Download already in progress for ${resource.url}, skipping.`)
continue
@ -238,6 +241,8 @@ export class ZimService {
allowedMimeTypes: ZIM_MIME_TYPES,
forceNew: true,
filetype: 'zim',
title: (resource as any).title || undefined,
totalBytes: (resource as any).size_mb ? (resource as any).size_mb * 1024 * 1024 : undefined,
resourceMetadata: {
resource_id: resource.id,
version: resource.version,
@ -256,6 +261,17 @@ export class ZimService {
await this.onWikipediaDownloadComplete(url, true)
}
}
// Update the kiwix library XML after all downloaded ZIM files are in place.
// This covers all ZIM types including Wikipedia. Rebuilding once from disk
// avoids repeated XML parse/write cycles and reduces the chance of write races
// when multiple download jobs complete concurrently.
const kiwixLibraryService = new KiwixLibraryService()
try {
await kiwixLibraryService.rebuildFromDisk()
} catch (err) {
logger.error('[ZimService] Failed to rebuild kiwix library from disk:', err)
}
if (restart) {
// Check if there are any remaining ZIM download jobs before restarting
@ -272,7 +288,9 @@ export class ZimService {
// Filter out completed jobs (progress === 100) to avoid race condition
// where this job itself is still in the active queue
const activeIncompleteJobs = activeJobs.filter((job) => {
const progress = typeof job.progress === 'number' ? job.progress : 0
const progress = typeof job.progress === 'object' && job.progress !== null
? (job.progress as any).percent
: typeof job.progress === 'number' ? job.progress : 0
return progress < 100
})
@ -283,13 +301,20 @@ export class ZimService {
if (hasRemainingZimJobs) {
logger.info('[ZimService] Skipping container restart - more ZIM downloads pending')
} else {
// Restart KIWIX container to pick up new ZIM file
logger.info('[ZimService] No more ZIM downloads pending - restarting KIWIX container')
await this.dockerService
.affectContainer(SERVICE_NAMES.KIWIX, 'restart')
.catch((error) => {
logger.error(`[ZimService] Failed to restart KIWIX container:`, error) // Don't stop the download completion, just log the error.
})
// If kiwix is already running in library mode, --monitorLibrary will pick up
// the XML change automatically — no restart needed.
const isLegacy = await this.dockerService.isKiwixOnLegacyConfig()
if (!isLegacy) {
logger.info('[ZimService] Kiwix is in library mode — XML updated, no container restart needed.')
} else {
// Legacy config: restart (affectContainer will trigger migration instead)
logger.info('[ZimService] No more ZIM downloads pending - restarting KIWIX container')
await this.dockerService
.affectContainer(SERVICE_NAMES.KIWIX, 'restart')
.catch((error) => {
logger.error(`[ZimService] Failed to restart KIWIX container:`, error)
})
}
}
}
@ -347,6 +372,12 @@ export class ZimService {
await deleteFileIfExists(fullPath)
// Remove from kiwix library XML so --monitorLibrary stops serving the deleted file
const kiwixLibraryService = new KiwixLibraryService()
await kiwixLibraryService.removeBook(fileName).catch((err) => {
logger.error(`[ZimService] Failed to remove ${fileName} from kiwix library:`, err)
})
// Clean up InstalledResource entry
const parsed = CollectionManifestService.parseZimFilename(fileName)
if (parsed) {
@ -458,7 +489,7 @@ export class ZimService {
}
// Check if already downloading
const existingJob = await RunDownloadJob.getByUrl(selectedOption.url)
const existingJob = await RunDownloadJob.getActiveByUrl(selectedOption.url)
if (existingJob) {
return { success: false, message: 'Download already in progress' }
}
@ -497,6 +528,8 @@ export class ZimService {
allowedMimeTypes: ZIM_MIME_TYPES,
forceNew: true,
filetype: 'zim',
title: selectedOption.name,
totalBytes: selectedOption.size_mb ? selectedOption.size_mb * 1024 * 1024 : undefined,
})
if (!result || !result.job) {

View File

@ -5,6 +5,7 @@ import { createReadStream } from 'fs'
import { LSBlockDevice, NomadDiskInfoRaw } from '../../types/system.js'
export const ZIM_STORAGE_PATH = '/storage/zim'
export const KIWIX_LIBRARY_XML_PATH = '/storage/zim/kiwix-library.xml'
export async function listDirectoryContents(path: string): Promise<FileEntry[]> {
const entries = await readdir(path, { withFileTypes: true })
@ -49,7 +50,7 @@ export async function listDirectoryContentsRecursive(path: string): Promise<File
export async function ensureDirectoryExists(path: string): Promise<void> {
try {
await stat(path)
} catch (error) {
} catch (error: any) {
if (error.code === 'ENOENT') {
await mkdir(path, { recursive: true })
}
@ -73,7 +74,7 @@ export async function getFile(
return createReadStream(path)
}
return await readFile(path)
} catch (error) {
} catch (error: any) {
if (error.code === 'ENOENT') {
return null
}
@ -90,7 +91,7 @@ export async function getFileStatsIfExists(
size: stats.size,
modifiedTime: stats.mtime,
}
} catch (error) {
} catch (error: any) {
if (error.code === 'ENOENT') {
return null
}
@ -101,7 +102,7 @@ export async function getFileStatsIfExists(
export async function deleteFileIfExists(path: string): Promise<void> {
try {
await unlink(path)
} catch (error) {
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error
}
@ -138,21 +139,20 @@ export function matchesDevice(fsPath: string, deviceName: string): boolean {
// Remove /dev/ and /dev/mapper/ prefixes
const normalized = fsPath.replace('/dev/mapper/', '').replace('/dev/', '')
// Direct match
// Direct match (covers /dev/sda1 ↔ sda1, /dev/nvme0n1p1 ↔ nvme0n1p1)
if (normalized === deviceName) {
return true
}
// LVM volumes use dashes instead of slashes
// e.g., ubuntu--vg-ubuntu--lv matches the device name
if (fsPath.includes(deviceName)) {
// LVM/device-mapper: e.g., /dev/mapper/ubuntu--vg-ubuntu--lv contains "ubuntu--lv"
if (fsPath.startsWith('/dev/mapper/') && fsPath.includes(deviceName)) {
return true
}
return false
}
export function determineFileType(filename: string): 'image' | 'pdf' | 'text' | 'zim' | 'unknown' {
export function determineFileType(filename: string): 'image' | 'pdf' | 'text' | 'epub' | 'zim' | 'unknown' {
const ext = path.extname(filename).toLowerCase()
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'].includes(ext)) {
return 'image'
@ -160,6 +160,8 @@ export function determineFileType(filename: string): 'image' | 'pdf' | 'text' |
return 'pdf'
} else if (['.txt', '.md', '.docx', '.rtf'].includes(ext)) {
return 'text'
} else if (ext === '.epub') {
return 'epub'
} else if (ext === '.zim') {
return 'zim'
} else {

View File

@ -22,6 +22,8 @@ export function assertNotPrivateUrl(urlString: string): void {
/^169\.254\.\d+\.\d+$/, // Link-local / cloud metadata
/^\[::1\]$/,
/^\[?fe80:/i, // IPv6 link-local
/^\[::ffff:/i, // IPv4-mapped IPv6 (e.g. [::ffff:7f00:1] = 127.0.0.1)
/^\[::\]$/, // IPv6 all-zeros (equivalent to 0.0.0.0)
]
if (blockedPatterns.some((re) => re.test(hostname))) {

View File

@ -1,6 +1,9 @@
import vine from "@vinejs/vine";
import { SETTINGS_KEYS } from "../../constants/kv_store.js";
export const getSettingSchema = vine.compile(vine.object({
key: vine.enum(SETTINGS_KEYS),
}))
export const updateSettingSchema = vine.compile(vine.object({
key: vine.enum(SETTINGS_KEYS),

View File

@ -61,10 +61,17 @@ export default class QueueWork extends BaseCommand {
{
connection: queueConfig.connection,
concurrency: this.getConcurrencyForQueue(queueName),
lockDuration: 300000,
autorun: true,
}
)
// Required to prevent Node from treating BullMQ internal errors as unhandled
// EventEmitter errors that crash the process.
worker.on('error', (err) => {
this.logger.error(`[${queueName}] Worker error: ${err.message}`)
})
worker.on('failed', async (job, err) => {
this.logger.error(`[${queueName}] Job failed: ${job?.id}, Error: ${err.message}`)
@ -96,6 +103,15 @@ export default class QueueWork extends BaseCommand {
await CheckUpdateJob.scheduleNightly()
await CheckServiceUpdatesJob.scheduleNightly()
// Safety net: log unhandled rejections instead of crashing the worker process.
// Individual job errors are already caught by BullMQ; this catches anything that
// escapes (e.g. a fire-and-forget promise in a callback that rejects unexpectedly).
process.on('unhandledRejection', (reason) => {
this.logger.error(
`Unhandled promise rejection in worker process: ${reason instanceof Error ? reason.message : String(reason)}`
)
})
// Graceful shutdown for all workers
process.on('SIGTERM', async () => {
this.logger.info('SIGTERM received. Shutting down workers...')

View File

@ -47,7 +47,7 @@ const bodyParserConfig = defineConfig({
* Maximum limit of data to parse including all files
* and fields
*/
limit: '20mb',
limit: '110mb', // Set to 110MB to allow for some overhead beyond the 100MB file size limit
types: ['multipart/form-data'],
},
})

View File

@ -13,7 +13,12 @@ const dbConfig = defineConfig({
user: env.get('DB_USER'),
password: env.get('DB_PASSWORD'),
database: env.get('DB_DATABASE'),
ssl: env.get('DB_SSL') ?? true, // Default to true
ssl: env.get('DB_SSL') ? {} : false,
},
pool: {
min: 2,
max: 15,
acquireTimeoutMillis: 10000, // Fail fast (10s) instead of silently hanging for ~60s
},
migrations: {
naturalSort: true,

View File

@ -3,6 +3,12 @@ import { SystemService } from '#services/system_service'
import { defineConfig } from '@adonisjs/inertia'
import type { InferSharedProps } from '@adonisjs/inertia/types'
let _assistantNameCache: { value: string; expiresAt: number } | null = null
export function invalidateAssistantNameCache() {
_assistantNameCache = null
}
const inertiaConfig = defineConfig({
/**
* Path to the Edge view that will be used as the root view for Inertia responses
@ -16,8 +22,14 @@ const inertiaConfig = defineConfig({
appVersion: () => SystemService.getAppVersion(),
environment: process.env.NODE_ENV || 'production',
aiAssistantName: async () => {
const now = Date.now()
if (_assistantNameCache && now < _assistantNameCache.expiresAt) {
return _assistantNameCache.value
}
const customName = await KVStore.getValue('ai.assistantCustomName')
return (customName && customName.trim()) ? customName : 'AI Assistant'
const value = (customName && customName.trim()) ? customName : 'AI Assistant'
_assistantNameCache = { value, expiresAt: now + 60_000 }
return value
},
},

View File

@ -3,7 +3,7 @@ import { defineConfig } from '@adonisjs/transmit'
import { redis } from '@adonisjs/transmit/transports'
export default defineConfig({
pingInterval: false,
pingInterval: '30s',
transport: {
driver: redis({
host: env.get('REDIS_HOST'),

2
admin/constants/kiwix.ts Normal file
View File

@ -0,0 +1,2 @@
export const KIWIX_LIBRARY_CMD = '--library /data/kiwix-library.xml --monitorLibrary --address=all'

View File

@ -1,3 +1,3 @@
import { KVStoreKey } from "../types/kv_store.js";
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName'];
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName', 'ai.remoteOllamaUrl', 'ai.ollamaFlashAttention'];

View File

@ -0,0 +1,29 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
this.defer(async (db) => {
await db
.from(this.tableName)
.where('service_name', 'nomad_kiwix_server')
.whereRaw('`container_command` LIKE ?', ['%*.zim%'])
.update({
container_command: '--library /data/kiwix-library.xml --monitorLibrary --address=all',
})
})
}
async down() {
this.defer(async (db) => {
await db
.from(this.tableName)
.where('service_name', 'nomad_kiwix_server')
.where('container_command', '--library /data/kiwix-library.xml --monitorLibrary --address=all')
.update({
container_command: '*.zim --address=all',
})
})
}
}

View File

@ -0,0 +1,25 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'map_markers'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('name').notNullable()
table.double('longitude').notNullable()
table.double('latitude').notNullable()
table.string('color', 20).notNullable().defaultTo('orange')
table.string('marker_type', 20).notNullable().defaultTo('pin')
table.string('route_id').nullable()
table.integer('route_order').nullable()
table.text('notes').nullable()
table.timestamp('created_at')
table.timestamp('updated_at')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -3,6 +3,7 @@ import { BaseSeeder } from '@adonisjs/lucid/seeders'
import { ModelAttributes } from '@adonisjs/lucid/types/model'
import env from '#start/env'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
export default class ServiceSeeder extends BaseSeeder {
// Use environment variable with fallback to production default
@ -24,7 +25,7 @@ export default class ServiceSeeder extends BaseSeeder {
icon: 'IconBooks',
container_image: 'ghcr.io/kiwix/kiwix-serve:3.8.1',
source_repo: 'https://github.com/kiwix/kiwix-tools',
container_command: '*.zim --address=all',
container_command: KIWIX_LIBRARY_CMD,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
@ -70,7 +71,7 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 3,
description: 'Local AI chat that runs entirely on your hardware - no internet required',
icon: 'IconWand',
container_image: 'ollama/ollama:0.15.2',
container_image: 'ollama/ollama:0.18.1',
source_repo: 'https://github.com/ollama/ollama',
container_command: 'serve',
container_config: JSON.stringify({
@ -94,7 +95,7 @@ export default class ServiceSeeder extends BaseSeeder {
display_order: 11,
description: 'Swiss Army knife for data encoding, encryption, and analysis',
icon: 'IconChefHat',
container_image: 'ghcr.io/gchq/cyberchef:10.19.4',
container_image: 'ghcr.io/gchq/cyberchef:10.22.1',
source_repo: 'https://github.com/gchq/CyberChef',
container_command: null,
container_config: JSON.stringify({

200
admin/docs/api-reference.md Normal file
View File

@ -0,0 +1,200 @@
# API Reference
N.O.M.A.D. exposes a REST API for all operations. All endpoints are under `/api/` and return JSON.
---
## Conventions
**Base URL:** `http://<your-server>/api`
**Responses:**
- Success responses include `{ "success": true }` and an HTTP 2xx status
- Error responses return the appropriate HTTP status (400, 404, 409, 500) with an error message
- 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.
---
## 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 |
---
## 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

@ -10,14 +10,14 @@ If this is your first time using N.O.M.A.D., the Easy Setup wizard will help you
**[Launch Easy Setup →](/easy-setup)**
![Easy Setup Wizard — Step 1: Choose your capabilities](/docs/easy-setup-step1.png)
![Easy Setup Wizard — Step 1: Choose your capabilities](/docs/easy-setup-step1.webp)
The wizard walks you through four simple steps:
1. **Capabilities** — Choose what to enable: Information Library, AI Assistant, Education Platform, Maps, Data Tools, and Notes
2. **Maps** — Select geographic regions for offline maps
3. **Content** — Choose curated content collections with Essential, Standard, or Comprehensive tiers
![Content tiers — Essential, Standard, and Comprehensive](/docs/easy-setup-tiers.png)
![Content tiers — Essential, Standard, and Comprehensive](/docs/easy-setup-tiers.webp)
4. **Review** — Confirm your selections and start downloading
Depending on what you selected, downloads may take a while. You can monitor progress in the Settings area, continue using features that are already installed, or leave your server running overnight for large downloads.
@ -64,7 +64,7 @@ The Education Platform provides complete educational courses that work offline.
### AI Assistant — Built-in Chat
![AI Chat interface](/docs/ai-chat.png)
![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.
@ -90,7 +90,7 @@ N.O.M.A.D. includes a built-in AI chat interface powered by Ollama. It runs enti
### Knowledge Base — Document-Aware AI
![Knowledge Base upload interface](/docs/knowledge-base.png)
![Knowledge Base upload interface](/docs/knowledge-base.webp)
The Knowledge Base lets you upload documents so the AI can reference them when answering your questions. It uses semantic search (RAG via Qdrant) to find relevant information from your uploaded files.
@ -115,7 +115,7 @@ The Knowledge Base lets you upload documents so the AI can reference them when a
### Maps — Offline Navigation
![Offline maps viewer](/docs/maps.png)
![Offline maps viewer](/docs/maps.webp)
View maps without internet. Download the regions you need before going offline.
@ -148,7 +148,7 @@ As your needs change, you can add more content anytime:
### Wikipedia Selector
![Content Explorer — browse and download Wikipedia packages and curated collections](/docs/content-explorer.png)
![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.
@ -161,7 +161,7 @@ N.O.M.A.D. includes a dedicated Wikipedia content management tool for browsing a
### System Benchmark
![System Benchmark with NOMAD Score and Builder Tag](/docs/benchmark.png)
![System Benchmark with NOMAD Score and Builder Tag](/docs/benchmark.webp)
Test your hardware performance and see how your NOMAD build stacks up against the community.

View File

@ -8,7 +8,7 @@ Your personal offline knowledge server is ready to use.
Think of it as having Wikipedia, Khan Academy, an AI assistant, and offline maps all in one place, running on hardware you control.
![Command Center Dashboard](/docs/dashboard.png)
![Command Center Dashboard](/docs/dashboard.webp)
## What Can You Do?

View File

@ -1,6 +1,64 @@
# Release Notes
## Unreleased
## Version 1.31.0 - April 3, 2026
### Features
- **AI Assistant**: Added support for remote OpenAI-compatible hosts (e.g. Ollama, LM Studio, etc.) to support running models on seperate hardware from the Command Center host. Thanks @hestela for the contribution!
- **AI Assistant**: Disabled Ollama Cloud support (not compatible with NOMAD's architecture) and added support for flash_attn to improve performance of compatible models. Thanks @hestela for the contribution!
- **Information Library (Kiwix)**: The Kiwix container now uses an XML library file approach instead of a glob-based approach to inform the Kiwix container of available ZIM files. This allows for much more robust handling of ZIM files and avoids issues with the container failing to start due to incomplete/corrupt ZIM files being present in the storage directory. Thanks @jakeaturner for the contribution!
- **RAG**: Added support for EPUB file embedding into the Knowledge Base. Thanks @arn6694 for the contribution!
- **RAG**: Added support for multiple file uploads (<=5, 100mb each) to the Knowledge Base. Thanks @jakeaturner for the contribution!
- **Maps**: Added support for customizable location markers on the map with database persistence. Thanks @chriscrosstalk for the contribution!
- **Maps**: The global map file can now be downloaded directly from PMTiles for users who want to the full map and/or regions outside of the U.S. that haven't been added to the curated collections yet. Thanks @bgauger for the contribution!
- **Maps**: Added a scale bar to the map viewer with imperial and metric options. Thanks @chriscrosstalk for the contribution!
- **Downloads**: Added support/improvements for rich progress, friendly names, cancellation, and live status updates for active downloads in the UI. Thanks @chriscrosstalk for the contribution!
- **UI**: Converted all PNGs to WEBP for reduced image sizes and improved performance. Thanks @hestela for the contribution!
- **UI**: Added an Installed Models section to AI Assistant settings. Thanks @chriscrosstalk for the contribution!
### Bug Fixes
- **Maps**: The maps API endpoints now properly check for "X-Forwarded-Proto" to support scenarios where the Command Center is behind a reverse proxy that terminates TLS. Thanks @davidgross for the fix!
- **Maps**: Fixed an issue where the maps API endpoints could fail with an internal error if a hostname was used to access the Command Center instead of an IP address or localhost. Thanks @jakeaturner for the fix!
- **Queue**: Increased the BullMQ lockDuration to prevent jobs from being killed prematurely on slower systems. Thanks @bgauger for the contribution!
- **Queue**: Added better handling for very large downloads and user-initated cancellations. Thanks @bgauger for the contribution!
- **Install**: The install script now checks for the presence of gpg (required for NVIDIA toolkit install) and automatically attempts to install it if it's missing. Thanks @chriscrosstalk for the fix!
- **Security**: Added key validation to the settings read API endpoint. Thanks @LuisMIguelFurlanettoSousa for the fix!
- **Security**: Improved URL validation logic for ZIM downloads to prevent SSRF vulnerabilities. Thanks @sebastiondev for the fix!
- **UI**: Fixed the activity feed height in Easy Setup and added automatic scrolling to the latest message during installation. Thanks @chriscrosstalk for the contribution!
### Improvements
- **Dependencies**: Updated various dependencies to close security vulnerabilities and improve stability
- **Docker**: NOMAD now adds 'com.docker.compose.project': 'project-nomad-managed' and 'io.project-nomad.managed': 'true' labels to all containers installed via the Command Center to improve compatibility with other Docker management tools and make it easier to identify and manage NOMAD containers. Thanks @techyogi for the contribution!
- **Docs**: Added a simple API reference for power users and developers. Thanks @hestela for the contribution!
- **Docs**: Re-formatted the Quick Install command into multiple lines for better readability in the README. Thanks @samsara-02 for the contribution!
- **Docs**: Updated the CONTRIBUTING and FAQ guides with the latest information and clarified some common questions. Thanks @jakeaturner for the contribution!
- **Ops**: Bumped GitHub Actions to their latest versions. Thanks @salmanmkc for the contribution!
- **Performance**: Shrunk the bundle size of the Command Center UI significantly by optimizing dependencies and tree-shaking, resulting in faster load times and a snappier user experience. Thanks @jakeaturner for the contribution!
- **Performance**: Implemented gzip compression by default for all HTTP registered routes from the Command Center backend to further improve performance, especially on slower connections. The DISABLE_COMPRESSION environment variable can be used to turn off this feature if needed. Thanks @jakeaturner for the contribution!
- **Performance**: Added light caching of certain Docker socket interactions and custom AI Assistant name resolution to improve performance and reduce redundant calls to the Docker API. Thanks @jakeaturner for the contribution!
- **Performance**: Switched to Inertia router navigation calls where appropriate to take advantage of Inertia's built-in caching and performance optimizations for a smoother user experience. Thanks @jakeaturner for the contribution!
## Version 1.30.3 - March 25, 2026
### Features
### Bug Fixes
- **Benchmark**: Fixed an issue where CPU and Disk Write scores could be displayed as 0 if the measured values was less than half of the reference mark. Thanks @bortlesboat for the fix!
- **Content Manager**: Fixed a missing API client method that was causing ZIM file deletions to fail. Thanks @LuisMIguelFurlanettoSousa for the fix!
- **Install**: Fixed an issue where the install script could incorrectly report the Docker NVIDIA runtime as missing. Thanks @brenex for the fix!
- **Support the Project**: Fixed a broken link to Rogue Support. Thanks @chriscrosstalk for the fix!
### Improvements
- **AI Assistant**: Improved error reporting and handling for model downloads. Thanks @chriscrosstalk for the contribution!
- **AI Assistant**: Bumped the default version of Ollama installed to v0.18.1 to take advantage of the latest performance improvements and bug fixes.
- **Apps**: Improved error reporting and handling for service installation failures. Thanks @trek-e for the contribution!
- **Collections**: Updated various curated collection links to their latest versions. Thanks @builder555 for the contribution!
- **Cyberchef**: Bumped the default version of CyberChef installed to v10.22.1 to take advantage of the latest features and bug fixes.
- **Docs**: Added a link to the step-by-step installation guide and video tutorial. Thanks @chriscrosstalk for the contribution!
- **Install**: Increased the retries limit for the MySQL service in Docker Compose to improve stability during installation on systems with slower performance. Thanks @dx4956 for the contribution!
- **Install**: Fixed an issue where stale data could cause credentials mismatch in MySQL on reinstall. Thanks @chriscrosstalk for the fix!
## Version 1.30.0 - March 20, 2026
### Features
- **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!
@ -10,11 +68,14 @@
### Bug Fixes
- **Settings**: Storage usage display now prefers real block devices over tempfs. Thanks @Bortlesboat for the fix!
- **Settings**: Fixed an issue where device matching and mount entry deduplication logic could cause incorrect storage usage reporting and missing devices in storage displays.
- **Maps**: The Maps page now respects the request protocol (http vs https) to ensure map tiles load correctly. Thanks @davidgross for the bug report!
- **Knowledge Base**: Fixed an issue where file embedding jobs could cause a retry storm if the Ollama service was unavailable. Thanks @skyam25 for the bug report!
- **Curated Collections**: Fixed some broken links in the curated collections definitions (maps and ZIM files) that were causing some resources to fail to download.
- **Easy Setup**: Fixed an issue where the "Start Here" badge would persist even after visiting the Easy Setup Wizard for the first time. Thanks @chriscrosstalk for the fix!
- **UI**: Fixed an issue where the loading spinner could look strange in certain use cases.
- **System Updates**: Fixed an issue where the update banner would persist even after the system was updated successfully. Thanks @chriscrosstalk for the fix!
- **Performance**: Various small memory leak fixes and performance improvements across the UI to ensure a smoother experience.
### Improvements
- **Ollama**: Improved GPU detection logic to ensure the latest GPU config is always passed to the Ollama container on update

View File

@ -40,7 +40,7 @@ createInertiaApp({
createRoot(el).render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<TransmitProvider baseUrl={window.location.origin} enableLogging={true}>
<TransmitProvider baseUrl={window.location.origin} enableLogging={environment === 'development'}>
<NotificationsProvider>
<ModalsProvider>
<App {...props} />

View File

@ -1,8 +1,8 @@
import { useRef, useState, useCallback } from 'react'
import useDownloads, { useDownloadsProps } from '~/hooks/useDownloads'
import HorizontalBarChart from './HorizontalBarChart'
import { extractFileName } from '~/lib/util'
import { extractFileName, formatBytes } from '~/lib/util'
import StyledSectionHeader from './StyledSectionHeader'
import { IconAlertTriangle, IconX } from '@tabler/icons-react'
import { IconAlertTriangle, IconX, IconLoader2 } from '@tabler/icons-react'
import api from '~/lib/api'
interface ActiveDownloadProps {
@ -10,62 +10,251 @@ interface ActiveDownloadProps {
withHeader?: boolean
}
function formatSpeed(bytesPerSec: number): string {
if (bytesPerSec <= 0) return '0 B/s'
if (bytesPerSec < 1024) return `${Math.round(bytesPerSec)} B/s`
if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`
}
type DownloadStatus = 'queued' | 'active' | 'stalled' | 'failed'
function getDownloadStatus(download: {
progress: number
lastProgressTime?: number
status?: string
}): DownloadStatus {
if (download.status === 'failed') return 'failed'
if (download.status === 'waiting' || download.status === 'delayed') return 'queued'
// Fallback heuristic for model jobs and in-flight jobs from before this deploy
if (download.progress === 0 && !download.lastProgressTime) return 'queued'
if (download.lastProgressTime) {
const elapsed = Date.now() - download.lastProgressTime
if (elapsed > 60_000) return 'stalled'
}
return 'active'
}
const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps) => {
const { data: downloads, invalidate } = useDownloads({ filetype })
const [cancellingJobs, setCancellingJobs] = useState<Set<string>>(new Set())
const [confirmingCancel, setConfirmingCancel] = useState<string | null>(null)
// Track previous downloadedBytes for speed calculation
const prevBytesRef = useRef<Map<string, { bytes: number; time: number }>>(new Map())
const speedRef = useRef<Map<string, number[]>>(new Map())
const getSpeed = useCallback(
(jobId: string, currentBytes?: number): number => {
if (!currentBytes || currentBytes <= 0) return 0
const prev = prevBytesRef.current.get(jobId)
const now = Date.now()
if (prev && prev.bytes > 0 && currentBytes > prev.bytes) {
const deltaBytes = currentBytes - prev.bytes
const deltaSec = (now - prev.time) / 1000
if (deltaSec > 0) {
const instantSpeed = deltaBytes / deltaSec
// Simple moving average (last 5 samples)
const samples = speedRef.current.get(jobId) || []
samples.push(instantSpeed)
if (samples.length > 5) samples.shift()
speedRef.current.set(jobId, samples)
const avg = samples.reduce((a, b) => a + b, 0) / samples.length
prevBytesRef.current.set(jobId, { bytes: currentBytes, time: now })
return avg
}
}
// Only set initial observation; never advance timestamp when bytes unchanged
if (!prev) {
prevBytesRef.current.set(jobId, { bytes: currentBytes, time: now })
}
return speedRef.current.get(jobId)?.at(-1) || 0
},
[]
)
const handleDismiss = async (jobId: string) => {
await api.removeDownloadJob(jobId)
invalidate()
}
const handleCancel = async (jobId: string) => {
setCancellingJobs((prev) => new Set(prev).add(jobId))
setConfirmingCancel(null)
try {
await api.cancelDownloadJob(jobId)
// Clean up speed tracking refs
prevBytesRef.current.delete(jobId)
speedRef.current.delete(jobId)
} finally {
setCancellingJobs((prev) => {
const next = new Set(prev)
next.delete(jobId)
return next
})
invalidate()
}
}
return (
<>
{withHeader && <StyledSectionHeader title="Active Downloads" className="mt-12 mb-4" />}
<div className="space-y-4">
{downloads && downloads.length > 0 ? (
downloads.map((download) => (
<div
key={download.jobId}
className={`bg-desert-white rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${
download.status === 'failed'
? 'border-red-300'
: 'border-desert-stone-light'
}`}
>
{download.status === 'failed' ? (
<div className="flex items-center gap-2">
<IconAlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{extractFileName(download.filepath) || download.url}
</p>
<p className="text-xs text-red-600 mt-0.5">
Download failed{download.failedReason ? `: ${download.failedReason}` : ''}
</p>
downloads.map((download) => {
const filename = extractFileName(download.filepath) || download.url
const status = getDownloadStatus(download)
const speed = getSpeed(download.jobId, download.downloadedBytes)
const isCancelling = cancellingJobs.has(download.jobId)
const isConfirming = confirmingCancel === download.jobId
return (
<div
key={download.jobId}
className={`rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${
status === 'failed'
? 'bg-surface-primary border-red-300'
: 'bg-surface-primary border-default'
}`}
>
{status === 'failed' ? (
<div className="flex items-center gap-2">
<IconAlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">
{download.title || 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>
</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>
<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>
) : (
<HorizontalBarChart
items={[
{
label: extractFileName(download.filepath) || download.url,
value: download.progress,
total: '100%',
used: `${download.progress}%`,
type: download.filetype,
},
]}
/>
)}
</div>
))
) : (
<div className="space-y-2">
{/* Title + Cancel button row */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="font-semibold text-desert-green truncate">
{download.title || filename}
</p>
{download.title && (
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-text-muted truncate font-mono">
{filename}
</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-desert-stone-lighter text-desert-stone-dark font-mono flex-shrink-0">
{download.filetype}
</span>
</div>
)}
{!download.title && download.filetype && (
<span className="text-xs px-1.5 py-0.5 rounded bg-desert-stone-lighter text-desert-stone-dark font-mono">
{download.filetype}
</span>
)}
</div>
{isConfirming ? (
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => handleCancel(download.jobId)}
className="text-xs px-2 py-1 rounded bg-red-100 text-red-700 hover:bg-red-200 transition-colors"
>
Confirm
</button>
<button
onClick={() => setConfirmingCancel(null)}
className="text-xs px-2 py-1 rounded bg-desert-stone-lighter text-text-muted hover:bg-desert-stone-light transition-colors"
>
Keep
</button>
</div>
) : isCancelling ? (
<IconLoader2 className="w-4 h-4 text-text-muted animate-spin flex-shrink-0" />
) : (
<button
onClick={() => setConfirmingCancel(download.jobId)}
className="flex-shrink-0 p-1 rounded hover:bg-red-100 transition-colors"
title="Cancel download"
>
<IconX className="w-4 h-4 text-text-muted hover:text-red-500" />
</button>
)}
</div>
{/* Size info */}
<div className="flex justify-between items-baseline text-sm text-text-muted font-mono">
<span>
{download.downloadedBytes && download.totalBytes
? `${formatBytes(download.downloadedBytes, 1)} / ${formatBytes(download.totalBytes, 1)}`
: `${download.progress}% / 100%`}
</span>
</div>
{/* Progress bar */}
<div className="relative">
<div className="h-6 bg-desert-green-lighter bg-opacity-20 rounded-lg border border-default overflow-hidden">
<div
className="h-full rounded-lg transition-all duration-1000 ease-out bg-desert-green"
style={{ width: `${download.progress}%` }}
/>
</div>
<div
className={`absolute top-1/2 -translate-y-1/2 font-bold text-xs ${
download.progress > 15
? 'left-2 text-white drop-shadow-md'
: 'right-2 text-desert-green'
}`}
>
{Math.round(download.progress)}%
</div>
</div>
{/* Status indicator */}
<div className="flex items-center gap-2">
{status === 'queued' && (
<>
<div className="w-2 h-2 rounded-full bg-desert-stone" />
<span className="text-xs text-text-muted">Waiting...</span>
</>
)}
{status === 'active' && (
<>
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-xs text-text-muted">
Downloading...{speed > 0 ? ` ${formatSpeed(speed)}` : ''}
</span>
</>
)}
{status === 'stalled' && download.lastProgressTime && (
<>
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse" />
<span className="text-xs text-orange-600">
No data received for{' '}
{Math.floor((Date.now() - download.lastProgressTime) / 60_000)}m...
</span>
</>
)}
</div>
</div>
)}
</div>
)
})
) : (
<p className="text-text-muted">No active downloads</p>
)}

View File

@ -1,6 +1,7 @@
import useOllamaModelDownloads from '~/hooks/useOllamaModelDownloads'
import HorizontalBarChart from './HorizontalBarChart'
import StyledSectionHeader from './StyledSectionHeader'
import { IconAlertTriangle } from '@tabler/icons-react'
interface ActiveModelDownloadsProps {
withHeader?: boolean
@ -17,19 +18,31 @@ const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps)
downloads.map((download) => (
<div
key={download.model}
className="bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow"
className={`bg-desert-white rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${
download.error ? 'border-red-400' : 'border-desert-stone-light'
}`}
>
<HorizontalBarChart
items={[
{
label: download.model,
value: download.percent,
total: '100%',
used: `${download.percent.toFixed(1)}%`,
type: 'ollama-model',
},
]}
/>
{download.error ? (
<div className="flex items-start gap-3">
<IconAlertTriangle className="text-red-500 flex-shrink-0 mt-0.5" size={20} />
<div>
<p className="font-medium text-text-primary">{download.model}</p>
<p className="text-sm text-red-600 mt-1">{download.error}</p>
</div>
</div>
) : (
<HorizontalBarChart
items={[
{
label: download.model,
value: download.percent,
total: '100%',
used: `${download.percent.toFixed(1)}%`,
type: 'ollama-model',
},
]}
/>
)}
</div>
))
) : (

View File

@ -1,6 +1,5 @@
import * as Icons from '@tabler/icons-react'
import classNames from '~/lib/classNames'
import DynamicIcon from './DynamicIcon'
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
import StyledButton, { StyledButtonProps } from './StyledButton'
export type AlertProps = React.HTMLAttributes<HTMLDivElement> & {
@ -10,7 +9,7 @@ export type AlertProps = React.HTMLAttributes<HTMLDivElement> & {
children?: React.ReactNode
dismissible?: boolean
onDismiss?: () => void
icon?: keyof typeof Icons
icon?: DynamicIconName
variant?: 'standard' | 'bordered' | 'solid'
buttonProps?: StyledButtonProps
}
@ -27,7 +26,7 @@ export default function Alert({
buttonProps,
...props
}: AlertProps) {
const getDefaultIcon = (): keyof typeof Icons => {
const getDefaultIcon = (): DynamicIconName => {
switch (type) {
case 'warning':
return 'IconAlertTriangle'

View File

@ -31,7 +31,7 @@ const FadingImage = ({ alt = "Fading image", className = "" }) => {
isVisible ? 'opacity-100' : 'opacity-0'
}`}>
<img
src={`/project_nomad_logo.png`}
src={`/project_nomad_logo.webp`}
alt={alt}
className={`w-64 h-64 ${className}`}
/>

View File

@ -1,36 +1,26 @@
import classNames from 'classnames'
import * as TablerIcons from '@tabler/icons-react'
import { icons } from '../lib/icons'
export type DynamicIconName = keyof typeof TablerIcons
export type { DynamicIconName } from '../lib/icons'
interface DynamicIconProps {
icon?: DynamicIconName
icon?: keyof typeof icons
className?: string
stroke?: number
onClick?: () => void
}
/**
* Renders a dynamic icon from the TablerIcons library based on the provided icon name.
* @param icon - The name of the icon to render.
* @param className - Optional additional CSS classes to apply to the icon.
* @param stroke - Optional stroke width for the icon.
* @returns A React element representing the icon, or null if no matching icon is found.
*/
const DynamicIcon: React.FC<DynamicIconProps> = ({ icon, className, stroke, onClick }) => {
if (!icon) return null
const Icon = TablerIcons[icon]
const Icon = icons[icon]
if (!Icon) {
console.warn(`Icon "${icon}" not found in TablerIcons.`)
console.warn(`Icon "${icon}" not found in icon map.`)
return null
}
return (
// @ts-ignore
<Icon className={classNames('h-5 w-5', className)} stroke={stroke || 2} onClick={onClick} />
)
return <Icon className={classNames('h-5 w-5', className)} strokeWidth={stroke ?? 2} onClick={onClick} />
}
export default DynamicIcon

View File

@ -1,3 +1,4 @@
import { useEffect, useRef } from 'react'
import { IconCircleCheck, IconCircleX } from '@tabler/icons-react'
import classNames from '~/lib/classNames'
@ -12,16 +13,30 @@ export type InstallActivityFeedProps = {
| 'created'
| 'preinstall'
| 'preinstall-complete'
| 'preinstall-error'
| 'starting'
| 'started'
| 'finalizing'
| 'completed'
| 'checking-dependencies'
| 'dependency-installed'
| 'image-exists'
| 'gpu-config'
| 'stopping'
| 'removing'
| 'recreating'
| 'cleanup-warning'
| 'no-volumes'
| 'volume-removed'
| 'volume-cleanup-warning'
| 'error'
| 'update-pulling'
| 'update-stopping'
| 'update-creating'
| 'update-starting'
| 'update-complete'
| 'update-rollback'
| (string & {})
timestamp: string
message: string
}>
@ -30,10 +45,18 @@ export type InstallActivityFeedProps = {
}
const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, className, withHeader = false }) => {
const listRef = useRef<HTMLUListElement>(null)
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight
}
}, [activity])
return (
<div className={classNames('bg-surface-primary shadow-sm rounded-lg p-6', className)}>
{withHeader && <h2 className="text-lg font-semibold text-text-primary">Installation Activity</h2>}
<ul role="list" className={classNames("space-y-6 text-desert-green", withHeader ? 'mt-6' : '')}>
<ul ref={listRef} role="list" className={classNames("space-y-6 text-desert-green max-h-[400px] overflow-y-auto scroll-smooth", withHeader ? 'mt-6' : '')}>
{activity.map((activityItem, activityItemIdx) => (
<li key={activityItem.timestamp} className="relative flex gap-x-4">
<div
@ -48,7 +71,7 @@ const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, cla
<div className="relative flex size-6 flex-none items-center justify-center bg-transparent">
{activityItem.type === 'completed' || activityItem.type === 'update-complete' ? (
<IconCircleCheck aria-hidden="true" className="size-6 text-indigo-600" />
) : activityItem.type === 'update-rollback' ? (
) : activityItem.type === 'error' || activityItem.type === 'update-rollback' || activityItem.type === 'preinstall-error' ? (
<IconCircleX aria-hidden="true" className="size-6 text-red-500" />
) : (
<div className="size-1.5 rounded-full bg-surface-secondary ring-1 ring-border-default" />
@ -56,7 +79,7 @@ const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, cla
</div>
<p className="flex-auto py-0.5 text-xs/5 text-text-muted">
<span className="font-semibold text-text-primary">{activityItem.service_name}</span> -{' '}
{activityItem.type.charAt(0).toUpperCase() + activityItem.type.slice(1)}
{activityItem.message || activityItem.type.charAt(0).toUpperCase() + activityItem.type.slice(1)}
</p>
<time
dateTime={activityItem.timestamp}

View File

@ -15,9 +15,9 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
}) => {
if (!fullscreen) {
return (
<div className={`flex flex-col items-center justify-center ${className}`}>
<div className="flex flex-col items-center justify-center">
<div
className={`w-8 h-8 border-[3px] ${light ? 'border-white' : 'border-text-muted'} border-t-transparent rounded-full animate-spin`}
className={`w-8 h-8 border-[3px] ${light ? 'border-white' : 'border-text-muted'} border-t-transparent rounded-full animate-spin ${className || ''}`}
></div>
{!iconOnly && (
<div className={light ? 'text-white mt-2' : 'text-text-primary mt-2'}>

View File

@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
import { Dialog, DialogBackdrop, DialogPanel, TransitionChild } from '@headlessui/react'
import classNames from '~/lib/classNames'
import { IconArrowLeft, IconBug } from '@tabler/icons-react'
import { usePage } from '@inertiajs/react'
import { Link, usePage } from '@inertiajs/react'
import { UsePageProps } from '../../types/system'
import { IconMenu2, IconX } from '@tabler/icons-react'
import ThemeToggle from '~/components/ThemeToggle'
@ -32,21 +32,29 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
}, [])
const ListItem = (item: SidebarItem) => {
const className = classNames(
item.current
? 'bg-desert-green text-white'
: 'text-text-primary hover:bg-desert-green-light hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold'
)
const content = (
<>
{item.icon && <item.icon aria-hidden="true" className="size-6 shrink-0" />}
{item.name}
</>
)
return (
<li key={item.name}>
<a
href={item.href}
target={item.target}
className={classNames(
item.current
? 'bg-desert-green text-white'
: 'text-text-primary hover:bg-desert-green-light hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold'
)}
>
{item.icon && <item.icon aria-hidden="true" className="size-6 shrink-0" />}
{item.name}
</a>
{item.target === '_blank' ? (
<a href={item.href} target="_blank" rel="noopener noreferrer" className={className}>
{content}
</a>
) : (
<Link href={item.href} className={className}>
{content}
</Link>
)}
</li>
)
}
@ -55,7 +63,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
return (
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-desert-sand px-6 ring-1 ring-white/5 pt-4 shadow-md">
<div className="flex h-16 shrink-0 items-center">
<img src="/project_nomad_logo.png" alt="Project Nomad Logo" className="h-16 w-16" />
<img src="/project_nomad_logo.webp" alt="Project Nomad Logo" className="h-16 w-16" />
<h1 className="ml-3 text-xl font-semibold text-text-primary">{title}</h1>
</div>
<nav className="flex flex-1 flex-col">
@ -66,23 +74,23 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
<ListItem key={item.name} {...item} current={currentPath === item.href} />
))}
<li className="ml-2 mt-4">
<a
<Link
href="/home"
className="flex flex-row items-center gap-x-3 text-desert-green text-sm font-semibold"
>
<IconArrowLeft aria-hidden="true" className="size-6 shrink-0" />
Back to Home
</a>
</Link>
</li>
</ul>
</li>
</ul>
</nav>
<div className="mb-4 flex flex-col items-center gap-1 text-sm text-text-secondary">
<div className="mb-4 flex flex-col items-center gap-1 text-sm text-text-secondary text-center">
<p>Project N.O.M.A.D. Command Center v{appVersion}</p>
<button
onClick={() => setDebugModalOpen(true)}
className="mt-1 text-gray-500 hover:text-desert-green inline-flex items-center gap-1 cursor-pointer"
className="text-gray-500 hover:text-desert-green inline-flex items-center gap-1 cursor-pointer"
>
<IconBug className="size-3.5" />
Debug Info

View File

@ -13,7 +13,7 @@ export default function ThemeToggle({ compact = false }: ThemeToggleProps) {
<button
onClick={toggleTheme}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm transition-colors
text-desert-stone hover:text-desert-green-darker"
text-desert-stone hover:text-desert-green-darker cursor-pointer"
aria-label={isDark ? 'Switch to Day Ops' : 'Switch to Night Ops'}
title={isDark ? 'Switch to Day Ops' : 'Switch to Night Ops'}
>

View File

@ -6,6 +6,7 @@ import { resolveTierResources } from '~/lib/collections'
import { formatBytes } from '~/lib/util'
import classNames from 'classnames'
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
import StyledButton from './StyledButton'
interface TierSelectionModalProps {
isOpen: boolean
@ -213,18 +214,14 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
{/* Footer */}
<div className="bg-surface-secondary px-6 py-4 flex justify-end gap-3">
<button
<StyledButton
variant='primary'
size='lg'
onClick={handleSubmit}
disabled={!localSelectedSlug}
className={classNames(
'px-4 py-2 rounded-md font-medium transition-colors',
localSelectedSlug
? 'bg-desert-green text-white hover:bg-desert-green/90'
: 'bg-border-default text-text-muted cursor-not-allowed'
)}
>
Submit
</button>
</StyledButton>
</div>
</Dialog.Panel>
</Transition.Child>

View File

@ -49,7 +49,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
{/* Downloading status message */}
{isDownloading && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center gap-2">
<LoadingSpinner fullscreen={false} iconOnly className="size-5" />
<LoadingSpinner fullscreen={false} iconOnly className="size-4" />
<span className="text-sm text-blue-700">
Downloading Wikipedia... This may take a while for larger packages.
</span>

View File

@ -213,7 +213,7 @@ export default function ChatInterface({
<p className="text-text-primary">
This will dispatch a background download job for{' '}
<span className="font-mono font-medium">{DEFAULT_QUERY_REWRITE_MODEL}</span> and may take some time to complete. The model
will be used to rewrite queries for improved RAG retrieval performance.
will be used to rewrite queries for improved RAG retrieval performance. Note that download is only supported when using Ollama. If using an OpenAI API interface, please download the model with that software.
</p>
</StyledModal>
</div>

View File

@ -89,7 +89,7 @@ export default function ChatSidebar({
)}
</div>
<div className="p-4 flex flex-col items-center justify-center gap-y-2">
<img src="/project_nomad_logo.png" alt="Project Nomad Logo" className="h-28 w-28 mb-6" />
<img src="/project_nomad_logo.webp" alt="Project Nomad Logo" className="h-28 w-28 mb-6" />
<StyledButton
onClick={() => {
if (isInModal) {

View File

@ -24,6 +24,7 @@ function sourceToDisplayName(source: string): string {
export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", onClose }: KnowledgeBaseModalProps) {
const { addNotification } = useNotifications()
const [files, setFiles] = useState<File[]>([])
const [isUploading, setIsUploading] = useState(false)
const [confirmDeleteSource, setConfirmDeleteSource] = useState<string | null>(null)
const fileUploaderRef = useRef<React.ComponentRef<typeof FileUploader>>(null)
const { openModal, closeModal } = useModals()
@ -37,22 +38,6 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
const uploadMutation = useMutation({
mutationFn: (file: File) => api.uploadDocument(file),
onSuccess: (data) => {
addNotification({
type: 'success',
message: data?.message || 'Document uploaded and queued for processing',
})
setFiles([])
if (fileUploaderRef.current) {
fileUploaderRef.current.clear()
}
},
onError: (error: any) => {
addNotification({
type: 'error',
message: error?.message || 'Failed to upload document',
})
},
})
const deleteMutation = useMutation({
@ -68,6 +53,17 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
},
})
const cleanupFailedMutation = useMutation({
mutationFn: () => api.cleanupFailedEmbedJobs(),
onSuccess: (data) => {
addNotification({ type: 'success', message: data?.message || 'Failed jobs cleaned up.' })
queryClient.invalidateQueries({ queryKey: ['failedEmbedJobs'] })
},
onError: (error: any) => {
addNotification({ type: 'error', message: error?.message || 'Failed to clean up jobs.' })
},
})
const syncMutation = useMutation({
mutationFn: () => api.syncRAGStorage(),
onSuccess: (data) => {
@ -84,9 +80,34 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
},
})
const handleUpload = () => {
if (files.length > 0) {
uploadMutation.mutate(files[0])
const handleUpload = async () => {
if (files.length === 0) return
setIsUploading(true)
let successCount = 0
const failedNames: string[] = []
for (const file of files) {
try {
await uploadMutation.mutateAsync(file)
successCount++
} catch (error: any) {
failedNames.push(file.name)
}
}
setIsUploading(false)
setFiles([])
fileUploaderRef.current?.clear()
queryClient.invalidateQueries({ queryKey: ['embed-jobs'] })
if (successCount > 0) {
addNotification({
type: 'success',
message: `${successCount} file${successCount > 1 ? 's' : ''} queued for processing.`,
})
}
for (const name of failedNames) {
addNotification({ type: 'error', message: `Failed to upload: ${name}` })
}
}
@ -133,7 +154,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
<FileUploader
ref={fileUploaderRef}
minFiles={1}
maxFiles={1}
maxFiles={5}
onUpload={(uploadedFiles) => {
setFiles(Array.from(uploadedFiles))
}}
@ -144,8 +165,8 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
size="lg"
icon="IconUpload"
onClick={handleUpload}
disabled={files.length === 0 || uploadMutation.isPending}
loading={uploadMutation.isPending}
disabled={files.length === 0 || isUploading}
loading={isUploading}
>
Upload
</StyledButton>
@ -207,7 +228,20 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
</div>
</div>
<div className="my-8">
<ActiveEmbedJobs withHeader={true} />
<div className="flex items-center justify-between mb-4">
<StyledSectionHeader title="Processing Queue" className="!mb-0" />
<StyledButton
variant="danger"
size="md"
icon="IconTrash"
onClick={() => cleanupFailedMutation.mutate()}
loading={cleanupFailedMutation.isPending}
disabled={cleanupFailedMutation.isPending}
>
Clean Up Failed
</StyledButton>
</div>
<ActiveEmbedJobs withHeader={false} />
</div>
<div className="my-12">
@ -218,8 +252,8 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
size="md"
icon='IconRefresh'
onClick={handleConfirmSync}
disabled={syncMutation.isPending || uploadMutation.isPending}
loading={syncMutation.isPending || uploadMutation.isPending}
disabled={syncMutation.isPending || isUploading}
loading={syncMutation.isPending || isUploading}
>
Sync Storage
</StyledButton>

View File

@ -53,6 +53,14 @@ export default function Chat({
const activeSession = sessions.find((s) => s.id === activeSessionId)
const { data: lastModelSetting } = useSystemSetting({ key: 'chat.lastModel', enabled })
const { data: remoteOllamaUrlSetting } = useSystemSetting({ key: 'ai.remoteOllamaUrl', enabled })
const { data: remoteStatus } = useQuery({
queryKey: ['remoteOllamaStatus'],
queryFn: () => api.getRemoteOllamaStatus(),
enabled: enabled && !!remoteOllamaUrlSetting?.value,
refetchInterval: 15000,
})
const { data: installedModels = [], isLoading: isLoadingModels } = useQuery({
queryKey: ['installedModels'],
@ -363,6 +371,18 @@ export default function Chat({
{activeSession?.title || 'New Chat'}
</h2>
<div className="flex items-center gap-4">
{remoteOllamaUrlSetting?.value && (
<span
className={classNames(
'text-xs rounded px-2 py-1 font-medium',
remoteStatus?.connected === false
? 'text-red-700 bg-red-50 border border-red-200'
: 'text-green-700 bg-green-50 border border-green-200'
)}
>
{remoteStatus?.connected === false ? 'Remote Disconnected' : 'Remote Connected'}
</span>
)}
<div className="flex items-center gap-2">
<label htmlFor="model-select" className="text-sm text-text-secondary">
Model:
@ -380,7 +400,7 @@ export default function Chat({
>
{installedModels.map((model) => (
<option key={model.name} value={model.name}>
{model.name} ({formatBytes(model.size)})
{model.name}{model.size > 0 ? ` (${formatBytes(model.size)})` : ''}
</option>
))}
</select>

View File

@ -29,7 +29,7 @@ const FileUploader = forwardRef<FileUploaderRef, FileUploaderProps>((props, ref)
const {
minFiles = 0,
maxFiles = 1,
maxFileSize = 10485760, // default to 10MB
maxFileSize = 104857600, // default to 100MB
fileTypes,
disabled = false,
onUpload,

View File

@ -1,10 +1,41 @@
import Map, { FullscreenControl, NavigationControl, MapProvider } from 'react-map-gl/maplibre'
import Map, {
FullscreenControl,
NavigationControl,
ScaleControl,
Marker,
Popup,
MapProvider,
} from 'react-map-gl/maplibre'
import type { MapRef, MapLayerMouseEvent } from 'react-map-gl/maplibre'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { Protocol } from 'pmtiles'
import { useEffect } from 'react'
import { useEffect, useRef, useState, useCallback } from 'react'
type ScaleUnit = 'imperial' | 'metric'
import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers'
import type { PinColorId } from '~/hooks/useMapMarkers'
import MarkerPin from './MarkerPin'
import MarkerPanel from './MarkerPanel'
export default function MapComponent() {
const mapRef = useRef<MapRef>(null)
const { markers, addMarker, deleteMarker } = useMapMarkers()
const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null)
const [markerName, setMarkerName] = useState('')
const [markerColor, setMarkerColor] = useState<PinColorId>('orange')
const [selectedMarkerId, setSelectedMarkerId] = useState<number | null>(null)
const [scaleUnit, setScaleUnit] = useState<ScaleUnit>(
() => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric'
)
const toggleScaleUnit = useCallback(() => {
setScaleUnit((prev) => {
const next = prev === 'metric' ? 'imperial' : 'metric'
localStorage.setItem('nomad:map-scale-unit', next)
return next
})
}, [])
// Add the PMTiles protocol to maplibre-gl
useEffect(() => {
@ -15,9 +46,40 @@ export default function MapComponent() {
}
}, [])
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat })
setMarkerName('')
setMarkerColor('orange')
setSelectedMarkerId(null)
}, [])
const handleSaveMarker = useCallback(() => {
if (placingMarker && markerName.trim()) {
addMarker(markerName.trim(), placingMarker.lng, placingMarker.lat, markerColor)
setPlacingMarker(null)
setMarkerName('')
setMarkerColor('orange')
}
}, [placingMarker, markerName, markerColor, addMarker])
const handleFlyTo = useCallback((longitude: number, latitude: number) => {
mapRef.current?.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 })
}, [])
const handleDeleteMarker = useCallback(
(id: number) => {
if (selectedMarkerId === id) setSelectedMarkerId(null)
deleteMarker(id)
},
[selectedMarkerId, deleteMarker]
)
const selectedMarker = selectedMarkerId ? markers.find((m) => m.id === selectedMarkerId) : null
return (
<MapProvider>
<Map
ref={mapRef}
reuseMaps
style={{
width: '100%',
@ -30,10 +92,153 @@ export default function MapComponent() {
latitude: 40,
zoom: 3.5,
}}
onClick={handleMapClick}
>
<NavigationControl style={{ marginTop: '110px', marginRight: '36px' }} />
<FullscreenControl style={{ marginTop: '30px', marginRight: '36px' }} />
<ScaleControl position="bottom-left" maxWidth={150} unit={scaleUnit} />
<div style={{ position: 'absolute', bottom: '30px', left: '10px', zIndex: 2 }}>
<div
style={{
display: 'inline-flex',
borderRadius: '4px',
boxShadow: '0 0 0 2px rgba(0,0,0,0.1)',
overflow: 'hidden',
fontSize: '11px',
fontWeight: 600,
lineHeight: 1,
}}
>
<button
onClick={() => { if (scaleUnit !== 'metric') toggleScaleUnit() }}
style={{
background: scaleUnit === 'metric' ? '#424420' : 'white',
color: scaleUnit === 'metric' ? 'white' : '#666',
border: 'none',
padding: '4px 8px',
cursor: 'pointer',
}}
>
Metric
</button>
<button
onClick={() => { if (scaleUnit !== 'imperial') toggleScaleUnit() }}
style={{
background: scaleUnit === 'imperial' ? '#424420' : 'white',
color: scaleUnit === 'imperial' ? 'white' : '#666',
border: 'none',
padding: '4px 8px',
cursor: 'pointer',
}}
>
Imperial
</button>
</div>
</div>
{/* Existing markers */}
{markers.map((marker) => (
<Marker
key={marker.id}
longitude={marker.longitude}
latitude={marker.latitude}
anchor="bottom"
onClick={(e) => {
e.originalEvent.stopPropagation()
setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
setPlacingMarker(null)
}}
>
<MarkerPin
color={PIN_COLORS.find((c) => c.id === marker.color)?.hex}
active={marker.id === selectedMarkerId}
/>
</Marker>
))}
{/* Popup for selected marker */}
{selectedMarker && (
<Popup
longitude={selectedMarker.longitude}
latitude={selectedMarker.latitude}
anchor="bottom"
offset={[0, -36] as [number, number]}
onClose={() => setSelectedMarkerId(null)}
closeOnClick={false}
>
<div className="text-sm font-medium">{selectedMarker.name}</div>
</Popup>
)}
{/* Popup for placing a new marker */}
{placingMarker && (
<Popup
longitude={placingMarker.lng}
latitude={placingMarker.lat}
anchor="bottom"
onClose={() => setPlacingMarker(null)}
closeOnClick={false}
>
<div className="p-1">
<input
autoFocus
type="text"
placeholder="Name this location"
value={markerName}
onChange={(e) => setMarkerName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveMarker()
if (e.key === 'Escape') setPlacingMarker(null)
}}
className="block w-full rounded border border-gray-300 px-2 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:border-gray-500"
/>
<div className="mt-1.5 flex gap-1 items-center">
{PIN_COLORS.map((c) => (
<button
key={c.id}
onClick={() => setMarkerColor(c.id)}
title={c.label}
className="rounded-full p-0.5 transition-transform"
style={{
outline: markerColor === c.id ? `2px solid ${c.hex}` : '2px solid transparent',
outlineOffset: '1px',
}}
>
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: c.hex }}
/>
</button>
))}
</div>
<div className="mt-1.5 flex gap-1.5 justify-end">
<button
onClick={() => setPlacingMarker(null)}
className="text-xs text-gray-500 hover:text-gray-700 px-2 py-1 rounded transition-colors"
>
Cancel
</button>
<button
onClick={handleSaveMarker}
disabled={!markerName.trim()}
className="text-xs bg-[#424420] text-white rounded px-2.5 py-1 hover:bg-[#525530] disabled:opacity-40 transition-colors"
>
Save
</button>
</div>
</div>
</Popup>
)}
</Map>
{/* Marker panel overlay */}
<MarkerPanel
markers={markers}
onDelete={handleDeleteMarker}
onFlyTo={handleFlyTo}
onSelect={setSelectedMarkerId}
selectedMarkerId={selectedMarkerId}
/>
</MapProvider>
)
}

View File

@ -0,0 +1,116 @@
import { useState } from 'react'
import { IconMapPinFilled, IconTrash, IconMapPin, IconX } from '@tabler/icons-react'
import { PIN_COLORS } from '~/hooks/useMapMarkers'
import type { MapMarker } from '~/hooks/useMapMarkers'
interface MarkerPanelProps {
markers: MapMarker[]
onDelete: (id: number) => void
onFlyTo: (longitude: number, latitude: number) => void
onSelect: (id: number | null) => void
selectedMarkerId: number | null
}
export default function MarkerPanel({
markers,
onDelete,
onFlyTo,
onSelect,
selectedMarkerId,
}: MarkerPanelProps) {
const [open, setOpen] = useState(false)
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="absolute left-4 top-[72px] z-40 flex items-center gap-1.5 rounded-lg bg-surface-primary/95 px-3 py-2 shadow-lg border border-border-subtle backdrop-blur-sm hover:bg-surface-secondary transition-colors"
title="Show saved locations"
>
<IconMapPin size={18} className="text-desert-orange" />
<span className="text-sm font-medium text-text-primary">Pins</span>
{markers.length > 0 && (
<span className="ml-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-desert-orange text-[11px] font-bold text-white px-1">
{markers.length}
</span>
)}
</button>
)
}
return (
<div className="absolute left-4 top-[72px] z-40 w-72 rounded-lg bg-surface-primary/95 shadow-lg border border-border-subtle backdrop-blur-sm">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border-subtle">
<div className="flex items-center gap-2">
<IconMapPin size={18} className="text-desert-orange" />
<span className="text-sm font-semibold text-text-primary">
Saved Locations
</span>
{markers.length > 0 && (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-desert-orange text-[11px] font-bold text-white px-1">
{markers.length}
</span>
)}
</div>
<button
onClick={() => setOpen(false)}
className="rounded p-0.5 text-text-muted hover:text-text-primary hover:bg-surface-secondary transition-colors"
title="Close panel"
>
<IconX size={16} />
</button>
</div>
{/* Marker list */}
<div className="max-h-[calc(100vh-180px)] overflow-y-auto">
{markers.length === 0 ? (
<div className="px-3 py-6 text-center">
<IconMapPinFilled size={24} className="mx-auto mb-2 text-text-muted" />
<p className="text-sm text-text-muted">
Click anywhere on the map to drop a pin
</p>
</div>
) : (
<ul>
{markers.map((marker) => (
<li
key={marker.id}
className={`flex items-center gap-2 px-3 py-2 border-b border-border-subtle last:border-b-0 group transition-colors ${
marker.id === selectedMarkerId
? 'bg-desert-green/10'
: 'hover:bg-surface-secondary'
}`}
>
<IconMapPinFilled
size={16}
className="shrink-0"
style={{ color: PIN_COLORS.find((c) => c.id === marker.color)?.hex ?? '#a84a12' }}
/>
<button
onClick={() => {
onSelect(marker.id)
onFlyTo(marker.longitude, marker.latitude)
}}
className="flex-1 min-w-0 text-left"
title={marker.name}
>
<p className="text-sm font-medium text-text-primary truncate">
{marker.name}
</p>
</button>
<button
onClick={() => onDelete(marker.id)}
className="shrink-0 rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-desert-red hover:bg-surface-secondary transition-all"
title="Delete pin"
>
<IconTrash size={14} />
</button>
</li>
))}
</ul>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,17 @@
import { IconMapPinFilled } from '@tabler/icons-react'
interface MarkerPinProps {
color?: string
active?: boolean
}
export default function MarkerPin({ color = '#a84a12', active = false }: MarkerPinProps) {
return (
<div className="cursor-pointer" style={{ filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.4))' }}>
<IconMapPinFilled
size={active ? 36 : 32}
style={{ color }}
/>
</div>
)
}

View File

@ -118,4 +118,41 @@ body {
--color-btn-green-active: #3a3c24;
color-scheme: dark;
}
/* MapLibre popup styling for dark mode */
[data-theme="dark"] .maplibregl-popup-content {
background: #2a2918;
color: #f7eedc;
}
[data-theme="dark"] .maplibregl-popup-content input {
background: #353420;
color: #f7eedc;
border-color: #424420;
}
[data-theme="dark"] .maplibregl-popup-content input::placeholder {
color: #8f8f82;
}
[data-theme="dark"] .maplibregl-popup-tip {
border-top-color: #2a2918;
}
[data-theme="dark"] .maplibregl-popup-anchor-bottom .maplibregl-popup-tip {
border-top-color: #2a2918;
}
[data-theme="dark"] .maplibregl-popup-anchor-top .maplibregl-popup-tip {
border-bottom-color: #2a2918;
}
[data-theme="dark"] .maplibregl-popup-close-button {
color: #afafa5;
}
[data-theme="dark"] .maplibregl-popup-close-button:hover {
color: #f7eedc;
background: #353420;
}

View File

@ -0,0 +1,82 @@
import { NomadDiskInfo } from '../../types/system'
import { Systeminformation } from 'systeminformation'
import { formatBytes } from '~/lib/util'
type DiskDisplayItem = {
label: string
value: number
total: string
used: string
subtext: string
totalBytes: number
usedBytes: number
}
/** Get all valid disks formatted for display (settings/system page) */
export function getAllDiskDisplayItems(
disks: NomadDiskInfo[] | undefined,
fsSize: Systeminformation.FsSizeData[] | undefined
): DiskDisplayItem[] {
const validDisks = disks?.filter((d) => d.totalSize > 0) || []
if (validDisks.length > 0) {
return validDisks.map((disk) => ({
label: disk.name || 'Unknown',
value: disk.percentUsed || 0,
total: formatBytes(disk.totalSize),
used: formatBytes(disk.totalUsed),
subtext: `${formatBytes(disk.totalUsed || 0)} / ${formatBytes(disk.totalSize || 0)}`,
totalBytes: disk.totalSize,
usedBytes: disk.totalUsed,
}))
}
if (fsSize && fsSize.length > 0) {
const seen = new Set<number>()
const uniqueFs = fsSize.filter((fs) => {
if (fs.size <= 0 || seen.has(fs.size)) return false
seen.add(fs.size)
return true
})
const realDevices = uniqueFs.filter((fs) => fs.fs.startsWith('/dev/'))
const displayFs = realDevices.length > 0 ? realDevices : uniqueFs
return displayFs.map((fs) => ({
label: fs.fs || 'Unknown',
value: fs.use || 0,
total: formatBytes(fs.size),
used: formatBytes(fs.used),
subtext: `${formatBytes(fs.used)} / ${formatBytes(fs.size)}`,
totalBytes: fs.size,
usedBytes: fs.used,
}))
}
return []
}
/** Get primary disk info for storage projection (easy-setup page) */
export function getPrimaryDiskInfo(
disks: NomadDiskInfo[] | undefined,
fsSize: Systeminformation.FsSizeData[] | undefined
): { totalSize: number; totalUsed: number } | null {
const validDisks = disks?.filter((d) => d.totalSize > 0) || []
if (validDisks.length > 0) {
const diskWithRoot = validDisks.find((d) =>
d.filesystems?.some((fs) => fs.mount === '/' || fs.mount === '/storage')
)
const primary =
diskWithRoot || validDisks.reduce((a, b) => (b.totalSize > a.totalSize ? b : a))
return { totalSize: primary.totalSize, totalUsed: primary.totalUsed }
}
if (fsSize && fsSize.length > 0) {
const realDevices = fsSize.filter((fs) => fs.fs.startsWith('/dev/'))
const primary =
realDevices.length > 0
? realDevices.reduce((a, b) => (b.size > a.size ? b : a))
: fsSize[0]
return { totalSize: primary.size, totalUsed: primary.used }
}
return null
}

View File

@ -17,7 +17,11 @@ const useDownloads = (props: useDownloadsProps) => {
const queryData = useQuery({
queryKey: queryKey,
queryFn: () => api.listDownloadJobs(props.filetype),
refetchInterval: 2000, // Refetch every 2 seconds to get updated progress
refetchInterval: (query) => {
const data = query.state.data
// Only poll when there are active downloads; otherwise use a slower interval
return data && data.length > 0 ? 2000 : 30000
},
enabled: props.enabled ?? true,
})

View File

@ -1,16 +1,31 @@
import { useEffect, useRef } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import api from '~/lib/api'
const useEmbedJobs = (props: { enabled?: boolean } = {}) => {
const queryClient = useQueryClient()
const prevCountRef = useRef<number>(0)
const queryData = useQuery({
queryKey: ['embed-jobs'],
queryFn: () => api.getActiveEmbedJobs().then((data) => data ?? []),
refetchInterval: 2000,
refetchInterval: (query) => {
const data = query.state.data
// Only poll when there are active jobs; otherwise use a slower interval
return data && data.length > 0 ? 2000 : 30000
},
enabled: props.enabled ?? true,
})
// When jobs drain to zero, refresh stored files so they appear without reopening the modal
useEffect(() => {
const currentCount = queryData.data?.length ?? 0
if (prevCountRef.current > 0 && currentCount === 0) {
queryClient.invalidateQueries({ queryKey: ['storedFiles'] })
}
prevCountRef.current = currentCount
}, [queryData.data, queryClient])
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['embed-jobs'] })
}

View File

@ -0,0 +1,86 @@
import { useState, useCallback, useEffect } from 'react'
import api from '~/lib/api'
export const PIN_COLORS = [
{ id: 'orange', label: 'Orange', hex: '#a84a12' },
{ id: 'red', label: 'Red', hex: '#994444' },
{ id: 'green', label: 'Green', hex: '#424420' },
{ id: 'blue', label: 'Blue', hex: '#2563eb' },
{ id: 'purple', label: 'Purple', hex: '#7c3aed' },
{ id: 'yellow', label: 'Yellow', hex: '#ca8a04' },
] as const
export type PinColorId = typeof PIN_COLORS[number]['id']
export interface MapMarker {
id: number
name: string
longitude: number
latitude: number
color: PinColorId
createdAt: string
}
export function useMapMarkers() {
const [markers, setMarkers] = useState<MapMarker[]>([])
const [loaded, setLoaded] = useState(false)
// Load markers from API on mount
useEffect(() => {
api.listMapMarkers().then((data) => {
if (data) {
setMarkers(
data.map((m) => ({
id: m.id,
name: m.name,
longitude: m.longitude,
latitude: m.latitude,
color: m.color as PinColorId,
createdAt: m.created_at,
}))
)
}
setLoaded(true)
})
}, [])
const addMarker = useCallback(
async (name: string, longitude: number, latitude: number, color: PinColorId = 'orange') => {
const result = await api.createMapMarker({ name, longitude, latitude, color })
if (result) {
const marker: MapMarker = {
id: result.id,
name: result.name,
longitude: result.longitude,
latitude: result.latitude,
color: result.color as PinColorId,
createdAt: result.created_at,
}
setMarkers((prev) => [...prev, marker])
return marker
}
return null
},
[]
)
const updateMarker = useCallback(async (id: number, updates: { name?: string; color?: string }) => {
const result = await api.updateMapMarker(id, updates)
if (result) {
setMarkers((prev) =>
prev.map((m) =>
m.id === id
? { ...m, name: result.name, color: result.color as PinColorId }
: m
)
)
}
}, [])
const deleteMarker = useCallback(async (id: number) => {
await api.deleteMapMarker(id)
setMarkers((prev) => prev.filter((m) => m.id !== id))
}, [])
return { markers, loaded, addMarker, updateMarker, deleteMarker }
}

View File

@ -1,31 +1,47 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTransmit } from 'react-adonis-transmit'
export type OllamaModelDownload = {
model: string
percent: number
timestamp: string
error?: string
}
export default function useOllamaModelDownloads() {
const { subscribe } = useTransmit()
const [downloads, setDownloads] = useState<Map<string, OllamaModelDownload>>(new Map())
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
useEffect(() => {
const unsubscribe = subscribe('ollama-model-download', (data: OllamaModelDownload) => {
setDownloads((prev) => {
const updated = new Map(prev)
if (data.percent >= 100) {
if (data.percent === -1) {
// Download failed — show error state, auto-remove after 15 seconds
updated.set(data.model, data)
const errorTimeout = setTimeout(() => {
timeoutsRef.current.delete(errorTimeout)
setDownloads((current) => {
const next = new Map(current)
next.delete(data.model)
return next
})
}, 15000)
timeoutsRef.current.add(errorTimeout)
} else if (data.percent >= 100) {
// If download is complete, keep it for a short time before removing to allow UI to show 100% progress
updated.set(data.model, data)
setTimeout(() => {
const timeout = setTimeout(() => {
timeoutsRef.current.delete(timeout)
setDownloads((current) => {
const next = new Map(current)
next.delete(data.model)
return next
})
}, 2000)
timeoutsRef.current.add(timeout)
} else {
updated.set(data.model, data)
}
@ -36,7 +52,10 @@ export default function useOllamaModelDownloads() {
return () => {
unsubscribe()
timeoutsRef.current.forEach(clearTimeout)
timeoutsRef.current.clear()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscribe])
const downloadsArray = Array.from(downloads.values())

View File

@ -4,7 +4,7 @@ import ChatButton from '~/components/chat/ChatButton'
import ChatModal from '~/components/chat/ChatModal'
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
import { SERVICE_NAMES } from '../../constants/service_names'
import { Link } from '@inertiajs/react'
import { Link, router } from '@inertiajs/react'
import { IconArrowLeft } from '@tabler/icons-react'
import classNames from 'classnames'
@ -23,9 +23,9 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
)}
<div
className="p-2 flex gap-2 flex-col items-center justify-center cursor-pointer"
onClick={() => (window.location.href = '/home')}
onClick={() => router.visit('/home')}
>
<img src="/project_nomad_logo.png" alt="Project Nomad Logo" className="h-40 w-40" />
<img src="/project_nomad_logo.webp" alt="Project Nomad Logo" className="h-40 w-40" />
<h1 className="text-5xl font-bold text-desert-green">Command Center</h1>
</div>
<hr className={

View File

@ -1,4 +1,4 @@
import axios, { AxiosInstance } from 'axios'
import axios, { AxiosError, AxiosInstance } from 'axios'
import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim'
import { ServiceSlim } from '../../types/services'
import { FileEntry } from '../../types/files'
@ -7,8 +7,7 @@ import { DownloadJobWithProgress, WikipediaState } from '../../types/downloads'
import { EmbedJobWithProgress } from '../../types/rag'
import type { CategoryWithStatus, CollectionWithStatus, ContentUpdateCheckResult, ResourceUpdateInfo } from '../../types/collections'
import { catchInternal } from './util'
import { NomadOllamaModel, OllamaChatRequest } from '../../types/ollama'
import { ChatResponse, ModelResponse } from 'ollama'
import { NomadChatResponse, NomadInstalledModel, NomadOllamaModel, OllamaChatRequest } from '../../types/ollama'
import BenchmarkResult from '#models/benchmark_result'
import { BenchmarkType, RunBenchmarkResponse, SubmitBenchmarkResponse, UpdateBuilderTagResponse } from '../../types/benchmark'
@ -25,13 +24,19 @@ class API {
}
async affectService(service_name: string, action: 'start' | 'stop' | 'restart') {
return catchInternal(async () => {
try {
const response = await this.client.post<{ success: boolean; message: string }>(
'/system/services/affect',
{ service_name, action }
)
return response.data
})()
} catch (error) {
if (error instanceof AxiosError && error.response?.data?.message) {
return { success: false, message: error.response.data.message }
}
console.error('Error affecting service:', error)
return undefined
}
}
async checkLatestVersion(force: boolean = false) {
@ -43,6 +48,25 @@ class API {
})()
}
async getRemoteOllamaStatus(): Promise<{ configured: boolean; connected: boolean }> {
return catchInternal(async () => {
const response = await this.client.get<{ configured: boolean; connected: boolean }>(
'/ollama/remote-status'
)
return response.data
})()
}
async configureRemoteOllama(remoteUrl: string | null): Promise<{ success: boolean; message: string }> {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>(
'/ollama/configure-remote',
{ remoteUrl }
)
return response.data
})()
}
async deleteModel(model: string): Promise<{ success: boolean; message: string }> {
return catchInternal(async () => {
const response = await this.client.delete('/ollama/models', { data: { model } })
@ -192,13 +216,19 @@ class API {
}
async forceReinstallService(service_name: string) {
return catchInternal(async () => {
try {
const response = await this.client.post<{ success: boolean; message: string }>(
`/system/services/force-reinstall`,
{ service_name }
)
return response.data
})()
} catch (error) {
if (error instanceof AxiosError && error.response?.data?.message) {
return { success: false, message: error.response.data.message }
}
console.error('Error force reinstalling service:', error)
return undefined
}
}
async getChatSuggestions(signal?: AbortSignal) {
@ -227,7 +257,7 @@ class API {
async getInstalledModels() {
return catchInternal(async () => {
const response = await this.client.get<ModelResponse[]>('/ollama/installed-models')
const response = await this.client.get<NomadInstalledModel[]>('/ollama/installed-models')
return response.data
})()
}
@ -246,7 +276,7 @@ class API {
async sendChatMessage(chatRequest: OllamaChatRequest) {
return catchInternal(async () => {
const response = await this.client.post<ChatResponse>('/ollama/chat', chatRequest)
const response = await this.client.post<NomadChatResponse>('/ollama/chat', chatRequest)
return response.data
})()
}
@ -407,6 +437,20 @@ class API {
})()
}
async getFailedEmbedJobs(): Promise<EmbedJobWithProgress[] | undefined> {
return catchInternal(async () => {
const response = await this.client.get<EmbedJobWithProgress[]>('/rag/failed-jobs')
return response.data
})()
}
async cleanupFailedEmbedJobs(): Promise<{ message: string; cleaned: number; filesDeleted: number } | undefined> {
return catchInternal(async () => {
const response = await this.client.delete<{ message: string; cleaned: number; filesDeleted: number }>('/rag/failed-jobs')
return response.data
})()
}
async getStoredRAGFiles() {
return catchInternal(async () => {
const response = await this.client.get<{ files: string[] }>('/rag/files')
@ -459,12 +503,41 @@ class API {
}
async installService(service_name: string) {
return catchInternal(async () => {
try {
const response = await this.client.post<{ success: boolean; message: string }>(
'/system/services/install',
{ service_name }
)
return response.data
} catch (error) {
if (error instanceof AxiosError && error.response?.data?.message) {
return { success: false, message: error.response.data.message }
}
console.error('Error installing service:', error)
return undefined
}
}
async getGlobalMapInfo() {
return catchInternal(async () => {
const response = await this.client.get<{
url: string
date: string
size: number
key: string
}>('/maps/global-map-info')
return response.data
})()
}
async downloadGlobalMap() {
return catchInternal(async () => {
const response = await this.client.post<{
message: string
filename: string
jobId?: string
}>('/maps/download-global-map')
return response.data
})()
}
@ -498,6 +571,39 @@ class API {
})()
}
async listMapMarkers() {
return catchInternal(async () => {
const response = await this.client.get<
Array<{ id: number; name: string; longitude: number; latitude: number; color: string; created_at: string }>
>('/maps/markers')
return response.data
})()
}
async createMapMarker(data: { name: string; longitude: number; latitude: number; color?: string }) {
return catchInternal(async () => {
const response = await this.client.post<
{ id: number; name: string; longitude: number; latitude: number; color: string; created_at: string }
>('/maps/markers', data)
return response.data
})()
}
async updateMapMarker(id: number, data: { name?: string; color?: string }) {
return catchInternal(async () => {
const response = await this.client.patch<
{ id: number; name: string; longitude: number; latitude: number; color: string }
>(`/maps/markers/${id}`, data)
return response.data
})()
}
async deleteMapMarker(id: number) {
return catchInternal(async () => {
await this.client.delete(`/maps/markers/${id}`)
})()
}
async listRemoteZimFiles({
start = 0,
count = 12,
@ -518,6 +624,13 @@ class API {
})()
}
async deleteZimFile(filename: string) {
return catchInternal(async () => {
const response = await this.client.delete<{ message: string }>(`/zim/${filename}`)
return response.data
})()
}
async listZimFiles() {
return catchInternal(async () => {
return await this.client.get<ListZimFilesResponse>('/zim/list')
@ -538,6 +651,15 @@ class API {
})()
}
async cancelDownloadJob(jobId: string): Promise<{ success: boolean; message: string } | undefined> {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>(
`/downloads/jobs/${jobId}/cancel`
)
return response.data
})()
}
async runBenchmark(type: BenchmarkType, sync: boolean = false) {
return catchInternal(async () => {
const response = await this.client.post<RunBenchmarkResponse>(

104
admin/inertia/lib/icons.ts Normal file
View File

@ -0,0 +1,104 @@
import {
IconArrowUp,
IconBooks,
IconBrain,
IconChefHat,
IconCheck,
IconChevronLeft,
IconChevronRight,
IconCloudDownload,
IconCloudUpload,
IconCpu,
IconDatabase,
IconDownload,
IconHome,
IconLogs,
IconNotes,
IconPlayerPlay,
IconPlus,
IconRefresh,
IconRefreshAlert,
IconRobot,
IconSchool,
IconSettings,
IconTrash,
IconUpload,
IconWand,
IconWorld,
IconX,
IconAlertTriangle,
IconXboxX,
IconCircleCheck,
IconInfoCircle,
IconBug,
IconCopy,
IconServer,
IconMenu2,
IconArrowLeft,
IconArrowRight,
IconSun,
IconMoon,
IconStethoscope,
IconShieldCheck,
IconTool,
IconPlant,
IconCode,
IconMap,
} from '@tabler/icons-react'
/**
* An explicit import of used icons in the DynamicIcon component to ensure we get maximum tree-shaking
* while still providing us a nice DX with the DynamicIcon component and icon name inference.
* Only icons that are actually used by DynamicIcon should be added here. Yes, it does introduce
* some manual maintenance, but the bundle size benefits are worth it since we use a (relatively)
* very limited subset of the full Tabler Icons library.
*/
export const icons = {
IconAlertTriangle,
IconArrowLeft,
IconArrowRight,
IconArrowUp,
IconBooks,
IconBrain,
IconBug,
IconChefHat,
IconCheck,
IconChevronLeft,
IconChevronRight,
IconCircleCheck,
IconCloudDownload,
IconCloudUpload,
IconCode,
IconCopy,
IconCpu,
IconDatabase,
IconDownload,
IconHome,
IconInfoCircle,
IconLogs,
IconMap,
IconMenu2,
IconMoon,
IconNotes,
IconPlant,
IconPlayerPlay,
IconPlus,
IconRefresh,
IconRefreshAlert,
IconRobot,
IconSchool,
IconServer,
IconSettings,
IconShieldCheck,
IconStethoscope,
IconSun,
IconTool,
IconTrash,
IconUpload,
IconWand,
IconWorld,
IconX,
IconXboxX
} as const
export type DynamicIconName = keyof typeof icons

View File

@ -16,6 +16,7 @@ import StorageProjectionBar from '~/components/StorageProjectionBar'
import { useNotifications } from '~/context/NotificationContext'
import useInternetStatus from '~/hooks/useInternetStatus'
import { useSystemInfo } from '~/hooks/useSystemInfo'
import { getPrimaryDiskInfo } from '~/hooks/useDiskDisplayData'
import classNames from 'classnames'
import type { CategoryWithStatus, SpecTier, SpecResource } from '../../../types/collections'
import { resolveTierResources } from '~/lib/collections'
@ -111,7 +112,9 @@ const CURATED_MAP_COLLECTIONS_KEY = 'curated-map-collections'
const CURATED_CATEGORIES_KEY = 'curated-categories'
const WIKIPEDIA_STATE_KEY = 'wikipedia-state'
export default function EasySetupWizard(props: { system: { services: ServiceSlim[] } }) {
export default function EasySetupWizard(props: {
system: { services: ServiceSlim[]; remoteOllamaUrl: string }
}) {
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const CORE_CAPABILITIES = buildCoreCapabilities(aiAssistantName)
@ -121,6 +124,11 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
const [selectedAiModels, setSelectedAiModels] = useState<string[]>([])
const [isProcessing, setIsProcessing] = useState(false)
const [showAdditionalTools, setShowAdditionalTools] = useState(false)
const [remoteOllamaEnabled, setRemoteOllamaEnabled] = useState(
() => !!props.system.remoteOllamaUrl
)
const [remoteOllamaUrl, setRemoteOllamaUrl] = useState(() => props.system.remoteOllamaUrl ?? '')
const [remoteOllamaUrlError, setRemoteOllamaUrlError] = useState<string | null>(null)
// Category/tier selection state
const [selectedTiers, setSelectedTiers] = useState<Map<string, SpecTier>>(new Map())
@ -296,46 +304,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
])
// Get primary disk/filesystem info for storage projection
// Try disk array first (Linux/production), fall back to fsSize (Windows/dev)
// Filter out invalid disks (totalSize === 0) and prefer disk with root mount or largest valid disk
const getPrimaryDisk = () => {
if (!systemInfo?.disk || systemInfo.disk.length === 0) return null
// Filter to only valid disks with actual storage
const validDisks = systemInfo.disk.filter((d) => d.totalSize > 0)
if (validDisks.length === 0) return null
// Prefer disk containing root mount (/) or /storage mount
const diskWithRoot = validDisks.find((d) =>
d.filesystems?.some((fs) => fs.mount === '/' || fs.mount === '/storage')
)
if (diskWithRoot) return diskWithRoot
// Fall back to largest valid disk
return validDisks.reduce((largest, current) =>
current.totalSize > largest.totalSize ? current : largest
)
}
const primaryDisk = getPrimaryDisk()
// When falling back to fsSize (systeminformation), prefer real block devices
// over virtual filesystems like tmpfs which report misleading capacity.
const getPrimaryFs = () => {
if (!systemInfo?.fsSize || systemInfo.fsSize.length === 0) return null
const realDevices = systemInfo.fsSize.filter((fs) => fs.fs.startsWith('/dev/'))
if (realDevices.length > 0) {
return realDevices.reduce((largest, current) =>
current.size > largest.size ? current : largest
)
}
return systemInfo.fsSize[0]
}
const primaryFs = getPrimaryFs()
const storageInfo = primaryDisk
? { totalSize: primaryDisk.totalSize, totalUsed: primaryDisk.totalUsed }
: primaryFs
? { totalSize: primaryFs.size, totalUsed: primaryFs.used }
: null
const storageInfo = getPrimaryDiskInfo(systemInfo?.disk, systemInfo?.fsSize)
const canProceedToNextStep = () => {
if (!isOnline) return false // Must be online to proceed
@ -369,8 +338,24 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
setIsProcessing(true)
try {
// If using remote Ollama, configure it first before other installs
if (remoteOllamaEnabled && remoteOllamaUrl) {
const remoteResult = await api.configureRemoteOllama(remoteOllamaUrl)
if (!remoteResult?.success) {
const msg = (remoteResult as any)?.message || 'Failed to configure remote Ollama.'
setRemoteOllamaUrlError(msg)
setIsProcessing(false)
setCurrentStep(1)
return
}
}
// All of these ops don't actually wait for completion, they just kick off the process, so we can run them in parallel without awaiting each one sequentially
const installPromises = selectedServices.map((serviceName) => api.installService(serviceName))
// Exclude Ollama from local install when using remote mode
const servicesToInstall = remoteOllamaEnabled
? selectedServices.filter((s) => s !== SERVICE_NAMES.OLLAMA)
: selectedServices
const installPromises = servicesToInstall.map((serviceName) => api.installService(serviceName))
await Promise.all(installPromises)
@ -699,9 +684,53 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<div>
<h3 className="text-lg font-semibold text-text-primary mb-4">Core Capabilities</h3>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{existingCoreCapabilities.map((capability) =>
renderCapabilityCard(capability, true)
)}
{existingCoreCapabilities.map((capability) => {
if (capability.id === 'ai') {
const isAiSelected = isCapabilitySelected(capability)
return (
<div key={capability.id}>
{renderCapabilityCard(capability, true)}
{isAiSelected && !isCapabilityInstalled(capability) && (
<div
className="mt-2 p-4 bg-gray-50 rounded-lg border border-gray-200"
onClick={(e) => e.stopPropagation()}
>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={remoteOllamaEnabled}
onChange={(e) => {
setRemoteOllamaEnabled(e.target.checked)
setRemoteOllamaUrlError(null)
}}
className="w-4 h-4 accent-desert-green"
/>
<span className="text-sm font-medium text-gray-700">Use remote Ollama instance</span>
</label>
{remoteOllamaEnabled && (
<div className="mt-3">
<input
type="text"
value={remoteOllamaUrl}
onChange={(e) => {
setRemoteOllamaUrl(e.target.value)
setRemoteOllamaUrlError(null)
}}
placeholder="http://192.168.1.100:11434"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-desert-green"
/>
{remoteOllamaUrlError && (
<p className="mt-1 text-xs text-red-600">{remoteOllamaUrlError}</p>
)}
</div>
)}
</div>
)}
</div>
)
}
return renderCapabilityCard(capability, true)
})}
</div>
</div>
)}
@ -815,8 +844,14 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<p className="text-sm text-text-muted">Select models to download for offline AI</p>
</div>
</div>
{isLoadingRecommendedModels ? (
{remoteOllamaEnabled && remoteOllamaUrl ? (
<Alert
title="Remote Ollama selected"
message="Models are managed on the remote machine. You can add models from Settings > AI Assistant after setup, note this is only supported when using Ollama, not LM Studio and other OpenAI API software."
type="info"
variant="bordered"
/>
) : isLoadingRecommendedModels ? (
<div className="flex justify-center py-12">
<LoadingSpinner />
</div>

View File

@ -6,7 +6,7 @@ import {
IconSettings,
IconWifiOff,
} from '@tabler/icons-react'
import { Head, usePage } from '@inertiajs/react'
import { Head, Link, router, usePage } from '@inertiajs/react'
import AppLayout from '~/layouts/AppLayout'
import { getServiceLink } from '~/lib/navigation'
import { ServiceSlim } from '../../types/services'
@ -146,9 +146,7 @@ export default function Home(props: {
variant: 'primary',
children: 'Go to Settings',
icon: 'IconSettings',
onClick: () => {
window.location.href = '/settings/update'
},
onClick: () => router.visit('/settings/update'),
}}
/>
</div>
@ -159,26 +157,34 @@ export default function Home(props: {
const isEasySetup = item.label === 'Easy Setup'
const shouldHighlight = isEasySetup && shouldHighlightEasySetup
return (
<a key={item.label} href={item.to} target={item.target}>
<div className="relative rounded border-desert-green border-2 bg-desert-green hover:bg-transparent hover:text-text-primary text-white transition-colors shadow-sm h-48 flex flex-col items-center justify-center cursor-pointer text-center px-4">
{shouldHighlight && (
<span className="absolute top-2 right-2 flex items-center justify-center">
<span
className="animate-ping absolute inline-flex w-16 h-6 rounded-full bg-desert-orange-light opacity-75"
style={{ animationDuration: '1.5s' }}
></span>
<span className="relative inline-flex items-center rounded-full px-2.5 py-1 bg-desert-orange-light text-xs font-semibold text-white shadow-sm">
Start here!
</span>
const tileContent = (
<div className="relative rounded border-desert-green border-2 bg-desert-green hover:bg-transparent hover:text-text-primary text-white transition-colors shadow-sm h-48 flex flex-col items-center justify-center cursor-pointer text-center px-4">
{shouldHighlight && (
<span className="absolute top-2 right-2 flex items-center justify-center">
<span
className="animate-ping absolute inline-flex w-16 h-6 rounded-full bg-desert-orange-light opacity-75"
style={{ animationDuration: '1.5s' }}
></span>
<span className="relative inline-flex items-center rounded-full px-2.5 py-1 bg-desert-orange-light text-xs font-semibold text-white shadow-sm">
Start here!
</span>
)}
<div className="flex items-center justify-center mb-2">{item.icon}</div>
<h3 className="font-bold text-2xl">{item.label}</h3>
{item.poweredBy && <p className="text-sm opacity-80">Powered by {item.poweredBy}</p>}
<p className="xl:text-lg mt-2">{item.description}</p>
</div>
</span>
)}
<div className="flex items-center justify-center mb-2">{item.icon}</div>
<h3 className="font-bold text-2xl">{item.label}</h3>
{item.poweredBy && <p className="text-sm opacity-80">Powered by {item.poweredBy}</p>}
<p className="xl:text-lg mt-2">{item.description}</p>
</div>
)
return item.target === '_blank' ? (
<a key={item.label} href={item.to} target="_blank" rel="noopener noreferrer">
{tileContent}
</a>
) : (
<Link key={item.label} href={item.to}>
{tileContent}
</Link>
)
})}
</div>

View File

@ -1,5 +1,5 @@
import MapsLayout from '~/layouts/MapsLayout'
import { Head, Link } from '@inertiajs/react'
import { Head, Link, router } from '@inertiajs/react'
import MapComponent from '~/components/maps/MapComponent'
import StyledButton from '~/components/StyledButton'
import { IconArrowLeft } from '@tabler/icons-react'
@ -42,9 +42,7 @@ export default function Maps(props: {
variant: 'secondary',
children: 'Go to Map Settings',
icon: 'IconSettings',
onClick: () => {
window.location.href = '/settings/maps'
},
onClick: () => router.visit('/settings/maps'),
}}
/>
</div>

View File

@ -1,5 +1,5 @@
import { Head, Link, usePage } from '@inertiajs/react'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import SettingsLayout from '~/layouts/SettingsLayout'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import CircularGauge from '~/components/systeminfo/CircularGauge'
@ -40,6 +40,7 @@ export default function BenchmarkPage(props: {
const aiInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
const [progress, setProgress] = useState<BenchmarkProgressWithID | null>(null)
const [isRunning, setIsRunning] = useState(props.benchmark.status !== 'idle')
const refetchLatestRef = useRef<(() => void) | null>(null)
const [showDetails, setShowDetails] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showAIRequiredAlert, setShowAIRequiredAlert] = useState(false)
@ -60,6 +61,7 @@ export default function BenchmarkPage(props: {
},
initialData: props.benchmark.latestResult,
})
refetchLatestRef.current = refetchLatest
// Fetch all benchmark results for history
const { data: benchmarkHistory } = useQuery({
@ -306,14 +308,15 @@ export default function BenchmarkPage(props: {
setProgress(data)
if (data.status === 'completed' || data.status === 'error') {
setIsRunning(false)
refetchLatest()
refetchLatestRef.current?.()
}
})
return () => {
unsubscribe()
}
}, [subscribe, refetchLatest])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscribe])
const formatBytes = (bytes: number) => {
const gb = bytes / (1024 * 1024 * 1024)

View File

@ -16,8 +16,10 @@ import CuratedCollectionCard from '~/components/CuratedCollectionCard'
import type { CollectionWithStatus } from '../../../types/collections'
import ActiveDownloads from '~/components/ActiveDownloads'
import Alert from '~/components/Alert'
import { formatBytes } from '~/lib/util'
const CURATED_COLLECTIONS_KEY = 'curated-map-collections'
const GLOBAL_MAP_INFO_KEY = 'global-map-info'
export default function MapsManager(props: {
maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }
@ -38,6 +40,31 @@ export default function MapsManager(props: {
enabled: true,
})
const { data: globalMapInfo } = useQuery({
queryKey: [GLOBAL_MAP_INFO_KEY],
queryFn: () => api.getGlobalMapInfo(),
refetchOnWindowFocus: false,
})
const downloadGlobalMap = useMutation({
mutationFn: () => api.downloadGlobalMap(),
onSuccess: () => {
invalidateDownloads()
addNotification({
type: 'success',
message: 'Global map download has been queued. This is a large file (~125 GB) and may take a while.',
})
closeAllModals()
},
onError: (error) => {
console.error('Error downloading global map:', error)
addNotification({
type: 'error',
message: 'Failed to start the global map download. Please try again.',
})
},
})
async function downloadBaseAssets() {
try {
setDownloading(true)
@ -146,6 +173,29 @@ export default function MapsManager(props: {
)
}
async function confirmGlobalMapDownload() {
if (!globalMapInfo) return
openModal(
<StyledModal
title="Download Global Map?"
onConfirm={() => downloadGlobalMap.mutate()}
onCancel={closeAllModals}
open={true}
confirmText="Download"
cancelText="Cancel"
confirmVariant="primary"
confirmLoading={downloadGlobalMap.isPending}
>
<p className="text-text-secondary">
This will download the full Protomaps global map ({formatBytes(globalMapInfo.size, 1)}, build {globalMapInfo.date}).
Covers the entire planet so you won't need individual region files.
Make sure you have enough disk space.
</p>
</StyledModal>,
'confirm-global-map-download-modal'
)
}
async function openDownloadModal() {
openModal(
<DownloadURLModal
@ -201,6 +251,23 @@ export default function MapsManager(props: {
}}
/>
)}
{globalMapInfo && (
<Alert
title="Global Map Coverage Available"
message={`Download a complete worldwide map from Protomaps (${formatBytes(globalMapInfo.size, 1)}, build ${globalMapInfo.date}). This is a large file but covers the entire planet — no individual region downloads needed.`}
type="info-inverted"
variant="bordered"
className="mt-8"
icon="IconWorld"
buttonProps={{
variant: 'primary',
children: 'Download Global Map',
icon: 'IconCloudDownload',
loading: downloadGlobalMap.isPending,
onClick: () => confirmGlobalMapDownload(),
}}
/>
)}
<div className="mt-8 mb-6 flex items-center justify-between">
<StyledSectionHeader title="Curated Map Regions" className="!mb-0" />
<StyledButton

View File

@ -10,13 +10,14 @@ import { useNotifications } from '~/context/NotificationContext'
import api from '~/lib/api'
import { useModals } from '~/context/ModalContext'
import StyledModal from '~/components/StyledModal'
import { ModelResponse } from 'ollama'
import type { NomadInstalledModel } from '../../../types/ollama'
import { SERVICE_NAMES } from '../../../constants/service_names'
import Switch from '~/components/inputs/Switch'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import { useMutation, useQuery } from '@tanstack/react-query'
import Input from '~/components/inputs/Input'
import { IconSearch, IconRefresh } from '@tabler/icons-react'
import { formatBytes } from '~/lib/util'
import useDebounce from '~/hooks/useDebounce'
import ActiveModelDownloads from '~/components/ActiveModelDownloads'
import { useSystemInfo } from '~/hooks/useSystemInfo'
@ -24,8 +25,8 @@ import { useSystemInfo } from '~/hooks/useSystemInfo'
export default function ModelsPage(props: {
models: {
availableModels: NomadOllamaModel[]
installedModels: ModelResponse[]
settings: { chatSuggestionsEnabled: boolean; aiAssistantCustomName: string }
installedModels: NomadInstalledModel[]
settings: { chatSuggestionsEnabled: boolean; aiAssistantCustomName: string; remoteOllamaUrl: string; ollamaFlashAttention: boolean }
}
}) {
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
@ -94,9 +95,49 @@ export default function ModelsPage(props: {
const [chatSuggestionsEnabled, setChatSuggestionsEnabled] = useState(
props.models.settings.chatSuggestionsEnabled
)
const [ollamaFlashAttention, setOllamaFlashAttention] = useState(
props.models.settings.ollamaFlashAttention
)
const [aiAssistantCustomName, setAiAssistantCustomName] = useState(
props.models.settings.aiAssistantCustomName
)
const [remoteOllamaUrl, setRemoteOllamaUrl] = useState(props.models.settings.remoteOllamaUrl)
const [remoteOllamaError, setRemoteOllamaError] = useState<string | null>(null)
const [remoteOllamaSaving, setRemoteOllamaSaving] = useState(false)
async function handleSaveRemoteOllama() {
setRemoteOllamaError(null)
setRemoteOllamaSaving(true)
try {
const res = await api.configureRemoteOllama(remoteOllamaUrl || null)
if (res?.success) {
addNotification({ message: res.message, type: 'success' })
router.reload()
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Failed to configure remote Ollama.'
setRemoteOllamaError(msg)
} finally {
setRemoteOllamaSaving(false)
}
}
async function handleClearRemoteOllama() {
setRemoteOllamaError(null)
setRemoteOllamaSaving(true)
try {
const res = await api.configureRemoteOllama(null)
if (res?.success) {
setRemoteOllamaUrl('')
addNotification({ message: 'Remote Ollama configuration cleared.', type: 'success' })
router.reload()
}
} catch (error: any) {
setRemoteOllamaError(error?.message || 'Failed to clear remote Ollama.')
} finally {
setRemoteOllamaSaving(false)
}
}
const [query, setQuery] = useState('')
const [queryUI, setQueryUI] = useState('')
@ -270,6 +311,15 @@ export default function ModelsPage(props: {
label="Chat Suggestions"
description="Display AI-generated conversation starters in the chat interface"
/>
<Switch
checked={ollamaFlashAttention}
onChange={(newVal) => {
setOllamaFlashAttention(newVal)
updateSettingMutation.mutate({ key: 'ai.ollamaFlashAttention', value: newVal })
}}
label="Flash Attention"
description="Enables OLLAMA_FLASH_ATTENTION=1 for improved memory efficiency. Disable if you experience instability. Takes effect after reinstalling the AI Assistant."
/>
<Input
name="aiAssistantCustomName"
label="Assistant Name"
@ -286,9 +336,119 @@ export default function ModelsPage(props: {
/>
</div>
</div>
<StyledSectionHeader title="Installed Models" className="mt-12 mb-4" />
<div className="bg-surface-primary rounded-lg border-2 border-border-subtle p-6">
{props.models.installedModels.length === 0 ? (
<p className="text-text-muted">
No models installed. Browse the model catalog below to get started.
</p>
) : (
<table className="min-w-full divide-y divide-border-subtle">
<thead>
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Model
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Parameters
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Disk Size
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">
Action
</th>
</tr>
</thead>
<tbody className="divide-y divide-border-subtle">
{props.models.installedModels.map((model) => (
<tr key={model.name} className="hover:bg-surface-secondary">
<td className="px-4 py-3">
<span className="text-sm font-medium text-text-primary">{model.name}</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-text-secondary">
{model.details.parameter_size || 'N/A'}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-text-secondary">
{formatBytes(model.size)}
</span>
</td>
<td className="px-4 py-3 text-right">
<StyledButton
variant="danger"
size="sm"
onClick={() => confirmDeleteModel(model.name)}
icon="IconTrash"
>
Delete
</StyledButton>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<StyledSectionHeader title="Remote Connection" className="mt-8 mb-4" />
<div className="bg-surface-primary rounded-lg border-2 border-border-subtle p-6">
<p className="text-sm text-text-secondary mb-4">
Connect to any OpenAI-compatible API server Ollama, LM Studio, llama.cpp, and others are all supported.
For remote Ollama instances, the host must be started with <code className="bg-surface-secondary px-1 rounded">OLLAMA_HOST=0.0.0.0</code>.
</p>
<div className="flex items-end gap-3">
<div className="flex-1">
<Input
name="remoteOllamaUrl"
label="Remote Ollama/OpenAI API URL"
placeholder="http://192.168.1.100:11434 (or :1234 for OpenAI API Compatible Apps)"
value={remoteOllamaUrl}
onChange={(e) => {
setRemoteOllamaUrl(e.target.value)
setRemoteOllamaError(null)
}}
/>
{remoteOllamaError && (
<p className="text-sm text-red-600 mt-1">{remoteOllamaError}</p>
)}
</div>
<StyledButton
variant="primary"
onClick={handleSaveRemoteOllama}
loading={remoteOllamaSaving}
disabled={remoteOllamaSaving || !remoteOllamaUrl}
className="mb-0.5"
>
Save &amp; Test
</StyledButton>
{props.models.settings.remoteOllamaUrl && (
<StyledButton
variant="danger"
onClick={handleClearRemoteOllama}
loading={remoteOllamaSaving}
disabled={remoteOllamaSaving}
className="mb-0.5"
>
Clear
</StyledButton>
)}
</div>
</div>
<ActiveModelDownloads withHeader />
<StyledSectionHeader title="Models" className="mt-12 mb-4" />
<Alert
type="info"
variant="bordered"
title="Model downloading is only supported when using a Ollama backend."
message="If you are connected to an OpenAI API host (e.g. LM Studio), please download models directly in that application."
className="mb-4"
/>
<div className="flex justify-start items-center gap-3 mt-4">
<Input
name="search"

View File

@ -36,13 +36,13 @@ export default function SupportPage() {
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-3">Need Help With Your Home Network?</h2>
<a
href="https://roguesupport.com"
href="https://rogue.support"
target="_blank"
rel="noopener noreferrer"
className="block mb-4 rounded-lg overflow-hidden hover:opacity-90 transition-opacity"
>
<img
src="/rogue-support-banner.png"
src="/rogue-support-banner.webp"
alt="Rogue Support — Conquer Your Home Network"
className="w-full"
/>
@ -52,12 +52,12 @@ export default function SupportPage() {
Think of it as Uber for computer networking expert help when you need it.
</p>
<a
href="https://roguesupport.com"
href="https://rogue.support"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-blue-600 hover:underline font-medium"
>
Visit RogueSupport.com
Visit Rogue.Support
<IconExternalLink size={16} />
</a>
</section>

View File

@ -3,6 +3,7 @@ import { Head } from '@inertiajs/react'
import SettingsLayout from '~/layouts/SettingsLayout'
import { SystemInformationResponse } from '../../../types/system'
import { formatBytes } from '~/lib/util'
import { getAllDiskDisplayItems } from '~/hooks/useDiskDisplayData'
import CircularGauge from '~/components/systeminfo/CircularGauge'
import HorizontalBarChart from '~/components/HorizontalBarChart'
import InfoCard from '~/components/systeminfo/InfoCard'
@ -105,42 +106,7 @@ export default function SettingsPage(props: {
: `${uptimeMinutes}m`
// Build storage display items - fall back to fsSize when disk array is empty
// (Same approach as Easy Setup wizard fix from PR #90)
const validDisks = info?.disk?.filter((d) => d.totalSize > 0) || []
let storageItems: {
label: string
value: number
total: string
used: string
subtext: string
}[] = []
if (validDisks.length > 0) {
storageItems = validDisks.map((disk) => ({
label: disk.name || 'Unknown',
value: disk.percentUsed || 0,
total: disk.totalSize ? formatBytes(disk.totalSize) : 'N/A',
used: disk.totalUsed ? formatBytes(disk.totalUsed) : 'N/A',
subtext: `${formatBytes(disk.totalUsed || 0)} / ${formatBytes(disk.totalSize || 0)}`,
}))
} else if (info?.fsSize && info.fsSize.length > 0) {
// Deduplicate by size (same physical disk mounted in multiple places shows identical sizes)
const seen = new Set<number>()
const uniqueFs = info.fsSize.filter((fs) => {
if (fs.size <= 0 || seen.has(fs.size)) return false
seen.add(fs.size)
return true
})
// Prefer real block devices (/dev/), exclude virtual filesystems (efivarfs, tmpfs, etc.)
const realDevices = uniqueFs.filter((fs) => fs.fs.startsWith('/dev/'))
const displayFs = realDevices.length > 0 ? realDevices : uniqueFs
storageItems = displayFs.map((fs) => ({
label: fs.fs || 'Unknown',
value: fs.use || 0,
total: formatBytes(fs.size),
used: formatBytes(fs.used),
subtext: `${formatBytes(fs.used)} / ${formatBytes(fs.size)}`,
}))
}
const storageItems = getAllDiskDisplayItems(info?.disk, info?.fsSize)
return (
<SettingsLayout>

341
admin/package-lock.json generated
View File

@ -42,18 +42,21 @@
"better-sqlite3": "^12.1.1",
"bullmq": "^5.65.1",
"cheerio": "^1.2.0",
"compression": "^1.8.1",
"dockerode": "^4.0.7",
"edge.js": "^6.2.1",
"fast-xml-parser": "^5.5.6",
"fast-xml-parser": "^5.5.7",
"fuse.js": "^7.1.0",
"jszip": "^3.10.1",
"luxon": "^3.6.1",
"maplibre-gl": "^4.7.1",
"mysql2": "^3.14.1",
"ollama": "^0.6.3",
"openai": "^6.27.0",
"pdf-parse": "^2.4.5",
"pdf2pic": "^3.2.0",
"pino-pretty": "^13.0.0",
"pmtiles": "^4.3.0",
"pmtiles": "^4.4.0",
"postcss": "^8.5.6",
"react": "^19.1.0",
"react-adonis-transmit": "^1.0.1",
@ -65,11 +68,11 @@
"sharp": "^0.34.5",
"stopword": "^3.1.5",
"systeminformation": "^5.31.0",
"tailwindcss": "^4.1.10",
"tailwindcss": "^4.2.1",
"tar": "^7.5.11",
"tesseract.js": "^7.0.0",
"url-join": "^5.0.0",
"yaml": "^2.8.0"
"yaml": "^2.8.3"
},
"devDependencies": {
"@adonisjs/assembler": "^7.8.2",
@ -81,7 +84,8 @@
"@japa/runner": "^4.2.0",
"@swc/core": "1.11.24",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@types/dockerode": "^3.3.41",
"@types/compression": "^1.8.1",
"@types/dockerode": "^4.0.1",
"@types/luxon": "^3.6.2",
"@types/node": "^22.15.18",
"@types/react": "^19.1.8",
@ -4609,6 +4613,12 @@
"tailwindcss": "4.1.18"
}
},
"node_modules/@tailwindcss/node/node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT"
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
@ -4905,6 +4915,12 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@tailwindcss/vite/node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT"
},
"node_modules/@tanstack/eslint-plugin-query": {
"version": "5.91.4",
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.91.4.tgz",
@ -5130,6 +5146,17 @@
"@types/node": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@ -5141,6 +5168,27 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@ -5169,9 +5217,9 @@
}
},
"node_modules/@types/dockerode": {
"version": "3.3.47",
"resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.47.tgz",
"integrity": "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-4.0.1.tgz",
"integrity": "sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -5195,6 +5243,31 @@
"@types/estree": "*"
}
},
"node_modules/@types/express": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
"@types/serve-static": "^2"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@ -5225,6 +5298,13 @@
"integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA==",
"license": "MIT"
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@ -5379,6 +5459,13 @@
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
@ -5398,6 +5485,27 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*"
}
},
"node_modules/@types/ssh2": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
@ -7209,6 +7317,60 @@
"devOptional": true,
"license": "ISC"
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -7307,6 +7469,12 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
@ -8635,9 +8803,9 @@
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.6",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz",
"integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==",
"version": "5.5.9",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz",
"integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==",
"funding": [
{
"type": "github",
@ -8647,8 +8815,8 @@
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.1.3",
"strnum": "^2.1.2"
"path-expression-matcher": "^1.2.0",
"strnum": "^2.2.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
@ -8726,9 +8894,9 @@
}
},
"node_modules/file-type": {
"version": "21.3.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz",
"integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==",
"version": "21.3.2",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.2.tgz",
"integrity": "sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.4.1",
@ -9810,6 +9978,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -10134,6 +10308,12 @@
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -10366,6 +10546,48 @@
"node": "*"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/junk": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/junk/-/junk-4.0.1.tgz",
@ -10533,6 +10755,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@ -12616,6 +12847,15 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -12640,6 +12880,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openai": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz",
"integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
@ -12785,6 +13046,12 @@
"quansync": "^0.2.7"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -12956,9 +13223,9 @@
}
},
"node_modules/path-expression-matcher": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz",
"integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz",
"integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==",
"funding": [
{
"type": "github",
@ -13204,9 +13471,9 @@
}
},
"node_modules/pmtiles": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.3.2.tgz",
"integrity": "sha512-Ath2F2U2E37QyNXjN1HOF+oLiNIbdrDYrk/K3C9K4Pgw2anwQX10y4WYWEH9O75vPiu0gBbSWIAbSG19svyvZg==",
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.4.0.tgz",
"integrity": "sha512-tCLI1C5134MR54i8izUWhse0QUtO/EC33n9yWp1N5dYLLvyc197U0fkF5gAJhq1TdWO9Tvl+9hgvFvM0fR27Zg==",
"license": "BSD-3-Clause",
"dependencies": {
"fflate": "^0.8.2"
@ -13436,6 +13703,12 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
@ -14401,6 +14674,12 @@
"node": ">=0.10.0"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -15148,9 +15427,9 @@
}
},
"node_modules/strnum": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
"funding": [
{
"type": "github",
@ -15287,9 +15566,9 @@
}
},
"node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"license": "MIT"
},
"node_modules/tapable": {
@ -16469,9 +16748,9 @@
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"

View File

@ -47,7 +47,8 @@
"@japa/runner": "^4.2.0",
"@swc/core": "1.11.24",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@types/dockerode": "^3.3.41",
"@types/compression": "^1.8.1",
"@types/dockerode": "^4.0.1",
"@types/luxon": "^3.6.2",
"@types/node": "^22.15.18",
"@types/react": "^19.1.8",
@ -94,18 +95,21 @@
"better-sqlite3": "^12.1.1",
"bullmq": "^5.65.1",
"cheerio": "^1.2.0",
"compression": "^1.8.1",
"dockerode": "^4.0.7",
"edge.js": "^6.2.1",
"fast-xml-parser": "^5.5.6",
"fast-xml-parser": "^5.5.7",
"fuse.js": "^7.1.0",
"jszip": "^3.10.1",
"luxon": "^3.6.1",
"maplibre-gl": "^4.7.1",
"mysql2": "^3.14.1",
"ollama": "^0.6.3",
"openai": "^6.27.0",
"pdf-parse": "^2.4.5",
"pdf2pic": "^3.2.0",
"pino-pretty": "^13.0.0",
"pmtiles": "^4.3.0",
"pmtiles": "^4.4.0",
"postcss": "^8.5.6",
"react": "^19.1.0",
"react-adonis-transmit": "^1.0.1",
@ -117,11 +121,11 @@
"sharp": "^0.34.5",
"stopword": "^3.1.5",
"systeminformation": "^5.31.0",
"tailwindcss": "^4.1.10",
"tailwindcss": "^4.2.1",
"tar": "^7.5.11",
"tesseract.js": "^7.0.0",
"url-join": "^5.0.0",
"yaml": "^2.8.0"
"yaml": "^2.8.3"
},
"hotHook": {
"boundaries": [

View File

@ -0,0 +1,53 @@
import logger from '@adonisjs/core/services/logger'
import type { ApplicationService } from '@adonisjs/core/types'
/**
* Checks whether the installed kiwix container is still using the legacy glob-pattern
* command (`*.zim --address=all`) and, if so, migrates it to library mode
* (`--library /data/kiwix-library.xml --monitorLibrary --address=all`) automatically.
*
* This provider runs once on every admin startup. After migration the check is a no-op
* (inspects the container and finds the new command).
*/
export default class KiwixMigrationProvider {
constructor(protected app: ApplicationService) {}
async boot() {
// Only run in the web (HTTP server) environment — skip for ace commands and tests
if (this.app.getEnvironment() !== 'web') return
// Defer past synchronous boot so DB connections and all providers are fully ready
setImmediate(async () => {
try {
const Service = (await import('#models/service')).default
const { SERVICE_NAMES } = await import('../constants/service_names.js')
const { DockerService } = await import('#services/docker_service')
const kiwixService = await Service.query()
.where('service_name', SERVICE_NAMES.KIWIX)
.first()
if (!kiwixService?.installed) {
logger.info('[KiwixMigrationProvider] Kiwix not installed — skipping migration check.')
return
}
const dockerService = new DockerService()
const isLegacy = await dockerService.isKiwixOnLegacyConfig()
if (!isLegacy) {
logger.info('[KiwixMigrationProvider] Kiwix is already in library mode — no migration needed.')
return
}
logger.info('[KiwixMigrationProvider] Kiwix on legacy config — running automatic migration to library mode.')
await dockerService.migrateKiwixToLibraryMode()
logger.info('[KiwixMigrationProvider] Startup migration complete.')
} catch (err: any) {
logger.error(`[KiwixMigrationProvider] Startup migration failed: ${err.message}`)
// Non-fatal: the next affectContainer('restart') call will retry via the
// intercept in DockerService.affectContainer().
}
})
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

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