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:
parent
cae3dde8b4
commit
ba0ea31e36
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }>(
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue