mirror of
https://github.com/lone-cloud/gerbil
synced 2026-06-04 12:13:28 -07:00
599 lines
16 KiB
TypeScript
599 lines
16 KiB
TypeScript
import { spawn } from 'child_process';
|
|
import { dirname, join } from 'path';
|
|
import {
|
|
access,
|
|
stat,
|
|
mkdir,
|
|
unlink,
|
|
copyFile,
|
|
readFile,
|
|
writeFile,
|
|
rm,
|
|
} from 'fs/promises';
|
|
import { platform, on } from 'process';
|
|
import type { ChildProcess } from 'child_process';
|
|
import yauzl from 'yauzl';
|
|
import { createWriteStream } from 'fs';
|
|
import { app } from 'electron';
|
|
|
|
import { logError, safeExecute, tryExecute } from '@/utils/node/logging';
|
|
import { sendKoboldOutput } from './window';
|
|
import { getInstallDir } from './config';
|
|
import { COMFYUI, SERVER_READY_SIGNALS, GITHUB_API } from '@/constants';
|
|
import { terminateProcess } from '@/utils/node/process';
|
|
import { parseKoboldConfig } from '@/utils/node/kobold';
|
|
import { ensureDir } from '@/utils/node/fs';
|
|
import { detectGPU } from './hardware';
|
|
import { getUvEnvironment } from './dependencies';
|
|
|
|
interface ComfyUIVersionInfo {
|
|
sha: string;
|
|
date: string;
|
|
}
|
|
|
|
async function getLatestComfyUIVersion() {
|
|
return safeExecute(async () => {
|
|
const response = await fetch(GITHUB_API.COMFYUI_LATEST_COMMIT_URL);
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Failed to fetch ComfyUI version: ${response.statusText}`
|
|
);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return {
|
|
sha: data.sha,
|
|
date: data.commit.committer.date,
|
|
};
|
|
}, 'Failed to fetch latest ComfyUI version');
|
|
}
|
|
|
|
async function getCurrentComfyUIVersion(workspaceDir: string) {
|
|
try {
|
|
const versionFile = join(workspaceDir, '.version.json');
|
|
const content = await readFile(versionFile, 'utf-8');
|
|
return JSON.parse(content);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function saveComfyUIVersion(
|
|
workspaceDir: string,
|
|
version: ComfyUIVersionInfo
|
|
) {
|
|
await tryExecute(async () => {
|
|
const versionFile = join(workspaceDir, '.version.json');
|
|
await writeFile(versionFile, JSON.stringify(version, null, 2));
|
|
}, 'Failed to save ComfyUI version info');
|
|
}
|
|
|
|
async function shouldUpdateComfyUI(workspaceDir: string) {
|
|
const current = await getCurrentComfyUIVersion(workspaceDir);
|
|
const latest = await getLatestComfyUIVersion();
|
|
|
|
if (!current || !latest) {
|
|
return false;
|
|
}
|
|
|
|
return current.sha !== latest.sha;
|
|
}
|
|
|
|
let comfyUIProcess: ChildProcess | null = null;
|
|
|
|
function getPythonPath(workspaceDir: string) {
|
|
const isWindows = platform === 'win32';
|
|
const pythonExecutable = isWindows ? 'python.exe' : 'python';
|
|
const scriptsDir = isWindows ? 'Scripts' : 'bin';
|
|
return join(workspaceDir, '.venv', scriptsDir, pythonExecutable);
|
|
}
|
|
|
|
on('SIGINT', () => {
|
|
void stopFrontend();
|
|
});
|
|
|
|
on('SIGTERM', () => {
|
|
void stopFrontend();
|
|
});
|
|
|
|
async function shouldForceCPUMode() {
|
|
try {
|
|
const gpuInfo = await detectGPU();
|
|
const { hasAMD, hasNVIDIA } = gpuInfo;
|
|
const isWindows = platform === 'win32';
|
|
|
|
return (hasAMD && isWindows) || (!hasAMD && !hasNVIDIA);
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
async function getPyTorchInstallArgs(pythonPath: string) {
|
|
const args = [
|
|
'pip',
|
|
'install',
|
|
'--python',
|
|
pythonPath,
|
|
'torch',
|
|
'torchvision',
|
|
'torchaudio',
|
|
];
|
|
|
|
try {
|
|
const gpuInfo = await detectGPU();
|
|
const { hasAMD, hasNVIDIA } = gpuInfo;
|
|
const isWindows = platform === 'win32';
|
|
|
|
if (hasAMD && !isWindows) {
|
|
sendKoboldOutput(
|
|
'AMD GPU detected, installing PyTorch with ROCm support...'
|
|
);
|
|
args.push('--index-url', 'https://download.pytorch.org/whl/rocm6.4');
|
|
} else if (hasNVIDIA) {
|
|
sendKoboldOutput(
|
|
'NVIDIA GPU detected, installing PyTorch with CUDA support...'
|
|
);
|
|
args.push('--extra-index-url', 'https://download.pytorch.org/whl/cu121');
|
|
} else {
|
|
if (hasAMD && isWindows) {
|
|
sendKoboldOutput(
|
|
'AMD GPU detected on Windows, installing CPU PyTorch (ROCm not available on Windows)...'
|
|
);
|
|
} else {
|
|
sendKoboldOutput(
|
|
'No dedicated GPU detected, installing CPU-only PyTorch...'
|
|
);
|
|
}
|
|
args.push('--index-url', 'https://download.pytorch.org/whl/cpu');
|
|
}
|
|
} catch {
|
|
sendKoboldOutput('Could not detect GPU, installing default PyTorch...');
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
async function downloadAndExtractComfyUI(workspaceDir: string) {
|
|
sendKoboldOutput('Downloading ComfyUI...');
|
|
|
|
const response = await fetch(GITHUB_API.COMFYUI_DOWNLOAD_URL);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to download ComfyUI: ${response.statusText}`);
|
|
}
|
|
|
|
const zipPath = join(workspaceDir, 'comfyui.zip');
|
|
const stream = createWriteStream(zipPath);
|
|
|
|
if (response.body) {
|
|
const reader = response.body.getReader();
|
|
const pump = async (): Promise<void> => {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
stream.end();
|
|
return;
|
|
}
|
|
stream.write(Buffer.from(value));
|
|
return pump();
|
|
};
|
|
await pump();
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
stream.on('finish', resolve);
|
|
stream.on('error', reject);
|
|
});
|
|
}
|
|
|
|
const zipStats = await stat(zipPath);
|
|
sendKoboldOutput(
|
|
`ComfyUI downloaded (${Math.round(zipStats.size / 1024 / 1024)}MB), extracting...`
|
|
);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
zipfile!.readEntry();
|
|
zipfile!.on('entry', (entry) => {
|
|
const entryPath = entry.fileName;
|
|
const destPath = join(workspaceDir, entryPath.replace(/^[^/]+\//, ''));
|
|
|
|
if (/\/$/.test(entryPath)) {
|
|
mkdir(destPath, { recursive: true })
|
|
.then(() => zipfile!.readEntry())
|
|
.catch(reject);
|
|
} else {
|
|
zipfile!.openReadStream(entry, (err, readStream) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
mkdir(dirname(destPath), { recursive: true })
|
|
.then(() => {
|
|
const writeStream = createWriteStream(destPath);
|
|
readStream!.pipe(writeStream);
|
|
writeStream.on('close', () => zipfile!.readEntry());
|
|
writeStream.on('error', reject);
|
|
})
|
|
.catch(reject);
|
|
});
|
|
}
|
|
});
|
|
|
|
zipfile!.on('end', () => {
|
|
unlink(zipPath)
|
|
.then(() => resolve())
|
|
.catch(reject);
|
|
});
|
|
});
|
|
});
|
|
|
|
sendKoboldOutput('ComfyUI extracted successfully');
|
|
|
|
const latestVersion = await getLatestComfyUIVersion();
|
|
if (latestVersion) {
|
|
await saveComfyUIVersion(workspaceDir, latestVersion);
|
|
}
|
|
}
|
|
|
|
async function installDependencies(
|
|
workspaceDir: string,
|
|
env: Record<string, string | undefined>
|
|
) {
|
|
sendKoboldOutput('Installing ComfyUI dependencies...');
|
|
|
|
const requirementsPath = join(workspaceDir, 'requirements.txt');
|
|
const requirementsExists = await access(requirementsPath)
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
|
|
if (!requirementsExists) {
|
|
throw new Error('requirements.txt not found in ComfyUI directory');
|
|
}
|
|
|
|
const pythonPath = getPythonPath(workspaceDir);
|
|
const torchInstallArgs = await getPyTorchInstallArgs(pythonPath);
|
|
|
|
const torchInstallProcess = spawn('uv', torchInstallArgs, {
|
|
stdio: 'pipe',
|
|
env,
|
|
});
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
torchInstallProcess.on('exit', (torchCode: number | null) => {
|
|
if (torchCode === 0) {
|
|
sendKoboldOutput(
|
|
'PyTorch installed successfully, installing remaining dependencies...'
|
|
);
|
|
|
|
const pipInstallProcess = spawn(
|
|
'uv',
|
|
[
|
|
'pip',
|
|
'install',
|
|
'--python',
|
|
getPythonPath(workspaceDir),
|
|
'-r',
|
|
requirementsPath,
|
|
],
|
|
{
|
|
stdio: 'pipe',
|
|
env,
|
|
}
|
|
);
|
|
|
|
pipInstallProcess.on('exit', (pipCode: number | null) => {
|
|
if (pipCode === 0) {
|
|
sendKoboldOutput('ComfyUI dependencies installed successfully');
|
|
|
|
const verifyProcess = spawn(
|
|
getPythonPath(workspaceDir),
|
|
[
|
|
'-c',
|
|
'import torch; print("PyTorch version:", torch.__version__)',
|
|
],
|
|
{
|
|
stdio: 'pipe',
|
|
env,
|
|
}
|
|
);
|
|
|
|
verifyProcess.on('exit', (verifyCode: number | null) => {
|
|
if (verifyCode === 0) {
|
|
sendKoboldOutput('ComfyUI installation verified successfully');
|
|
resolve();
|
|
} else {
|
|
reject(
|
|
new Error(
|
|
`ComfyUI verification failed with code ${verifyCode}`
|
|
)
|
|
);
|
|
}
|
|
});
|
|
|
|
verifyProcess.on('error', (error) => {
|
|
reject(error);
|
|
});
|
|
|
|
if (verifyProcess.stdout) {
|
|
verifyProcess.stdout.on('data', (data: Buffer) => {
|
|
sendKoboldOutput(data.toString('utf8'), true);
|
|
});
|
|
}
|
|
|
|
if (verifyProcess.stderr) {
|
|
verifyProcess.stderr.on('data', (data: Buffer) => {
|
|
sendKoboldOutput(
|
|
`Verify Error: ${data.toString('utf8')}`,
|
|
true
|
|
);
|
|
});
|
|
}
|
|
} else {
|
|
reject(
|
|
new Error(`Dependency installation failed with code ${pipCode}`)
|
|
);
|
|
}
|
|
});
|
|
|
|
pipInstallProcess.on('error', (error) => {
|
|
reject(error);
|
|
});
|
|
|
|
if (pipInstallProcess.stdout) {
|
|
pipInstallProcess.stdout.on('data', (data: Buffer) => {
|
|
sendKoboldOutput(data.toString('utf8'), true);
|
|
});
|
|
}
|
|
|
|
if (pipInstallProcess.stderr) {
|
|
pipInstallProcess.stderr.on('data', (data: Buffer) => {
|
|
sendKoboldOutput(data.toString('utf8'), true);
|
|
});
|
|
}
|
|
} else {
|
|
reject(new Error(`PyTorch installation failed with code ${torchCode}`));
|
|
}
|
|
});
|
|
|
|
torchInstallProcess.on('error', (error) => {
|
|
reject(error);
|
|
});
|
|
|
|
if (torchInstallProcess.stdout) {
|
|
torchInstallProcess.stdout.on('data', (data: Buffer) => {
|
|
sendKoboldOutput(data.toString('utf8'), true);
|
|
});
|
|
}
|
|
|
|
if (torchInstallProcess.stderr) {
|
|
torchInstallProcess.stderr.on('data', (data: Buffer) => {
|
|
sendKoboldOutput(data.toString('utf8'), true);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
async function installComfyUI(workspaceDir: string) {
|
|
const env = await getUvEnvironment();
|
|
|
|
sendKoboldOutput('Creating virtual environment...');
|
|
|
|
const installProcess = spawn(
|
|
'uv',
|
|
['venv', '--python', '3.11', join(workspaceDir, '.venv')],
|
|
{
|
|
stdio: 'pipe',
|
|
env,
|
|
}
|
|
);
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
installProcess.on('exit', async (code) => {
|
|
if (code === 0) {
|
|
try {
|
|
await downloadAndExtractComfyUI(workspaceDir);
|
|
await installDependencies(workspaceDir, env);
|
|
resolve();
|
|
} catch (error) {
|
|
reject(error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
} else {
|
|
reject(
|
|
new Error(`Virtual environment creation failed with code ${code}`)
|
|
);
|
|
}
|
|
});
|
|
|
|
installProcess.on('error', (error) => {
|
|
reject(error);
|
|
});
|
|
|
|
if (installProcess.stdout) {
|
|
installProcess.stdout.on('data', (data: Buffer) => {
|
|
sendKoboldOutput(data.toString('utf8'), true);
|
|
});
|
|
}
|
|
|
|
if (installProcess.stderr) {
|
|
installProcess.stderr.on('data', (data: Buffer) => {
|
|
sendKoboldOutput(data.toString('utf8'), true);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
async function updateComfyUI(workspaceDir: string) {
|
|
const backupDir = join(workspaceDir, 'backup');
|
|
await ensureDir(backupDir);
|
|
|
|
const filesToBackup = ['main.py', 'requirements.txt'];
|
|
const backupPromises = filesToBackup.map(async (file) => {
|
|
const srcPath = join(workspaceDir, file);
|
|
const destPath = join(backupDir, file);
|
|
try {
|
|
await copyFile(srcPath, destPath);
|
|
} catch (error) {
|
|
void error;
|
|
}
|
|
});
|
|
|
|
await Promise.all(backupPromises);
|
|
|
|
try {
|
|
await downloadAndExtractComfyUI(workspaceDir);
|
|
sendKoboldOutput('ComfyUI updated successfully');
|
|
|
|
await rm(backupDir, { recursive: true, force: true });
|
|
} catch (error) {
|
|
sendKoboldOutput('Update failed, restoring backup...');
|
|
|
|
const restorePromises = filesToBackup.map(async (file) => {
|
|
const srcPath = join(backupDir, file);
|
|
const destPath = join(workspaceDir, file);
|
|
try {
|
|
await copyFile(srcPath, destPath);
|
|
} catch (error) {
|
|
void error;
|
|
}
|
|
});
|
|
|
|
await Promise.all(restorePromises);
|
|
await rm(backupDir, { recursive: true, force: true });
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function ensureComfyUIInstalled() {
|
|
const installDir = getInstallDir();
|
|
const comfyUIWorkspace = join(installDir, 'comfyui-workspace');
|
|
|
|
await ensureDir(comfyUIWorkspace);
|
|
|
|
const comfyUIMainPath = join(comfyUIWorkspace, 'main.py');
|
|
const isComfyUIInstalled = await access(comfyUIMainPath)
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
|
|
const needsUpdate =
|
|
isComfyUIInstalled && (await shouldUpdateComfyUI(comfyUIWorkspace));
|
|
|
|
if (!isComfyUIInstalled) {
|
|
sendKoboldOutput('ComfyUI not found, installing...');
|
|
await installComfyUI(comfyUIWorkspace);
|
|
} else if (needsUpdate) {
|
|
sendKoboldOutput('ComfyUI update available, updating...');
|
|
await updateComfyUI(comfyUIWorkspace);
|
|
} else {
|
|
sendKoboldOutput('ComfyUI found in workspace and up to date');
|
|
}
|
|
}
|
|
|
|
async function waitForComfyUIToStart() {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const checkForOutput = (data: Buffer) => {
|
|
const output = data.toString();
|
|
if (output.includes(SERVER_READY_SIGNALS.COMFYUI)) {
|
|
sendKoboldOutput('ComfyUI is now running!');
|
|
resolve();
|
|
|
|
if (comfyUIProcess?.stdout) {
|
|
comfyUIProcess.stdout.removeListener('data', checkForOutput);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (comfyUIProcess?.stdout) {
|
|
comfyUIProcess.stdout.on('data', checkForOutput);
|
|
} else {
|
|
reject(new Error('ComfyUI process stdout not available'));
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function startFrontend(args: string[]) {
|
|
try {
|
|
const config = {
|
|
name: 'comfyui',
|
|
port: COMFYUI.PORT,
|
|
};
|
|
const { host: koboldHost, port: koboldPort } = parseKoboldConfig(args);
|
|
|
|
sendKoboldOutput(`Starting ComfyUI frontend on port ${config.port}...`);
|
|
|
|
await ensureComfyUIInstalled();
|
|
|
|
const installDir = getInstallDir();
|
|
const comfyUIWorkspace = join(installDir, 'comfyui-workspace');
|
|
const pythonPath = getPythonPath(comfyUIWorkspace);
|
|
|
|
const comfyUIArgs = [
|
|
join(comfyUIWorkspace, 'main.py'),
|
|
'--port',
|
|
config.port.toString(),
|
|
'--enable-cors-header',
|
|
];
|
|
|
|
const forceCPU = await shouldForceCPUMode();
|
|
if (forceCPU) {
|
|
comfyUIArgs.push('--cpu');
|
|
sendKoboldOutput(
|
|
'Forcing ComfyUI to use CPU mode (no compatible GPU acceleration available)'
|
|
);
|
|
}
|
|
|
|
if (koboldHost && koboldPort) {
|
|
sendKoboldOutput(
|
|
`Connecting to KoboldCpp at ${koboldHost}:${koboldPort}`
|
|
);
|
|
}
|
|
|
|
sendKoboldOutput(`Running ComfyUI with Python: ${pythonPath}`);
|
|
sendKoboldOutput(`ComfyUI args: ${comfyUIArgs.join(' ')}`);
|
|
|
|
comfyUIProcess = spawn(pythonPath, comfyUIArgs, {
|
|
cwd: comfyUIWorkspace,
|
|
stdio: 'pipe',
|
|
});
|
|
|
|
const version = await app.getVersion();
|
|
sendKoboldOutput(`ComfyUI started via Gerbil v${version}`);
|
|
|
|
if (comfyUIProcess.stdout) {
|
|
comfyUIProcess.stdout.on('data', (data: Buffer) => {
|
|
sendKoboldOutput(data.toString('utf8'), true);
|
|
});
|
|
}
|
|
|
|
if (comfyUIProcess.stderr) {
|
|
comfyUIProcess.stderr.on('data', (data: Buffer) => {
|
|
sendKoboldOutput(data.toString('utf8'), true);
|
|
});
|
|
}
|
|
|
|
comfyUIProcess.on('exit', (code: number | null, signal: string | null) => {
|
|
if (code === null && signal) {
|
|
sendKoboldOutput(`ComfyUI process terminated by signal: ${signal}`);
|
|
} else {
|
|
sendKoboldOutput(`ComfyUI process exited with code: ${code}`);
|
|
}
|
|
comfyUIProcess = null;
|
|
});
|
|
|
|
comfyUIProcess.on('error', (error: Error) => {
|
|
logError('ComfyUI process error', error);
|
|
comfyUIProcess = null;
|
|
});
|
|
|
|
await waitForComfyUIToStart();
|
|
} catch (error) {
|
|
logError('Failed to start ComfyUI', error as Error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export const stopFrontend = () => terminateProcess(comfyUIProcess);
|