Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions ui/src/app/api/files/install/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import { getDatasetsRoot, getTrainingFolder, getLoraInstallPath } from '@/server/settings';

export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { filePath } = body;

if (!filePath || typeof filePath !== 'string') {
return NextResponse.json({ error: 'filePath is required' }, { status: 400 });
}

const loraInstallPath = await getLoraInstallPath();
if (!loraInstallPath) {
return NextResponse.json({ error: 'LoRA Install Path is not configured in settings' }, { status: 400 });
}

// Security check: ensure source path is in allowed directories
const datasetRoot = await getDatasetsRoot();
const trainingRoot = await getTrainingFolder();
const allowedDirs = [datasetRoot, trainingRoot];

const isAllowed =
allowedDirs.some(allowedDir => filePath.startsWith(allowedDir)) && !filePath.includes('..');

if (!isAllowed) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
}

// Verify source file exists
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: 'Source file not found' }, { status: 404 });
}

const stat = fs.statSync(filePath);
if (!stat.isFile()) {
return NextResponse.json({ error: 'Source path is not a file' }, { status: 400 });
}

// Verify destination directory exists
if (!fs.existsSync(loraInstallPath)) {
return NextResponse.json({ error: `Install directory does not exist: ${loraInstallPath}` }, { status: 400 });
}

const destStat = fs.statSync(loraInstallPath);
if (!destStat.isDirectory()) {
return NextResponse.json({ error: 'Install path is not a directory' }, { status: 400 });
}

const fileName = path.basename(filePath);
const destPath = path.join(loraInstallPath, fileName);

await fs.promises.copyFile(filePath, destPath);

