Add an info button on the files to show size, format, and layer keys and shaped for the safetensors files in the ui

This commit is contained in:
Jaret Burkett committed 2026-10-27 10:16:13 -06:00
1 parent 221cac39a0
commit b69b983145
4 files changed
+406 -1

No files matched your search

+111
View File
@@ -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<string, number> = {
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<string, any>;
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<string, string> = header.__metadata__ ?? {};
const tensors: { name: string; dtype: string; shape: number[]; numel: number; bytes: number }[] = [];
const dtypeCounts: Record<string, { count: number; numel: number; bytes: number }> = {};
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 });
}
}
+2
View File
@@ -10,6 +10,7 @@ import DocModal from '@/components/DocModal';
import os from 'os'; import os from 'os';
import { CaptionDatasetModal } from '@/components/CaptionDatasetModal'; import { CaptionDatasetModal } from '@/components/CaptionDatasetModal';
import MergeLoRAsModal from '@/components/MergeLoRAsModal'; import MergeLoRAsModal from '@/components/MergeLoRAsModal';
import SafetensorsInfoModal from '@/components/SafetensorsInfoModal';
import UpsamplePromptsModal from '@/components/UpsamplePromptsModal'; import UpsamplePromptsModal from '@/components/UpsamplePromptsModal';
import PromptBoxEditorModal from '@/components/PromptBoxEditorModal'; import PromptBoxEditorModal from '@/components/PromptBoxEditorModal';
@@ -65,6 +66,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<DocModal /> <DocModal />
<CaptionDatasetModal /> <CaptionDatasetModal />
<MergeLoRAsModal /> <MergeLoRAsModal />
<SafetensorsInfoModal />
<UpsamplePromptsModal /> <UpsamplePromptsModal />
<PromptBoxEditorModal /> <PromptBoxEditorModal />
</body> </body>
+12 -1
View File
@@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import useFilesList from '@/hooks/useFilesList'; 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 { openMergeLoRAsModal } from './MergeLoRAsModal';
import { openSafetensorsInfoModal } from './SafetensorsInfoModal';
import { getFilename, getFoldername, encodeFilePathForUrl } from '@/utils/basic'; import { getFilename, getFoldername, encodeFilePathForUrl } from '@/utils/basic';
import { openConfirm } from './ConfirmModal'; import { openConfirm } from './ConfirmModal';
import { apiClient } from '@/utils/api'; import { apiClient } from '@/utils/api';
@@ -114,6 +115,16 @@ export default function FilesWidget({ jobID, jobName }: { jobID: string; jobName
</a> </a>
<div className="flex items-center space-x-3 flex-shrink-0"> <div className="flex items-center space-x-3 flex-shrink-0">
<span className="text-xs text-gray-400">{cleanSize(file.size)}</span> <span className="text-xs text-gray-400">{cleanSize(file.size)}</span>
{fileName.endsWith('.safetensors') && (
<button
type="button"
onClick={() => openSafetensorsInfoModal(file.path)}
className="bg-sky-500 bg-opacity-0 group-hover:bg-opacity-10 hover:!bg-opacity-30 rounded-full p-1 transition-all"
title="Safetensors info"
>
<Info className="w-3 h-3 text-sky-400" />
</button>
)}
<a <a
target="_blank" target="_blank"
href={`/api/files/${encodeFilePathForUrl(file.path)}`} href={`/api/files/${encodeFilePathForUrl(file.path)}`}
+281
View File
@@ -0,0 +1,281 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { createGlobalState } from 'react-global-hooks';
import { Loader2, AlertCircle, Search } from 'lucide-react';
import { Modal } from './Modal';
import { getFilename } from '@/utils/basic';
import { apiClient } from '@/utils/api';
export interface SafetensorsInfoModalState {
filePath: string;
}
interface TensorInfo {
name: string;
dtype: string;
shape: number[];
numel: number;
bytes: number;
}
interface SafetensorsInfo {
filePath: string;
fileSize: number;
headerSize: number;
metadata: Record<string, string>;
tensors: TensorInfo[];
tensorCount: number;
totalParams: number;
totalBytes: number;
dtypeCounts: Record<string, { count: number; numel: number; bytes: number }>;
}
export const safetensorsInfoModalState = createGlobalState<SafetensorsInfoModalState | null>(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<SafetensorsInfo | null>(null);
const [error, setError] = useState<string | null>(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 (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="xl">
{isLoading && (
<div className="flex flex-col items-center justify-center py-16 space-y-3 text-gray-300">
<Loader2 className="w-8 h-8 text-purple-400 animate-spin" />
<span className="text-sm">Gathering info...</span>
</div>
)}
{!isLoading && error && (
<div className="flex items-center justify-center py-10 text-rose-400 space-x-2">
<AlertCircle className="w-5 h-5" />
<span className="text-sm">{String(error)}</span>
</div>
)}
{!isLoading && info && (
<div className="space-y-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<div className="bg-gray-800 rounded-lg px-3 py-2">
<div className="text-gray-500">File size</div>
<div className="text-gray-100 font-mono">{fmtBytes(info.fileSize)}</div>
</div>
<div className="bg-gray-800 rounded-lg px-3 py-2">
<div className="text-gray-500">Tensors</div>
<div className="text-gray-100 font-mono">{info.tensorCount.toLocaleString()}</div>
</div>
<div className="bg-gray-800 rounded-lg px-3 py-2">
<div className="text-gray-500">Parameters</div>
<div className="text-gray-100 font-mono" title={info.totalParams.toLocaleString()}>
{fmtCount(info.totalParams)}
</div>
</div>
<div className="bg-gray-800 rounded-lg px-3 py-2">
<div className="text-gray-500">Header</div>
<div className="text-gray-100 font-mono">{fmtBytes(info.headerSize)}</div>
</div>
</div>
<div className="flex flex-wrap gap-1.5 text-xs">
{Object.entries(info.dtypeCounts)
.sort((a, b) => b[1].bytes - a[1].bytes)
.map(([dtype, d]) => (
<span
key={dtype}
className="px-2 py-0.5 rounded-full bg-purple-500/10 text-purple-300 font-mono"
title={`${d.count} tensors · ${d.numel.toLocaleString()} params`}
>
{dtype} · {d.count} · {fmtBytes(d.bytes)}
</span>
))}
</div>
<div className="flex items-center justify-between border-b border-gray-800">
<div className="flex space-x-1">
<button
type="button"
onClick={() => setTab('tensors')}
className={`px-3 py-1.5 text-sm border-b-2 ${
tab === 'tensors'
? 'border-purple-500 text-gray-100'
: 'border-transparent text-gray-400 hover:text-gray-200'
}`}
>
Tensors
</button>
<button
type="button"
onClick={() => setTab('metadata')}
className={`px-3 py-1.5 text-sm border-b-2 ${
tab === 'metadata'
? 'border-purple-500 text-gray-100'
: 'border-transparent text-gray-400 hover:text-gray-200'
}`}
>
Metadata
<span className="ml-1.5 px-1.5 py-0.5 bg-gray-700 rounded-full text-xs text-gray-300">
{metadataEntries.length}
</span>
</button>
</div>
{tab === 'tensors' && (
<div className="relative">
<Search className="w-3.5 h-3.5 text-gray-500 absolute left-2 top-1/2 -translate-y-1/2" />
<input
type="text"
value={filter}
onChange={e => 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"
/>
</div>
)}
</div>
{tab === 'tensors' && (
<div className="overflow-auto max-h-[60vh] rounded-md border border-gray-800">
<table className="w-full text-xs font-mono">
<thead className="sticky top-0 bg-gray-800 text-gray-400">
<tr>
<th className="text-left px-2 py-1.5 font-normal">Name</th>
<th className="text-left px-2 py-1.5 font-normal">Shape</th>
<th className="text-left px-2 py-1.5 font-normal">Dtype</th>
<th className="text-right px-2 py-1.5 font-normal">Params</th>
<th className="text-right px-2 py-1.5 font-normal">Size</th>
</tr>
</thead>
<tbody>
{filteredTensors.slice(0, MAX_ROWS).map(t => (
<tr key={t.name} className="border-t border-gray-800/60 hover:bg-gray-800/50">
<td className="px-2 py-1 text-gray-200 break-all">{t.name}</td>
<td className="px-2 py-1 text-gray-300 whitespace-nowrap">[{t.shape.join(', ')}]</td>
<td className="px-2 py-1 text-purple-300 whitespace-nowrap">{t.dtype}</td>
<td className="px-2 py-1 text-gray-400 text-right whitespace-nowrap">
{t.numel.toLocaleString()}
</td>
<td className="px-2 py-1 text-gray-400 text-right whitespace-nowrap">{fmtBytes(t.bytes)}</td>
</tr>
))}
{filteredTensors.length === 0 && (
<tr>
<td colSpan={5} className="px-2 py-4 text-center text-gray-500">
No tensors match
</td>
</tr>
)}
</tbody>
</table>
{filteredTensors.length > MAX_ROWS && (
<div className="px-2 py-1.5 text-xs text-gray-500 bg-gray-800 border-t border-gray-700">
Showing {MAX_ROWS.toLocaleString()} of {filteredTensors.length.toLocaleString()} — narrow with the
filter
</div>
)}
</div>
)}
{tab === 'metadata' && (
<div className="overflow-auto max-h-[60vh] rounded-md border border-gray-800">
{metadataEntries.length === 0 ? (
<div className="px-2 py-4 text-center text-gray-500 text-xs">No metadata</div>
) : (
<div className="divide-y divide-gray-800/60">
{metadataEntries.map(([key, value]) => (
<div key={key} className="px-2 py-1.5">
<div className="text-xs text-purple-300 font-mono">{key}</div>
<pre className="text-xs text-gray-200 font-mono whitespace-pre-wrap break-all mt-0.5">
{fmtMetaValue(String(value))}
</pre>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</Modal>
);
};
export default SafetensorsInfoModal;