feat(rag): support dynamic KB collection creation, rename, and removal

Extends collection organization with a Manage Collections UI: collections
are created on the fly when a file is assigned to a new name, can be
renamed (bulk-updates every tagged file and Qdrant point), and can be
removed (reassigns tagged files back to Uncategorized rather than
deleting anything).
This commit is contained in:
John Cortright 2026-07-06 21:11:49 -07:00
parent cae3dde8b4
commit ba0ea31e36
6 changed files with 292 additions and 7 deletions

View File

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

View File

@ -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

View File

@ -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<string | null>(null)
const [editValue, setEditValue] = useState('')
const [confirmDelete, setConfirmDelete] = useState<string | null>(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 (
<StyledModal
open={true}
title="Manage Collections"
onClose={onClose}
cancelText="Close"
onCancel={onClose}
large
>
<div className="text-left">
<p className="text-sm text-text-secondary mb-4">
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.
</p>
{isLoading && <p className="text-sm text-text-muted">Loading</p>}
{!isLoading && collections.length === 0 && (
<p className="text-sm text-text-muted">
No collections yet. Assign a file to a collection from the Knowledge Base table to create one.
</p>
)}
<ul className="divide-y divide-border-subtle">
{collections.map((name) => (
<li key={name} className="flex items-center justify-between gap-3 py-3">
{editingName === name ? (
<>
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="flex-1 rounded border border-border-subtle bg-surface-primary px-2 py-1 text-sm text-text-primary"
/>
<StyledButton
variant="primary"
icon="IconCheck"
loading={renameMutation.isPending}
disabled={!editValue.trim() || editValue.trim() === name}
onClick={() =>
renameMutation.mutate({ oldName: name, newName: editValue.trim() })
}
>
Save
</StyledButton>
<StyledButton variant="outline" onClick={() => setEditingName(null)}>
Cancel
</StyledButton>
</>
) : confirmDelete === name ? (
<>
<span className="flex-1 text-sm text-text-primary">
Remove "{name}"? Files move to Uncategorized.
</span>
<StyledButton
variant="danger"
icon="IconTrash"
loading={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(name)}
>
Confirm
</StyledButton>
<StyledButton variant="outline" onClick={() => setConfirmDelete(null)}>
Cancel
</StyledButton>
</>
) : (
<>
<span className="flex-1 text-sm text-text-primary">{name}</span>
<StyledButton
variant="secondary"
icon="IconPencil"
onClick={() => {
setEditingName(name)
setEditValue(name)
}}
>
Rename
</StyledButton>
<StyledButton
variant="danger"
icon="IconTrash"
onClick={() => setConfirmDelete(name)}
>
Remove
</StyledButton>
</>
)}
</li>
))}
</ul>
</div>
</StyledModal>
)
}

View File

@ -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<string>('')
const [collectionFilter, setCollectionFilter] = useState<string>('All')
const [manageCollectionsOpen, setManageCollectionsOpen] = useState(false)
const [confirmDeleteSource, setConfirmDeleteSource] = useState<string | null>(null)
const [confirmReembed, setConfirmReembed] = useState<{ source: string; displayName: string } | null>(null)
const [bulkMode, setBulkMode] = useState<null | 'reembed' | 'reset'>(null)
@ -464,13 +465,21 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
Collection:
<select
value={uploadCollection}
onChange={(e) => setUploadCollection(e.target.value)}
onChange={(e) => {
if (e.target.value === '__new__') {
const name = window.prompt('New collection name:')
if (name && name.trim()) setUploadCollection(name.trim())
return
}
setUploadCollection(e.target.value)
}}
className="rounded border border-border-subtle bg-surface-primary px-3 py-2 text-text-primary"
>
<option value="">Uncategorized</option>
{KB_COLLECTIONS.map((c) => (
{knownCollections.map((c) => (
<option key={c} value={c}>{c}</option>
))}
<option value="__new__">+ New collection</option>
</select>
</label>
<StyledButton
@ -624,11 +633,19 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
className="rounded border border-border-subtle bg-surface-primary px-3 py-2 text-text-primary"
>
<option value="All">All</option>
{KB_COLLECTIONS.map((c) => (
{knownCollections.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</label>
<StyledButton
variant="secondary"
size="md"
icon="IconSettings"
onClick={() => setManageCollectionsOpen(true)}
>
Manage Collections
</StyledButton>
<StyledButton
variant="danger"
size="md"
@ -760,15 +777,23 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
<select
value={record.collection ?? ''}
disabled={isSaving}
onChange={(e) =>
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"
>
<option value="">Uncategorized</option>
{KB_COLLECTIONS.map((c) => (
{knownCollections.map((c) => (
<option key={c} value={c}>{c}</option>
))}
<option value="__new__">+ New collection</option>
</select>
)
},
@ -1016,6 +1041,10 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
onClose={() => setViewerSource(null)}
/>
)}
{manageCollectionsOpen && (
<CollectionsManager onClose={() => setManageCollectionsOpen(false)} />
)}
</div>
)
}

View File

@ -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 }>(

View File

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