Add lora on generate

This commit is contained in:
Jaret Burkett committed 2026-09-10 18:23:23 -06:00
1 parent 6e1b39c7a8
commit 732c761231
9 files changed
+812 -8

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import prisma from '@/server/prisma';
import { getModelsPath, getTrainingFolder } from '@/server/settings';
export const dynamic = 'force-dynamic';
interface LoraFile {
name: string;
path: string;
size: number;
mtime: number;
}
const listSafetensors = async (dir: string): Promise<LoraFile[]> => {
try {
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
const files = entries.filter(e => e.isFile() && e.name.endsWith('.safetensors'));
return (
await Promise.all(
files.map(async e => {
const p = path.join(dir, e.name);
const st = await fs.promises.stat(p);
return { name: e.name, path: p, size: st.size, mtime: st.mtimeMs };
}),
)
).sort((a, b) => b.mtime - a.mtime);
} catch {
return [];
}
};
const walk = async (root: string, rel = '', depth = 0, out: (LoraFile & { relpath: string })[] = []) => {
if (depth > 5) return out;
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(path.join(root, rel), { withFileTypes: true });
} catch {
return out;
}
for (const e of entries) {
if (e.name.startsWith('.')) continue;
const r = rel ? path.join(rel, e.name) : e.name;
if (e.isDirectory()) {
await walk(root, r, depth + 1, out);
} else if (e.isFile() && e.name.endsWith('.safetensors')) {
const p = path.join(root, r);
const st = await fs.promises.stat(p);
out.push({ name: e.name, relpath: r, path: p, size: st.size, mtime: st.mtimeMs });
}
}
return out;
};
// LoRA files available to the Generate page: every training job's saved
// checkpoints, and everything under <MODELS_PATH>/loras.
export async function GET() {
const trainingFolder = await getTrainingFolder();
const jobs = await prisma.job.findMany({ where: { job_type: 'train' }, orderBy: { updated_at: 'desc' } });
const jobEntries = (
await Promise.all(
jobs.map(async job => ({
id: job.id,
name: job.name,
status: job.status,
updated_at: job.updated_at,
files: await listSafetensors(path.join(trainingFolder, job.name)),
})),
)
).filter(j => j.files.length > 0);
const modelsPath = await getModelsPath();
const lorasRoot = path.join(modelsPath, 'loras');
const models = (await walk(lorasRoot)).sort((a, b) => a.relpath.localeCompare(b.relpath));
return NextResponse.json({ jobs: jobEntries, models, lorasRoot });
}
+74 -1
View File
@@ -3,7 +3,7 @@
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Button } from '@headlessui/react';
import { ChevronDown, ChevronLeft, ChevronRight, Loader2, OctagonX, Play, Square, Sparkles, Trash2, X } from 'lucide-react';
import { ChevronDown, ChevronLeft, ChevronRight, Loader2, OctagonX, Play, Plus, Square, Sparkles, Trash2, X } from 'lucide-react';
import { openConfirm } from '@/components/ConfirmModal';
import { TopBar, MainContent } from '@/components/layout';
import { Checkbox, CreatableSelectInput, NumberInput, SelectInput, SliderInput, TextAreaInput, TextInput } from '@/components/formInputs';
@@ -18,6 +18,7 @@ import { latentToImage, payloadToFloat32, readEngineFrames, PreviewInfo } from '
import { isMac } from '@/helpers/basic';
import { modelArchs, getGenerateDefaults, GenerateDefaults } from '@/app/jobs/new/options';
import GenerateFooter from '@/components/generate/GenerateFooter';
import LoraBrowserModal, { LoraPick } from '@/components/generate/LoraBrowserModal';
interface EngineStatus {
@@ -298,6 +299,13 @@ function GeneratePageInner() {
const [showPreview, setShowPreview] = useState<boolean>(!persisted.results?.length);
const [sidebarOpen, setSidebarOpen] = useState<boolean>(persisted.sidebarOpen ?? true);
const [cards, setCards] = useState<{ [key: string]: boolean }>(persisted.cards || {});
const [loraModalOpen, setLoraModalOpen] = useState(false);
const loras: { path: string; name: string; strength: number }[] = model.loras || [];
const setLoras = (next: { path: string; name: string; strength: number }[]) => setModel(m => ({ ...m, loras: next }));
const addLora = (pick: LoraPick) => {
if (loras.some(l => l.path === pick.path)) return;
setLoras([...loras, { path: pick.path, name: pick.name, strength: 1.0 }]);
};
const cardOpen = (key: string) => cards[key] ?? true;
const toggleCard = (key: string) => setCards(c => ({ ...c, [key]: !(c[key] ?? true) }));
const resultUrl = (r: ResultItem) => `/api/files/${encodeFilePathForUrl(r.path)}`;
@@ -934,6 +942,70 @@ function GeneratePageInner() {
)}
</Card>
<Card title="LoRAs" subtitle={loras.length ? `${loras.length} loaded` : 'none'} open={cardOpen('loras')} onToggle={() => toggleCard('loras')}>
<div className="space-y-2">
{loras.map((l, i) => (
<div key={l.path} className="bg-gray-950/60 border border-gray-800 rounded-md px-2 py-1.5">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-200 truncate flex-1" title={l.path}>
{l.name}
</span>
<input
type="number"
step={0.05}
min={-2}
max={3}
value={l.strength}
onChange={e => {
const v = parseFloat(e.target.value);
const next = [...loras];
next[i] = { ...l, strength: isNaN(v) ? 0 : v };
setLoras(next);
}}
className="w-16 text-xs px-1.5 py-0.5 bg-gray-950 border border-gray-700 rounded text-gray-100 text-right"
/>
<button
type="button"
onClick={() => setLoras(loras.filter(x => x.path !== l.path))}
className="text-gray-500 hover:text-red-400"
title="Remove"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
<div className="flex items-center gap-2">
<Button
onClick={() => setLoraModalOpen(true)}
className="flex-1 px-2 py-1 rounded-md bg-gray-800 hover:bg-gray-700 text-gray-200 text-xs flex items-center justify-center gap-1"
>
<Plus className="w-3.5 h-3.5" /> Add LoRA
</Button>
</div>
<Checkbox
label="Merge into weights"
checked={model.lora_mode === 'merge'}
onChange={v => setModel(m => ({ ...m, lora_mode: v ? 'merge' : 'hook' }))}
doc={{
title: 'Merge LoRAs into weights',
description: (
<>
<p>
<strong>Off</strong> (default): each LoRA is applied dynamically as an extra term on the layers it targets. Strength
changes take effect on the next generation and adding or removing a LoRA never reloads the model.
</p>
<p className="mt-2">
<strong>On</strong>: the LoRA deltas are added into the model weights. Quantized weights are dequantized, merged, and
re-quantized with stochastic rounding so small deltas are not rounded away. Merged weights cannot be un-merged, so
changing the LoRA set or strengths reloads the affected components. Slightly faster per step than the dynamic path.
</p>
</>
),
}}
/>
</div>
</Card>
<Card title="Prompt" open={cardOpen('prompt')} onToggle={() => toggleCard('prompt')}>
<TextAreaInput label="Prompt" value={sample.prompt || ''} onChange={v => setSample(s => ({ ...s, prompt: v }))} placeholder={modality === 'audio' ? 'song description, lyrics, bpm…' : 'describe what to generate'} />
{(sample.guidance_scale ?? 4) > 1 && (
@@ -976,6 +1048,7 @@ function GeneratePageInner() {
</div>
</MainContent>
<GenerateFooter jobId={engineJobId} status={footerStatus} busy={running || isStarting} progress={running ? progress : null} />
<LoraBrowserModal isOpen={loraModalOpen} onClose={() => setLoraModalOpen(false)} onPick={addLora} />
</>
);
}
+29 -2
View File
@@ -147,7 +147,34 @@ body {
.compact-form textarea {
min-height: 3.5rem;
}
.compact-form [class*='control'] {
min-height: 30px !important;
.compact-form .aitk-react-select-container .aitk-react-select__control {
height: 26px !important;
min-height: 26px !important;
font-size: 12px !important;
line-height: 1.2 !important;
}
.compact-form .aitk-react-select-container .aitk-react-select__value-container {
padding: 0 6px !important;
}
.compact-form .aitk-react-select-container .aitk-react-select__single-value,
.compact-form .aitk-react-select-container .aitk-react-select__placeholder,
.compact-form .aitk-react-select-container .aitk-react-select__input-container,
.compact-form .aitk-react-select-container .aitk-react-select__input,
.compact-form .aitk-react-select-container .aitk-react-select__option {
font-size: 12px !important;
line-height: 1.2 !important;
}
.compact-form .aitk-react-select-container .aitk-react-select__input-container {
margin: 0 !important;
padding: 0 !important;
}
.compact-form .aitk-react-select-container .aitk-react-select__option {
padding: 4px 8px !important;
}
.compact-form .aitk-react-select-container .aitk-react-select__indicator {
padding: 2px !important;
}
.compact-form .aitk-react-select-container .aitk-react-select__indicator svg {
width: 14px;
height: 14px;
}
@@ -0,0 +1,175 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, FolderOpen, Loader2, Search } from 'lucide-react';
import { Modal } from '@/components/Modal';
import { apiClient } from '@/utils/api';
export interface LoraPick {
path: string;
name: string;
}
interface LoraFile {
name: string;
path: string;
size: number;
mtime: number;
relpath?: string;
}
interface JobEntry {
id: string;
name: string;
status: string;
updated_at: string;
files: LoraFile[];
}
interface Props {
isOpen: boolean;
onClose: () => void;
onPick: (lora: LoraPick) => void;
}
const fmtSize = (n: number) => (n > 1e9 ? `${(n / 1e9).toFixed(2)} GB` : `${(n / 1e6).toFixed(0)} MB`);
const fmtDate = (ms: number) => new Date(ms).toLocaleString();
export default function LoraBrowserModal({ isOpen, onClose, onPick }: Props) {
const [tab, setTab] = useState<'jobs' | 'models'>('jobs');
const [loading, setLoading] = useState(false);
const [jobs, setJobs] = useState<JobEntry[]>([]);
const [models, setModels] = useState<LoraFile[]>([]);
const [lorasRoot, setLorasRoot] = useState('');
const [filter, setFilter] = useState('');
const [open, setOpen] = useState<Set<string>>(new Set());
useEffect(() => {
if (!isOpen) return;
setLoading(true);
apiClient
.get('/api/loras')
.then(r => {
setJobs(r.data.jobs || []);
setModels(r.data.models || []);
setLorasRoot(r.data.lorasRoot || '');
})
.catch(() => {})
.finally(() => setLoading(false));
}, [isOpen]);
const q = filter.trim().toLowerCase();
const filteredJobs = useMemo(
() => (q ? jobs.filter(j => j.name.toLowerCase().includes(q) || j.files.some(f => f.name.toLowerCase().includes(q))) : jobs),
[jobs, q],
);
const filteredModels = useMemo(() => (q ? models.filter(m => (m.relpath || m.name).toLowerCase().includes(q)) : models), [models, q]);
const pick = (f: LoraFile, jobName?: string) => {
onPick({ path: f.path, name: jobName ? `${jobName} / ${f.name.replace(/\.safetensors$/, '')}` : (f.relpath || f.name).replace(/\.safetensors$/, '') });
onClose();
};
const tabClass = (t: 'jobs' | 'models') =>
`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'}`;
return (
<Modal isOpen={isOpen} onClose={onClose} title="Add LoRA" size="lg">
<div className="text-gray-200">
<div className="flex items-center gap-2 border-b border-gray-800 mb-3">
<button className={tabClass('jobs')} onClick={() => setTab('jobs')}>
Training jobs
</button>
<button className={tabClass('models')} onClick={() => setTab('models')}>
Models folder
</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" />
<input
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="filter"
className="pl-7 pr-2 py-1 text-xs bg-gray-950 border border-gray-700 rounded text-gray-100 w-48"
/>
</div>
</div>
<div className="max-h-[60vh] overflow-y-auto pr-1">
{loading && (
<div className="text-gray-500 text-sm flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Loading…
</div>
)}
{!loading && tab === 'jobs' && (
<div className="space-y-1">
{filteredJobs.length === 0 && <div className="text-xs text-gray-500">No training jobs with saved LoRAs.</div>}
{filteredJobs.map(j => {
const isOpen = open.has(j.id) || !!q;
return (
<div key={j.id} className="rounded-md bg-gray-900 border border-gray-800">
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-gray-800/60"
onClick={() =>
setOpen(prev => {
const n = new Set(prev);
if (n.has(j.id)) n.delete(j.id);
else n.add(j.id);
return n;
})
}
>
{isOpen ? <ChevronDown className="w-4 h-4 text-gray-500" /> : <ChevronRight className="w-4 h-4 text-gray-500" />}
<span className="truncate flex-1">{j.name}</span>
<span className="text-[11px] text-gray-500">
{j.files.length} file{j.files.length === 1 ? '' : 's'}
</span>
</button>
{isOpen && (
<div className="px-2 pb-2 space-y-0.5">
{j.files
.filter(f => !q || f.name.toLowerCase().includes(q) || j.name.toLowerCase().includes(q))
.map(f => (
<button
key={f.path}
onClick={() => pick(f, j.name)}
className="w-full flex items-center gap-2 px-2 py-1 rounded text-xs text-left hover:bg-blue-900/40"
title={f.path}
>
<span className="truncate flex-1 font-mono">{f.name}</span>
<span className="text-gray-500 shrink-0">{fmtSize(f.size)}</span>
<span className="text-gray-600 shrink-0 hidden sm:inline">{fmtDate(f.mtime)}</span>
</button>
))}
</div>
)}
</div>
);
})}
</div>
)}
{!loading && tab === 'models' && (
<div>
<div className="text-[11px] text-gray-500 mb-2 flex items-center gap-1">
<FolderOpen className="w-3.5 h-3.5" /> {lorasRoot || 'MODELS_PATH/loras'}
</div>
{filteredModels.length === 0 && <div className="text-xs text-gray-500">No .safetensors files found under the loras folder.</div>}
<div className="space-y-0.5">
{filteredModels.map(f => (
<button
key={f.path}
onClick={() => pick(f)}
className="w-full flex items-center gap-2 px-2 py-1 rounded text-xs text-left hover:bg-blue-900/40"
title={f.path}
>
<span className="truncate flex-1 font-mono">{f.relpath || f.name}</span>
<span className="text-gray-500 shrink-0">{fmtSize(f.size)}</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
</Modal>
);
}
+8
View File
@@ -90,3 +90,11 @@ export const getDataRoot = async () => {
myCache.set(key, dataRoot);
return dataRoot;
};
// MODELS_PATH: env wins, then the setting, then <toolkit>/models
export const getModelsPath = async () => {
if (process.env.MODELS_PATH && process.env.MODELS_PATH.trim() !== '') return process.env.MODELS_PATH;
const row = await prisma.settings.findFirst({ where: { key: 'MODELS_PATH' } });
if (row?.value && row.value !== '') return row.value;
return path.join(process.cwd(), '..', 'models');
};