From ef8110cef7c36e9812619616e3309d52f69f16ea Mon Sep 17 00:00:00 2001 From: Jaret Burkett Date: Thu, 17 Sep 2026 05:17:34 -0600 Subject: [PATCH] Add LoRA uploader to the lora picker on the generator --- ui/src/app/api/loras/upload/route.ts | 136 +++++++++ .../components/generate/LoraBrowserModal.tsx | 263 +++++++++++++++++- version.py | 2 +- 3 files changed, 388 insertions(+), 13 deletions(-) create mode 100644 ui/src/app/api/loras/upload/route.ts diff --git a/ui/src/app/api/loras/upload/route.ts b/ui/src/app/api/loras/upload/route.ts new file mode 100644 index 0000000..49748be --- /dev/null +++ b/ui/src/app/api/loras/upload/route.ts @@ -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 /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); + } +} diff --git a/ui/src/components/generate/LoraBrowserModal.tsx b/ui/src/components/generate/LoraBrowserModal.tsx index 14c938c..999f7be 100644 --- a/ui/src/components/generate/LoraBrowserModal.tsx +++ b/ui/src/components/generate/LoraBrowserModal.tsx @@ -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(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>(new Set()); + const [upload, setUpload] = useState(null); + const [overwrite, setOverwrite] = useState(false); + const abortRef = useRef(null); + const fileInputRef = useRef(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) => ({ 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 ( - +
{hasCloud && ( - )} - - +
@@ -213,6 +340,118 @@ export default function LoraBrowserModal({ isOpen, onClose, onPick, cloudLoras } })}
)} + {tab === 'upload' && ( +
+
+ Uploads to {lorasRoot || 'MODELS_PATH/loras'} +
+ onFileChosen(e.target.files)} + /> + {!upload || upload.status === 'cancelled' || upload.status === 'error' ? ( +
+ + + {upload?.status === 'error' && ( +
+ {upload.file.name}: {upload.error} +
+ )} + {upload?.status === 'cancelled' && ( +
Upload of {upload.file.name} cancelled.
+ )} +
+ ) : ( +
+
+ {upload.status === 'uploading' && } + {upload.file.name} + {fmtSize(upload.file.size)} +
+
+
+
+
+ + {fmtSize(upload.sent)} / {fmtSize(upload.file.size)} ({pct.toFixed(1)}%) + + {upload.status === 'uploading' && ( + + {fmtSize(rate)}/s{eta > 0 ? ` · ~${Math.ceil(eta)}s left` : ''} + + )} +
+ {upload.status === 'uploading' && ( +
+ + Uploading… this window stays open until the transfer finishes. + + +
+ )} + {upload.status === 'done' && upload.result && ( +
+ Upload complete. +
+ + +
+
+ )} +
+ )} +
+ )} {!loading && tab === 'models' && (
diff --git a/version.py b/version.py index c6950bd..0156c0c 100644 --- a/version.py +++ b/version.py @@ -1 +1 @@ -VERSION = "0.13.14" +VERSION = "0.13.15"