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:
1 parent
221cac39a0
commit
b69b983145
4 files changed
+406
-1
No files matched your search
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
<DocModal />
|
||||
<CaptionDatasetModal />
|
||||
<MergeLoRAsModal />
|
||||
<SafetensorsInfoModal />
|
||||
<UpsamplePromptsModal />
|
||||
<PromptBoxEditorModal />
|
||||
</body>
|
||||
|
||||
@@ -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
|
||||
</a>
|
||||
<div className="flex items-center space-x-3 flex-shrink-0">
|
||||
<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
|
||||
target="_blank"
|
||||
href={`/api/files/${encodeFilePathForUrl(file.path)}`}
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user