From cae3dde8b4b5cf69f27ad5d3a9b2664294f61b31 Mon Sep 17 00:00:00 2001 From: John Cortright Date: Fri, 3 Jul 2026 14:16:01 -0700 Subject: [PATCH 1/6] feat(rag): add subject/collection organization to knowledge base - Add nullable collection field to KbIngestState, propagated through the embed job, RAG service, and Qdrant point payloads (indexed for filtering) - Add upload-time category selection and per-file collection reassignment in the Knowledge Base modal, with a filterable Stored Files table - Add a 'Search in' collection filter to the chat interface, threaded through to searchSimilarDocuments as an optional Qdrant filter - Fix .docx extraction: previously routed through raw-text extraction (garbage output for a ZIP-based XML format); adds a proper mammoth-based extractor and a dedicated 'docx' file-type case --- .gitignore | 2 + admin/app/controllers/ollama_controller.ts | 5 +- admin/app/controllers/rag_controller.ts | 28 +- admin/app/jobs/embed_file_job.ts | 12 +- admin/app/models/kb_ingest_state.ts | 30 +- admin/app/services/rag_service.ts | 89 +++++- admin/app/utils/fs.ts | 6 +- admin/constants/kb_collections.ts | 13 + ...00001_add_collection_to_kb_ingest_state.ts | 17 ++ .../components/chat/KnowledgeBaseModal.tsx | 123 ++++++--- admin/inertia/components/chat/index.tsx | 30 +- admin/inertia/lib/api.ts | 70 +++-- admin/inertia/lib/kb_file_grouping.ts | 5 + admin/package-lock.json | 259 ++++++++---------- admin/package.json | 1 + admin/start/routes.ts | 2 + admin/tests/unit/kb_file_grouping.spec.ts | 15 +- admin/types/ollama.ts | 1 + admin/types/rag.ts | 2 + 19 files changed, 457 insertions(+), 253 deletions(-) create mode 100644 admin/constants/kb_collections.ts create mode 100644 admin/database/migrations/1776400000001_add_collection_to_kb_ingest_state.ts diff --git a/.gitignore b/.gitignore index 4273fe3..4bf6304 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ admin/public/assets # Admin specific development files admin/storage +nomad_admin_config.json +nomad_admin_hostconfig.json diff --git a/admin/app/controllers/ollama_controller.ts b/admin/app/controllers/ollama_controller.ts index 9174616..e886165 100644 --- a/admin/app/controllers/ollama_controller.ts +++ b/admin/app/controllers/ollama_controller.ts @@ -77,10 +77,12 @@ export default class OllamaController { logger.debug(`[OllamaController] Rewritten query for RAG: "${rewrittenQuery}"`) if (rewrittenQuery) { + const collectionFilter: string | null = request.input('collection', null) const relevantDocs = await this.ragService.searchSimilarDocuments( rewrittenQuery, 5, // Top 5 most relevant chunks - 0.3 // Minimum similarity score of 0.3 + 0.3, // Minimum similarity score of 0.3 + collectionFilter ?? undefined ) logger.debug(`[RAG] Retrieved ${relevantDocs.length} relevant documents for query: "${rewrittenQuery}"`) @@ -456,3 +458,4 @@ export default class OllamaController { } } } + diff --git a/admin/app/controllers/rag_controller.ts b/admin/app/controllers/rag_controller.ts index 7738d0f..1b2954a 100644 --- a/admin/app/controllers/rag_controller.ts +++ b/admin/app/controllers/rag_controller.ts @@ -20,6 +20,8 @@ export default class RagController { return response.status(400).json({ error: 'No file uploaded' }) } + const collection: string | null = request.input('collection', null) + const randomSuffix = randomBytes(6).toString('hex') const sanitizedName = sanitizeFilename(uploadedFile.clientName) @@ -34,6 +36,7 @@ export default class RagController { const result = await EmbedFileJob.dispatch({ filePath: fullPath, fileName, + ...(collection ? { collection } : {}), }) return response.status(202).json({ @@ -42,9 +45,9 @@ export default class RagController { fileName, filePath: `/${RagService.UPLOADS_STORAGE_PATH}/${fileName}`, alreadyProcessing: !result.created, + ...(collection ? { collection } : {}), }) } - public async getActiveJobs({ response }: HttpContext) { const jobs = await EmbedFileJob.listActiveJobs() return response.status(200).json(jobs) @@ -68,6 +71,29 @@ export default class RagController { return response.status(200).json({ files }) } + public async getKnowledgeCollections({ response }: HttpContext) { + const collections = await this.ragService.getKnowledgeCollections() + return response.status(200).json({ collections }) + } + + public async updateFileCollection({ request, response }: HttpContext) { + const source: string | null = request.input('source', null) + // Empty string means "clear back to Uncategorized" — coerce to null rather + // than rejecting, so the frontend can offer an Uncategorized option. + const rawCollection: string | null = request.input('collection', null) + const collection = rawCollection && rawCollection.trim() !== '' ? rawCollection : null + + if (!source) { + return response.status(400).json({ error: 'source is required.' }) + } + + const result = await this.ragService.updateFileCollection(source, collection) + if (!result.success) { + return response.status(500).json({ error: result.message }) + } + return response.status(200).json({ message: result.message }) + } + public async getFileWarnings({ response }: HttpContext) { const result = await this.ragService.computeFileWarnings() return response.status(200).json(result) diff --git a/admin/app/jobs/embed_file_job.ts b/admin/app/jobs/embed_file_job.ts index ba52ca8..7bae3fb 100644 --- a/admin/app/jobs/embed_file_job.ts +++ b/admin/app/jobs/embed_file_job.ts @@ -18,11 +18,8 @@ export interface EmbedFileJobParams { batchOffset?: number // Current batch offset (for ZIM files) totalArticles?: number // Total articles in ZIM (for progress tracking) isFinalBatch?: boolean // Whether this is the last batch (prevents premature deletion) - // Running total of chunks embedded across prior batches in this dispatch chain. - // Carried forward so the final batch can persist an accurate `chunks_embedded` - // count via KbIngestState.markIndexed (see #933 — without this, only the last - // batch's chunk count was stored while Qdrant held the full set). chunksSoFar?: number + collection?: string } export class EmbedFileJob { @@ -56,7 +53,7 @@ export class EmbedFileJob { } async handle(job: Job) { - const { filePath, fileName, batchOffset, totalArticles } = job.data as EmbedFileJobParams + const { filePath, fileName, batchOffset, totalArticles, collection } = job.data as EmbedFileJobParams const isZimBatch = batchOffset !== undefined const batchInfo = isZimBatch ? ` (batch offset: ${batchOffset})` : '' @@ -136,7 +133,8 @@ export class EmbedFileJob { filePath, allowDeletion, batchOffset, - onProgress + onProgress, + collection ) if (!result.success) { @@ -242,7 +240,7 @@ export class EmbedFileJob { // BullMQ's :completed retention (50 jobs) ages out, so the state row is // the only durable record of "this file finished embedding". try { - await KbIngestState.markIndexed(filePath, totalChunks) + await KbIngestState.markIndexed(filePath, totalChunks, collection) } catch (stateErr) { logger.warn( `[EmbedFileJob] Failed to persist ingest state for ${fileName}: %s`, diff --git a/admin/app/models/kb_ingest_state.ts b/admin/app/models/kb_ingest_state.ts index 2add407..76da782 100644 --- a/admin/app/models/kb_ingest_state.ts +++ b/admin/app/models/kb_ingest_state.ts @@ -28,6 +28,9 @@ export default class KbIngestState extends BaseModel { @column() declare chunks_embedded: number + @column() + declare collection: string | null + @column() declare last_error: string | null @@ -37,20 +40,21 @@ export default class KbIngestState extends BaseModel { @column.dateTime({ autoCreate: true, autoUpdate: true }) declare updated_at: DateTime - static async getOrCreate(filePath: string): Promise { - return this.firstOrCreate( - { file_path: filePath }, - { file_path: filePath, state: 'pending_decision', chunks_embedded: 0 } - ) - } + static async getOrCreate(filePath: string, collection?: string): Promise { + return this.firstOrCreate( + { file_path: filePath }, + { file_path: filePath, state: 'pending_decision', chunks_embedded: 0, collection: collection ?? null } + ) + } - static async markIndexed(filePath: string, chunksEmbedded: number): Promise { - const row = await this.getOrCreate(filePath) - row.state = 'indexed' - row.chunks_embedded = chunksEmbedded - row.last_error = null - await row.save() - } + static async markIndexed(filePath: string, chunksEmbedded: number, collection?: string): Promise { + const row = await this.getOrCreate(filePath, collection) + row.state = 'indexed' + row.chunks_embedded = chunksEmbedded + row.last_error = null + if (collection) row.collection = collection + await row.save() + } static async markFailed(filePath: string, errorMessage: string): Promise { const row = await this.getOrCreate(filePath) diff --git a/admin/app/services/rag_service.ts b/admin/app/services/rag_service.ts index 29b65cf..01ca212 100644 --- a/admin/app/services/rag_service.ts +++ b/admin/app/services/rag_service.ts @@ -10,6 +10,7 @@ import { createWorker } from 'tesseract.js' import { fromBuffer } from 'pdf2pic' import JSZip from 'jszip' import * as cheerio from 'cheerio' +import mammoth from 'mammoth' import { OllamaService } from './ollama_service.js' import { SERVICE_NAMES } from '../../constants/service_names.js' import { removeStopwords } from 'stopword' @@ -133,6 +134,10 @@ export class RagService { field_name: 'content_type', field_schema: 'keyword', }) + await this.qdrant!.createPayloadIndex(collectionName, { + field_name: 'collection', + field_schema: 'keyword', + }) } catch (error) { logger.error('Error ensuring Qdrant collection:', error) throw error @@ -611,6 +616,16 @@ export class RagService { return await this.extractTXTText(fileBuffer) } + /** + * Extract text content from a DOCX file using mammoth. DOCX is a ZIP-based + * XML format, so raw-text extraction (extractTXTText) would return garbage — + * this parses the document XML properly and returns clean plain text. + */ + private async processDocxFile(fileBuffer: Buffer): Promise { + const { value: text } = await mammoth.extractRawText({ buffer: fileBuffer }) + return text + } + /** * Extract text content from an EPUB file. * EPUBs are ZIP archives containing XHTML content files. @@ -695,14 +710,16 @@ export class RagService { extractedText: string, filepath: string, deleteAfterEmbedding: boolean = false, - onProgress?: (percent: number) => Promise + onProgress?: (percent: number) => Promise, + collection?: string ): Promise<{ success: boolean; message: string; chunks?: number }> { if (!extractedText || extractedText.trim().length === 0) { return { success: false, message: 'Process completed succesfully, but no text was found to embed.' } } const embedResult = await this.embedAndStoreText(extractedText, { - source: filepath + source: filepath, + ...(collection ? { collection } : {}) }, onProgress) if (!embedResult) { @@ -732,7 +749,8 @@ export class RagService { filepath: string, deleteAfterEmbedding: boolean = false, batchOffset?: number, - onProgress?: (percent: number) => Promise + onProgress?: (percent: number) => Promise, + collection?: string ): Promise { try { const fileType = determineFileType(filepath) @@ -765,6 +783,9 @@ export class RagService { case 'pdf': extractedText = await this.processPDFFile(fileBuffer!) break + case 'docx': + extractedText = await this.processDocxFile(fileBuffer!) + break case 'epub': extractedText = await this.processEPUBFile(fileBuffer!) break @@ -781,7 +802,7 @@ export class RagService { : undefined // Embed extracted text and cleanup - return await this.embedTextAndCleanup(extractedText, filepath, deleteAfterEmbedding, scaledProgress) + return await this.embedTextAndCleanup(extractedText, filepath, deleteAfterEmbedding, scaledProgress, collection) } catch (error) { logger.error('[RAG] Error processing and embedding file:', error) return { success: false, message: 'Error processing and embedding file.' } @@ -800,7 +821,8 @@ export class RagService { public async searchSimilarDocuments( query: string, limit: number = 5, - scoreThreshold: number = 0.3 // Lower default threshold - was 0.7, now 0.3 + scoreThreshold: number = 0.3, // Lower default threshold - was 0.7, now 0.3 + collection?: string ): Promise }>> { try { logger.debug(`[RAG] Starting similarity search for query: "${query}"`) @@ -873,6 +895,7 @@ export class RagService { limit: searchLimit, score_threshold: scoreThreshold, with_payload: true, + ...(collection ? { filter: { must: [{ key: 'collection', match: { value: collection } }] } } : {}), }) logger.debug(`[RAG] Found ${searchResults.length} results above threshold ${scoreThreshold}`) @@ -1121,14 +1144,15 @@ export class RagService { // in particular) have no row to attach to. The state machine is the // authoritative "what's on disk?" view; Qdrant is "what made it into // the vector store?". Both are needed to render the KB UI honestly. - const stateByPath = new Map() + const stateByPath = new Map() try { - const stateRows = await KbIngestState.query().select('file_path', 'state', 'chunks_embedded') + const stateRows = await KbIngestState.query().select('file_path', 'state', 'chunks_embedded', 'collection') for (const row of stateRows) { sources.add(row.file_path) stateByPath.set(row.file_path, { state: row.state, chunks_embedded: row.chunks_embedded, + collection: row.collection, }) } } catch (error) { @@ -1155,6 +1179,7 @@ export class RagService { size: stats?.size ?? null, uploadedAt: stats?.modifiedTime.toISOString() ?? null, isUserUpload, + collection: row?.collection ?? null, } }) ) @@ -1164,6 +1189,56 @@ export class RagService { } } + /** + * Enumerate distinct `collection` values currently in the knowledge base, + * for populating a subject-picker in the upload/chat UI. Mirrors the + * `source` facet pattern used elsewhere in this file (see getStoredFiles). + */ + public async getKnowledgeCollections(): Promise { + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION) + const facetResult = await this.qdrant!.facet(RagService.CONTENT_COLLECTION_NAME, { + key: 'collection', + limit: RagService.FACET_SOURCE_LIMIT, + exact: true, + }) + const collections = new Set() + for (const hit of facetResult.hits) { + if (typeof hit.value === 'string') collections.add(hit.value) + } + return Array.from(collections).sort() + } + + /** + * Reassign a stored file's collection after the fact. Updates the `collection` + * payload field on every existing Qdrant point for this source in place (no + * re-chunking or re-embedding needed), then mirrors the change onto the + * KbIngestState row so getStoredFiles() reflects it immediately. + */ + public async updateFileCollection( + source: string, + collection: string | null + ): Promise<{ success: boolean; message: string }> { + try { + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION) + + await this.qdrant!.setPayload(RagService.CONTENT_COLLECTION_NAME, { + payload: { collection }, + filter: { must: [{ key: 'source', match: { value: source } }] }, + }) + + const row = await KbIngestState.query().where('file_path', source).first() + if (row) { + row.collection = collection + await row.save() + } + + return { success: true, message: collection ? `Moved to "${collection}".` : 'Moved to Uncategorized.' } + } catch (error) { + logger.error('[RAG] Error updating file collection:', error) + return { success: false, message: 'Error updating file collection.' } + } + } + /** * Resolve a stored-file `source` to an absolute disk path, but only if the * path lives under the uploads directory. Mirrors the docs_service traversal diff --git a/admin/app/utils/fs.ts b/admin/app/utils/fs.ts index 69b5463..6711ebc 100644 --- a/admin/app/utils/fs.ts +++ b/admin/app/utils/fs.ts @@ -190,13 +190,15 @@ export function matchesDevice(fsPath: string, deviceName: string): boolean { return false } -export function determineFileType(filename: string): 'image' | 'pdf' | 'text' | 'epub' | 'zim' | 'unknown' { +export function determineFileType(filename: string): 'image' | 'pdf' | 'text' | 'docx' | 'epub' | 'zim' | 'unknown' { const ext = path.extname(filename).toLowerCase() if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'].includes(ext)) { return 'image' } else if (ext === '.pdf') { return 'pdf' - } else if (['.txt', '.md', '.docx', '.rtf'].includes(ext)) { + } else if (ext === '.docx') { + return 'docx' + } else if (['.txt', '.md', '.rtf'].includes(ext)) { return 'text' } else if (ext === '.epub') { return 'epub' diff --git a/admin/constants/kb_collections.ts b/admin/constants/kb_collections.ts new file mode 100644 index 0000000..ce636f8 --- /dev/null +++ b/admin/constants/kb_collections.ts @@ -0,0 +1,13 @@ +export const KB_COLLECTIONS = [ + 'food', + 'water', + 'shelter', + 'electricity', + 'survival', + 'farming', + 'computer', + 'medical', + 'boating', +] as const + +export type KbCollection = (typeof KB_COLLECTIONS)[number] diff --git a/admin/database/migrations/1776400000001_add_collection_to_kb_ingest_state.ts b/admin/database/migrations/1776400000001_add_collection_to_kb_ingest_state.ts new file mode 100644 index 0000000..ed49ac5 --- /dev/null +++ b/admin/database/migrations/1776400000001_add_collection_to_kb_ingest_state.ts @@ -0,0 +1,17 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'kb_ingest_state' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.string('collection').nullable().index() + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('collection') + }) + } +} diff --git a/admin/inertia/components/chat/KnowledgeBaseModal.tsx b/admin/inertia/components/chat/KnowledgeBaseModal.tsx index cd47d04..aa2d224 100644 --- a/admin/inertia/components/chat/KnowledgeBaseModal.tsx +++ b/admin/inertia/components/chat/KnowledgeBaseModal.tsx @@ -27,15 +27,13 @@ import { useModals } from '~/context/ModalContext' import StyledModal from '../StyledModal' import ActiveEmbedJobs from '~/components/ActiveEmbedJobs' import { SERVICE_NAMES } from '../../../constants/service_names' +import { KB_COLLECTIONS } from '../../../constants/kb_collections' interface KnowledgeBaseModalProps { aiAssistantName?: string onClose: () => void } -// File extensions the in-browser viewer can render. Must stay in sync with -// `RagService.VIEWABLE_TEXT_EXTENSIONS` — anything outside this set falls back -// to Download. const VIEWABLE_EXTENSIONS = new Set(['md', 'txt', 'csv', 'json', 'yaml', 'yml', 'toml', 'xml', 'html']) function isViewableExtension(filename: string): boolean { @@ -69,13 +67,6 @@ function renderSortHeader( ) } -/** - * Compact label for the per-row ingestion state. Files that exist in Qdrant - * with no `kb_ingest_state` row (`state === null`) are legacy/pre-RFC-883 - * installs whose chunks are real, so we display them as "Indexed" rather than - * surfacing the absent-row detail. Admin-docs group has no pill (the "Managed - * by NOMAD" message in the action column carries the same signal). - */ function renderStatePill(record: KbFileGroup): React.ReactNode { if (record.bucket === 'admin_docs') return null const effective: KbIngestStateValue = record.state ?? 'indexed' @@ -114,13 +105,6 @@ type RowAction = | { kind: 'index'; label: string; force: boolean; variant: 'primary'; icon: DynamicIconName } | { kind: 'reembed'; label: string; force: true; variant: 'secondary'; icon: DynamicIconName } -/** - * Pick the single adaptive per-row action button. Returns null when no action - * makes sense for the current state (e.g. healthy indexed file with no - * warnings — bulk Re-embed All covers that case). `hasWarnings` lets us - * surface a Re-embed affordance specifically when a file *looks* indexed but - * has zero chunks or a stalled-mid-ingestion warning attached. - */ function pickRowAction(record: KbFileGroup, hasWarnings: boolean): RowAction | null { if (record.bucket === 'admin_docs') return null const effective: KbIngestStateValue = record.state ?? 'indexed' @@ -143,6 +127,8 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o const { addNotification } = useNotifications() const [files, setFiles] = useState([]) const [isUploading, setIsUploading] = useState(false) + const [uploadCollection, setUploadCollection] = useState('') + const [collectionFilter, setCollectionFilter] = useState('All') const [confirmDeleteSource, setConfirmDeleteSource] = useState(null) const [confirmReembed, setConfirmReembed] = useState<{ source: string; displayName: string } | null>(null) const [bulkMode, setBulkMode] = useState(null) @@ -172,10 +158,12 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o select: (data) => data || [], }) - // Per-file conditional warnings (RFC #883 §6). `ok: false` means the - // computation itself failed (Qdrant/DB/FS) — distinct from `ok: true` with - // an empty map, which means everything is healthy. We surface the failure - // explicitly so a silent backend failure doesn't masquerade as health. + const { data: knownCollections = [] } = useQuery({ + queryKey: ['kbCollections'], + queryFn: () => api.getKnowledgeCollections(), + select: (data) => data?.collections ?? [], + }) + const { data: warningsResult } = useQuery({ queryKey: ['kbFileWarnings'], queryFn: () => api.getKbFileWarnings(), @@ -184,9 +172,6 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o const fileWarnings = warningsResult?.warnings ?? {} const warningsUnavailable = warningsResult !== undefined && warningsResult.ok === false - // Global auto-index policy. KVStore returns `null` for an unset key, which - // we treat as 'Always' for backward compatibility with installs that predate - // this UI. The user can opt into Manual mode from the toggle below. const { data: ingestPolicySetting } = useQuery({ queryKey: ['ingestPolicy'], queryFn: () => api.getSetting('rag.defaultIngestPolicy'), @@ -216,7 +201,20 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o }) const uploadMutation = useMutation({ - mutationFn: (file: File) => api.uploadDocument(file), + mutationFn: (file: File) => api.uploadDocument(file, uploadCollection || undefined), + }) + + const updateCollectionMutation = useMutation({ + mutationFn: ({ source, collection }: { source: string; collection: string }) => + api.updateFileCollection(source, collection || null), + onSuccess: (data) => { + addNotification({ type: 'success', message: data?.message || 'Collection updated.' }) + queryClient.invalidateQueries({ queryKey: ['storedFiles'] }) + queryClient.invalidateQueries({ queryKey: ['kbCollections'] }) + }, + onError: (error: any) => { + addNotification({ type: 'error', message: error?.message || 'Failed to update collection.' }) + }, }) const deleteMutation = useMutation({ @@ -461,7 +459,20 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o setFiles(Array.from(uploadedFiles)) }} /> -
+
+ Clean Up Failed - {/* Not gated on qdrantOffline: clearing stuck jobs must work during - a Qdrant/Ollama outage, which is exactly when they wedge. */}
+ — } @@ -725,13 +745,38 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o ) }, }, + { + accessor: 'collection', + title: 'Collection', + className: 'whitespace-nowrap', + render(record) { + if (record.bucket === 'admin_docs') { + return + } + const isSaving = + updateCollectionMutation.isPending && + updateCollectionMutation.variables?.source === record.source + return ( + + ) + }, + }, { accessor: 'source', title: '', render(record) { - // Admin docs are auto-discovered and managed by NOMAD itself — - // deleting one would just be re-embedded on the next sync, so - // we surface them as informational only and hide Delete. if (record.bucket === 'admin_docs') { return (
@@ -827,7 +872,12 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o }, }, ]} - data={groupAndSortKbFiles(storedFiles, sort)} + data={groupAndSortKbFiles( + collectionFilter === 'All' + ? storedFiles + : storedFiles.filter((f) => f.collection === collectionFilter), + sort + )} loading={isLoadingFiles} />
@@ -977,13 +1027,8 @@ function FileViewerModal({ source, onClose }: { source: string; onClose: () => v staleTime: 60_000, }) - // Title falls back to the trailing path segment so the modal still has a - // useful header while the fetch is in-flight or if it failed. const fallbackName = source.split(/[/\\]/).at(-1) ?? source const title = data?.fileName ?? fallbackName - // `catchInternal` swallows errors and resolves to undefined, surfacing a - // toast — so the "couldn't load" branch is gated on a finished-but-empty - // fetch rather than on react-query's `isError`. const showError = isFetched && !data return ( diff --git a/admin/inertia/components/chat/index.tsx b/admin/inertia/components/chat/index.tsx index 9be60d4..b24e771 100644 --- a/admin/inertia/components/chat/index.tsx +++ b/admin/inertia/components/chat/index.tsx @@ -11,6 +11,7 @@ import { ChatMessage } from '../../../types/chat' import classNames from '~/lib/classNames' import { IconX } from '@tabler/icons-react' import { DEFAULT_QUERY_REWRITE_MODEL } from '../../../constants/ollama' +import { KB_COLLECTIONS } from '../../../constants/kb_collections' import { useSystemSetting } from '~/hooks/useSystemSetting' interface ChatProps { @@ -33,6 +34,7 @@ export default function Chat({ const [activeSessionId, setActiveSessionId] = useState(null) const [messages, setMessages] = useState([]) const [selectedModel, setSelectedModel] = useState('') + const [collectionFilter, setCollectionFilter] = useState('') const [pendingModelSwitch, setPendingModelSwitch] = useState(null) const pageLoadNormalizedRef = useRef(false) const [isStreamingResponse, setIsStreamingResponse] = useState(false) @@ -102,6 +104,7 @@ export default function Chat({ model: string messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> sessionId?: number + collection?: string }) => api.sendChatMessage({ ...request, stream: false }), onSuccess: async (data) => { if (!data || !activeSessionId) { @@ -323,7 +326,13 @@ export default function Chat({ try { await api.streamChatMessage( - { model: selectedModel || 'llama3.2', messages: chatMessages, stream: true, sessionId: sessionId ? Number(sessionId) : undefined }, + { + model: selectedModel || 'llama3.2', + messages: chatMessages, + stream: true, + sessionId: sessionId ? Number(sessionId) : undefined, + collection: collectionFilter || undefined, + }, (chunkContent, chunkThinking, done) => { if (chunkThinking.length > 0 && thinkingStartTime === null) { thinkingStartTime = Date.now() @@ -414,10 +423,11 @@ export default function Chat({ model: selectedModel || 'llama3.2', messages: chatMessages, sessionId: sessionId ? Number(sessionId) : undefined, + collection: collectionFilter || undefined, }) } }, - [activeSessionId, messages, selectedModel, chatMutation, queryClient, streamingEnabled] + [activeSessionId, messages, selectedModel, collectionFilter, chatMutation, queryClient, streamingEnabled] ) return ( @@ -471,6 +481,22 @@ export default function Chat({ {remoteStatus?.connected === false ? 'Remote Disconnected' : 'Remote Connected'} )} +
+ + +
- {KB_COLLECTIONS.map((c) => ( + {knownCollections.map((c) => ( ))} + setManageCollectionsOpen(true)} + > + Manage Collections + + onChange={(e) => { + if (e.target.value === '__new__') { + const name = window.prompt('New collection name:') + if (name && name.trim()) { + updateCollectionMutation.mutate({ source: record.source, collection: name.trim() }) + } + return + } updateCollectionMutation.mutate({ source: record.source, collection: e.target.value }) - } + }} className="rounded border border-border-subtle bg-surface-primary px-2 py-1 text-xs text-text-primary" > - {KB_COLLECTIONS.map((c) => ( + {knownCollections.map((c) => ( ))} + ) }, @@ -1016,6 +1041,10 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o onClose={() => setViewerSource(null)} /> )} + + {manageCollectionsOpen && ( + setManageCollectionsOpen(false)} /> + )}
) } diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index 1e873f5..e9c0fc8 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -999,6 +999,25 @@ class API { })() } + async renameCollection(oldName: string, newName: string) { + return catchInternal(async () => { + const response = await this.client.post<{ message: string }>('/rag/rename-collection', { + oldName, + newName, + }) + return response.data + })() + } + + async deleteCollection(name: string) { + return catchInternal(async () => { + const response = await this.client.post<{ message: string }>('/rag/delete-collection', { + name, + }) + return response.data + })() + } + async getSetting(key: string) { return catchInternal(async () => { const response = await this.client.get<{ key: string; value: any }>( diff --git a/admin/start/routes.ts b/admin/start/routes.ts index 9b54b4f..408d17b 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -163,6 +163,8 @@ router router.get('/health', [RagController, 'health']) router.get('/collections', [RagController, 'getKnowledgeCollections']) router.post('/update-collection', [RagController, 'updateFileCollection']) + router.post('/rename-collection', [RagController, 'renameKnowledgeCollection']) + router.post('/delete-collection', [RagController, 'deleteKnowledgeCollection']) }) .prefix('/api/rag') From c10ceda43e5f0e7af3a4cc75651f6a4f39eaa192 Mon Sep 17 00:00:00 2001 From: John Cortright Date: Tue, 7 Jul 2026 16:11:44 -0700 Subject: [PATCH 3/6] fix(rag): use dynamic collections query in chat search filter chat/index.tsx still imported the static KB_COLLECTIONS constant for its 'Search in' dropdown, inconsistent with KnowledgeBaseModal.tsx which already uses the live getKnowledgeCollections() query. Renamed/added collections via the new Manage Collections UI weren't reflected in the chat filter. --- admin/inertia/components/chat/index.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/admin/inertia/components/chat/index.tsx b/admin/inertia/components/chat/index.tsx index b24e771..928db00 100644 --- a/admin/inertia/components/chat/index.tsx +++ b/admin/inertia/components/chat/index.tsx @@ -11,7 +11,6 @@ import { ChatMessage } from '../../../types/chat' import classNames from '~/lib/classNames' import { IconX } from '@tabler/icons-react' import { DEFAULT_QUERY_REWRITE_MODEL } from '../../../constants/ollama' -import { KB_COLLECTIONS } from '../../../constants/kb_collections' import { useSystemSetting } from '~/hooks/useSystemSetting' interface ChatProps { @@ -74,6 +73,12 @@ export default function Chat({ select: (data) => data || [], }) + const { data: knownCollections = [] } = useQuery({ + queryKey: ['kbCollections'], + queryFn: () => api.getKnowledgeCollections(), + select: (data) => data?.collections ?? [], + }) + const { data: chatSuggestions, isLoading: chatSuggestionsLoading } = useQuery({ queryKey: ['chatSuggestions'], queryFn: async ({ signal }) => { @@ -492,7 +497,7 @@ export default function Chat({ className="px-3 py-1.5 border border-border-default rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-surface-primary" > - {KB_COLLECTIONS.map((c) => ( + {knownCollections.map((c) => ( ))} From b93ec297e4d0b3a2bdf17e179bbd22b2cc83e228 Mon Sep 17 00:00:00 2001 From: John Cortright Date: Mon, 13 Jul 2026 18:18:10 -0700 Subject: [PATCH 4/6] feat(rag): broaden preset tags and add creatable collection combobox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the survival-specific preset list with general-purpose starter tags (recipes, diy, health, technology, finance, travel, hobbies, reference, survival, energy) so the Knowledge Base reads well for home-lab/reference use, not just prepping. Adds sanitizeCollectionName() (trim, lowercase, length cap) applied on every write path server-side, and a dependency-free CollectionCombobox component replacing the plain { + setQuery(e.target.value) + setIsOpen(true) + }} + onFocus={() => setIsOpen(true)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + const trimmed = query.trim() + if (!trimmed) { + if (allowUncategorized) commit('') + return + } + commit(exactMatch ?? trimmed.toLowerCase()) + } else if (e.key === 'Escape') { + setIsOpen(false) + setQuery(value) + } + }} + placeholder={placeholder} + className="w-full rounded border border-border-subtle bg-surface-primary px-2 py-1 text-sm text-text-primary disabled:opacity-50" + /> + {isOpen && !disabled && ( +
+ {allowUncategorized && ( + + )} + {filtered.map((opt) => ( + + ))} + {showCreateOption && ( + + )} + {filtered.length === 0 && !showCreateOption && ( +
No matches
+ )} +
+ )} +
+ ) +} diff --git a/admin/inertia/components/chat/KnowledgeBaseModal.tsx b/admin/inertia/components/chat/KnowledgeBaseModal.tsx index 727f11c..77ac969 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 { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import FileUploader from '~/components/file-uploader' import StyledButton from '~/components/StyledButton' import type { DynamicIconName } from '~/lib/icons' @@ -28,6 +28,8 @@ import StyledModal from '../StyledModal' import ActiveEmbedJobs from '~/components/ActiveEmbedJobs' import { SERVICE_NAMES } from '../../../constants/service_names' import CollectionsManager from './CollectionsManager' +import { KB_COLLECTIONS } from '../../../constants/kb_collections' +import CollectionCombobox from './CollectionCombobox' interface KnowledgeBaseModalProps { aiAssistantName?: string @@ -165,6 +167,10 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o select: (data) => data?.collections ?? [], }) + const comboboxOptions = useMemo(() => { + return Array.from(new Set([...KB_COLLECTIONS, ...knownCollections])).sort() + }, [knownCollections]) + const { data: warningsResult } = useQuery({ queryKey: ['kbFileWarnings'], queryFn: () => api.getKbFileWarnings(), @@ -463,24 +469,12 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
updateCollectionMutation.mutate({ source: record.source, collection: val })} + options={comboboxOptions} disabled={isSaving} - onChange={(e) => { - if (e.target.value === '__new__') { - const name = window.prompt('New collection name:') - if (name && name.trim()) { - updateCollectionMutation.mutate({ source: record.source, collection: name.trim() }) - } - return - } - updateCollectionMutation.mutate({ source: record.source, collection: e.target.value }) - }} - className="rounded border border-border-subtle bg-surface-primary px-2 py-1 text-xs text-text-primary" - > - - {knownCollections.map((c) => ( - - ))} - - + className="w-40" + /> ) }, }, From e88b3e2252597f54dd5291f1de37f8e2d0254fda Mon Sep 17 00:00:00 2001 From: John Cortright Date: Mon, 13 Jul 2026 18:38:29 -0700 Subject: [PATCH 5/6] chore(rag): remove .docx fix from this branch, split into #1100 Per review feedback, the .docx extraction fix is unrelated to the collections feature and can merge independently. Moved to a standalone PR (Crosstalk-Solutions/project-nomad#1100) off dev. --- admin/app/services/rag_service.ts | 14 ---- admin/app/utils/fs.ts | 6 +- admin/package-lock.json | 111 ------------------------------ admin/package.json | 1 - 4 files changed, 2 insertions(+), 130 deletions(-) diff --git a/admin/app/services/rag_service.ts b/admin/app/services/rag_service.ts index 9c0fafb..3fc5493 100644 --- a/admin/app/services/rag_service.ts +++ b/admin/app/services/rag_service.ts @@ -10,7 +10,6 @@ import { createWorker } from 'tesseract.js' import { fromBuffer } from 'pdf2pic' import JSZip from 'jszip' import * as cheerio from 'cheerio' -import mammoth from 'mammoth' import { OllamaService } from './ollama_service.js' import { SERVICE_NAMES } from '../../constants/service_names.js' import { removeStopwords } from 'stopword' @@ -616,16 +615,6 @@ export class RagService { return await this.extractTXTText(fileBuffer) } - /** - * Extract text content from a DOCX file using mammoth. DOCX is a ZIP-based - * XML format, so raw-text extraction (extractTXTText) would return garbage — - * this parses the document XML properly and returns clean plain text. - */ - private async processDocxFile(fileBuffer: Buffer): Promise { - const { value: text } = await mammoth.extractRawText({ buffer: fileBuffer }) - return text - } - /** * Extract text content from an EPUB file. * EPUBs are ZIP archives containing XHTML content files. @@ -783,9 +772,6 @@ export class RagService { case 'pdf': extractedText = await this.processPDFFile(fileBuffer!) break - case 'docx': - extractedText = await this.processDocxFile(fileBuffer!) - break case 'epub': extractedText = await this.processEPUBFile(fileBuffer!) break diff --git a/admin/app/utils/fs.ts b/admin/app/utils/fs.ts index 6711ebc..69b5463 100644 --- a/admin/app/utils/fs.ts +++ b/admin/app/utils/fs.ts @@ -190,15 +190,13 @@ export function matchesDevice(fsPath: string, deviceName: string): boolean { return false } -export function determineFileType(filename: string): 'image' | 'pdf' | 'text' | 'docx' | 'epub' | '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' } else if (ext === '.pdf') { return 'pdf' - } else if (ext === '.docx') { - return 'docx' - } else if (['.txt', '.md', '.rtf'].includes(ext)) { + } else if (['.txt', '.md', '.docx', '.rtf'].includes(ext)) { return 'text' } else if (ext === '.epub') { return 'epub' diff --git a/admin/package-lock.json b/admin/package-lock.json index ccaa795..2c08c3c 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -52,7 +52,6 @@ "ipaddr.js": "2.4.0", "jszip": "3.10.1", "luxon": "3.7.2", - "mammoth": "^1.12.0", "maplibre-gl": "4.7.1", "mysql2": "3.22.5", "ollama": "0.6.3", @@ -6232,15 +6231,6 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", - "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/abbrev": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", @@ -6700,12 +6690,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/bluebird": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", - "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", - "license": "MIT" - }, "node_modules/bmp-js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", @@ -7969,12 +7953,6 @@ "node": ">=0.3.1" } }, - "node_modules/dingbat-to-unicode": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", - "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", - "license": "BSD-2-Clause" - }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -8081,15 +8059,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/duck": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", - "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", - "license": "BSD", - "dependencies": { - "underscore": "^1.13.1" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -11394,17 +11363,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lop": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", - "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", - "license": "BSD-2-Clause", - "dependencies": { - "duck": "^0.1.12", - "option": "~0.2.1", - "underscore": "^1.13.1" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -11484,39 +11442,6 @@ "node": ">= 0.6" } }, - "node_modules/mammoth": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", - "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", - "license": "BSD-2-Clause", - "dependencies": { - "@xmldom/xmldom": "^0.8.6", - "argparse": "~1.0.3", - "base64-js": "^1.5.1", - "bluebird": "~3.4.0", - "dingbat-to-unicode": "^1.0.1", - "jszip": "^3.7.1", - "lop": "^0.4.2", - "path-is-absolute": "^1.0.0", - "underscore": "^1.13.1", - "xmlbuilder": "^10.0.0" - }, - "bin": { - "mammoth": "bin/mammoth" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/mammoth/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, "node_modules/maplibre-gl": { "version": "4.7.1", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", @@ -13231,12 +13156,6 @@ "opencollective-postinstall": "index.js" } }, - "node_modules/option": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", - "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", - "license": "BSD-2-Clause" - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -13581,15 +13500,6 @@ "node": ">=14.0.0" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -15603,12 +15513,6 @@ "node": ">= 10.x" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, "node_modules/sql-escaper": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", @@ -16595,12 +16499,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/underscore": { - "version": "1.13.8", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", - "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", - "license": "MIT" - }, "node_modules/undici": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", @@ -17232,15 +17130,6 @@ "node": ">=16.0.0" } }, - "node_modules/xmlbuilder": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", - "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/admin/package.json b/admin/package.json index fb960a6..18254d8 100644 --- a/admin/package.json +++ b/admin/package.json @@ -105,7 +105,6 @@ "ipaddr.js": "2.4.0", "jszip": "3.10.1", "luxon": "3.7.2", - "mammoth": "^1.12.0", "maplibre-gl": "4.7.1", "mysql2": "3.22.5", "ollama": "0.6.3", From 38b880b5f2e2781de5518078d30c279e43161d6b Mon Sep 17 00:00:00 2001 From: John Cortright Date: Tue, 14 Jul 2026 22:15:12 -0700 Subject: [PATCH 6/6] chore: remove unrelated diff noise (lockfile, comments, indentation) --- .gitignore | 2 - admin/app/jobs/embed_file_job.ts | 4 + admin/app/models/kb_ingest_state.ts | 24 +-- .../components/chat/KnowledgeBaseModal.tsx | 29 ++++ admin/inertia/lib/api.ts | 15 +- admin/package-lock.json | 148 ++++++++++++++++-- 6 files changed, 197 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 4bf6304..4273fe3 100644 --- a/.gitignore +++ b/.gitignore @@ -41,5 +41,3 @@ admin/public/assets # Admin specific development files admin/storage -nomad_admin_config.json -nomad_admin_hostconfig.json diff --git a/admin/app/jobs/embed_file_job.ts b/admin/app/jobs/embed_file_job.ts index 7bae3fb..cdb775b 100644 --- a/admin/app/jobs/embed_file_job.ts +++ b/admin/app/jobs/embed_file_job.ts @@ -18,6 +18,10 @@ export interface EmbedFileJobParams { batchOffset?: number // Current batch offset (for ZIM files) totalArticles?: number // Total articles in ZIM (for progress tracking) isFinalBatch?: boolean // Whether this is the last batch (prevents premature deletion) + // Running total of chunks embedded across prior batches in this dispatch chain. + // Carried forward so the final batch can persist an accurate `chunks_embedded` + // count via KbIngestState.markIndexed (see #933 -- without this, only the last + // batch's chunk count was stored while Qdrant held the full set). chunksSoFar?: number collection?: string } diff --git a/admin/app/models/kb_ingest_state.ts b/admin/app/models/kb_ingest_state.ts index 76da782..dac1028 100644 --- a/admin/app/models/kb_ingest_state.ts +++ b/admin/app/models/kb_ingest_state.ts @@ -41,20 +41,20 @@ export default class KbIngestState extends BaseModel { declare updated_at: DateTime static async getOrCreate(filePath: string, collection?: string): Promise { - return this.firstOrCreate( - { file_path: filePath }, - { file_path: filePath, state: 'pending_decision', chunks_embedded: 0, collection: collection ?? null } - ) - } + return this.firstOrCreate( + { file_path: filePath }, + { file_path: filePath, state: 'pending_decision', chunks_embedded: 0, collection: collection ?? null } + ) + } static async markIndexed(filePath: string, chunksEmbedded: number, collection?: string): Promise { - const row = await this.getOrCreate(filePath, collection) - row.state = 'indexed' - row.chunks_embedded = chunksEmbedded - row.last_error = null - if (collection) row.collection = collection - await row.save() - } + const row = await this.getOrCreate(filePath, collection) + row.state = 'indexed' + row.chunks_embedded = chunksEmbedded + row.last_error = null + if (collection) row.collection = collection + await row.save() + } static async markFailed(filePath: string, errorMessage: string): Promise { const row = await this.getOrCreate(filePath) diff --git a/admin/inertia/components/chat/KnowledgeBaseModal.tsx b/admin/inertia/components/chat/KnowledgeBaseModal.tsx index 77ac969..942fb0a 100644 --- a/admin/inertia/components/chat/KnowledgeBaseModal.tsx +++ b/admin/inertia/components/chat/KnowledgeBaseModal.tsx @@ -36,6 +36,9 @@ interface KnowledgeBaseModalProps { onClose: () => void } +// File extensions the in-browser viewer can render. Must stay in sync with +// `RagService.VIEWABLE_TEXT_EXTENSIONS` -- anything outside this set falls back +// to Download. const VIEWABLE_EXTENSIONS = new Set(['md', 'txt', 'csv', 'json', 'yaml', 'yml', 'toml', 'xml', 'html']) function isViewableExtension(filename: string): boolean { @@ -69,6 +72,13 @@ function renderSortHeader( ) } +/** + * Compact label for the per-row ingestion state. Files that exist in Qdrant + * with no `kb_ingest_state` row (`state === null`) are legacy/pre-RFC-883 + * installs whose chunks are real, so we display them as "Indexed" rather than + * surfacing the absent-row detail. Admin-docs group has no pill (the "Managed + * by NOMAD" message in the action column carries the same signal). + */ function renderStatePill(record: KbFileGroup): React.ReactNode { if (record.bucket === 'admin_docs') return null const effective: KbIngestStateValue = record.state ?? 'indexed' @@ -107,6 +117,13 @@ type RowAction = | { kind: 'index'; label: string; force: boolean; variant: 'primary'; icon: DynamicIconName } | { kind: 'reembed'; label: string; force: true; variant: 'secondary'; icon: DynamicIconName } +/** + * Pick the single adaptive per-row action button. Returns null when no action + * makes sense for the current state (e.g. healthy indexed file with no + * warnings -- bulk Re-embed All covers that case). `hasWarnings` lets us + * surface a Re-embed affordance specifically when a file *looks* indexed but + * has zero chunks or a stalled-mid-ingestion warning attached. + */ function pickRowAction(record: KbFileGroup, hasWarnings: boolean): RowAction | null { if (record.bucket === 'admin_docs') return null const effective: KbIngestStateValue = record.state ?? 'indexed' @@ -171,6 +188,10 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o return Array.from(new Set([...KB_COLLECTIONS, ...knownCollections])).sort() }, [knownCollections]) + // Per-file conditional warnings (RFC #883 section 6). `ok: false` means the + // computation itself failed (Qdrant/DB/FS) -- distinct from `ok: true` with + // an empty map, which means everything is healthy. We surface the failure + // explicitly so a silent backend failure doesn't masquerade as health. const { data: warningsResult } = useQuery({ queryKey: ['kbFileWarnings'], queryFn: () => api.getKbFileWarnings(), @@ -179,6 +200,9 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o const fileWarnings = warningsResult?.warnings ?? {} const warningsUnavailable = warningsResult !== undefined && warningsResult.ok === false + // Global auto-index policy. KVStore returns `null` for an unset key, which + // we treat as 'Always' for backward compatibility with installs that predate + // this UI. The user can opt into Manual mode from the toggle below. const { data: ingestPolicySetting } = useQuery({ queryKey: ['ingestPolicy'], queryFn: () => api.getSetting('rag.defaultIngestPolicy'), @@ -1036,8 +1060,13 @@ function FileViewerModal({ source, onClose }: { source: string; onClose: () => v staleTime: 60_000, }) + // Title falls back to the trailing path segment so the modal still has a + // useful header while the fetch is in-flight or if it failed. const fallbackName = source.split(/[/\\]/).at(-1) ?? source const title = data?.fileName ?? fallbackName + // `catchInternal` swallows errors and resolves to undefined, surfacing a + // toast -- so the "couldn't load" branch is gated on a finished-but-empty + // fetch rather than on react-query's `isError`. const showError = isFetched && !data return ( diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index e9c0fc8..c9baa8a 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -273,6 +273,14 @@ class API { })() } + /** + * Ask the backend to send Ollama `keep_alive: 0` to every currently-loaded + * chat model except `targetModel` (and the embedding model, which is always + * exempt server-side). Fire-and-forget -- the chat UI doesn't await this + * before creating a new session, since unload is housekeeping. + * + * Pass `null` to unload every chat model. + */ async unloadChatModels(targetModel: string | null) { return catchInternal(async () => { const response = await this.client.post<{ unloaded: string[] }>( @@ -307,6 +315,7 @@ class API { onChunk: (content: string, thinking: string, done: boolean) => void, signal?: AbortSignal ): Promise { + // Axios doesn't support ReadableStream in browser, so need to use fetch const response = await fetch('/api/ollama/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -336,7 +345,7 @@ class API { let data: any try { data = JSON.parse(line.slice(6)) - } catch { continue } + } catch { continue /* skip malformed chunks */ } if (data.error) throw new Error('The model encountered an error. Please try again.') @@ -856,11 +865,13 @@ class API { const response = await this.client.post('/benchmark/submit', { benchmark_id, anonymous }) return response.data } catch (error: any) { + // For 409 Conflict errors, throw a specific error that the UI can handle if (error.response?.status === 409) { const err = new Error(error.response?.data?.error || 'This benchmark has already been submitted to the repository') ; (err as any).status = 409 throw err } + // For other errors, extract the message and throw const errorMessage = error.response?.data?.error || error.message || 'Failed to submit benchmark' throw new Error(errorMessage) } @@ -934,6 +945,8 @@ class API { })() } + // Wikipedia selector methods + async getWikipediaState(): Promise { return catchInternal(async () => { const response = await this.client.get('/zim/wikipedia') diff --git a/admin/package-lock.json b/admin/package-lock.json index 2c08c3c..0a18db6 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -2230,6 +2230,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2246,6 +2249,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2262,6 +2268,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2278,6 +2287,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2294,6 +2306,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2310,6 +2325,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2326,6 +2344,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2342,6 +2363,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2358,6 +2382,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2380,6 +2407,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2402,6 +2432,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2424,6 +2457,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2446,6 +2482,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2468,6 +2507,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2490,6 +2532,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2512,6 +2557,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3387,6 +3435,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3403,6 +3454,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3419,6 +3473,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3435,6 +3492,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3451,6 +3511,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4136,6 +4199,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4149,6 +4215,9 @@ "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4162,6 +4231,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4175,6 +4247,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4188,6 +4263,9 @@ "cpu": [ "loong64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4201,6 +4279,9 @@ "cpu": [ "loong64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4214,6 +4295,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4227,6 +4311,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4240,6 +4327,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4253,6 +4343,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4266,6 +4359,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4279,6 +4375,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4292,6 +4391,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4488,7 +4590,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4505,7 +4606,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4522,7 +4622,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4539,7 +4638,9 @@ "cpu": [ "arm64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4556,7 +4657,9 @@ "cpu": [ "arm64" ], - "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4573,7 +4676,9 @@ "cpu": [ "x64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4590,7 +4695,9 @@ "cpu": [ "x64" ], - "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4607,7 +4714,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4624,7 +4730,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4641,7 +4746,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4834,6 +4938,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4850,6 +4957,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4866,6 +4976,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4882,6 +4995,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -11124,6 +11240,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11144,6 +11263,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11164,6 +11286,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11184,6 +11309,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [