Add LoRA uploader to the lora picker on the generator
This commit is contained in:
1 parent
e65c4d0fb6
commit
ef8110cef7
3 files changed
+388
-13
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1 +1 @@
|
||||
VERSION = "0.13.14"
|
||||
VERSION = "0.13.15"
|
||||
Reference in new issue
Block a user