feat(KB): group admin docs into single row in Stored Files (RFC #883 §9)
Project NOMAD's bundled docs (`/app/docs/*.md` and `README.md`) each embed as their own KB source — currently rendering as 12+ individual rows that swamp user-uploaded content in the Stored Files table. Collapse them into one informational row: > Project NOMAD documentation · 12 files · Managed by NOMAD The admin-docs row hides the Delete button (those files would be re-embedded on the next sync anyway, so deleting is a footgun). User uploads and ZIMs keep their existing per-row Delete UX. Also adds deterministic sort: ZIMs → user uploads → admin docs → other, alphabetical within each bucket. Pure frontend change — `/api/rag/files` response shape unchanged. Decision logic extracted to `kb_file_grouping.ts` with 9 unit tests covering bucket classification, sort order, count noun pluralization, and empty-input handling.
This commit is contained in:
parent
4d6b140a1f
commit
c64ec97de4
|
|
@ -6,6 +6,10 @@ import StyledSectionHeader from '~/components/StyledSectionHeader'
|
|||
import StyledTable from '~/components/StyledTable'
|
||||
import { useNotifications } from '~/context/NotificationContext'
|
||||
import api from '~/lib/api'
|
||||
import {
|
||||
groupAndSortKbFiles,
|
||||
type KbFileGroup,
|
||||
} from '~/lib/kb_file_grouping'
|
||||
import { IconX } from '@tabler/icons-react'
|
||||
import { useModals } from '~/context/ModalContext'
|
||||
import StyledModal from '../StyledModal'
|
||||
|
|
@ -17,11 +21,6 @@ interface KnowledgeBaseModalProps {
|
|||
onClose: () => void
|
||||
}
|
||||
|
||||
function sourceToDisplayName(source: string): string {
|
||||
const parts = source.split(/[/\\]/)
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", onClose }: KnowledgeBaseModalProps) {
|
||||
const { addNotification } = useNotifications()
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
|
@ -362,7 +361,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
|
||||
</div>
|
||||
</div>
|
||||
<StyledTable<{ source: string }>
|
||||
<StyledTable<KbFileGroup>
|
||||
className="font-semibold"
|
||||
rowLines={true}
|
||||
columns={[
|
||||
|
|
@ -370,13 +369,28 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
accessor: 'source',
|
||||
title: 'File Name',
|
||||
render(record) {
|
||||
return <span className="text-text-primary">{sourceToDisplayName(record.source)}</span>
|
||||
return (
|
||||
<span className="text-text-primary">{record.displayName}</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<div className="flex justify-end">
|
||||
<span className="text-sm text-text-muted italic">
|
||||
Managed by NOMAD
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isConfirming = confirmDeleteSource === record.source
|
||||
const isDeleting = deleteMutation.isPending && confirmDeleteSource === record.source
|
||||
if (isConfirming) {
|
||||
|
|
@ -417,7 +431,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
},
|
||||
},
|
||||
]}
|
||||
data={storedFiles.map((source) => ({ source }))}
|
||||
data={groupAndSortKbFiles(storedFiles)}
|
||||
loading={isLoadingFiles}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Knowledge-base files come back as a flat list of source paths from
|
||||
* `/api/rag/files`. The UI groups them so the user sees the categories that
|
||||
* matter to them — ZIMs, uploaded documents, and a single rolled-up entry for
|
||||
* Project NOMAD's bundled docs (rather than the 12+ individual markdown files
|
||||
* those break into).
|
||||
*
|
||||
* Bucket assignment is purely by path prefix; matching is done on `/` so the
|
||||
* server-emitted absolute paths work regardless of which Linux mount the admin
|
||||
* container uses.
|
||||
*/
|
||||
export type KbFileBucket = 'zim' | 'upload' | 'admin_docs' | 'other'
|
||||
|
||||
const ADMIN_DOCS_PREFIXES = ['/app/docs/', '/app/README.md']
|
||||
const ZIM_PREFIX = '/app/storage/zim/'
|
||||
const UPLOADS_PREFIX = '/app/storage/kb_uploads/'
|
||||
|
||||
export function classifyKbFile(source: string): KbFileBucket {
|
||||
if (
|
||||
ADMIN_DOCS_PREFIXES.some((p) =>
|
||||
p.endsWith('/') ? source.startsWith(p) : source === p
|
||||
)
|
||||
) {
|
||||
return 'admin_docs'
|
||||
}
|
||||
if (source.startsWith(ZIM_PREFIX)) return 'zim'
|
||||
if (source.startsWith(UPLOADS_PREFIX)) return 'upload'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
export function sourceToDisplayName(source: string): string {
|
||||
const parts = source.split(/[/\\]/)
|
||||
return parts[parts.length - 1] || source
|
||||
}
|
||||
|
||||
export interface KbFileGroup {
|
||||
bucket: KbFileBucket
|
||||
/** Source path used as the row's stable React key. For collapsed admin docs
|
||||
* this is a synthetic marker; individual file paths live in `members`. */
|
||||
source: string
|
||||
displayName: string
|
||||
/** Number of underlying files this row represents (1 for non-collapsed). */
|
||||
count: number
|
||||
/** All member source paths — populated for collapsed groups, empty otherwise. */
|
||||
members: string[]
|
||||
}
|
||||
|
||||
const BUCKET_SORT_ORDER: KbFileBucket[] = ['zim', 'upload', 'admin_docs', 'other']
|
||||
|
||||
/**
|
||||
* Group raw source paths into rows for the Stored Files table.
|
||||
*
|
||||
* - Admin docs (`/app/docs/*`, README) collapse into a single
|
||||
* "Project NOMAD documentation · N files" row.
|
||||
* - ZIMs, uploads, and others stay as individual rows, sorted by bucket then
|
||||
* alphabetically by filename so related items cluster naturally.
|
||||
*/
|
||||
export function groupAndSortKbFiles(sources: string[]): KbFileGroup[] {
|
||||
const buckets: Record<KbFileBucket, string[]> = {
|
||||
zim: [],
|
||||
upload: [],
|
||||
admin_docs: [],
|
||||
other: [],
|
||||
}
|
||||
for (const source of sources) {
|
||||
buckets[classifyKbFile(source)].push(source)
|
||||
}
|
||||
|
||||
const groups: KbFileGroup[] = []
|
||||
|
||||
for (const bucket of BUCKET_SORT_ORDER) {
|
||||
const members = buckets[bucket]
|
||||
if (members.length === 0) continue
|
||||
|
||||
if (bucket === 'admin_docs') {
|
||||
groups.push({
|
||||
bucket,
|
||||
source: '__admin_docs_group__',
|
||||
displayName: `Project NOMAD documentation · ${members.length} file${members.length === 1 ? '' : 's'}`,
|
||||
count: members.length,
|
||||
members,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
for (const source of members.sort((a, b) =>
|
||||
sourceToDisplayName(a).localeCompare(sourceToDisplayName(b))
|
||||
)) {
|
||||
groups.push({
|
||||
bucket,
|
||||
source,
|
||||
displayName: sourceToDisplayName(source),
|
||||
count: 1,
|
||||
members: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import * as assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import {
|
||||
classifyKbFile,
|
||||
groupAndSortKbFiles,
|
||||
sourceToDisplayName,
|
||||
} from '../../inertia/lib/kb_file_grouping.js'
|
||||
|
||||
test('classifyKbFile distinguishes ZIM, upload, admin_docs, and other', () => {
|
||||
assert.equal(
|
||||
classifyKbFile('/app/storage/zim/devdocs_en_python_2026-02.zim'),
|
||||
'zim'
|
||||
)
|
||||
assert.equal(
|
||||
classifyKbFile('/app/storage/kb_uploads/federalist.txt-8cc4ec95aa8f.txt'),
|
||||
'upload'
|
||||
)
|
||||
assert.equal(classifyKbFile('/app/docs/release-notes.md'), 'admin_docs')
|
||||
assert.equal(classifyKbFile('/app/README.md'), 'admin_docs')
|
||||
assert.equal(classifyKbFile('/unexpected/path/file.txt'), 'other')
|
||||
})
|
||||
|
||||
test('classifyKbFile does not match /app/READMEs that are not the bundled one', () => {
|
||||
assert.equal(classifyKbFile('/app/README.md.bak'), 'other')
|
||||
})
|
||||
|
||||
test('sourceToDisplayName returns the basename', () => {
|
||||
assert.equal(
|
||||
sourceToDisplayName('/app/storage/zim/devdocs_en_python_2026-02.zim'),
|
||||
'devdocs_en_python_2026-02.zim'
|
||||
)
|
||||
assert.equal(sourceToDisplayName('/app/docs/release-notes.md'), 'release-notes.md')
|
||||
})
|
||||
|
||||
test('groupAndSortKbFiles collapses all admin docs into a single row', () => {
|
||||
const groups = groupAndSortKbFiles([
|
||||
'/app/docs/release-notes.md',
|
||||
'/app/docs/getting-started.md',
|
||||
'/app/docs/maps.md',
|
||||
'/app/README.md',
|
||||
])
|
||||
|
||||
assert.equal(groups.length, 1)
|
||||
assert.equal(groups[0].bucket, 'admin_docs')
|
||||
assert.equal(groups[0].count, 4)
|
||||
assert.equal(groups[0].displayName, 'Project NOMAD documentation · 4 files')
|
||||
assert.deepEqual(groups[0].members.sort(), [
|
||||
'/app/README.md',
|
||||
'/app/docs/getting-started.md',
|
||||
'/app/docs/maps.md',
|
||||
'/app/docs/release-notes.md',
|
||||
])
|
||||
})
|
||||
|
||||
test('groupAndSortKbFiles orders buckets ZIM → upload → admin_docs → other', () => {
|
||||
const groups = groupAndSortKbFiles([
|
||||
'/app/docs/release-notes.md',
|
||||
'/unexpected/foo.txt',
|
||||
'/app/storage/kb_uploads/upload.pdf',
|
||||
'/app/storage/zim/devdocs.zim',
|
||||
])
|
||||
|
||||
assert.deepEqual(
|
||||
groups.map((g) => g.bucket),
|
||||
['zim', 'upload', 'admin_docs', 'other']
|
||||
)
|
||||
})
|
||||
|
||||
test('groupAndSortKbFiles alphabetizes within a bucket', () => {
|
||||
const groups = groupAndSortKbFiles([
|
||||
'/app/storage/zim/wikipedia.zim',
|
||||
'/app/storage/zim/devdocs.zim',
|
||||
'/app/storage/zim/ifixit.zim',
|
||||
])
|
||||
|
||||
assert.deepEqual(
|
||||
groups.map((g) => g.displayName),
|
||||
['devdocs.zim', 'ifixit.zim', 'wikipedia.zim']
|
||||
)
|
||||
})
|
||||
|
||||
test('groupAndSortKbFiles uses singular noun when only one admin doc exists', () => {
|
||||
const groups = groupAndSortKbFiles(['/app/docs/release-notes.md'])
|
||||
assert.equal(groups[0].displayName, 'Project NOMAD documentation · 1 file')
|
||||
})
|
||||
|
||||
test('groupAndSortKbFiles handles empty input', () => {
|
||||
assert.deepEqual(groupAndSortKbFiles([]), [])
|
||||
})
|
||||
|
||||
test('groupAndSortKbFiles preserves a stable synthetic key for the admin docs group', () => {
|
||||
const groups = groupAndSortKbFiles([
|
||||
'/app/docs/release-notes.md',
|
||||
'/app/docs/maps.md',
|
||||
])
|
||||
// The admin-docs row uses a synthetic source key (not a real path) so it
|
||||
// can be used as a React key without colliding with any real file row.
|
||||
assert.equal(groups[0].source, '__admin_docs_group__')
|
||||
})
|
||||
Loading…
Reference in New Issue