return NextResponse.json({ success: true, destPath });
} catch (error) {
console.error('Error installing file:', error);
return NextResponse.json({ error: 'Failed to install file' }, { status: 500 });
}
}
9 changes: 7 additions & 2 deletions ui/src/app/api/settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ export async function GET() {
export async function POST(request: Request) {
try {
const body = await request.json();
const { HF_TOKEN, TRAINING_FOLDER, DATASETS_FOLDER } = body;
const { HF_TOKEN, TRAINING_FOLDER, DATASETS_FOLDER, LORA_INSTALL_PATH } = body;

// Upsert both settings
// Upsert all settings
await Promise.all([
prisma.settings.upsert({
where: { key: 'HF_TOKEN' },
Expand All @@ -48,6 +48,11 @@ export async function POST(request: Request) {
update: { value: DATASETS_FOLDER },
create: { key: 'DATASETS_FOLDER', value: DATASETS_FOLDER },
}),
prisma.settings.upsert({
where: { key: 'LORA_INSTALL_PATH' },
update: { value: LORA_INSTALL_PATH || '' },
create: { key: 'LORA_INSTALL_PATH', value: LORA_INSTALL_PATH || '' },
}),
]);

flushCache();
Expand Down
18 changes: 18 additions & 0 deletions ui/src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,24 @@ export default function Settings() {
placeholder="Enter datasets folder path"
/>
</div>
<div>
<label htmlFor="LORA_INSTALL_PATH" className="block text-sm font-medium mb-2">
LoRA Install Path
<div className="text-gray-500 text-sm ml-1">
Directory where LoRA files will be copied when using the Install button on checkpoints. Leave blank
to hide the Install button.
</div>
</label>
<input
type="text"
id="LORA_INSTALL_PATH"
name="LORA_INSTALL_PATH"
value={settings.LORA_INSTALL_PATH}
onChange={handleChange}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-gray-600 focus:border-transparent"
placeholder="Enter LoRA install path"
/>
</div>
</div>
</div>
</div>
Expand Down
72 changes: 61 additions & 11 deletions ui/src/components/FilesWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,42 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import useFilesList from '@/hooks/useFilesList';
import Link from 'next/link';
import { Loader2, AlertCircle, Download, Box, Brain } from 'lucide-react';
import { Loader2, AlertCircle, Download, Box, Brain, FolderInput, Check } from 'lucide-react';
import { apiClient } from '@/utils/api';

export default function FilesWidget({ jobID }: { jobID: string }) {
const { files, status, refreshFiles } = useFilesList(jobID, 5000);
const [loraInstallPath, setLoraInstallPath] = useState('');
const [installingFiles, setInstallingFiles] = useState<Record<string, 'idle' | 'installing' | 'success' | 'error'>>(
{},
);

useEffect(() => {
apiClient
.get('/api/settings')
.then(res => res.data)
.then(data => {
setLoraInstallPath(data.LORA_INSTALL_PATH || '');
})
.catch(() => {});
}, []);

const handleInstall = async (filePath: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setInstallingFiles(prev => ({ ...prev, [filePath]: 'installing' }));
try {
await apiClient.post('/api/files/install', { filePath });
setInstallingFiles(prev => ({ ...prev, [filePath]: 'success' }));
setTimeout(() => {
setInstallingFiles(prev => ({ ...prev, [filePath]: 'idle' }));
}, 2000);
} catch {
setInstallingFiles(prev => ({ ...prev, [filePath]: 'error' }));
setTimeout(() => {
setInstallingFiles(prev => ({ ...prev, [filePath]: 'idle' }));
}, 2000);
}
};

const cleanSize = (size: number) => {
if (size < 1024) {
Expand Down Expand Up @@ -47,11 +79,10 @@ export default function FilesWidget({ jobID }: { jobID: string }) {
{files.map((file, index) => {
const fileName = file.path.split('/').pop() || '';
const nameWithoutExt = fileName.replace('.safetensors', '');
const installStatus = installingFiles[file.path] || 'idle';
return (
<a
<div
key={index}
target="_blank"
href={`/api/files/${encodeURIComponent(file.path)}`}
className="group flex items-center justify-between px-2 py-1.5 rounded-lg hover:bg-gray-800 transition-all duration-200"
>
<div className="flex items-center space-x-2 min-w-0">
Expand All @@ -65,13 +96,32 @@ export default function FilesWidget({ jobID }: { jobID: string }) {
<span className="text-xs text-gray-500">.safetensors</span>
</div>
</div>
<div className="flex items-center space-x-3 flex-shrink-0">
<span className="text-xs text-gray-400">{cleanSize(file.size)}</span>
<div className="bg-purple-500 bg-opacity-0 group-hover:bg-opacity-10 rounded-full p-1 transition-all">
<div className="flex items-center space-x-1 flex-shrink-0">
<span className="text-xs text-gray-400 mr-2">{cleanSize(file.size)}</span>
{loraInstallPath && (
<button
onClick={e => handleInstall(file.path, e)}
disabled={installStatus === 'installing'}
title={`Install to ${loraInstallPath}`}
className="bg-green-500 bg-opacity-0 hover:bg-opacity-10 rounded-full p-1 transition-all disabled:opacity-50"
>
{installStatus === 'installing' && (
<Loader2 className="w-3 h-3 text-green-400 animate-spin" />
)}
{installStatus === 'success' && <Check className="w-3 h-3 text-green-400" />}
{installStatus === 'error' && <AlertCircle className="w-3 h-3 text-red-400" />}
{installStatus === 'idle' && <FolderInput className="w-3 h-3 text-green-400" />}
</button>
)}
<a
href={`/api/files/${encodeURIComponent(file.path)}`}
target="_blank"
className="bg-purple-500 bg-opacity-0 hover:bg-opacity-10 rounded-full p-1 transition-all"
>
<Download className="w-3 h-3 text-purple-400" />
</div>
</a>
</div>
</a>
</div>
);
})}
</div>
Expand Down
3 changes: 3 additions & 0 deletions ui/src/hooks/useSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ export interface Settings {
HF_TOKEN: string;
TRAINING_FOLDER: string;
DATASETS_FOLDER: string;
LORA_INSTALL_PATH: string;
}

export default function useSettings() {
const [settings, setSettings] = useState({
HF_TOKEN: '',
TRAINING_FOLDER: '',
DATASETS_FOLDER: '',
LORA_INSTALL_PATH: '',
});
const [isSettingsLoaded, setIsLoaded] = useState(false);
useEffect(() => {
Expand All @@ -26,6 +28,7 @@ export default function useSettings() {
HF_TOKEN: data.HF_TOKEN || '',
TRAINING_FOLDER: data.TRAINING_FOLDER || '',
DATASETS_FOLDER: data.DATASETS_FOLDER || '',
LORA_INSTALL_PATH: data.LORA_INSTALL_PATH || '',
});
setIsLoaded(true);
})
Expand Down
19 changes: 19 additions & 0 deletions ui/src/server/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,25 @@ export const getHFToken = async () => {
return token;
};

export const getLoraInstallPath = async () => {
const key = 'LORA_INSTALL_PATH';
let loraPath = myCache.get(key) as string;
if (loraPath !== undefined) {
return loraPath;
}
let row = await prisma.settings.findFirst({
where: {
key: key,
},
});
loraPath = '';
if (row?.value && row.value !== '') {
loraPath = row.value;
}
myCache.set(key, loraPath);
return loraPath;
};

export const getDataRoot = async () => {
const key = 'DATA_ROOT';
let dataRoot = myCache.get(key) as string;
Expand Down