feat(KnowledgeBase): add document viewer, download, metadata, and sorting (#721)

Rebuilt on top of dev's RFC #883 state-machine UI rather than the now-defunct
StoredFile shape:

- Extend StoredFileInfo with fileName/size/uploadedAt/isUserUpload
- Populate metadata from on-disk stats in RagService.getStoredFiles
- Add fileSourceSchema validator + getFileContent/downloadFile endpoints
  scoped to the uploads directory only (tighter than the original PR — matches
  docs_service traversal pattern)
- KnowledgeBaseModal: sortable Size and Uploaded columns; View/Download
  buttons on upload-bucket rows; new FileViewerModal for in-browser text
  preview. Bucket grouping preserved — sort applies within each bucket.
- Use formatBytes from ~/lib/util rather than redefining
This commit is contained in:
Benjamin Smith 2026-06-20 00:42:17 -04:00 committed by jakeaturner
parent fad9e30ddc
commit 5181637926
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
9 changed files with 418 additions and 20 deletions

View File

@ -7,7 +7,7 @@ import app from '@adonisjs/core/services/app'
import { randomBytes } from 'node:crypto'
import { sanitizeFilename } from '../utils/fs.js'
import { basename } from 'node:path'
import { deleteFileSchema, embedFileSchema, estimateBatchSchema, getJobStatusSchema } from '#validators/rag'
import { deleteFileSchema, embedFileSchema, estimateBatchSchema, fileSourceSchema, getJobStatusSchema } from '#validators/rag'
import logger from '@adonisjs/core/services/logger'
@inject()
@ -162,4 +162,23 @@ export default class RagController {
const result = await KbRatioRegistry.estimateBatch(normalized)
return response.status(200).json(result)
}
public async getFileContent({ request, response }: HttpContext) {
const { source } = await request.validateUsing(fileSourceSchema)
const result = await this.ragService.readFileContent(source)
if (!result) {
return response.status(404).json({ error: 'File not found or not viewable' })
}
return response.status(200).json(result)
}
public async downloadFile({ request, response }: HttpContext) {
const { source } = await request.validateUsing(fileSourceSchema)
const filePath = await this.ragService.resolveDownloadPath(source)
if (!filePath) {
return response.status(404).json({ error: 'File not found' })
}
const fileName = filePath.split(/[/\\]/).at(-1) ?? 'download'
return response.attachment(filePath, fileName)
}
}

View File

