From cae3dde8b4b5cf69f27ad5d3a9b2664294f61b31 Mon Sep 17 00:00:00 2001 From: John Cortright Date: Fri, 3 Jul 2026 14:16:01 -0700 Subject: [PATCH] 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'} )} +
+ + +