Files
bce78481fd 3.0.6
- Voice (Beta): talk to LU and hear it answer
- Computer control (Beta): a model works in the apps you approve, off by default
- Local models with image input download with their image part
- Create in chat (Beta): ask for an image or a clip in a Cloud chat
- Ten more chat models on LU Cloud
- A running task says what it is doing, and dialogs have a new look
- The Linux packages carry the voice engine
- Ten security fixes in the new features
- A microphone field in the settings, and seven fixes from user reports

Co-authored-by: Alan Vo <101240385+ALANDVO@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:43:43 -07:00

545 lines
22 KiB
TypeScript

import { spawn, execSync, execFileSync, type ChildProcess } from 'child_process'
import { existsSync, readdirSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { resolve, join } from 'path'
import type { RouteMount } from './routes'
import { pythonBin } from './python'
import { ENV_FILE } from './paths'
import { requirePost, withJsonBody, failRequest } from './http'
import { bodyString } from '../src/dev/http-body'
import { customNodeDir } from '../src/dev/model-paths'
import { errorText } from '../src/types/json-guards'
export function findComfyUI(): string | null {
// 1. Check .env / environment variable
const envPath = process.env.COMFYUI_PATH
console.log(`[ComfyUI] COMFYUI_PATH env: ${envPath || '(not set)'}`)
if (envPath) {
// Try the path directly (handles spaces in paths)
const mainPy = join(envPath, 'main.py')
console.log(`[ComfyUI] Checking: ${mainPy} -> ${existsSync(mainPy)}`)
if (existsSync(mainPy)) return envPath
}
const home = process.env.USERPROFILE || process.env.HOME || ''
// 2. Check common locations
const fixed = [
resolve(home, 'ComfyUI'),
resolve(home, 'Desktop/ComfyUI'),
resolve(home, 'Documents/ComfyUI'),
'C:\\ComfyUI',
]
for (const p of fixed) {
if (existsSync(resolve(p, 'main.py'))) return p
}
// 3. Recursive scan Desktop, Documents, and drive roots (up to 4 levels deep)
const scanRoots = [
resolve(home, 'Desktop'),
resolve(home, 'Documents'),
resolve(home, 'Downloads'),
...(process.platform === 'win32' ? ['C:\\', 'D:\\'] : ['/opt', '/usr/local']),
]
const skipNames = new Set(['node_modules', '.git', '__pycache__', 'venv', '.venv', 'site-packages', 'Windows', 'Program Files', 'Program Files (x86)', '$Recycle.Bin', 'AppData'])
function scanForComfyUI(dir: string, depth: number): string | null {
if (depth <= 0) return null
try {
const entries = readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.') || skipNames.has(entry.name)) continue
const full = join(dir, entry.name)
// Check if this directory IS ComfyUI (has main.py + folder named ComfyUI or contains comfy-specific files)
if (entry.name === 'ComfyUI' || entry.name === 'comfyui') {
if (existsSync(join(full, 'main.py'))) return full
}
// Recurse deeper
const found = scanForComfyUI(full, depth - 1)
if (found) return found
}
} catch { /* skip unreadable dirs */ }
return null
}
for (const root of scanRoots) {
if (!existsSync(root)) continue
const found = scanForComfyUI(root, 4)
if (found) return found
}
return null
}
export function isComfyRunning(): Promise<boolean> {
return fetch('http://localhost:8188/system_stats')
.then(r => r.ok)
.catch(() => false)
}
/**
* K8 (GH #134): dev-mode proxy for the Rust side's
* `is_comfyui_install_complete` (process.rs): same QUESTION ("can this
* install actually run, or is it a half-cloned carcass"), a simpler answer.
* The real check walks candidate python prefixes looking for an installed
* torch; that is desktop/Tauri-only logic this dev server has no reason to
* duplicate. A running ComfyUI is definitionally complete, and short of
* that, an already-built venv/.venv next to main.py is the same signal the
* dev launcher itself already keys off of (see getComfyPython above), good
* enough for onboarding's dev-mode auto-detect not to get stuck on "Install
* ComfyUI" when ComfyUI is right there and already running (the actual bug).
*/
function isComfyInstallComplete(comfyPath: string, running: boolean): boolean {
if (running) return true
return ['venv', '.venv'].some((v) => existsSync(join(comfyPath, v)))
}
/**
* Der ComfyUI-Kindprozess und alles, was ihn anfasst.
*
* Ein Modul, weil hier genau EIN veränderlicher Zustand liegt — der laufende
* Prozess und sein Logpuffer — und weil jede Zeile, die ihn anfasst, in
* derselben Datei stehen muss, damit niemand ihn von aussen umgeht.
*/
export function createComfyLauncher() {
let comfyProcess: ChildProcess | null = null
let comfyLogs: string[] = []
// Mirror the Rust launcher (process.rs): prefer a venv python so ComfyUI runs
// inside the env pip installed torch into. Checks both the classic `venv` and
// the modern `.venv` (issue #51). Dev-mode only.
const getComfyPython = (comfyPath: string): string => {
const isWin = process.platform === 'win32'
for (const v of ['venv', '.venv']) {
const vp = isWin
? join(comfyPath, v, 'Scripts', 'python.exe')
: join(comfyPath, v, 'bin', 'python')
if (existsSync(vp)) {
console.log(`[ComfyUI] Using venv python: ${vp}`)
return vp
}
}
return pythonBin
}
const startComfy = (comfyPath: string): { status: string; path: string } => {
if (comfyProcess && !comfyProcess.killed) {
return { status: 'already_running', path: comfyPath }
}
comfyLogs = []
const executable = getComfyPython(comfyPath)
console.log(`[ComfyUI] Spawning ${executable} in: ${comfyPath}`)
comfyProcess = spawn(executable, ['main.py', '--listen', '127.0.0.1', '--port', '8188', '--enable-cors-header', '*'], {
cwd: comfyPath,
stdio: ['ignore', 'pipe', 'pipe'],
shell: false,
windowsHide: true,
// Mirror the Rust launcher (process.rs): force UTF-8 I/O so ComfyUI's
// Unicode progress glyphs don't crash on a non-UTF-8 Windows codepage
// (a user 'charmap' codec UnicodeEncodeError, Discord 2026-06-07).
env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' },
})
comfyProcess.stdout?.on('data', (d) => {
const line = d.toString()
comfyLogs.push(line)
if (comfyLogs.length > 200) comfyLogs.shift()
})
comfyProcess.stderr?.on('data', (d) => {
const line = d.toString()
comfyLogs.push(line)
if (comfyLogs.length > 200) comfyLogs.shift()
})
comfyProcess.on('exit', () => { comfyProcess = null })
console.log(`[ComfyUI] Starting from: ${comfyPath}`)
return { status: 'started', path: comfyPath }
}
const stopComfy = () => {
if (comfyProcess && !comfyProcess.killed) {
// Kill process tree on Windows
try {
if (process.platform === 'win32' && comfyProcess.pid) {
execSync(`taskkill /pid ${comfyProcess.pid} /T /F`, { stdio: 'ignore' })
} else {
comfyProcess.kill('SIGTERM')
}
} catch { /* already dead */ }
comfyProcess = null
console.log('[ComfyUI] Stopped')
}
}
return {
startComfy,
stopComfy,
/** Läuft unser eigener Kindprozess noch? (comfyui-status) */
processAlive: () => comfyProcess !== null && !comfyProcess.killed,
/** Die letzten Ausgabezeilen, wie comfyui-status sie immer meldete. */
recentLogs: () => comfyLogs.slice(-20),
}
}
export type ComfyLauncher = ReturnType<typeof createComfyLauncher>
/** Startet ComfyUI eine Sekunde nach dem Dev-Server, wenn es nicht schon läuft. */
export function autostartComfy(comfy: ComfyLauncher): void {
// Auto-start ComfyUI when dev server starts
setTimeout(async () => {
try {
const running = await isComfyRunning()
if (!running) {
const comfyPath = findComfyUI()
if (comfyPath) {
console.log(`[ComfyUI] Auto-starting from: ${comfyPath}`)
const result = comfy.startComfy(comfyPath)
console.log(`[ComfyUI] Start result: ${result.status}`)
} else {
console.log('[ComfyUI] Not found. Set COMFYUI_PATH in .env or install ComfyUI.')
}
} else {
console.log('[ComfyUI] Already running on port 8188')
}
} catch (err) {
console.error('[ComfyUI] Auto-start error:', err)
}
}, 1000)
}
/** Start und Stopp von Hand. */
export function registerComfyControlRoutes(routes: RouteMount, comfy: ComfyLauncher): void {
// API: Manual start
routes.use('/local-api/start-comfyui', async (_req, res) => {
const alreadyRunning = await isComfyRunning()
if (alreadyRunning) {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'already_running' }))
return
}
const comfyPath = findComfyUI()
if (!comfyPath) {
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'not_found', message: 'ComfyUI not found. Set COMFYUI_PATH in .env file.' }))
return
}
try {
const result = comfy.startComfy(comfyPath)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(result))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'error', message: String(err) }))
}
})
// API: Stop
routes.use('/local-api/stop-comfyui', (_req, res) => {
comfy.stopComfy()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'stopped' }))
})
// K8 (GH #134): `find_comfyui` is mapped in endpointMap (src/api/backend.ts)
// but had NO route here at all, so onboarding's ComfyStep.tsx auto-detect
// 404'd on both this AND its primary `detect_all_comfyui_installs` call
// (also added below), fell through its own catch-all, and got stuck
// showing "Install ComfyUI" even with ComfyUI already running on :8188.
// Independent of LAN/--host: this was broken under plain `npm run dev`.
routes.use('/local-api/find-comfyui', async (_req, res) => {
const comfyPath = findComfyUI()
res.writeHead(200, { 'Content-Type': 'application/json' })
if (!comfyPath) {
res.end(JSON.stringify({ found: false, path: null, complete: false }))
return
}
let running = false
try { running = await isComfyRunning() } catch { /* ignore */ }
res.end(JSON.stringify({ found: true, path: comfyPath, complete: isComfyInstallComplete(comfyPath, running) }))
})
// K8: the primary auto-detect path ComfyStep.tsx tries first. The dev
// server can only ever scan up to ONE install (findComfyUI has no
// multi-install enumeration, unlike the Rust scan it mirrors), so this
// answers with 0 or 1 entries, the same shape the picker UI already
// handles as "no disambiguation needed", not a lie about capability.
routes.use('/local-api/detect-all-comfyui-installs', async (_req, res) => {
const comfyPath = findComfyUI()
let running = false
try { running = await isComfyRunning() } catch { /* ignore */ }
const installs = comfyPath
? [{
path: comfyPath,
complete: isComfyInstallComplete(comfyPath, running),
has_embedded_python: existsSync(join(comfyPath, 'python_embeded')),
source: 'scan',
}]
: []
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(installs))
})
}
/** Installation, Pfadwahl und Status — alles, was eine ComfyUI erst herstellt. */
export function registerComfyInstallRoutes(routes: RouteMount, comfy: ComfyLauncher): void {
// API: Install custom node (git clone into ComfyUI/custom_nodes/)
routes.use('/local-api/install-custom-node', (req, res) => {
if (!requirePost(req, res)) return
withJsonBody(req, res, (body) => {
try {
const repoUrl = bodyString(body, 'repoUrl', 'repo_url') ?? ''
const nodeName = bodyString(body, 'nodeName', 'node_name') ?? ''
const comfyPath = findComfyUI()
if (!comfyPath) {
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'ComfyUI not found. Install ComfyUI first.' }))
return
}
const customNodesDir = join(comfyPath, 'custom_nodes')
if (!existsSync(customNodesDir)) mkdirSync(customNodesDir, { recursive: true })
// SICHERHEITSBEFUND: `nodeName` hing ungeprüft an `custom_nodes/`
// (`'../../..'` klonte ausserhalb von ComfyUI), und `repoUrl` ging in
// eine SHELL-Zeichenkette — `x"; rm -rf ~; echo "` war ein zweites
// Kommando. Der Käfig steht jetzt in src/dev/model-paths.ts, und
// execFileSync übergibt Argumente ohne Shell dazwischen.
const targetDir = customNodeDir(comfyPath, nodeName, repoUrl)
if (existsSync(targetDir)) {
console.log(`[CustomNode] Already installed: ${nodeName}`)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'already_installed', path: targetDir }))
return
}
console.log(`[CustomNode] Installing ${nodeName} from ${repoUrl}...`)
try {
execFileSync('git', ['clone', repoUrl, targetDir], { timeout: 120000 })
// A pinned pack goes onto its tested commit (model-bundles.ts).
const commit = bodyString(body, 'commit') ?? ''
if (/^[0-9a-f]{40}$/i.test(commit)) {
execFileSync('git', ['checkout', '--detach', commit], { cwd: targetDir, timeout: 60000 })
}
// Try pip install if requirements.txt exists
const reqFile = join(targetDir, 'requirements.txt')
if (existsSync(reqFile)) {
try {
execFileSync('pip', ['install', '-r', reqFile], { cwd: targetDir, timeout: 300000 })
} catch (pipErr) {
console.warn(`[CustomNode] pip install failed for ${nodeName}:`, errorText(pipErr))
}
}
console.log(`[CustomNode] Installed: ${nodeName}`)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'installed', path: targetDir }))
} catch (gitErr) {
const detail = errorText(gitErr)
console.error(`[CustomNode] Git clone failed:`, detail)
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: `Git clone failed: ${detail}` }))
}
} catch (err) {
failRequest(res, err)
}
})
})
// API: Set ComfyUI path (writes to .env and starts ComfyUI)
routes.use('/local-api/set-comfyui-path', (req, res) => {
if (!requirePost(req, res)) return
withJsonBody(req, res, (body) => {
try {
const newPath = bodyString(body, 'path') ?? ''
// Ohne Pfad gibt es nichts zu prüfen; `join(undefined, …)` warf hier
// vorher eine TypeError in den Catch und meldete sie als "error".
const mainPy = newPath ? join(newPath, 'main.py') : ''
if (!mainPy || !existsSync(mainPy)) {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'error', error: `main.py not found in "${newPath}". Make sure this is the ComfyUI root folder.` }))
return
}
// Write to .env file
const envPath = ENV_FILE
let envContent = ''
try { envContent = readFileSync(envPath, 'utf8') } catch { /* no .env yet */ }
const currentMatch = envContent.match(/^COMFYUI_PATH=(.*)$/m)
if (!currentMatch || currentMatch[1].trim() !== newPath) {
if (envContent.includes('COMFYUI_PATH=')) {
envContent = envContent.replace(/COMFYUI_PATH=.*/g, `COMFYUI_PATH=${newPath}`)
} else {
envContent += `${envContent.endsWith('\n') || envContent === '' ? '' : '\n'}COMFYUI_PATH=${newPath}\n`
}
writeFileSync(envPath, envContent, 'utf8')
}
// Update process.env
process.env.COMFYUI_PATH = newPath
console.log(`[ComfyUI] Path set to: ${newPath}`)
// Auto-start ComfyUI
const result = comfy.startComfy(newPath)
console.log(`[ComfyUI] Start result: ${result.status}`)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'ok', path: newPath }))
} catch (err) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'error', error: errorText(err) || String(err) }))
}
})
})
// API: Install ComfyUI from scratch
const installLogs: string[] = []
let installStatus: 'idle' | 'installing' | 'complete' | 'error' = 'idle'
let installError = ''
routes.use('/local-api/install-comfyui', (req, res) => {
if (req.method === 'GET') {
// Return install status
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: installStatus, error: installError, logs: installLogs.slice(-30) }))
return
}
if (req.method !== 'POST') { res.writeHead(405); res.end(); return }
if (installStatus === 'installing') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'already_installing' }))
return
}
// Check Python is available
try {
execSync('python --version', { stdio: 'ignore' })
} catch {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'error', error: 'Python not found. Install Python 3.10+ from python.org first.' }))
return
}
installStatus = 'installing'
installError = ''
installLogs.length = 0
const home = process.env.USERPROFILE || process.env.HOME || ''
const installDir = join(home, 'ComfyUI')
const log = (msg: string) => {
installLogs.push(msg)
if (installLogs.length > 200) installLogs.shift()
console.log(`[ComfyUI Install] ${msg}`)
}
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'started', path: installDir }))
// Run installation in background
;(async () => {
try {
// Step 1: Clone
if (!existsSync(installDir)) {
log('Cloning ComfyUI from GitHub...')
const clone = spawn('git', ['clone', 'https://github.com/comfyanonymous/ComfyUI.git', installDir], { shell: true, stdio: ['ignore', 'pipe', 'pipe'] })
clone.stdout?.on('data', (d) => log(d.toString().trim()))
clone.stderr?.on('data', (d) => log(d.toString().trim()))
await new Promise<void>((resolve, reject) => {
clone.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`git clone failed (exit ${code})`)))
})
log('Clone complete.')
} else if (existsSync(join(installDir, 'main.py'))) {
log('ComfyUI directory already exists, skipping clone.')
} else {
throw new Error(`${installDir} exists but is not ComfyUI. Delete it or choose another location.`)
}
// Step 2: Install Python dependencies
log('Installing Python dependencies (this may take several minutes)...')
const pip = spawn('pip', ['install', '-r', 'requirements.txt'], { cwd: installDir, shell: true, stdio: ['ignore', 'pipe', 'pipe'] })
pip.stdout?.on('data', (d) => {
const lines = d.toString().split('\n').filter((l: string) => l.trim())
lines.forEach((l: string) => log(l.trim()))
})
pip.stderr?.on('data', (d) => {
const lines = d.toString().split('\n').filter((l: string) => l.trim())
lines.forEach((l: string) => log(l.trim()))
})
await new Promise<void>((resolve, reject) => {
pip.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`pip install failed (exit ${code})`)))
})
log('Dependencies installed.')
// Step 3: Install PyTorch with CUDA (if NVIDIA GPU detected)
log('Checking for NVIDIA GPU...')
let hasNvidia = false
try {
execSync('nvidia-smi', { stdio: 'ignore' })
hasNvidia = true
} catch { /* no nvidia */ }
if (hasNvidia) {
log('NVIDIA GPU found. Installing PyTorch with CUDA support...')
const torch = spawn('pip', ['install', 'torch', 'torchvision', 'torchaudio', '--index-url', 'https://download.pytorch.org/whl/cu121'], { cwd: installDir, shell: true, stdio: ['ignore', 'pipe', 'pipe'] })
torch.stdout?.on('data', (d) => log(d.toString().trim()))
torch.stderr?.on('data', (d) => log(d.toString().trim()))
await new Promise<void>((resolve, reject) => {
torch.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`PyTorch CUDA install failed (exit ${code})`)))
})
log('PyTorch with CUDA installed.')
} else {
log('No NVIDIA GPU — using CPU PyTorch (already in requirements).')
}
// Step 4: Save path to .env
const envPath = ENV_FILE
let envContent = ''
try { envContent = readFileSync(envPath, 'utf8') } catch { /* no .env */ }
const currentMatch = envContent.match(/^COMFYUI_PATH=(.*)$/m)
if (!currentMatch || currentMatch[1].trim() !== installDir) {
if (envContent.includes('COMFYUI_PATH=')) {
envContent = envContent.replace(/COMFYUI_PATH=.*/g, `COMFYUI_PATH=${installDir}`)
} else {
envContent += `${envContent.endsWith('\n') || envContent === '' ? '' : '\n'}COMFYUI_PATH=${installDir}\n`
}
writeFileSync(envPath, envContent, 'utf8')
}
process.env.COMFYUI_PATH = installDir
log(`Path saved to .env: ${installDir}`)
// Step 5: Start ComfyUI
log('Starting ComfyUI...')
comfy.startComfy(installDir)
log('ComfyUI started! You can now download models and generate images/videos.')
installStatus = 'complete'
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
log(`ERROR: ${msg}`)
installError = msg
installStatus = 'error'
}
})()
})
// API: Status + logs
routes.use('/local-api/comfyui-status', async (_req, res) => {
let running = false
try { running = await isComfyRunning() } catch { /* ignore */ }
const comfyPath = findComfyUI()
const processAlive = comfy.processAlive()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
running,
starting: processAlive && !running,
found: comfyPath !== null,
path: comfyPath,
logs: comfy.recentLogs(),
processAlive,
// The dev server has no cross-session orphan case (Rust's
// `find_orphaned_comfyui`, box-gruen/n9 punkt 87 final review B2): a
// Node process here either holds the child handle or it does not, so
// this is always the same value as `processAlive`. Sent anyway so the
// panel's ownership label and Update's confirm-dialog guard behave the
// same in browser dev mode as in the real app.
ownedByApp: processAlive,
}))
})
}