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')