From 51ea98e55486aa6124c6d38d007250763187e459 Mon Sep 17 00:00:00 2001 From: bravosierra99 Date: Sun, 24 May 2026 16:51:11 +0000 Subject: [PATCH] feat(KnowledgeBase): add document viewer, download, metadata, and sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- admin/app/controllers/rag_controller.ts | 21 ++- admin/app/services/rag_service.ts | 86 +++++++++- admin/app/validators/rag.ts | 6 + .../components/chat/KnowledgeBaseModal.tsx | 155 +++++++++++++++++- admin/inertia/lib/api.ts | 11 ++ admin/inertia/lib/kb_file_grouping.ts | 59 ++++++- admin/start/routes.ts | 2 + admin/tests/unit/kb_file_grouping.spec.ts | 89 +++++++++- admin/types/rag.ts | 9 + 9 files changed, 418 insertions(+), 20 deletions(-) diff --git a/admin/app/controllers/rag_controller.ts b/admin/app/controllers/rag_controller.ts index 16cedcd2..68cb5ae8 100644 --- a/admin/app/controllers/rag_controller.ts +++ b/admin/app/controllers/rag_controller.ts @@ -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) + } } diff --git a/admin/app/services/rag_service.ts b/admin/app/services/rag_service.ts index dcbbd258..f02d744a 100644 --- a/admin/app/services/rag_service.ts +++ b/admin/app/services/rag_service.ts @@ -1120,20 +1120,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 = 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 { + 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 diff --git a/admin/app/validators/rag.ts b/admin/app/validators/rag.ts index 5bef8369..b57fceee 100644 --- a/admin/app/validators/rag.ts +++ b/admin/app/validators/rag.ts @@ -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 diff --git a/admin/inertia/components/chat/KnowledgeBaseModal.tsx b/admin/inertia/components/chat/KnowledgeBaseModal.tsx index 301bf679..25a64884 100644 --- a/admin/inertia/components/chat/KnowledgeBaseModal.tsx +++ b/admin/inertia/components/chat/KnowledgeBaseModal.tsx @@ -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 ( + + ) +} + /** * 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) const [resetTyped, setResetTyped] = useState('') + const [sort, setSort] = useState({ key: 'name', direction: 'asc' }) + const [viewerSource, setViewerSource] = useState(null) const fileUploaderRef = useRef>(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 + } + return {formatBytes(record.size)} + }, + }, + { + accessor: 'uploadedAt', + title: renderSortHeader('Uploaded', 'uploadedAt', sort, setSort), + className: 'whitespace-nowrap', + render(record) { + if (record.bucket === 'admin_docs' || !record.uploadedAt) { + return + } + const d = new Date(record.uploadedAt) + return ( + + {d.toLocaleDateString()} + + ) + }, + }, { 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 (
{action && ( @@ -662,6 +742,24 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o {action.label} )} + {canView && ( + setViewerSource(record.source)} + >View + )} + {canDownload && ( + { + window.location.href = `/api/rag/files/download?source=${encodeURIComponent(record.source)}` + }} + >Download + )}
@@ -807,6 +905,57 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o )} + + {viewerSource && ( + setViewerSource(null)} + /> + )} ) } + +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 ( + +
+ {isLoading && ( +
Loading…
+ )} + {showError && ( +
+ Couldn't load file. It may have been moved or its type isn't viewable. +
+ )} + {data && ( +
+            {data.content}
+          
+ )} +
+
+ ) +} diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index b6372e9c..01809601 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -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('/system/info') diff --git a/admin/inertia/lib/kb_file_grouping.ts b/admin/inertia/lib/kb_file_grouping.ts index 3d2ae045..cfe0789a 100644 --- a/admin/inertia/lib/kb_file_grouping.ts +++ b/admin/inertia/lib/kb_file_grouping.ts @@ -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 = { 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, }) } } diff --git a/admin/start/routes.ts b/admin/start/routes.ts index 84357783..703ee404 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -145,6 +145,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']) diff --git a/admin/tests/unit/kb_file_grouping.spec.ts b/admin/tests/unit/kb_file_grouping.spec.ts index 2dbab988..c8bad458 100644 --- a/admin/tests/unit/kb_file_grouping.spec.ts +++ b/admin/tests/unit/kb_file_grouping.spec.ts @@ -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) +}) diff --git a/admin/types/rag.ts b/admin/types/rag.ts index 0b9c6c85..465a539d 100644 --- a/admin/types/rag.ts +++ b/admin/types/rag.ts @@ -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 } /**