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 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>
|
||||||
|
|||||||
@@ -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)}`}
|
||||||
|
|||||||
@@ -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