From 90946ecf5a9bbfcf6bd5b3d22c6661045c8583c0 Mon Sep 17 00:00:00 2001 From: chriscrosstalk <49691103+chriscrosstalk@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:37:57 -0700 Subject: [PATCH 1/8] docs: require linked issue for non-trivial PRs (#799) Tightens the existing "open an issue first" guidance: non-trivial PRs must reference a corresponding issue and may be closed without one. Adds an explicit carveout for trivial fixes (typos, doc clarifications, small one-line bugs) so drive-by improvements still flow through. --- CONTRIBUTING.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1fda7fb..46e0558 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,14 @@ We are committed to providing a welcoming environment for everyone. Disrespectfu ## Before You Start -**Open an issue first.** Before writing any code, please [open an issue](../../issues/new) to discuss your proposed change. This helps avoid duplicate work and ensures your contribution aligns with the project's direction. +**Open an issue first.** Before writing any code for a non-trivial change, you must [open an issue](../../issues/new) to discuss your proposed change. This helps avoid duplicate work and ensures your contribution aligns with the project's direction. **Pull requests submitted without a corresponding issue may be closed at the maintainers' discretion.** + +**Trivial fixes are exempt** and may be submitted directly as a PR. Examples: +- Typo and grammar corrections +- Documentation clarifications +- Small one-line bug fixes with an obvious cause + +If you're not sure whether your change qualifies as trivial, open an issue first. When opening an issue: - Use a clear, descriptive title @@ -149,7 +156,7 @@ This project uses [Semantic Versioning](https://semver.org/). Versions are manag 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`) + - Reference the related issue (e.g., `Closes #123`) — required for non-trivial changes - Note any relevant testing steps or environment details 4. Be responsive to feedback — maintainers may request changes. Pull requests with no activity for an extended period may be closed. From b168001450e96ea52f53036416f9afd423d304c4 Mon Sep 17 00:00:00 2001 From: chriscrosstalk <49691103+chriscrosstalk@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:39:28 -0700 Subject: [PATCH 2/8] fix(install): warn loudly on non-x86_64 architectures before pulling images (#797) Detects the host architecture early in the preflight sequence. On any architecture other than x86_64/amd64, prints a 5-line warning that NOMAD officially supports x86_64 only, points at PR #419, and sleeps 10 seconds before continuing. Ctrl+C aborts cleanly before any Docker work happens. Preserves the community/hacker path: ARM64 users running with QEMU binfmt_misc emulation can still let the install proceed. The change just stops the silent 2.7GB amd64 pull on architectures where it will not work, which leaves partial images and /opt/project-nomad/ debris that confuse first-time users. Reported in #782. --- install/install_nomad.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/install/install_nomad.sh b/install/install_nomad.sh index 76ca807..ced178f 100644 --- a/install/install_nomad.sh +++ b/install/install_nomad.sh @@ -86,6 +86,21 @@ check_is_debian_based() { echo -e "${GREEN}#${RESET} This script is running on a Debian-based system.\\n" } +check_is_x86_64() { + local arch + arch="$(uname -m)" + if [[ "${arch}" != "x86_64" && "${arch}" != "amd64" ]]; then + echo -e "${YELLOW}#${RESET} WARNING: Detected architecture '${arch}'. NOMAD officially supports x86_64 only.\\n" + echo -e "${YELLOW}#${RESET} ARM64/aarch64 support is tracked in PR #419 and is not yet ready.\\n" + echo -e "${YELLOW}#${RESET} Continuing on an unsupported architecture will likely fail and may leave\\n" + echo -e "${YELLOW}#${RESET} partial Docker images and files behind that you'll need to clean up manually.\\n" + echo -e "${YELLOW}#${RESET} Continuing in 10 seconds... press Ctrl+C now to abort.\\n" + sleep 10 + return + fi + echo -e "${GREEN}#${RESET} Architecture check passed (${arch}).\\n" +} + ensure_dependencies_installed() { local missing_deps=() @@ -539,6 +554,7 @@ success_message() { # Pre-flight checks check_is_debian_based +check_is_x86_64 check_is_bash check_has_sudo ensure_dependencies_installed From 3bacd14dbd3e2fb1b41a04c7e0f9e42ae6de7696 Mon Sep 17 00:00:00 2001 From: chriscrosstalk <49691103+chriscrosstalk@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:49:51 -0700 Subject: [PATCH 3/8] feat(content-manager): add sortable file size column (#698) Closes #685 Content Manager now surfaces the on-disk size of each ZIM file alongside title/summary, and lets users sort the list by Size or Title. Defaults to Size descending so the largest files are visible first. - ZimService.list() now stats each file and returns size_bytes - Content Manager table adds a formatted Size column (via formatBytes) - Sortable headers for Title and Size with asc/desc toggle --- admin/app/services/zim_service.ts | 16 +++++- admin/inertia/pages/settings/zim/index.tsx | 64 +++++++++++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/admin/app/services/zim_service.ts b/admin/app/services/zim_service.ts index c6f427c..db6b5b7 100644 --- a/admin/app/services/zim_service.ts +++ b/admin/app/services/zim_service.ts @@ -40,7 +40,21 @@ export class ZimService { await ensureDirectoryExists(dirPath) const all = await listDirectoryContents(dirPath) - const files = all.filter((item) => item.name.endsWith('.zim')) + const zimEntries = all.filter((item) => item.name.endsWith('.zim')) + + const files = await Promise.all( + zimEntries.map(async (entry) => { + const filePath = entry.type === 'file' ? entry.key : join(dirPath, entry.name) + const stats = await getFileStatsIfExists(filePath) + return { + ...entry, + title: null, + summary: null, + author: null, + size_bytes: stats ? Number(stats.size) : null, + } + }) + ) return { files, diff --git a/admin/inertia/pages/settings/zim/index.tsx b/admin/inertia/pages/settings/zim/index.tsx index 9d22937..68ae2b1 100644 --- a/admin/inertia/pages/settings/zim/index.tsx +++ b/admin/inertia/pages/settings/zim/index.tsx @@ -1,5 +1,6 @@ import { Head } from '@inertiajs/react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMemo, useState } from 'react' import StyledTable from '~/components/StyledTable' import SettingsLayout from '~/layouts/SettingsLayout' import api from '~/lib/api' @@ -10,11 +11,18 @@ import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus' import Alert from '~/components/Alert' import { ZimFileWithMetadata } from '../../../../types/zim' import { SERVICE_NAMES } from '../../../../constants/service_names' +import { formatBytes } from '~/lib/util' +import { IconArrowDown, IconArrowUp, IconArrowsSort } from '@tabler/icons-react' + +type SortKey = 'name' | 'size' +type SortDirection = 'asc' | 'desc' export default function ZimPage() { const queryClient = useQueryClient() const { openModal, closeAllModals } = useModals() const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX) + const [sortKey, setSortKey] = useState('size') + const [sortDirection, setSortDirection] = useState('desc') const { data, isLoading } = useQuery({ queryKey: ['zim-files'], queryFn: getFiles, @@ -25,6 +33,49 @@ export default function ZimPage() { return res.data.files } + const sortedData = useMemo(() => { + if (!data) return [] + const copy = [...data] + copy.sort((a, b) => { + let cmp = 0 + if (sortKey === 'size') { + const aSize = a.size_bytes ?? 0 + const bSize = b.size_bytes ?? 0 + cmp = aSize - bSize + } else { + const aName = (a.title || a.name).toLowerCase() + const bName = (b.title || b.name).toLowerCase() + cmp = aName.localeCompare(bName) + } + return sortDirection === 'asc' ? cmp : -cmp + }) + return copy + }, [data, sortKey, sortDirection]) + + function toggleSort(key: SortKey) { + if (sortKey === key) { + setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc')) + } else { + setSortKey(key) + setSortDirection(key === 'size' ? 'desc' : 'asc') + } + } + + function renderSortHeader(label: string, key: SortKey) { + const active = sortKey === key + const Icon = !active ? IconArrowsSort : sortDirection === 'asc' ? IconArrowUp : IconArrowDown + return ( + + ) + } + async function confirmDeleteFile(file: ZimFileWithMetadata) { openModal( ( {record.title || record.name} @@ -99,6 +150,15 @@ export default function ZimPage() { ), }, + { + accessor: 'size_bytes', + title: renderSortHeader('Size', 'size'), + render: (record) => ( + + {record.size_bytes ? formatBytes(record.size_bytes, 1) : '—'} + + ), + }, { accessor: 'actions', title: 'Actions', @@ -117,7 +177,7 @@ export default function ZimPage() { ), }, ]} - data={data || []} + data={sortedData} /> From 00b4b26224b0bb47cc850d16d10af87f5e3f3d3c Mon Sep 17 00:00:00 2001 From: chriscrosstalk <49691103+chriscrosstalk@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:00:31 -0700 Subject: [PATCH 4/8] fix(API): skip compression for Server-Sent Events (#798) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(stream): skip compression for Server-Sent Events The global compression middleware (added in v1.31.0-rc.2) buffers response writes to determine encoding, which collapses per-token streaming into a single block delivered after generation completes. This broke the AI chat streaming UX from v1.31.0-rc.2 onward — text no longer appears progressively as the model generates it, only at the end. Adds a filter to compression() that returns false when the response Content-Type is text/event-stream. Other responses still go through the default compression filter (compressible types are still compressed; e.g. text/html via Brotli). Reproduced on NOMAD3 v1.31.1: before fix, all SSE chunks for a 1B model arrive within 10ms of each other after the model finishes. After fix, tokens arrive at ~150ms intervals as they're generated on a 12B model, with no Content-Encoding header on the SSE response. Verified on the same host that /home still returns Content-Encoding: br for HTML responses. Closes #781. Reported and bisected by @toasterking (works in v1.31.0-rc.1, broken from v1.31.0-rc.2 onward). * fix(stream): use any for filter params to match existing as-any pattern The compression library types its filter as (req: Request, res: Response) expecting Express types, but AdonisJS passes raw IncomingMessage/ServerResponse which is why the surrounding middleware uses `as any` casts at the call site. The IncomingMessage/ServerResponse types I added are runtime-correct but fail tsc against the library's declared types. Drop the typed import in favor of `any` parameters, which matches how the existing `compress(request.request as any, response.response as any, ...)` call resolves the same mismatch. --- admin/app/middleware/compression_middleware.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/admin/app/middleware/compression_middleware.ts b/admin/app/middleware/compression_middleware.ts index 0661ac7..9c11411 100644 --- a/admin/app/middleware/compression_middleware.ts +++ b/admin/app/middleware/compression_middleware.ts @@ -3,7 +3,21 @@ 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() +// Skip compression for Server-Sent Events. The compression library buffers +// response writes to determine encoding, which collapses per-token streaming +// into a single block delivered after generation completes (regression in +// v1.31.0-rc.2, reported in #781 by @toasterking). +const compress = env.get('DISABLE_COMPRESSION') + ? null + : compression({ + filter: (req: any, res: any) => { + const contentType = res.getHeader('Content-Type') + if (typeof contentType === 'string' && contentType.includes('text/event-stream')) { + return false + } + return compression.filter(req, res) + }, + }) export default class CompressionMiddleware { async handle({ request, response }: HttpContext, next: NextFn) { From b194dfa136aee0bfadcc4e5eb011de077de4c8ef Mon Sep 17 00:00:00 2001 From: chriscrosstalk <49691103+chriscrosstalk@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:43:10 -0700 Subject: [PATCH 5/8] fix(RAG): pass num_ctx and truncate to Ollama embed call (#763) Some Ollama installs ship nomic-embed-text:v1.5 with the embedding model's default num_ctx=2048, which the RAG chunker (sized for ~1500 tokens of estimated content with ratio=2 chars/token) can exceed on dense PDFs. The result is `400 the input length exceeds the context length` from /api/embed, which then hits the OpenAI-compatible fallback (which also errors), and surfaces as a BadRequestError. Pass options.num_ctx=8192 (nomic-embed-text v1.5's RoPE-extrapolated max) and truncate=true (silent truncation safety net) on every embed call so we don't depend on the local modelfile defaults. Reported on #756 by @NC4WD; same root cause as #369 and #670 which were closed without an actual fix. Co-authored-by: Claude Opus 4.7 (1M context) --- admin/app/services/ollama_service.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/admin/app/services/ollama_service.ts b/admin/app/services/ollama_service.ts index 27f5cac..fe0cb1c 100644 --- a/admin/app/services/ollama_service.ts +++ b/admin/app/services/ollama_service.ts @@ -480,10 +480,21 @@ export class OllamaService { } try { - // Prefer Ollama native endpoint (supports batch input natively) + // Prefer Ollama native endpoint (supports batch input natively). + // Pass num_ctx explicitly so we don't depend on the embedding model's + // modelfile defaults. Some installs ship nomic-embed-text:v1.5 with + // num_ctx=2048, which our chunker (sized for ~1500 tokens) can exceed + // on dense content, causing "input length exceeds context length" errors. + // truncate:true is a runtime safety net for any chunk that still overshoots. + // 8192 matches nomic-embed-text:v1.5's RoPE-extrapolated max. const response = await axios.post( `${this.baseUrl}/api/embed`, - { model, input }, + { + model, + input, + truncate: true, + options: { num_ctx: 8192 }, + }, { timeout: 60000 } ) // Some backends (e.g. LM Studio) return HTTP 200 for unknown endpoints with an incompatible From 269c7ce695c0d9b5d15a1ad95af82a790e45251b Mon Sep 17 00:00:00 2001 From: John Scherer Date: Tue, 28 Apr 2026 00:11:19 -0500 Subject: [PATCH 6/8] fix(API): accept notes, marker_type, and position on markers endpoints (#770) The VineJS validators in createMarker and updateMarker silently dropped fields not in their schema. The MapMarker model and DB include notes and marker_type, and GET responses return them, but POST and PATCH would not persist them. updateMarker additionally did not accept latitude/longitude, so markers could not be repositioned via the API after creation. - Add notes and marker_type to both validators and model assignments. - Add latitude/longitude to the update validator. - Add coordinate range validation on both endpoints. Closes #768 --- admin/app/controllers/maps_controller.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/admin/app/controllers/maps_controller.ts b/admin/app/controllers/maps_controller.ts index f5fb0f3..dd93a8b 100644 --- a/admin/app/controllers/maps_controller.ts +++ b/admin/app/controllers/maps_controller.ts @@ -137,9 +137,11 @@ export default class MapsController { vine.compile( vine.object({ name: vine.string().trim().minLength(1).maxLength(255), - longitude: vine.number(), - latitude: vine.number(), + longitude: vine.number().min(-180).max(180), + latitude: vine.number().min(-90).max(90), color: vine.string().trim().maxLength(20).optional(), + notes: vine.string().trim().nullable().optional(), + marker_type: vine.string().trim().maxLength(20).optional(), }) ) ) @@ -148,6 +150,8 @@ export default class MapsController { longitude: payload.longitude, latitude: payload.latitude, color: payload.color ?? 'orange', + notes: payload.notes ?? null, + marker_type: payload.marker_type ?? 'pin', }) return marker } @@ -163,11 +167,19 @@ export default class MapsController { vine.object({ name: vine.string().trim().minLength(1).maxLength(255).optional(), color: vine.string().trim().maxLength(20).optional(), + longitude: vine.number().min(-180).max(180).optional(), + latitude: vine.number().min(-90).max(90).optional(), + notes: vine.string().trim().nullable().optional(), + marker_type: vine.string().trim().maxLength(20).optional(), }) ) ) if (payload.name !== undefined) marker.name = payload.name if (payload.color !== undefined) marker.color = payload.color + if (payload.longitude !== undefined) marker.longitude = payload.longitude + if (payload.latitude !== undefined) marker.latitude = payload.latitude + if (payload.notes !== undefined) marker.notes = payload.notes + if (payload.marker_type !== undefined) marker.marker_type = payload.marker_type await marker.save() return marker } From fe57d598682c39c50880278ed0b71434030fa35d Mon Sep 17 00:00:00 2001 From: Kenneth Brewer Date: Tue, 28 Apr 2026 01:21:06 -0400 Subject: [PATCH 7/8] docs: add map markers to API reference (#783) Co-authored-by: Kenneth Brewer --- admin/docs/api-reference.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/admin/docs/api-reference.md b/admin/docs/api-reference.md index 5cf126f..928b1e3 100644 --- a/admin/docs/api-reference.md +++ b/admin/docs/api-reference.md @@ -148,6 +148,15 @@ ZIM files provide offline Wikipedia, books, and other content via Kiwix. | POST | `/api/maps/download-collection` | Download an entire collection by slug (async) | | DELETE | `/api/maps/:filename` | Delete a local map file | +### Map Markers + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/maps/markers` | List map markers | +| POST | `/api/maps/markers` | Add map marker (body: {"name": "Test Marker", "longitude": 0.0, "latitude": 0.0, "color": "yellow", "marker_type": "pin"} ) | +| PATCH | `/api/maps/markers/{id}` | Update a map marker (body: {"name": "Test Marker", "longitude": 0.0, "latitude": 0.0, "color": "yellow", "marker_type": "pin"} ) fields that don't change can be omitted| +| DELETE | `/api/maps/markers/{id}` | Delete a map marker | + --- ## Downloads From cc789c1863d9d5bc23ebdb7e3af0d00a0ed5a736 Mon Sep 17 00:00:00 2001 From: Henry Estela Date: Tue, 28 Apr 2026 05:26:46 +0000 Subject: [PATCH 8/8] fix(RAG): add start button in kb modal and ensure restart policy exists (#700) Adds a check to RAG health to make sure nomad_qdrant is online, if not then the user will be blocked from clicking any buttons in the KB modal until they click the start qdrant button and let the container start There is a new file qdrant_restart_policy_provider.ts, which tries to ensure that the restart policy always exists for the nomad_qdrant container even though the policy should have been there when the container is created. --- admin/adonisrc.ts | 1 + admin/app/controllers/rag_controller.ts | 5 ++ admin/app/services/rag_service.ts | 23 ++++++- .../components/chat/KnowledgeBaseModal.tsx | 49 +++++++++++++-- admin/inertia/lib/api.ts | 7 +++ .../qdrant_restart_policy_provider.ts | 62 +++++++++++++++++++ admin/start/routes.ts | 1 + 7 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 admin/providers/qdrant_restart_policy_provider.ts diff --git a/admin/adonisrc.ts b/admin/adonisrc.ts index 37046d2..a091ce2 100644 --- a/admin/adonisrc.ts +++ b/admin/adonisrc.ts @@ -55,6 +55,7 @@ export default defineConfig({ () => import('@adonisjs/transmit/transmit_provider'), () => import('#providers/map_static_provider'), () => import('#providers/kiwix_migration_provider'), + () => import('#providers/qdrant_restart_policy_provider'), ], /* diff --git a/admin/app/controllers/rag_controller.ts b/admin/app/controllers/rag_controller.ts index 149ba7e..c836393 100644 --- a/admin/app/controllers/rag_controller.ts +++ b/admin/app/controllers/rag_controller.ts @@ -97,4 +97,9 @@ export default class RagController { return response.status(500).json({ error: 'Error scanning and syncing storage' }) } } + + public async health({ response }: HttpContext) { + const result = await this.ragService.checkQdrantHealth() + return response.status(200).json(result) + } } diff --git a/admin/app/services/rag_service.ts b/admin/app/services/rag_service.ts index 81145f8..bd5371d 100644 --- a/admin/app/services/rag_service.ts +++ b/admin/app/services/rag_service.ts @@ -52,14 +52,33 @@ export class RagService { this.qdrantInitPromise = (async () => { const qdrantUrl = await this.dockerService.getServiceURL(SERVICE_NAMES.QDRANT) if (!qdrantUrl) { - throw new Error('Qdrant service is not installed or running.') + throw new Error('Qdrant vector database is offline. Restart the AI Assistant service in Settings to restore the Knowledge Base.') } this.qdrant = new QdrantClient({ url: qdrantUrl }) - })() + })().catch((err) => { + this.qdrantInitPromise = null + this.qdrant = null + throw err + }) } return this.qdrantInitPromise } + public async checkQdrantHealth(): Promise<{ online: boolean; message?: string }> { + try { + await this._ensureDependencies() + await this.qdrant!.getCollections() + return { online: true } + } catch { + this.qdrant = null + this.qdrantInitPromise = null + return { + online: false, + message: 'Qdrant vector database is offline. Restart the AI Assistant service in Settings to restore the Knowledge Base.', + } + } + } + private async _ensureDependencies() { if (!this.qdrant) { await this._initializeQdrantClient() diff --git a/admin/inertia/components/chat/KnowledgeBaseModal.tsx b/admin/inertia/components/chat/KnowledgeBaseModal.tsx index e77a0c9..6230398 100644 --- a/admin/inertia/components/chat/KnowledgeBaseModal.tsx +++ b/admin/inertia/components/chat/KnowledgeBaseModal.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import FileUploader from '~/components/file-uploader' import StyledButton from '~/components/StyledButton' import StyledSectionHeader from '~/components/StyledSectionHeader' @@ -10,6 +10,7 @@ import { IconX } from '@tabler/icons-react' import { useModals } from '~/context/ModalContext' import StyledModal from '../StyledModal' import ActiveEmbedJobs from '~/components/ActiveEmbedJobs' +import { SERVICE_NAMES } from '../../../constants/service_names' interface KnowledgeBaseModalProps { aiAssistantName?: string @@ -30,6 +31,19 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o const { openModal, closeModal } = useModals() const queryClient = useQueryClient() + const [isStartingQdrant, setIsStartingQdrant] = useState(false) + + const { data: healthStatus } = useQuery({ + queryKey: ['qdrantHealth'], + queryFn: () => api.checkRAGHealth(), + refetchInterval: isStartingQdrant ? 3_000 : 30_000, + }) + const qdrantOffline = healthStatus?.online === false + + useEffect(() => { + if (!qdrantOffline) setIsStartingQdrant(false) + }, [qdrantOffline]) + const { data: storedFiles = [], isLoading: isLoadingFiles } = useQuery({ queryKey: ['storedFiles'], queryFn: () => api.getStoredRAGFiles(), @@ -64,6 +78,17 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o }, }) + const startQdrantMutation = useMutation({ + mutationFn: () => api.affectService(SERVICE_NAMES.QDRANT, 'start'), + onSuccess: () => { + setIsStartingQdrant(true) + queryClient.invalidateQueries({ queryKey: ['qdrantHealth'] }) + }, + onError: (error: any) => { + addNotification({ type: 'error', message: error?.message || 'Failed to start Qdrant.' }) + }, + }) + const syncMutation = useMutation({ mutationFn: () => api.syncRAGStorage(), onSuccess: (data) => { @@ -149,6 +174,22 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
+ {qdrantOffline && ( +
+ + Knowledge Base unavailable: The Qdrant vector database is offline. + + startQdrantMutation.mutate()} + loading={startQdrantMutation.isPending || isStartingQdrant} + disabled={startQdrantMutation.isPending || isStartingQdrant} + > + {isStartingQdrant ? 'Starting…' : 'Start Qdrant'} + +
+ )}
Upload @@ -236,7 +277,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o icon="IconTrash" onClick={() => cleanupFailedMutation.mutate()} loading={cleanupFailedMutation.isPending} - disabled={cleanupFailedMutation.isPending} + disabled={cleanupFailedMutation.isPending || qdrantOffline} > Clean Up Failed @@ -252,7 +293,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o size="md" icon='IconRefresh' onClick={handleConfirmSync} - disabled={syncMutation.isPending || isUploading} + disabled={syncMutation.isPending || isUploading || qdrantOffline} loading={syncMutation.isPending || isUploading} > Sync Storage diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index dc1c7ed..0df95ae 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -451,6 +451,13 @@ class API { })() } + async checkRAGHealth() { + return catchInternal(async () => { + const response = await this.client.get<{ online: boolean; message?: string }>('/rag/health') + return response.data + })() + } + async getStoredRAGFiles() { return catchInternal(async () => { const response = await this.client.get<{ files: string[] }>('/rag/files') diff --git a/admin/providers/qdrant_restart_policy_provider.ts b/admin/providers/qdrant_restart_policy_provider.ts new file mode 100644 index 0000000..b909242 --- /dev/null +++ b/admin/providers/qdrant_restart_policy_provider.ts @@ -0,0 +1,62 @@ +import logger from '@adonisjs/core/services/logger' +import type { ApplicationService } from '@adonisjs/core/types' + +/** + * Ensures the nomad_qdrant container has the `unless-stopped` restart policy. + * + * Existing installations may have been created before this policy was enforced + * in the service seeder. Docker allows updating a container's restart policy + * without recreating it via the container.update() API. + * + * This provider runs once on every admin startup. If the policy is already + * correct, the check is a no-op. + */ +export default class QdrantRestartPolicyProvider { + constructor(protected app: ApplicationService) {} + + async boot() { + if (this.app.getEnvironment() !== 'web') return + + setImmediate(async () => { + try { + const Service = (await import('#models/service')).default + const { SERVICE_NAMES } = await import('../constants/service_names.js') + const Docker = (await import('dockerode')).default + + const qdrantService = await Service.query() + .where('service_name', SERVICE_NAMES.QDRANT) + .first() + + if (!qdrantService?.installed) { + logger.info('[QdrantRestartPolicyProvider] Qdrant not installed — skipping restart policy check.') + return + } + + const docker = new Docker({ socketPath: '/var/run/docker.sock' }) + const containers = await docker.listContainers({ all: true }) + const containerInfo = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.QDRANT}`)) + + if (!containerInfo) { + logger.warn('[QdrantRestartPolicyProvider] Qdrant container not found — skipping restart policy check.') + return + } + + const container = docker.getContainer(containerInfo.Id) + const inspected = await container.inspect() + const currentPolicy = inspected.HostConfig?.RestartPolicy?.Name + + if (currentPolicy === 'unless-stopped') { + logger.info('[QdrantRestartPolicyProvider] Qdrant already has unless-stopped restart policy — no update needed.') + return + } + + logger.info(`[QdrantRestartPolicyProvider] Qdrant restart policy is "${currentPolicy ?? 'none'}" — updating to unless-stopped.`) + await container.update({ RestartPolicy: { Name: 'unless-stopped', MaximumRetryCount: 0 } }) + logger.info('[QdrantRestartPolicyProvider] Qdrant restart policy updated successfully.') + } catch (err: any) { + logger.error(`[QdrantRestartPolicyProvider] Failed to update Qdrant restart policy: ${err.message}`) + // Non-fatal: the container will still run, just without auto-restart on crash. + } + }) + } +} diff --git a/admin/start/routes.ts b/admin/start/routes.ts index d201174..07ee6b9 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -143,6 +143,7 @@ router router.delete('/failed-jobs', [RagController, 'cleanupFailedJobs']) router.get('/job-status', [RagController, 'getJobStatus']) router.post('/sync', [RagController, 'scanAndSync']) + router.get('/health', [RagController, 'health']) }) .prefix('/api/rag')