Add LoRA uploader to the lora picker on the generator

This commit is contained in:
Jaret Burkett committed 2026-09-17 05:17:34 -06:00
1 parent e65c4d0fb6
commit ef8110cef7
3 files changed
+388 -13

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';
import { getModelsPath } from '@/server/settings';
export const dynamic = 'force-dynamic';
// Chunked upload of a .safetensors file into <MODELS_PATH>/loras.
// POST ?action=start&fileName=x.safetensors&size=N -> { uploadId }
// POST ?action=chunk&uploadId=..&offset=N (raw body bytes) -> { received }
// POST ?action=finish&uploadId=..&fileName=..&size=N -> { path, name }
// POST ?action=cancel&uploadId=.. -> { ok }
// Chunks are written at explicit offsets so a retried chunk is idempotent.
const PART_DIR = '.uploads';
const STALE_MS = 24 * 3600 * 1000;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const err = (msg: string, status = 400) => NextResponse.json({ error: msg }, { status });
const getRoots = async () => {
const lorasRoot = path.resolve(await getModelsPath(), 'loras');
const partDir = path.join(lorasRoot, PART_DIR);
return { lorasRoot, partDir };
};
// Basename only; must be a .safetensors file.
const cleanFileName = (raw: string | null): string | null => {
if (!raw) return null;
const name = raw
.trim()
.replace(/[\\/]/g, '_')
.replace(/[^\w.\- ()\[\]]/g, '_');
if (!name.toLowerCase().endsWith('.safetensors') || name.startsWith('.') || name.length < 13) return null;
return name;
};
const partPath = (partDir: string, uploadId: string | null): string | null => {
if (!uploadId || !UUID_RE.test(uploadId)) return null;
return path.join(partDir, `${uploadId}.part`);
};
const parseSize = (raw: string | null): number | null => {
const n = Number(raw);
return Number.isSafeInteger(n) && n > 0 ? n : null;
};
const removeStaleParts = async (partDir: string) => {
let entries: string[];
try {
entries = await fs.promises.readdir(partDir);
} catch {
return;
}
const now = Date.now();
for (const e of entries) {
if (!e.endsWith('.part')) continue;
const p = path.join(partDir, e);
try {
const st = await fs.promises.stat(p);
if (now - st.mtimeMs > STALE_MS) await fs.promises.unlink(p);
} catch { }
}
};
export async function POST(request: NextRequest) {
const sp = request.nextUrl.searchParams;
const action = sp.get('action');
const { lorasRoot, partDir } = await getRoots();
try {
if (action === 'start') {
const fileName = cleanFileName(sp.get('fileName'));
if (!fileName) return err('File must be a .safetensors file');
if (!parseSize(sp.get('size'))) return err('Invalid file size');
const dest = path.join(lorasRoot, fileName);
if (sp.get('overwrite') !== '1' && fs.existsSync(dest)) {
return NextResponse.json({ error: 'A file with that name already exists', exists: true }, { status: 409 });
}
await fs.promises.mkdir(partDir, { recursive: true });
await removeStaleParts(partDir);
const uploadId = randomUUID();
// Create empty part file so chunk writes can open it r+.
await fs.promises.writeFile(path.join(partDir, `${uploadId}.part`), Buffer.alloc(0));
return NextResponse.json({ uploadId, fileName });
}
const part = partPath(partDir, sp.get('uploadId'));
if (!part) return err('Invalid uploadId');
if (action === 'chunk') {
const offset = Number(sp.get('offset'));
if (!Number.isSafeInteger(offset) || offset < 0) return err('Invalid offset');
if (!fs.existsSync(part)) return err('Upload not found or expired', 404);
const buf = Buffer.from(await request.arrayBuffer());
if (buf.length === 0) return err('Empty chunk');
const fh = await fs.promises.open(part, 'r+');
try {
await fh.write(buf, 0, buf.length, offset);
} finally {
await fh.close();
}
return NextResponse.json({ received: buf.length });
}
if (action === 'finish') {
const fileName = cleanFileName(sp.get('fileName'));
const size = parseSize(sp.get('size'));
if (!fileName || !size) return err('Invalid fileName or size');
if (!fs.existsSync(part)) return err('Upload not found or expired', 404);
const st = await fs.promises.stat(part);
if (st.size !== size) {
await fs.promises.unlink(part).catch(() => { });
return err(`Upload incomplete: got ${st.size} of ${size} bytes`);
}
const dest = path.join(lorasRoot, fileName);
if (sp.get('overwrite') !== '1' && fs.existsSync(dest)) {
await fs.promises.unlink(part).catch(() => { });
return NextResponse.json({ error: 'A file with that name already exists', exists: true }, { status: 409 });
}
await fs.promises.rename(part, dest);
return NextResponse.json({ path: dest, name: fileName, size });
}
if (action === 'cancel') {
await fs.promises.unlink(part).catch(() => { });
return NextResponse.json({ ok: true });
}
return err('Unknown action');
} catch (e: any) {
console.error('LoRA upload error:', e);
return err(e?.message || 'Upload failed', 500);
}
}
+251 -12
View File
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Cloud, FolderOpen, Loader2, Search } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, ChevronRight, Cloud, FolderOpen, Loader2, Search, Upload, X } from 'lucide-react';
import { Modal } from '@/components/Modal';
import { apiClient } from '@/utils/api';
import { CloudLora } from '@/types';
@@ -36,11 +36,25 @@ interface Props {
cloudLoras?: CloudLora[];
}
type Tab = 'cloud' | 'jobs' | 'models';
type Tab = 'cloud' | 'jobs' | 'models' | 'upload';
const fmtSize = (n: number) => (n > 1e9 ? `${(n / 1e9).toFixed(2)} GB` : `${(n / 1e6).toFixed(0)} MB`);
const fmtDate = (ms: number) => new Date(ms).toLocaleString();
const CHUNK_SIZE = 16 * 1024 * 1024;
const CHUNK_RETRIES = 3;
interface UploadState {
file: File;
sent: number;
startedAt: number;
status: 'uploading' | 'done' | 'error' | 'cancelled';
error?: string;
result?: { path: string; name: string };
}
const isAbort = (e: any) => e?.code === 'ERR_CANCELED' || e?.name === 'CanceledError' || e?.name === 'AbortError';
export default function LoraBrowserModal({ isOpen, onClose, onPick, cloudLoras }: Props) {
const hasCloud = !!cloudLoras?.length;
const [tab, setTab] = useState<Tab>(hasCloud ? 'cloud' : 'jobs');
@@ -50,12 +64,15 @@ export default function LoraBrowserModal({ isOpen, onClose, onPick, cloudLoras }
const [lorasRoot, setLorasRoot] = useState('');
const [filter, setFilter] = useState('');
const [open, setOpen] = useState<Set<string>>(new Set());
const [upload, setUpload] = useState<UploadState | null>(null);
const [overwrite, setOverwrite] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const isUploading = upload?.status === 'uploading';
useEffect(() => {
if (!isOpen) return;
setTab(hasCloud ? 'cloud' : 'jobs');
const refresh = useCallback(() => {
setLoading(true);
apiClient
return apiClient
.get('/api/loras')
.then(r => {
setJobs(r.data.jobs || []);
@@ -64,8 +81,103 @@ export default function LoraBrowserModal({ isOpen, onClose, onPick, cloudLoras }
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!isOpen) return;
setTab(hasCloud ? 'cloud' : 'jobs');
setUpload(null);
setOverwrite(false);
refresh();
}, [isOpen]);
// Modal only closes via onClose; while a transfer runs, every close path is a no-op.
const guardedClose = useCallback(() => {
if (isUploading) return;
onClose();
}, [isUploading, onClose]);
useEffect(() => {
if (!isUploading) return;
const warn = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [isUploading]);
const startUpload = async (file: File) => {
const ctrl = new AbortController();
abortRef.current = ctrl;
setUpload({ file, sent: 0, startedAt: Date.now(), status: 'uploading' });
const params = (extra: Record<string, string | number>) => ({ overwrite: overwrite ? 1 : 0, ...extra });
let uploadId = '';
try {
const start = await apiClient.post('/api/loras/upload', null, {
params: params({ action: 'start', fileName: file.name, size: file.size }),
signal: ctrl.signal,
});
uploadId = start.data.uploadId;
for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
const chunk = file.slice(offset, Math.min(offset + CHUNK_SIZE, file.size));
let attempt = 0;
for (;;) {
try {
await apiClient.post('/api/loras/upload', chunk, {
params: { action: 'chunk', uploadId, offset },
headers: { 'Content-Type': 'application/octet-stream' },
signal: ctrl.signal,
onUploadProgress: ev => setUpload(u => (u ? { ...u, sent: offset + (ev.loaded || 0) } : u)),
});
break;
} catch (e: any) {
// Only transport failures are retried; the server rejects bad requests with 4xx.
if (isAbort(e) || e?.response || ++attempt >= CHUNK_RETRIES) throw e;
await new Promise(r => setTimeout(r, 1000 * attempt));
}
}
setUpload(u => (u ? { ...u, sent: offset + chunk.size } : u));
}
const fin = await apiClient.post('/api/loras/upload', null, {
params: params({ action: 'finish', uploadId, fileName: file.name, size: file.size }),
signal: ctrl.signal,
});
setUpload(u => (u ? { ...u, status: 'done', sent: file.size, result: fin.data } : u));
refresh();
} catch (e: any) {
if (uploadId)
apiClient.post('/api/loras/upload', null, { params: { action: 'cancel', uploadId } }).catch(() => {});
if (isAbort(e)) {
setUpload(u => (u ? { ...u, status: 'cancelled' } : u));
} else {
const msg = e?.response?.data?.error || e?.message || 'Upload failed';
setUpload(u => (u ? { ...u, status: 'error', error: msg } : u));
}
} finally {
abortRef.current = null;
}
};
const cancelUpload = () => abortRef.current?.abort();
const onFileChosen = (files: FileList | null) => {
const f = files?.[0];
if (fileInputRef.current) fileInputRef.current.value = '';
if (!f) return;
if (!f.name.toLowerCase().endsWith('.safetensors')) {
setUpload({
file: f,
sent: 0,
startedAt: Date.now(),
status: 'error',
error: 'Only .safetensors files can be uploaded',
});
return;
}
startUpload(f);
};
const q = filter.trim().toLowerCase();
const filteredJobs = useMemo(
() =>
@@ -99,23 +211,38 @@ export default function LoraBrowserModal({ isOpen, onClose, onPick, cloudLoras }
};
const tabClass = (t: Tab) =>
`px-3 py-1.5 text-sm border-b-2 ${tab === t ? 'border-blue-500 text-blue-400' : 'border-transparent text-gray-400 hover:text-gray-200'}`;
`px-3 py-1.5 text-sm border-b-2 disabled:opacity-50 ${tab === t ? 'border-blue-500 text-blue-400' : 'border-transparent text-gray-400 hover:text-gray-200'}`;
const pct = upload ? (upload.file.size ? Math.min(100, (upload.sent / upload.file.size) * 100) : 100) : 0;
const elapsed = upload ? (Date.now() - upload.startedAt) / 1000 : 0;
const rate = upload && elapsed > 0 ? upload.sent / elapsed : 0;
const eta = rate > 0 && upload ? (upload.file.size - upload.sent) / rate : 0;
return (
<Modal isOpen={isOpen} onClose={onClose} title="Add LoRA" size="lg">
<Modal
isOpen={isOpen}
onClose={guardedClose}
title="Add LoRA"
size="lg"
showCloseButton={!isUploading}
closeOnOverlayClick={!isUploading}
>
<div className="text-gray-200">
<div className="flex items-center gap-2 border-b border-gray-800 mb-3">
{hasCloud && (
<button type="button" className={tabClass('cloud')} onClick={() => setTab('cloud')}>
<button type="button" className={tabClass('cloud')} onClick={() => setTab('cloud')} disabled={isUploading}>
For this model
</button>
)}
<button type="button" className={tabClass('jobs')} onClick={() => setTab('jobs')}>
<button type="button" className={tabClass('jobs')} onClick={() => setTab('jobs')} disabled={isUploading}>
Training jobs
</button>
<button type="button" className={tabClass('models')} onClick={() => setTab('models')}>
<button type="button" className={tabClass('models')} onClick={() => setTab('models')} disabled={isUploading}>
Models folder
</button>
<button type="button" className={tabClass('upload')} onClick={() => setTab('upload')} disabled={isUploading}>
Upload
</button>
<div className="flex-1" />
<div className="relative mb-1">
<Search className="w-3.5 h-3.5 absolute left-2 top-2 text-gray-500" />
@@ -213,6 +340,118 @@ export default function LoraBrowserModal({ isOpen, onClose, onPick, cloudLoras }
})}
</div>
)}
{tab === 'upload' && (
<div>
<div className="text-[11px] text-gray-500 mb-2 flex items-center gap-1">
<FolderOpen className="w-3.5 h-3.5" /> Uploads to {lorasRoot || 'MODELS_PATH/loras'}
</div>
<input
ref={fileInputRef}
type="file"
accept=".safetensors"
className="hidden"
onChange={e => onFileChosen(e.target.files)}
/>
{!upload || upload.status === 'cancelled' || upload.status === 'error' ? (
<div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={e => e.preventDefault()}
onDrop={e => {
e.preventDefault();
onFileChosen(e.dataTransfer.files);
}}
className="w-full flex flex-col items-center justify-center gap-2 py-8 rounded-md border border-dashed border-gray-700 hover:border-blue-500 hover:bg-gray-900/60 text-sm text-gray-400"
>
<Upload className="w-6 h-6" />
<span>Click to choose a .safetensors file, or drop one here</span>
<span className="text-[11px] text-gray-500">
Large files are sent in {fmtSize(CHUNK_SIZE)} chunks
</span>
</button>
<label className="mt-2 flex items-center gap-2 text-xs text-gray-400">
<input type="checkbox" checked={overwrite} onChange={e => setOverwrite(e.target.checked)} />
Overwrite if a file with the same name exists
</label>
{upload?.status === 'error' && (
<div className="mt-2 text-xs text-red-400">
{upload.file.name}: {upload.error}
</div>
)}
{upload?.status === 'cancelled' && (
<div className="mt-2 text-xs text-gray-500">Upload of {upload.file.name} cancelled.</div>
)}
</div>
) : (
<div className="rounded-md bg-gray-900 border border-gray-800 p-3">
<div className="flex items-center gap-2 text-sm">
{upload.status === 'uploading' && <Loader2 className="w-4 h-4 animate-spin text-blue-400" />}
<span className="truncate flex-1 font-mono">{upload.file.name}</span>
<span className="text-gray-500 shrink-0">{fmtSize(upload.file.size)}</span>
</div>
<div className="mt-2 h-2 w-full rounded bg-gray-800 overflow-hidden">
<div
className={`h-full transition-[width] ${upload.status === 'done' ? 'bg-green-500' : 'bg-blue-500'}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-gray-500">
<span>
{fmtSize(upload.sent)} / {fmtSize(upload.file.size)} ({pct.toFixed(1)}%)
</span>
{upload.status === 'uploading' && (
<span>
{fmtSize(rate)}/s{eta > 0 ? ` · ~${Math.ceil(eta)}s left` : ''}
</span>
)}
</div>
{upload.status === 'uploading' && (
<div className="mt-3 flex items-center justify-between">
<span className="text-xs text-amber-400">
Uploading… this window stays open until the transfer finishes.
</span>
<button
type="button"
onClick={cancelUpload}
className="flex items-center gap-1 px-3 py-1 rounded text-xs bg-red-900/60 hover:bg-red-800 text-red-100"
>
<X className="w-3.5 h-3.5" /> Cancel
</button>
</div>
)}
{upload.status === 'done' && upload.result && (
<div className="mt-3 flex items-center justify-between">
<span className="text-xs text-green-400">Upload complete.</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => setUpload(null)}
className="px-3 py-1 rounded text-xs bg-gray-800 hover:bg-gray-700"
>
Upload another
</button>
<button
type="button"
onClick={() =>
pick({
name: upload.result!.name,
path: upload.result!.path,
size: upload.file.size,
mtime: Date.now(),
})
}
className="px-3 py-1 rounded text-xs bg-blue-700 hover:bg-blue-600 text-white"
>
Use this LoRA
</button>
</div>
</div>
)}
</div>
)}
</div>
)}
{!loading && tab === 'models' && (
<div>
<div className="text-[11px] text-gray-500 mb-2 flex items-center gap-1">
+1 -1
View File
@@ -1 +1 @@
VERSION = "0.13.14"
VERSION = "0.13.15"