From c64ec97de41b8f3ebf61415a499b7d6e25173c2f Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Thu, 14 May 2026 12:26:58 -0700 Subject: [PATCH] =?UTF-8?q?feat(KB):=20group=20admin=20docs=20into=20singl?= =?UTF-8?q?e=20row=20in=20Stored=20Files=20(RFC=20#883=20=C2=A79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/chat/KnowledgeBaseModal.tsx | 30 ++++-- admin/inertia/lib/kb_file_grouping.ts | 100 ++++++++++++++++++ admin/tests/unit/kb_file_grouping.spec.ts | 100 ++++++++++++++++++ 3 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 admin/inertia/lib/kb_file_grouping.ts create mode 100644 admin/tests/unit/kb_file_grouping.spec.ts diff --git a/admin/inertia/components/chat/KnowledgeBaseModal.tsx b/admin/inertia/components/chat/KnowledgeBaseModal.tsx index d3e3998..5ca7cc5 100644 --- a/admin/inertia/components/chat/KnowledgeBaseModal.tsx +++ b/admin/inertia/components/chat/KnowledgeBaseModal.tsx @@ -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([]) @@ -362,7 +361,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o - + 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 {sourceToDisplayName(record.source)} + return ( + {record.displayName} + ) }, }, { 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 ( +
+ + Managed by NOMAD + +
+ ) + } + 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} /> diff --git a/admin/inertia/lib/kb_file_grouping.ts b/admin/inertia/lib/kb_file_grouping.ts new file mode 100644 index 0000000..9f474c6 --- /dev/null +++ b/admin/inertia/lib/kb_file_grouping.ts @@ -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 = { + 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 +} diff --git a/admin/tests/unit/kb_file_grouping.spec.ts b/admin/tests/unit/kb_file_grouping.spec.ts new file mode 100644 index 0000000..caa1da7 --- /dev/null +++ b/admin/tests/unit/kb_file_grouping.spec.ts @@ -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__') +})