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. 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/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) { 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 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/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/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 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