Add lora on generate
This commit is contained in:
1 parent
6e1b39c7a8
commit
732c761231
9 files changed
+812
-8
No files matched your search
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
};
|
||||
Reference in new issue
Block a user