From b69b98314557f7c8b08e11bbb14e48d959e6ab26 Mon Sep 17 00:00:00 2001 From: Jaret Burkett Date: Tue, 27 Oct 2026 10:16:13 -0600 Subject: [PATCH] Add an info button on the files to show size, format, and layer keys and shaped for the safetensors files in the ui --- ui/src/app/api/files/info/route.ts | 111 ++++++++ ui/src/app/layout.tsx | 2 + ui/src/components/FilesWidget.tsx | 13 +- ui/src/components/SafetensorsInfoModal.tsx | 281 +++++++++++++++++++++ 4 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 ui/src/app/api/files/info/route.ts create mode 100644 ui/src/components/SafetensorsInfoModal.tsx diff --git a/ui/src/app/api/files/info/route.ts b/ui/src/app/api/files/info/route.ts new file mode 100644 index 0000000..462bd3b --- /dev/null +++ b/ui/src/app/api/files/info/route.ts @@ -0,0 +1,111 @@ +/* eslint-disable */ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import { getDatasetsRoot, getTrainingFolder } from '@/server/settings'; + +// safetensors header: u64 LE byte length, then that many bytes of JSON. +const MAX_HEADER_BYTES = 256 * 1024 * 1024; + +const DTYPE_BYTES: Record = { + F64: 8, I64: 8, U64: 8, + F32: 4, I32: 4, U32: 4, + F16: 2, BF16: 2, I16: 2, U16: 2, + F8_E4M3: 1, F8_E5M2: 1, F8_E8M0: 1, I8: 1, U8: 1, BOOL: 1, + F4: 0.5, U4: 0.5, I4: 0.5, +}; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { filePath } = body; + + if (!filePath || typeof filePath !== 'string') { + return new NextResponse('filePath is required', { status: 400 }); + } + + const decodedFilePath = decodeURIComponent(filePath); + const datasetRoot = await getDatasetsRoot(); + const trainingRoot = await getTrainingFolder(); + const allowedDirs = [datasetRoot, trainingRoot]; + + const resolvedFilePath = path.resolve(decodedFilePath); + const isAllowed = allowedDirs.some( + allowedDir => resolvedFilePath === allowedDir || resolvedFilePath.startsWith(allowedDir + path.sep), + ); + if (!isAllowed) { + console.warn(`Access denied: ${resolvedFilePath} not in ${allowedDirs.join(', ')}`); + return new NextResponse('Access denied', { status: 403 }); + } + + let stat; + try { + stat = await fs.promises.stat(resolvedFilePath); + } catch { + return new NextResponse('File not found', { status: 404 }); + } + if (!stat.isFile()) { + return new NextResponse('Not a file', { status: 400 }); + } + if (stat.size < 8) { + return new NextResponse('Not a safetensors file', { status: 400 }); + } + + const handle = await fs.promises.open(resolvedFilePath, 'r'); + let header: Record; + let headerSize: number; + try { + const lenBuf = Buffer.alloc(8); + await handle.read(lenBuf, 0, 8, 0); + const headerLen = lenBuf.readBigUInt64LE(0); + if (headerLen > BigInt(MAX_HEADER_BYTES) || headerLen > BigInt(stat.size - 8)) { + return new NextResponse('Not a safetensors file (bad header length)', { status: 400 }); + } + headerSize = Number(headerLen); + const headerBuf = Buffer.alloc(headerSize); + await handle.read(headerBuf, 0, headerSize, 8); + header = JSON.parse(headerBuf.toString('utf8')); + } catch (e) { + return new NextResponse('Not a safetensors file (unparseable header)', { status: 400 }); + } finally { + await handle.close(); + } + + const metadata: Record = header.__metadata__ ?? {}; + const tensors: { name: string; dtype: string; shape: number[]; numel: number; bytes: number }[] = []; + const dtypeCounts: Record = {}; + let totalParams = 0; + let totalBytes = 0; + + for (const [name, info] of Object.entries(header)) { + if (name === '__metadata__') continue; + const t = info as { dtype: string; shape: number[]; data_offsets?: [number, number] }; + const shape = Array.isArray(t.shape) ? t.shape : []; + const numel = shape.reduce((a, b) => a * b, 1); + const bytes = t.data_offsets ? t.data_offsets[1] - t.data_offsets[0] : numel * (DTYPE_BYTES[t.dtype] ?? 0); + tensors.push({ name, dtype: t.dtype, shape, numel, bytes }); + totalParams += numel; + totalBytes += bytes; + const d = (dtypeCounts[t.dtype] ??= { count: 0, numel: 0, bytes: 0 }); + d.count += 1; + d.numel += numel; + d.bytes += bytes; + } + tensors.sort((a, b) => a.name.localeCompare(b.name)); + + return NextResponse.json({ + filePath: resolvedFilePath, + fileSize: stat.size, + headerSize, + metadata, + tensors, + tensorCount: tensors.length, + totalParams, + totalBytes, + dtypeCounts, + }); + } catch (error) { + console.error('Error reading safetensors info:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index e4a91fa..79a3379 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -10,6 +10,7 @@ import DocModal from '@/components/DocModal'; import os from 'os'; import { CaptionDatasetModal } from '@/components/CaptionDatasetModal'; import MergeLoRAsModal from '@/components/MergeLoRAsModal'; +import SafetensorsInfoModal from '@/components/SafetensorsInfoModal'; import UpsamplePromptsModal from '@/components/UpsamplePromptsModal'; import PromptBoxEditorModal from '@/components/PromptBoxEditorModal'; @@ -65,6 +66,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + diff --git a/ui/src/components/FilesWidget.tsx b/ui/src/components/FilesWidget.tsx index 5bf86e6..b1d0485 100644 --- a/ui/src/components/FilesWidget.tsx +++ b/ui/src/components/FilesWidget.tsx @@ -1,7 +1,8 @@ import React from 'react'; import useFilesList from '@/hooks/useFilesList'; -import { Loader2, AlertCircle, Download, Box, Brain, Trash2, SlidersHorizontal } from 'lucide-react'; +import { Loader2, AlertCircle, Download, Box, Brain, Trash2, SlidersHorizontal, Info } from 'lucide-react'; import { openMergeLoRAsModal } from './MergeLoRAsModal'; +import { openSafetensorsInfoModal } from './SafetensorsInfoModal'; import { getFilename, getFoldername, encodeFilePathForUrl } from '@/utils/basic'; import { openConfirm } from './ConfirmModal'; import { apiClient } from '@/utils/api'; @@ -114,6 +115,16 @@ export default function FilesWidget({ jobID, jobName }: { jobID: string; jobName
{cleanSize(file.size)} + {fileName.endsWith('.safetensors') && ( + + )} ; + tensors: TensorInfo[]; + tensorCount: number; + totalParams: number; + totalBytes: number; + dtypeCounts: Record; +} + +export const safetensorsInfoModalState = createGlobalState(null); + +export const openSafetensorsInfoModal = (filePath: string) => { + safetensorsInfoModalState.set({ filePath }); +}; + +const fmtBytes = (size: number) => { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`; + return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`; +}; + +const fmtCount = (n: number) => { + if (n < 1000) return `${n}`; + if (n < 1e6) return `${(n / 1e3).toFixed(1)}K`; + if (n < 1e9) return `${(n / 1e6).toFixed(1)}M`; + return `${(n / 1e9).toFixed(2)}B`; +}; + +// metadata values are strings; many are JSON blobs worth pretty-printing +const fmtMetaValue = (value: string) => { + const trimmed = value.trim(); + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + return JSON.stringify(JSON.parse(trimmed), null, 2); + } catch { + /* not json */ + } + } + return value; +}; + +const MAX_ROWS = 2000; + +const SafetensorsInfoModal: React.FC = () => { + const [modalInfo, setModalInfo] = safetensorsInfoModalState.use(); + const isOpen = modalInfo !== null; + const [info, setInfo] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [filter, setFilter] = useState(''); + const [tab, setTab] = useState<'tensors' | 'metadata'>('tensors'); + + useEffect(() => { + if (!modalInfo) { + setInfo(null); + setError(null); + setIsLoading(false); + setFilter(''); + setTab('tensors'); + return; + } + let cancelled = false; + setIsLoading(true); + setInfo(null); + setError(null); + apiClient + .post('/api/files/info', { filePath: modalInfo.filePath }) + .then(res => { + if (cancelled) return; + setInfo(res.data as SafetensorsInfo); + }) + .catch(err => { + if (cancelled) return; + setError(err?.response?.data || err?.message || 'Failed to read file'); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [modalInfo]); + + const onClose = () => setModalInfo(null); + + const filteredTensors = useMemo(() => { + if (!info) return []; + const q = filter.trim().toLowerCase(); + if (!q) return info.tensors; + return info.tensors.filter(t => t.name.toLowerCase().includes(q) || t.dtype.toLowerCase().includes(q)); + }, [info, filter]); + + const metadataEntries = info ? Object.entries(info.metadata) : []; + const title = modalInfo ? getFilename(modalInfo.filePath) : 'Safetensors Info'; + + return ( + + {isLoading && ( +
+ + Gathering info... +
+ )} + + {!isLoading && error && ( +
+ + {String(error)} +
+ )} + + {!isLoading && info && ( +
+
+
+
File size
+
{fmtBytes(info.fileSize)}
+
+
+
Tensors
+
{info.tensorCount.toLocaleString()}
+
+
+
Parameters
+
+ {fmtCount(info.totalParams)} +
+
+
+
Header
+
{fmtBytes(info.headerSize)}
+
+
+ +
+ {Object.entries(info.dtypeCounts) + .sort((a, b) => b[1].bytes - a[1].bytes) + .map(([dtype, d]) => ( + + {dtype} · {d.count} · {fmtBytes(d.bytes)} + + ))} +
+ +
+
+ + +
+ {tab === 'tensors' && ( +
+ + setFilter(e.target.value)} + placeholder="Filter tensors" + className="text-xs pl-7 pr-2 py-1 w-56 bg-gray-950 dark:bg-gray-800 border border-gray-700 rounded-md text-gray-100 focus:ring-1 focus:ring-gray-600 focus:outline-none" + /> +
+ )} +
+ + {tab === 'tensors' && ( +
+ + + + + + + + + + + + {filteredTensors.slice(0, MAX_ROWS).map(t => ( + + + + + + + + ))} + {filteredTensors.length === 0 && ( + + + + )} + +
NameShapeDtypeParamsSize
{t.name}[{t.shape.join(', ')}]{t.dtype} + {t.numel.toLocaleString()} + {fmtBytes(t.bytes)}
+ No tensors match +
+ {filteredTensors.length > MAX_ROWS && ( +
+ Showing {MAX_ROWS.toLocaleString()} of {filteredTensors.length.toLocaleString()} — narrow with the + filter +
+ )} +
+ )} + + {tab === 'metadata' && ( +
+ {metadataEntries.length === 0 ? ( +
No metadata
+ ) : ( +
+ {metadataEntries.map(([key, value]) => ( +
+
{key}
+
+                        {fmtMetaValue(String(value))}
+                      
+
+ ))} +
+ )} +
+ )} +
+ )} +
+ ); +}; + +export default SafetensorsInfoModal;