diff --git a/admin/app/controllers/rag_controller.ts b/admin/app/controllers/rag_controller.ts index 1b2954a..ad915b6 100644 --- a/admin/app/controllers/rag_controller.ts +++ b/admin/app/controllers/rag_controller.ts @@ -94,6 +94,35 @@ export default class RagController { return response.status(200).json({ message: result.message }) } + public async renameKnowledgeCollection({ request, response }: HttpContext) { + const oldName: string | null = request.input('oldName', null) + const newName: string | null = request.input('newName', null) + + if (!oldName || !newName) { + return response.status(400).json({ error: 'oldName and newName are required.' }) + } + + const result = await this.ragService.renameKnowledgeCollection(oldName, newName) + if (!result.success) { + return response.status(500).json({ error: result.message }) + } + return response.status(200).json({ message: result.message }) + } + + public async deleteKnowledgeCollection({ request, response }: HttpContext) { + const name: string | null = request.input('name', null) + + if (!name) { + return response.status(400).json({ error: 'name is required.' }) + } + + const result = await this.ragService.deleteKnowledgeCollection(name) + 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/services/rag_service.ts b/admin/app/services/rag_service.ts index 01ca212..9c0fafb 100644 --- a/admin/app/services/rag_service.ts +++ b/admin/app/services/rag_service.ts @@ -1239,6 +1239,64 @@ export class RagService { } } + /** + * Rename a knowledge-base collection everywhere it's referenced: updates every + * Qdrant point tagged with the old name in place, and mirrors the change onto + * any matching KbIngestState rows so getStoredFiles() reflects it immediately. + */ + public async renameKnowledgeCollection( + oldName: string, + newName: string + ): Promise<{ success: boolean; message: string }> { + try { + if (!oldName || !newName || oldName === newName) { + return { success: false, message: 'Invalid collection names.' } + } + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION) + + await this.qdrant!.setPayload(RagService.CONTENT_COLLECTION_NAME, { + payload: { collection: newName }, + filter: { must: [{ key: 'collection', match: { value: oldName } }] }, + }) + + await KbIngestState.query().where('collection', oldName).update({ collection: newName }) + + return { success: true, message: `Renamed "${oldName}" to "${newName}".` } + } catch (error) { + logger.error('[RAG] Error renaming knowledge collection:', error) + return { success: false, message: 'Error renaming collection.' } + } + } + + /** + * Remove a collection by reassigning every file tagged with it back to + * Uncategorized (collection: null). Non-destructive — no files or embeddings + * are deleted, only the grouping label is cleared so items can be + * recategorized later. + */ + public async deleteKnowledgeCollection( + name: string + ): Promise<{ success: boolean; message: string }> { + try { + if (!name) { + return { success: false, message: 'Invalid collection name.' } + } + await this._ensureCollection(RagService.CONTENT_COLLECTION_NAME, RagService.EMBEDDING_DIMENSION) + + await this.qdrant!.setPayload(RagService.CONTENT_COLLECTION_NAME, { + payload: { collection: null }, + filter: { must: [{ key: 'collection', match: { value: name } }] }, + }) + + await KbIngestState.query().where('collection', name).update({ collection: null }) + + return { success: true, message: `"${name}" removed. Files moved to Uncategorized.` } + } catch (error) { + logger.error('[RAG] Error deleting knowledge collection:', error) + return { success: false, message: 'Error deleting 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/inertia/components/chat/CollectionsManager.tsx b/admin/inertia/components/chat/CollectionsManager.tsx new file mode 100644 index 0000000..d1015c6 --- /dev/null +++ b/admin/inertia/components/chat/CollectionsManager.tsx @@ -0,0 +1,148 @@ +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import StyledModal from '../StyledModal' +import StyledButton from '~/components/StyledButton' +import { useNotifications } from '~/context/NotificationContext' +import api from '~/lib/api' + +interface CollectionsManagerProps { + onClose: () => void +} + +export default function CollectionsManager({ onClose }: CollectionsManagerProps) { + const { addNotification } = useNotifications() + const queryClient = useQueryClient() + const [editingName, setEditingName] = useState(null) + const [editValue, setEditValue] = useState('') + const [confirmDelete, setConfirmDelete] = useState(null) + + const { data: collections = [], isLoading } = useQuery({ + queryKey: ['kbCollections'], + queryFn: () => api.getKnowledgeCollections(), + select: (data) => data?.collections ?? [], + }) + + const invalidateAll = () => { + queryClient.invalidateQueries({ queryKey: ['kbCollections'] }) + queryClient.invalidateQueries({ queryKey: ['storedFiles'] }) + } + + const renameMutation = useMutation({ + mutationFn: ({ oldName, newName }: { oldName: string; newName: string }) => + api.renameCollection(oldName, newName), + onSuccess: (data) => { + addNotification({ type: 'success', message: data?.message || 'Collection renamed.' }) + setEditingName(null) + invalidateAll() + }, + onError: (error: any) => { + addNotification({ type: 'error', message: error?.message || 'Failed to rename collection.' }) + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (name: string) => api.deleteCollection(name), + onSuccess: (data) => { + addNotification({ type: 'success', message: data?.message || 'Collection removed.' }) + setConfirmDelete(null) + invalidateAll() + }, + onError: (error: any) => { + addNotification({ type: 'error', message: error?.message || 'Failed to remove collection.' }) + }, + }) + + return ( + +
+

+ Rename or remove collections. Removing a collection doesn't delete any files — + they're simply moved back to Uncategorized so you can re-sort them. +

+ + {isLoading &&

Loading…

} + {!isLoading && collections.length === 0 && ( +

+ No collections yet. Assign a file to a collection from the Knowledge Base table to create one. +

+ )} + +
    + {collections.map((name) => ( +
  • + {editingName === name ? ( + <> + setEditValue(e.target.value)} + className="flex-1 rounded border border-border-subtle bg-surface-primary px-2 py-1 text-sm text-text-primary" + /> + + renameMutation.mutate({ oldName: name, newName: editValue.trim() }) + } + > + Save + + setEditingName(null)}> + Cancel + + + ) : confirmDelete === name ? ( + <> + + Remove "{name}"? Files move to Uncategorized. + + deleteMutation.mutate(name)} + > + Confirm + + setConfirmDelete(null)}> + Cancel + + + ) : ( + <> + {name} + { + setEditingName(name) + setEditValue(name) + }} + > + Rename + + setConfirmDelete(name)} + > + Remove + + + )} +
  • + ))} +
+
+
+ ) +} diff --git a/admin/inertia/components/chat/KnowledgeBaseModal.tsx b/admin/inertia/components/chat/KnowledgeBaseModal.tsx index aa2d224..727f11c 100644 --- a/admin/inertia/components/chat/KnowledgeBaseModal.tsx +++ b/admin/inertia/components/chat/KnowledgeBaseModal.tsx @@ -27,7 +27,7 @@ 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' +import CollectionsManager from './CollectionsManager' interface KnowledgeBaseModalProps { aiAssistantName?: string @@ -129,6 +129,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o const [isUploading, setIsUploading] = useState(false) const [uploadCollection, setUploadCollection] = useState('') const [collectionFilter, setCollectionFilter] = useState('All') + const [manageCollectionsOpen, setManageCollectionsOpen] = useState(false) const [confirmDeleteSource, setConfirmDeleteSource] = useState(null) const [confirmReembed, setConfirmReembed] = useState<{ source: string; displayName: string } | null>(null) const [bulkMode, setBulkMode] = useState(null) @@ -464,13 +465,21 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o Collection: - {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')