'use client'; import React, { useEffect, useRef, useState } from 'react'; import { createGlobalState } from 'react-global-hooks'; import { Trash2 } from 'lucide-react'; import { Modal } from './Modal'; import { TextInput, SelectInput } from '@/components/formInputs'; import { getFilename } from '@/utils/basic'; import { callScriptStream } from '@/utils/callScript'; import { SelectOption } from '@/types'; export interface MergeLoRAFile { path: string; } export interface MergeLoRAsModalState { folderPath: string; outputName: string; availableLoRAs: MergeLoRAFile[]; onClose?: () => void; } interface SelectedLoRA { path: string; strength: number; } export const mergeLoRAsModalState = createGlobalState(null); export const openMergeLoRAsModal = ( folderPath: string, outputName: string, availableLoRAs: MergeLoRAFile[], onClose?: () => void, ) => { mergeLoRAsModalState.set({ folderPath, outputName, availableLoRAs, onClose, }); }; const joinPath = (folder: string, name: string) => { const sep = folder.includes('\\') && !folder.includes('/') ? '\\' : '/'; const trimmed = folder.replace(/[\\/]+$/, ''); return `${trimmed}${sep}${name}.safetensors`; }; const MergeLoRAsModal: React.FC = () => { const [modalInfo, setModalInfo] = mergeLoRAsModalState.use(); const isOpen = modalInfo !== null; const [selectedLoRAs, setSelectedLoRAs] = useState([]); const [isRunning, setIsRunning] = useState(false); const [isDone, setIsDone] = useState(false); const [hasError, setHasError] = useState(false); const [logOutput, setLogOutput] = useState(''); const logRef = useRef(null); useEffect(() => { if (!modalInfo) { setSelectedLoRAs([]); setIsRunning(false); setIsDone(false); setHasError(false); setLogOutput(''); } }, [modalInfo]); useEffect(() => { if (logRef.current) { logRef.current.scrollTop = logRef.current.scrollHeight; } }, [logOutput]); const onClose = () => { if (isRunning) return; setModalInfo(null); modalInfo?.onClose?.(); }; const onSubmit = async () => { if (isRunning || !modalInfo) return; setIsRunning(true); setIsDone(false); setHasError(false); setLogOutput(''); const output = joinPath(modalInfo.folderPath, modalInfo.outputName); const append = (chunk: string) => setLogOutput(prev => prev + chunk); try { const finalEvent = await callScriptStream('merge_loras.py', { args: { loras: JSON.stringify(selectedLoRAs), output, save_dtype: 'bfloat16', device: 'cpu', }, onStdout: append, onStderr: append, }); const ok = finalEvent?.type === 'exit' && finalEvent.ok === true; if (!ok) { setHasError(true); if (finalEvent?.type === 'error' && finalEvent.message) { append(`\n${finalEvent.message}\n`); } else if (finalEvent?.type === 'exit' && finalEvent.timedOut) { append('\nScript timed out.\n'); } else if (finalEvent?.type === 'exit') { append(`\nScript exited with code ${finalEvent.exitCode}.\n`); } } } catch (err: any) { setHasError(true); append(`\n${err?.message || 'Unknown error'}\n`); } finally { setIsRunning(false); setIsDone(true); } }; const loraLabel = (path: string) => getFilename(path).replace('.safetensors', ''); const availableLoRAs = modalInfo?.availableLoRAs ?? []; const selectedPaths = selectedLoRAs.map(s => s.path); const options: SelectOption[] = availableLoRAs .filter(f => !selectedPaths.includes(f.path)) .map(f => ({ value: f.path, label: loraLabel(f.path) })); const rescale = (items: SelectedLoRA[]): SelectedLoRA[] => { if (items.length === 0) return items; const strength = Math.round((1 / items.length) * 1000) / 1000; return items.map(s => ({ ...s, strength })); }; const addLoRA = (path: string) => { if (!path || selectedPaths.includes(path)) return; setSelectedLoRAs(prev => rescale([...prev, { path, strength: 0 }])); }; const removeLoRA = (path: string) => { setSelectedLoRAs(prev => rescale(prev.filter(s => s.path !== path))); }; const updateStrength = (path: string, strength: number | null) => { setSelectedLoRAs(prev => prev.map(s => (s.path === path ? { ...s, strength: strength ?? 0 } : s))); }; const showLog = isRunning || isDone; return ( {showLog ? (
{isRunning && Merging LoRAs... please do not close this window.} {isDone && hasError && Merge failed. See log below.} {isDone && !hasError && Merge complete.}
{logOutput || (isRunning ? 'Starting...\n' : '')}
) : (
{ e.preventDefault(); onSubmit(); }} > { setModalInfo({ ...modalInfo, outputName: value, } as MergeLoRAsModalState); }} placeholder="Enter output filename" />
addLoRA(value)} options={options} />
{selectedLoRAs.length > 0 && (
{selectedLoRAs.map(s => (
{loraLabel(s.path)}
{ const raw = e.target.value; if (raw === '' || raw === '-') return; const n = Number(raw); if (!isNaN(n)) updateStrength(s.path, n); }} step="any" className="w-20 flex-shrink-0 text-xs px-2 py-0.5 bg-gray-950 dark:bg-gray-800 border border-gray-700 rounded-sm text-gray-100 focus:ring-1 focus:ring-gray-600 focus:outline-none" />
))}
)}
)}
); }; export default MergeLoRAsModal;