@ -1140,20 +1140,90 @@ export class RagService {
)
}
return Array.from(sources).map((source) => {
const row = stateByPath.get(source)
return {
source,
state: row?.state ?? null,
chunksEmbedded: row?.chunks_embedded ?? 0,
}
})
const uploadsAbsPath = resolve(join(process.cwd(), RagService.UPLOADS_STORAGE_PATH))
return await Promise.all(
Array.from(sources).map(async (source) => {
const row = stateByPath.get(source)
const fileName = source.split(/[/\\]/).at(-1) ?? source
const isUserUpload = resolve(source).startsWith(uploadsAbsPath + sep)
const stats = await getFileStatsIfExists(source)
return {
source,
state: row?.state ?? null,
chunksEmbedded: row?.chunks_embedded ?? 0,
fileName,
size: stats?.size ?? null,
uploadedAt: stats?.modifiedTime.toISOString() ?? null,
isUserUpload,
}
})
)
} catch (error) {
logger.error('Error retrieving stored files:', error)
return []
}
}
/**
* 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
* guard: resolve, then require the resolved path to be strictly inside the
* base + path separator (so siblings of `kb_uploads` can't slip through).
* Returns null for anything else ZIMs, admin docs, README, or paths
* outside the app entirely. The viewer/download endpoints lean on this so
* they don't need to repeat the check.
*/
private resolveUploadPath(source: string): string | null {
const uploadsAbsPath = resolve(join(process.cwd(), RagService.UPLOADS_STORAGE_PATH))
const resolved = resolve(source)
if (!resolved.startsWith(uploadsAbsPath + sep)) return null
return resolved
}
private static readonly VIEWABLE_TEXT_EXTENSIONS: ReadonlySet<string> = new Set([
'md', 'txt', 'csv', 'json', 'yaml', 'yml', 'toml', 'xml', 'html',
])
/**
* Read the text content of a user-uploaded file for in-browser viewing.
* Returns null when the source is outside uploads, missing, or not a
* recognized text extension. The extension allowlist is intentionally narrow
* PDFs/EPUBs/ZIMs round-trip through Download, not the viewer.
*/
public async readFileContent(
source: string
): Promise<{ content: string; extension: string; fileName: string } | null> {
const resolved = this.resolveUploadPath(source)
if (!resolved) return null
const extension = resolved.split('.').at(-1)?.toLowerCase() ?? ''
if (!RagService.VIEWABLE_TEXT_EXTENSIONS.has(extension)) return null
const stats = await getFileStatsIfExists(resolved)
if (!stats) return null
try {
const { readFile } = await import('node:fs/promises')
const content = await readFile(resolved, 'utf-8')
const fileName = resolved.split(/[/\\]/).at(-1) ?? resolved
return { content, extension, fileName }
} catch (error) {
logger.warn({ err: error, source }, '[RagService.readFileContent] read failed')
return null
}
}
/**
* Resolve a download-target path for a stored upload. Returns null if the
* source isn't an upload or the file is missing on disk.
*/
public async resolveDownloadPath(source: string): Promise<string | null> {
const resolved = this.resolveUploadPath(source)
if (!resolved) return null
const stats = await getFileStatsIfExists(resolved)
return stats ? resolved : null
}
/**
* Compute whether the first-chat JIT prompt should fire and surface the file
* count the banner uses in its copy ("Index your N existing files?"). The

View File

@ -19,6 +19,12 @@ export const embedFileSchema = vine.compile(
})
)
export const fileSourceSchema = vine.compile(
vine.object({
source: vine.string().minLength(1),
})
)
export const estimateBatchSchema = vine.compile(
vine.object({
files: vine

View File

@ -10,9 +10,19 @@ import api from '~/lib/api'
import {
groupAndSortKbFiles,
type KbFileGroup,
type KbFileSort,
type KbFileSortKey,
} from '~/lib/kb_file_grouping'
import type { KbIngestStateValue } from '../../../types/kb_ingest_state'
import { IconX } from '@tabler/icons-react'
import { formatBytes } from '~/lib/util'
import {
IconArrowsSort,
IconDownload,
IconEye,
IconSortAscending,
IconSortDescending,
IconX,
} from '@tabler/icons-react'
import { useModals } from '~/context/ModalContext'
import StyledModal from '../StyledModal'
import ActiveEmbedJobs from '~/components/ActiveEmbedJobs'
@ -23,6 +33,42 @@ interface KnowledgeBaseModalProps {
onClose: () => void
}
// File extensions the in-browser viewer can render. Must stay in sync with
// `RagService.VIEWABLE_TEXT_EXTENSIONS` — anything outside this set falls back
// to Download.
const VIEWABLE_EXTENSIONS = new Set(['md', 'txt', 'csv', 'json', 'yaml', 'yml', 'toml', 'xml', 'html'])
function isViewableExtension(filename: string): boolean {
const ext = filename.split('.').at(-1)?.toLowerCase() ?? ''
return VIEWABLE_EXTENSIONS.has(ext)
}
function renderSortHeader(
label: string,
key: KbFileSortKey,
sort: KbFileSort,
setSort: (s: KbFileSort) => void
): React.ReactNode {
const active = sort.key === key
const Icon = !active ? IconArrowsSort : sort.direction === 'asc' ? IconSortAscending : IconSortDescending
return (
<button
type="button"
className="inline-flex items-center gap-1 text-left hover:text-text-primary transition-colors"
onClick={() => {
if (!active) {
setSort({ key, direction: 'asc' })
} else {
setSort({ key, direction: sort.direction === 'asc' ? 'desc' : 'asc' })
}
}}
>
<span>{label}</span>
<Icon size={14} className={active ? 'text-text-primary' : 'text-text-muted'} aria-hidden="true" />
</button>
)
}
/**
* Compact label for the per-row ingestion state. Files that exist in Qdrant
* with no `kb_ingest_state` row (`state === null`) are legacy/pre-RFC-883
@ -101,6 +147,8 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
const [confirmReembed, setConfirmReembed] = useState<{ source: string; displayName: string } | null>(null)
const [bulkMode, setBulkMode] = useState<null | 'reembed' | 'reset'>(null)
const [resetTyped, setResetTyped] = useState('')
const [sort, setSort] = useState<KbFileSort>({ key: 'name', direction: 'asc' })
const [viewerSource, setViewerSource] = useState<string | null>(null)
const fileUploaderRef = useRef<React.ComponentRef<typeof FileUploader>>(null)
const { openModal, closeModal } = useModals()
const queryClient = useQueryClient()
@ -555,7 +603,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
columns={[
{
accessor: 'source',
title: 'File Name',
title: renderSortHeader('File Name', 'name', sort, setSort),
render(record) {
const warnings = fileWarnings[record.source] ?? []
const pill = renderStatePill(record)
@ -594,6 +642,35 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
)
},
},
{
accessor: 'size',
title: renderSortHeader('Size', 'size', sort, setSort),
className: 'whitespace-nowrap',
render(record) {
// The collapsed admin_docs group has no single size — leave blank
// rather than misleadingly summing across N files.
if (record.bucket === 'admin_docs' || record.size === null) {
return <span className="text-text-muted"></span>
}
return <span className="text-text-secondary">{formatBytes(record.size)}</span>
},
},
{
accessor: 'uploadedAt',
title: renderSortHeader('Uploaded', 'uploadedAt', sort, setSort),
className: 'whitespace-nowrap',
render(record) {
if (record.bucket === 'admin_docs' || !record.uploadedAt) {
return <span className="text-text-muted"></span>
}
const d = new Date(record.uploadedAt)
return (
<span className="text-text-secondary" title={d.toISOString()}>
{d.toLocaleDateString()}
</span>
)
},
},
{
accessor: 'source',
title: '',
@ -642,6 +719,9 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
const actionPendingForThisRow =
embedMutation.isPending && embedMutation.variables?.source === record.source
const canView = record.isUserUpload && isViewableExtension(record.displayName) && record.size !== null
const canDownload = record.isUserUpload && record.size !== null
return (
<div className="flex justify-end items-center gap-2">
{action && (
@ -662,6 +742,24 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
{action.label}
</StyledButton>
)}
{canView && (
<StyledButton
variant="ghost"
size="sm"
icon="IconEye"
onClick={() => setViewerSource(record.source)}
>View</StyledButton>
)}
{canDownload && (
<StyledButton
variant="ghost"
size="sm"
icon="IconDownload"
onClick={() => {
window.location.href = `/api/rag/files/download?source=${encodeURIComponent(record.source)}`
}}
>Download</StyledButton>
)}
<StyledButton
variant="danger"
size="sm"
@ -675,7 +773,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
},
},
]}
data={groupAndSortKbFiles(storedFiles)}
data={groupAndSortKbFiles(storedFiles, sort)}
loading={isLoadingFiles}
/>
</div>
@ -807,6 +905,57 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
</div>
</StyledModal>
)}
{viewerSource && (
<FileViewerModal
source={viewerSource}
onClose={() => setViewerSource(null)}
/>
)}
</div>
)
}
function FileViewerModal({ source, onClose }: { source: string; onClose: () => void }) {
const { data, isLoading, isFetched } = useQuery({
queryKey: ['rag', 'file-content', source],
queryFn: () => api.getFileContent(source),
staleTime: 60_000,
})
// Title falls back to the trailing path segment so the modal still has a
// useful header while the fetch is in-flight or if it failed.
const fallbackName = source.split(/[/\\]/).at(-1) ?? source
const title = data?.fileName ?? fallbackName
// `catchInternal` swallows errors and resolves to undefined, surfacing a
// toast — so the "couldn't load" branch is gated on a finished-but-empty
// fetch rather than on react-query's `isError`.
const showError = isFetched && !data
return (
<StyledModal
title={title}
open={true}
onClose={onClose}
onCancel={onClose}
cancelText="Close"
large
>
<div className="text-left text-sm">
{isLoading && (
<div className="text-text-secondary">Loading</div>
)}
{showError && (
<div className="text-amber-700 dark:text-amber-300">
Couldn't load file. It may have been moved or its type isn't viewable.
</div>
)}
{data && (
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded border border-border-subtle bg-surface-secondary p-3 font-mono text-xs text-text-primary">
{data.content}
</pre>
)}
</div>
</StyledModal>
)
}

View File

@ -514,6 +514,17 @@ class API {
})()
}
async getFileContent(source: string) {
return catchInternal(async () => {
const response = await this.client.get<{
content: string
extension: string
fileName: string
}>('/rag/files/content', { params: { source } })
return response.data
})()
}
async getSystemInfo() {
return catchInternal(async () => {
const response = await this.client.get<SystemInformationResponse>('/system/info')

View File

@ -52,19 +52,62 @@ export interface KbFileGroup {
/** Chunks currently embedded for this source; 0 for state-row-less or
* zero-chunk files. Always 0 for the collapsed admin_docs group. */
chunksEmbedded: number
/** File size in bytes from disk. Null for the collapsed admin_docs group,
* and for any file the scanner couldn't stat. */
size: number | null
/** Last-modified timestamp (ISO 8601). Null for collapsed groups and for
* files the scanner couldn't stat. */
uploadedAt: string | null
/** True when the row corresponds to a user upload drives whether the
* view/download buttons render. False for the collapsed admin_docs group. */
isUserUpload: boolean
}
const BUCKET_SORT_ORDER: KbFileBucket[] = ['zim', 'upload', 'admin_docs', 'other']
export type KbFileSortKey = 'name' | 'size' | 'uploadedAt'
export type KbFileSortDirection = 'asc' | 'desc'
export interface KbFileSort {
key: KbFileSortKey
direction: KbFileSortDirection
}
const DEFAULT_SORT: KbFileSort = { key: 'name', direction: 'asc' }
function compareForSort(a: StoredFileInfo, b: StoredFileInfo, sort: KbFileSort): number {
// Files the scanner couldn't stat sort to the end regardless of direction so
// they don't pollute the top of size/uploaded-at views.
const aMissing = sort.key !== 'name' && (sort.key === 'size' ? a.size === null : a.uploadedAt === null)
const bMissing = sort.key !== 'name' && (sort.key === 'size' ? b.size === null : b.uploadedAt === null)
if (aMissing && !bMissing) return 1
if (!aMissing && bMissing) return -1
let cmp = 0
if (sort.key === 'size') {
cmp = (a.size ?? 0) - (b.size ?? 0)
} else if (sort.key === 'uploadedAt') {
cmp = (a.uploadedAt ?? '').localeCompare(b.uploadedAt ?? '')
}
if (cmp === 0) {
// Tiebreak (and primary key for 'name') is filename — keeps stable order.
cmp = sourceToDisplayName(a.source).localeCompare(sourceToDisplayName(b.source))
}
return sort.direction === 'desc' ? -cmp : cmp
}
/**
* Group stored-file rows into table rows for the Stored Files panel.
*
* - 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.
* - ZIMs, uploads, and others stay as individual rows, sorted within their
* bucket by the active sort key. Bucket order itself is fixed sorting
* never flattens or reorders the groups themselves.
*/
export function groupAndSortKbFiles(files: StoredFileInfo[]): KbFileGroup[] {
export function groupAndSortKbFiles(
files: StoredFileInfo[],
sort: KbFileSort = DEFAULT_SORT
): KbFileGroup[] {
const buckets: Record<KbFileBucket, StoredFileInfo[]> = {
zim: [],
upload: [],
@ -90,13 +133,14 @@ export function groupAndSortKbFiles(files: StoredFileInfo[]): KbFileGroup[] {
members: members.map((m) => m.source),
state: null,
chunksEmbedded: 0,
size: null,
uploadedAt: null,
isUserUpload: false,
})
continue
}
for (const file of members.sort((a, b) =>
sourceToDisplayName(a.source).localeCompare(sourceToDisplayName(b.source))
)) {
for (const file of members.sort((a, b) => compareForSort(a, b, sort))) {
groups.push({
bucket,
source: file.source,
@ -105,6 +149,9 @@ export function groupAndSortKbFiles(files: StoredFileInfo[]): KbFileGroup[] {
members: [],
state: file.state,
chunksEmbedded: file.chunksEmbedded,
size: file.size,
uploadedAt: file.uploadedAt,
isUserUpload: file.isUserUpload,
})
}
}

View File

@ -147,6 +147,8 @@ router
router.get('/file-warnings', [RagController, 'getFileWarnings'])
router.delete('/files', [RagController, 'deleteFile'])
router.post('/files/embed', [RagController, 'embedFile'])
router.get('/files/content', [RagController, 'getFileContent'])
router.get('/files/download', [RagController, 'downloadFile'])
router.get('/active-jobs', [RagController, 'getActiveJobs'])
router.get('/failed-jobs', [RagController, 'getFailedJobs'])
router.delete('/failed-jobs', [RagController, 'cleanupFailedJobs'])

View File

@ -11,9 +11,19 @@ import type { StoredFileInfo } from '../../types/rag.js'
/** Wrap source paths into the minimal StoredFileInfo shape that
* `groupAndSortKbFiles` now expects. State + chunk count are irrelevant to
* grouping/sorting behavior; the per-file state-pill rendering is exercised
* separately in the modal's component tests (added in the follow-up PR). */
* separately in the modal's component tests (added in the follow-up PR).
* Metadata fields default to nullish/false individual tests that exercise
* sort-by-size or sort-by-uploadedAt override as needed. */
const asInfos = (sources: string[]): StoredFileInfo[] =>
sources.map((source) => ({ source, state: null, chunksEmbedded: 0 }))
sources.map((source) => ({
source,
state: null,
chunksEmbedded: 0,
fileName: sourceToDisplayName(source),
size: null,
uploadedAt: null,
isUserUpload: classifyKbFile(source) === 'upload',
}))
test('classifyKbFile distinguishes ZIM, upload, admin_docs, and other', () => {
assert.equal(
@ -106,3 +116,78 @@ test('groupAndSortKbFiles preserves a stable synthetic key for the admin docs gr
// can be used as a React key without colliding with any real file row.
assert.equal(groups[0].source, '__admin_docs_group__')
})
/** Sized fixtures for the sort tests. `size` and `uploadedAt` are set so the
* three sort keys (name, size, uploadedAt) produce visibly different orders
* if a test passed for name it had better fail for size. */
const sized: StoredFileInfo[] = [
{ source: '/app/storage/kb_uploads/charlie.txt', state: null, chunksEmbedded: 0, fileName: 'charlie.txt', size: 100, uploadedAt: '2026-01-01T00:00:00Z', isUserUpload: true },
{ source: '/app/storage/kb_uploads/alpha.txt', state: null, chunksEmbedded: 0, fileName: 'alpha.txt', size: 300, uploadedAt: '2026-03-01T00:00:00Z', isUserUpload: true },
{ source: '/app/storage/kb_uploads/bravo.txt', state: null, chunksEmbedded: 0, fileName: 'bravo.txt', size: 200, uploadedAt: '2026-02-01T00:00:00Z', isUserUpload: true },
]
test('groupAndSortKbFiles sorts by size ascending', () => {
const groups = groupAndSortKbFiles(sized, { key: 'size', direction: 'asc' })
assert.deepEqual(groups.map((g) => g.displayName), ['charlie.txt', 'bravo.txt', 'alpha.txt'])
})
test('groupAndSortKbFiles sorts by size descending', () => {
const groups = groupAndSortKbFiles(sized, { key: 'size', direction: 'desc' })
assert.deepEqual(groups.map((g) => g.displayName), ['alpha.txt', 'bravo.txt', 'charlie.txt'])
})
test('groupAndSortKbFiles sorts by uploadedAt ascending', () => {
const groups = groupAndSortKbFiles(sized, { key: 'uploadedAt', direction: 'asc' })
assert.deepEqual(groups.map((g) => g.displayName), ['charlie.txt', 'bravo.txt', 'alpha.txt'])
})
test('groupAndSortKbFiles sorts by uploadedAt descending', () => {
const groups = groupAndSortKbFiles(sized, { key: 'uploadedAt', direction: 'desc' })
assert.deepEqual(groups.map((g) => g.displayName), ['alpha.txt', 'bravo.txt', 'charlie.txt'])
})
test('groupAndSortKbFiles parks files with null size at the end of size sort', () => {
const withMissing: StoredFileInfo[] = [
...sized,
{ source: '/app/storage/kb_uploads/zzz_missing.txt', state: null, chunksEmbedded: 0, fileName: 'zzz_missing.txt', size: null, uploadedAt: null, isUserUpload: true },
]
// Missing-size files sort last regardless of direction so the "real" data
// owns the top of the view either way.
const asc = groupAndSortKbFiles(withMissing, { key: 'size', direction: 'asc' })
assert.equal(asc.at(-1)?.displayName, 'zzz_missing.txt')
const desc = groupAndSortKbFiles(withMissing, { key: 'size', direction: 'desc' })
assert.equal(desc.at(-1)?.displayName, 'zzz_missing.txt')
})
test('groupAndSortKbFiles preserves bucket order across all sort modes', () => {
const mixed: StoredFileInfo[] = [
{ source: '/app/storage/zim/big.zim', state: null, chunksEmbedded: 0, fileName: 'big.zim', size: 999, uploadedAt: '2026-01-01T00:00:00Z', isUserUpload: false },
{ source: '/app/storage/kb_uploads/small.txt', state: null, chunksEmbedded: 0, fileName: 'small.txt', size: 1, uploadedAt: '2026-09-01T00:00:00Z', isUserUpload: true },
{ source: '/app/docs/release-notes.md', state: null, chunksEmbedded: 0, fileName: 'release-notes.md', size: 50, uploadedAt: '2026-05-01T00:00:00Z', isUserUpload: false },
]
// Even if size-desc would put zim first naturally, sort runs *within* a
// bucket — buckets themselves stay in the canonical zim → upload → admin_docs
// order. This is the invariant that lets the per-bucket grouping stay
// legible while still giving the user a sortable view.
for (const direction of ['asc', 'desc'] as const) {
for (const key of ['name', 'size', 'uploadedAt'] as const) {
const groups = groupAndSortKbFiles(mixed, { key, direction })
assert.deepEqual(
groups.map((g) => g.bucket),
['zim', 'upload', 'admin_docs'],
`bucket order changed for ${key}/${direction}`
)
}
}
})
test('groupAndSortKbFiles emits null metadata + isUserUpload=false for the admin_docs group', () => {
const groups = groupAndSortKbFiles(asInfos([
'/app/docs/release-notes.md',
'/app/README.md',
]))
assert.equal(groups[0].bucket, 'admin_docs')
assert.equal(groups[0].size, null)
assert.equal(groups[0].uploadedAt, null)
assert.equal(groups[0].isUserUpload, false)
})

View File

@ -56,6 +56,15 @@ export type StoredFileInfo = {
source: string
state: import('./kb_ingest_state.js').KbIngestStateValue | null
chunksEmbedded: number
/** Filename portion of `source` (last path segment). */
fileName: string
/** File size in bytes from disk; null if the file is missing or unreadable. */
size: number | null
/** Last-modified timestamp from disk (ISO 8601); null if unavailable. */
uploadedAt: string | null
/** True when `source` lives under the user-uploads directory. Drives which
* rows offer view/download in the UI. */
isUserUpload: boolean
}
/**