diff --git a/backend/app/controller/chat_controller.py b/backend/app/controller/chat_controller.py index e85fbb275..a983acfd2 100644 --- a/backend/app/controller/chat_controller.py +++ b/backend/app/controller/chat_controller.py @@ -58,7 +58,8 @@ async def timeout_stream_wrapper(stream_generator, timeout_seconds: int = SSE_TI yield data except asyncio.TimeoutError: chat_logger.warning(f"SSE timeout: No data received for {timeout_seconds} seconds, closing connection") - yield sse_json("error", {"message": "Connection timeout: No data received for 10 minutes"}) + # yield sse_json("error", {"message": "Connection timeout: No data received for 10 minutes"}) + # TODO: Temporary change: suppress error signal to frontend on timeout. Needs proper fix later. break except StopAsyncIteration: break diff --git a/electron/main/index.ts b/electron/main/index.ts index 7916b0606..02cedbb87 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -89,6 +89,21 @@ app.commandLine.appendSwitch('max_old_space_size', '4096'); app.commandLine.appendSwitch('enable-features', 'MemoryPressureReduction'); app.commandLine.appendSwitch('renderer-process-limit', '8'); +// ==================== protocol privileges ==================== +// Register custom protocol privileges before app ready +protocol.registerSchemesAsPrivileged([ + { + scheme: 'localfile', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: false, + bypassCSP: false, + }, + }, +]); + // ==================== app config ==================== process.env.APP_ROOT = MAIN_DIST; process.env.VITE_PUBLIC = VITE_PUBLIC; @@ -996,10 +1011,45 @@ function registerIpcHandlers() { ipcMain.handle('install-dependencies', async () => { try { if(win === null) throw new Error("Window is null"); - //Force installation even if versionFile exists - const isInstalled = await checkAndInstallDepsOnUpdate({win, forceInstall: true}); - return { success: true, isInstalled }; + + // Prevent concurrent installations + if (isInstallationInProgress) { + log.info('[DEPS INSTALL] Installation already in progress, waiting...'); + await installationLock; + return { success: true, message: 'Installation completed by another process' }; + } + + log.info('[DEPS INSTALL] Manual installation/retry triggered'); + + // Set lock + isInstallationInProgress = true; + installationLock = checkAndInstallDepsOnUpdate({win, forceInstall: true}) + .finally(() => { + isInstallationInProgress = false; + }); + + const result = await installationLock; + + if (!result.success) { + log.error('[DEPS INSTALL] Manual installation failed:', result.message); + // Note: Failure event already sent by installDependencies function + return { success: false, error: result.message }; + } + + log.info('[DEPS INSTALL] Manual installation succeeded'); + + // IMPORTANT: Send install-dependencies-complete success event + if (!win.isDestroyed()) { + win.webContents.send('install-dependencies-complete', { success: true, code: 0 }); + log.info('[DEPS INSTALL] Sent install-dependencies-complete event after retry'); + } + + // Start backend after retry with cleanup + await startBackendAfterInstall(); + + return { success: true, isInstalled: result.success }; } catch (error) { + log.error('[DEPS INSTALL] Manual installation error:', error); return { success: false, error: (error as Error).message }; } }); @@ -1052,6 +1102,22 @@ const ensureEigentDirectories = () => { log.info('.eigent directory structure ensured'); }; +// ==================== Shared backend startup logic ==================== +// Starts backend after installation completes +// Used by both initial startup and retry flows +const startBackendAfterInstall = async () => { + log.info('[DEPS INSTALL] Starting backend...'); + + // Add a small delay to ensure any previous processes are fully cleaned up + await new Promise(resolve => setTimeout(resolve, 500)); + + await checkAndStartBackend(); +}; + +// ==================== installation lock ==================== +let isInstallationInProgress = false; +let installationLock = Promise.resolve(); + // ==================== window create ==================== async function createWindow() { const isMac = process.platform === 'darwin'; @@ -1250,58 +1316,21 @@ async function createWindow() { }); }); } else { - // Installation is complete - ensure initState is set to 'done' - log.info('Installation already complete - ensuring initState is done'); - - win.webContents.once('dom-ready', () => { - if (!win || win.isDestroyed()) { - log.warn('Window destroyed before DOM ready - skipping localStorage update'); - return; - } - log.info('DOM ready - checking and updating auth-storage to done state'); - win.webContents.executeJavaScript(` - (function() { - try { - const authStorage = localStorage.getItem('auth-storage'); - console.log('[ELECTRON DEBUG] Current auth-storage:', authStorage); - if (authStorage) { - const parsed = JSON.parse(authStorage); - console.log('[ELECTRON DEBUG] Parsed state:', parsed.state); - if (parsed.state && parsed.state.initState !== 'done') { - console.log('[ELECTRON] Updating initState from', parsed.state.initState, 'to done'); - // Only update the initState field, preserve all other data - const updatedStorage = { - ...parsed, - state: { - ...parsed.state, - initState: 'done' - } - }; - localStorage.setItem('auth-storage', JSON.stringify(updatedStorage)); - console.log('[ELECTRON] initState updated to done, reloading page...'); - return true; // Signal that we need to reload - } else { - console.log('[ELECTRON DEBUG] initState already done or state missing'); - } - } else { - console.log('[ELECTRON DEBUG] No auth-storage found in localStorage'); - } - return false; // No reload needed - } catch (e) { - console.error('[ELECTRON] Failed to update initState:', e); - // Don't modify localStorage if there's an error to prevent data corruption - return false; - } - })(); - `).then(needsReload => { - if (needsReload && win && !win.isDestroyed()) { - log.info('Reloading window after localStorage update'); - win.reload(); - } - }).catch(err => { - log.error('Failed to inject script:', err); - }); - }); + // REMOVED: Previously this block would directly set initState='done' when installation + // was already complete, bypassing the backend readiness check. + // + // This caused a critical bug where: + // 1. Frontend would show immediately (initState='done') + // 2. Backend would still be starting (10-15 seconds) + // 3. Users could interact before backend was ready, causing connection errors + // + // The proper flow is now handled by useInstallationSetup.ts with dual-check mechanism: + // 1. Installation complete event → installationCompleted.current = true + // 2. Backend ready event → backendReady.current = true + // 3. Only when BOTH are true → setInitState('done') + // + // This ensures frontend never shows before backend is ready. + log.info('Installation already complete - letting useInstallationSetup handle state transitions'); } // Load content @@ -1329,13 +1358,26 @@ async function createWindow() { let res:PromiseReturnType = await checkAndInstallDepsOnUpdate({ win }); if (!res.success) { log.info("[DEPS INSTALL] Dependency Error: ", res.message); - win.webContents.send('install-dependencies-complete', { success: false, code: 2, error: res.message }); + // Note: install-dependencies-complete failure event is already sent by installDependencies function + // in install-deps.ts, so we don't send it again here to avoid duplicate events return; } log.info("[DEPS INSTALL] Dependency Success: ", res.message); + // IMPORTANT: Wait a bit to ensure React components have mounted and registered event listeners + // This prevents race condition where events are sent before listeners are ready + await new Promise(resolve => setTimeout(resolve, 500)); + + // IMPORTANT: Always send install-dependencies-complete event when installation check succeeds + // This includes both cases: actual installation completed AND installation was skipped (already installed) + // The frontend needs this event to properly transition from installation screen to main app + if (!win.isDestroyed()) { + win.webContents.send('install-dependencies-complete', { success: true, code: 0 }); + log.info("[DEPS INSTALL] Sent install-dependencies-complete event to frontend"); + } + // Start backend after dependencies are ready - await checkAndStartBackend(); + await startBackendAfterInstall(); } // ==================== window event listeners ==================== @@ -1393,48 +1435,74 @@ const setupExternalLinkHandling = () => { const checkAndStartBackend = async () => { log.info('Checking and starting backend service...'); try { + // Clean up any existing backend process before starting new one + if (python_process && !python_process.killed) { + log.info('Cleaning up existing backend process before restart...'); + await cleanupPythonProcess(); + python_process = null; + } + const isToolInstalled = await checkToolInstalled(); if (isToolInstalled.success) { log.info('Tool installed, starting backend service...'); - // Notify frontend installation success - if (win && !win.isDestroyed()) { - win.webContents.send('install-dependencies-complete', { success: true, code: 0 }); - } - + // Start backend and wait for health check to pass python_process = await startBackend((port) => { backendPort = port; log.info('Backend service started successfully', { port }); }); - python_process?.on('exit', (code, signal) => { + // Notify frontend that backend is ready + if (win && !win.isDestroyed()) { + log.info('Backend is ready, notifying frontend...'); + win.webContents.send('backend-ready', { + success: true, + port: backendPort + }); + } + python_process?.on('exit', (code, signal) => { log.info('Python process exited', { code, signal }); }); } else { log.warn('Tool not installed, cannot start backend service'); + // Notify frontend that backend cannot start + if (win && !win.isDestroyed()) { + win.webContents.send('backend-ready', { + success: false, + error: 'Tools not installed' + }); + } } } catch (error) { - log.debug("Cannot Start Backend due to ", error) + log.error("Failed to start backend:", error); + // Notify frontend of backend startup failure + if (win && !win.isDestroyed()) { + win.webContents.send('backend-ready', { + success: false, + error: String(error) + }); + } } }; // ==================== process cleanup ==================== const cleanupPythonProcess = async () => { try { - // First attempt: Try to kill using PID + // First attempt: Try to kill using PID and all children if (python_process?.pid) { const pid = python_process.pid; - log.info('Cleaning up Python process', { pid }); + log.info('Cleaning up Python process and all children', { pid }); // Remove all listeners to prevent memory leaks python_process.removeAllListeners(); await new Promise((resolve) => { + // Kill the entire process tree (parent + all children) kill(pid, 'SIGTERM', (err) => { if (err) { log.error('Failed to clean up process tree with SIGTERM:', err); - // Try SIGKILL as fallback + // Try SIGKILL as fallback for entire tree kill(pid, 'SIGKILL', (killErr) => { if (killErr) { log.error('Failed to force kill process tree:', killErr); @@ -1442,8 +1510,14 @@ const cleanupPythonProcess = async () => { resolve(); }); } else { - log.info('Successfully cleaned up Python process tree'); - resolve(); + log.info('Successfully sent SIGTERM to process tree'); + // Give processes 1 second to clean up, then SIGKILL + setTimeout(() => { + kill(pid, 'SIGKILL', () => { + log.info('Sent SIGKILL to ensure cleanup'); + resolve(); + }); + }, 1000); } }); }); @@ -1518,12 +1592,24 @@ app.whenReady().then(async () => { }); // ==================== protocol handle ==================== - protocol.handle('localfile', async (request) => { + // Register protocol handler for both default session and main window session + const protocolHandler = async (request: Request) => { const url = decodeURIComponent(request.url.replace('localfile://', '')); const filePath = path.normalize(url); + log.info(`[PROTOCOL] Handling localfile request: ${request.url}`); + log.info(`[PROTOCOL] Decoded path: ${filePath}`); + try { + // Check if file exists + const fileExists = await fsp.access(filePath).then(() => true).catch(() => false); + if (!fileExists) { + log.error(`[PROTOCOL] File not found: ${filePath}`); + return new Response('File Not Found', { status: 404 }); + } + const data = await fsp.readFile(filePath); + log.info(`[PROTOCOL] Successfully read file, size: ${data.length} bytes`); // set correct Content-Type according to file extension const ext = path.extname(filePath).toLowerCase(); @@ -1537,17 +1623,46 @@ app.whenReady().then(async () => { case '.htm': contentType = 'text/html'; break; + case '.png': + contentType = 'image/png'; + break; + case '.jpg': + case '.jpeg': + contentType = 'image/jpeg'; + break; + case '.gif': + contentType = 'image/gif'; + break; + case '.svg': + contentType = 'image/svg+xml'; + break; + case '.webp': + contentType = 'image/webp'; + break; } + log.info(`[PROTOCOL] Returning file with Content-Type: ${contentType}`); + return new Response(new Uint8Array(data), { headers: { 'Content-Type': contentType, + 'Content-Length': data.length.toString(), }, }); } catch (err) { - return new Response('Not Found', { status: 404 }); + log.error(`[PROTOCOL] Error reading file: ${err}`); + return new Response('Internal Server Error', { status: 500 }); } - }); + }; + + // Register on default session + protocol.handle('localfile', protocolHandler); + + // Also register on main window session + const mainSession = session.fromPartition('persist:main_window'); + mainSession.protocol.handle('localfile', protocolHandler); + + log.info('[PROTOCOL] Registered localfile protocol on both default and main_window sessions'); // ==================== initialize app ==================== initializeApp(); diff --git a/electron/main/init.ts b/electron/main/init.ts index b09db6595..b23c61c15 100644 --- a/electron/main/init.ts +++ b/electron/main/init.ts @@ -7,7 +7,7 @@ import * as net from "net"; import * as http from "http"; import { ipcMain, BrowserWindow, app } from 'electron' import { promisify } from 'util' -import { detectInstallationLogs, PromiseReturnType } from "./install-deps"; +import { PromiseReturnType } from "./install-deps"; const execAsync = promisify(exec); @@ -163,58 +163,130 @@ export async function startBackend(setPort?: (port: number) => void): Promise { if (!data) return; const msg = data.toString().trimEnd(); - //Detect if uv sync is run - detectInstallationLogs(msg); + + // REMOVED: detectInstallationLogs(msg) + // Reason: Removed keyword-based detection to avoid false positives when backend + // outputs logs containing keywords like "Installing", "Updating", "Syncing" etc. + // Installation is now only handled through the explicit installation flow. if (msg.toLowerCase().includes("error") || msg.toLowerCase().includes("traceback")) { log.error(`BACKEND: ${msg}`); } else if (msg.toLowerCase().includes("warn")) { - //Skip Warnings - // log.warn(`BACKEND: ${msg}`); + // Skip warnings } else if (msg.includes("DEBUG")) { log.debug(`BACKEND: ${msg}`); } else { - log.info(`BACKEND: ${msg}`); // treat uvicorn info logs as normal + log.info(`BACKEND: ${msg}`); } } - return new Promise((resolve, reject) => { - //Implicitly runs uv sync + return new Promise(async (resolve, reject) => { + log.info(`Spawning backend process: ${uv_path} run uvicorn main:api --port ${port} --loop asyncio`); + log.info(`Backend working directory: ${backendPath}`); + log.info(`Using venv: ${venvPath}`); + + try { + const { stdout: uvVersion } = await execAsync(`${uv_path} --version`); + log.info(`UV version check: ${uvVersion.trim()}`); + + const { stdout: pythonTest } = await execAsync( + `${uv_path} run python -c "print('Python OK')"`, + { cwd: backendPath, env: env } + ); + log.info(`Python test output: ${pythonTest.trim()}`); + } catch (testErr) { + log.error(`Pre-flight check failed: ${testErr}`); + reject(new Error(`Backend environment check failed: ${testErr}`)); + return; + } + const node_process = spawn( uv_path, ["run", "uvicorn", "main:api", "--port", port.toString(), "--loop", "asyncio"], - { cwd: backendPath, env: env, detached: false } + { + cwd: backendPath, + env: env, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'] + } ); + // NOTE: Do NOT use unref() - we need to maintain the process reference + // to properly capture stdout/stderr and manage the process lifecycle + + log.info(`Backend process spawned with PID: ${node_process.pid}`); + + setTimeout(() => { + if (node_process.killed) { + log.error('Backend process was killed immediately after spawn'); + } else if (!node_process.pid) { + log.error('Backend process has no PID'); + } else { + log.info(`Backend process still running after 1s with PID ${node_process.pid}`); + } + }, 1000); let started = false; let healthCheckInterval: NodeJS.Timeout | null = null; + const startTimeout = setTimeout(() => { if (!started) { if (healthCheckInterval) clearInterval(healthCheckInterval); - node_process.kill(); + killBackendProcess(node_process); reject(new Error('Backend failed to start within timeout')); } - }, 30000); // 30 second timeout + }, 65000); + + const initialDelay = setTimeout(() => { + if (!started) { + log.info('Starting backend health check polling...'); + pollHealthEndpoint(); + } + }, 2000); + + const killBackendProcess = (proc: any) => { + if (!proc || !proc.pid) return; + + log.info(`Killing backend process ${proc.pid} and its children...`); + try { + if (process.platform === 'win32') { + spawn('taskkill', ['/pid', proc.pid.toString(), '/T', '/F']); + } else { + try { + process.kill(-proc.pid, 'SIGTERM'); + setTimeout(() => { + try { + process.kill(-proc.pid, 'SIGKILL'); + } catch (e) {} + }, 1000); + } catch (e) { + log.error(`Failed to kill process group: ${e}`); + proc.kill('SIGKILL'); + } + } + } catch (e) { + log.error(`Failed to kill backend process: ${e}`); + } + }; - // Helper function to poll health endpoint const pollHealthEndpoint = (): void => { let attempts = 0; - const maxAttempts = 20; // 5 seconds total (20 * 250ms) + const maxAttempts = 240; const intervalMs = 250; healthCheckInterval = setInterval(() => { attempts++; const healthUrl = `http://127.0.0.1:${port}/health`; - + log.debug(`Health check attempt ${attempts}/${maxAttempts}: ${healthUrl}`); + const req = http.get(healthUrl, { timeout: 1000 }, (res) => { if (res.statusCode === 200) { log.info(`Backend health check passed after ${attempts} attempts`); @@ -229,7 +301,7 @@ export async function startBackend(setPort?: (port: number) => void): Promise void): Promise void): Promise void): Promise { + log.debug(`Backend stdout received ${data.length} bytes`); displayFilteredLogs(data); - // check output content, judge if start success - if (!started && data.toString().includes("Uvicorn running on")) { - log.info('Uvicorn startup detected, starting health check polling...'); - pollHealthEndpoint(); - } }); node_process.stderr.on('data', (data) => { + log.debug(`Backend stderr received ${data.length} bytes`); displayFilteredLogs(data); - if (!started && data.toString().includes("Uvicorn running on")) { - log.info('Uvicorn startup detected (stderr), starting health check polling...'); - pollHealthEndpoint(); - } - - // Check for port binding errors if (data.toString().includes("Address already in use") || data.toString().includes("bind() failed")) { - started = true; // Prevent multiple rejections - clearTimeout(startTimeout); - if (healthCheckInterval) clearInterval(healthCheckInterval); - node_process.kill(); - reject(new Error(`Port ${port} is already in use`)); + if (!started) { + started = true; + clearTimeout(startTimeout); + clearTimeout(initialDelay); + if (healthCheckInterval) clearInterval(healthCheckInterval); + killBackendProcess(node_process); + reject(new Error(`Port ${port} is already in use`)); + } } }); - node_process.on('close', (code) => { - clearTimeout(startTimeout); - if (healthCheckInterval) clearInterval(healthCheckInterval); + node_process.on('error', (err) => { + log.error(`Backend process error: ${err.message}`); if (!started) { - reject(new Error(`fastapi exited with code ${code}`)); + started = true; + clearTimeout(startTimeout); + clearTimeout(initialDelay); + if (healthCheckInterval) clearInterval(healthCheckInterval); + reject(new Error(`Failed to spawn backend process: ${err.message}`)); + } + }); + + node_process.on('close', async (code, signal) => { + log.info(`Backend process closed with code ${code}, signal ${signal}`); + clearTimeout(startTimeout); + clearTimeout(initialDelay); + if (healthCheckInterval) clearInterval(healthCheckInterval); + + if (!started) { + log.info(`Backend exited before ready, cleaning up port ${port}...`); + await killProcessOnPort(port); + reject(new Error(`Backend exited prematurely with code ${code}`)); } }); }); diff --git a/electron/main/install-deps.ts b/electron/main/install-deps.ts index 257fca38e..0daa5a33a 100644 --- a/electron/main/install-deps.ts +++ b/electron/main/install-deps.ts @@ -129,26 +129,43 @@ export async function installCommandTool(): Promise { } console.log(`start install ${toolName}`); - await runInstallScript(scriptName); - const installed = await isBinaryExists(toolName); + try { + await runInstallScript(scriptName); + const installed = await isBinaryExists(toolName); - if (installed) { - safeMainWindowSend('install-dependencies-log', { - type: 'stdout', - data: `${toolName} installed successfully`, - }); - } else { + if (installed) { + safeMainWindowSend('install-dependencies-log', { + type: 'stdout', + data: `${toolName} installed successfully`, + }); + return { + message: `${toolName} installed successfully`, + success: true + }; + } else { + const errorMsg = `${toolName} installation failed: binary not found after installation`; + safeMainWindowSend('install-dependencies-complete', { + success: false, + code: 2, + error: errorMsg, + }); + return { + message: errorMsg, + success: false + }; + } + } catch (scriptError) { + const errorMsg = `${toolName} installation failed: ${scriptError instanceof Error ? scriptError.message : String(scriptError)}`; safeMainWindowSend('install-dependencies-complete', { success: false, code: 2, - error: `${toolName} installation failed (script exit code 2)`, + error: errorMsg, }); + return { + message: errorMsg, + success: false + }; } - - return { - message: installed ? `${toolName} installed successfully` : `${toolName} installation failed`, - success: installed - }; }; const uvResult = await ensureInstalled('uv', 'install-uv.js'); @@ -163,7 +180,14 @@ export async function installCommandTool(): Promise { return { message: "Command tools installed successfully", success: true }; } catch (error) { - return { message: `Command tool installation failed: ${error}`, success: false }; + const errorMessage = `Command tool installation failed: ${error}`; + log.error('[DEPS INSTALL] Exception during command tool installation:', error); + safeMainWindowSend('install-dependencies-complete', { + success: false, + code: 2, + error: errorMessage + }); + return { message: errorMessage, success: false }; } } @@ -599,6 +623,12 @@ export async function installDependencies(version: string): Promise - msg.includes(pattern) && !msg.includes("Uvicorn running on") - )) { - dependencyInstallationDetected = true; - log.info('[BACKEND STARTUP] UV dependency installation detected during uvicorn startup'); - - // Create installing lock file to maintain consistency with install-deps.ts - InstallLogs.setLockPath(); - log.info('[BACKEND STARTUP] Created uv_installing.lock file'); - - // Notify frontend that installation has started (only once) - if (!installationNotificationSent) { - installationNotificationSent = true; - const notificationSent = safeMainWindowSend('install-dependencies-start'); - if (notificationSent) { - log.info('[BACKEND STARTUP] Notified frontend of dependency installation start'); - } else { - log.warn('[BACKEND STARTUP] Failed to notify frontend of dependency installation start'); - } - } - } - - // Send installation logs to frontend if installation was detected - if (dependencyInstallationDetected && !msg.includes("Uvicorn running on")) { - safeMainWindowSend('install-dependencies-log', { - type: msg.toLowerCase().includes("error") || msg.toLowerCase().includes("traceback") ? 'stderr' : 'stdout', - data: msg - }); - } - - // Check if installation is complete (uvicorn starts successfully) - if (dependencyInstallationDetected && msg.includes("Uvicorn running on")) { - log.info('[BACKEND STARTUP] UV dependency installation completed, uvicorn started successfully'); - - // Clean up installing lock and create installed lock - InstallLogs.cleanLockPath(); - fs.writeFileSync(installedLockPath, ''); - log.info('[BACKEND STARTUP] Created uv_installed.lock file'); - - safeMainWindowSend('install-dependencies-complete', { - success: true, - message: 'Dependencies installed successfully during backend startup' - }); - } - - // Handle installation failures - if (dependencyInstallationDetected && ( - msg.toLowerCase().includes("failed to resolve dependencies") || - msg.toLowerCase().includes("installation failed") || - msg.includes("× No solution found when resolving dependencies") - )) { - log.error('[BACKEND STARTUP] UV dependency installation failed'); - - // Clean up installing lock file - InstallLogs.cleanLockPath(); - log.info('[BACKEND STARTUP] Cleaned up uv_installing.lock file after failure'); - - safeMainWindowSend('install-dependencies-complete', { - success: false, - error: 'Dependency installation failed during backend startup' - }); - } } \ No newline at end of file diff --git a/electron/main/update.ts b/electron/main/update.ts index ab7a45ac0..a5fc726e6 100644 --- a/electron/main/update.ts +++ b/electron/main/update.ts @@ -49,9 +49,17 @@ export function update(win: Electron.BrowserWindow) { autoUpdater.setFeedURL(feed) if (!app.isPackaged) { console.log('[DEV] setFeedURL:', feed) - autoUpdater.checkForUpdates() + // In development, check for updates but don't fail if it errors + autoUpdater.checkForUpdates().catch(err => { + console.log('[DEV] Update check failed (expected in dev environment):', err.message) + }) } + // Handle errors globally to prevent crashes + autoUpdater.on('error', (error: Error) => { + console.error('[AutoUpdater] Update error:', error.message) + // Don't crash the app on update errors + }) } /** diff --git a/electron/main/utils/process.ts b/electron/main/utils/process.ts index 232860a8e..66f502d7f 100644 --- a/electron/main/utils/process.ts +++ b/electron/main/utils/process.ts @@ -29,12 +29,16 @@ export function runInstallScript(scriptPath: string): Promise { env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } }) + let stderrOutput = ''; + nodeProcess.stdout.on('data', (data) => { log.info(`Script output: ${data}`) }) nodeProcess.stderr.on('data', (data) => { - log.error(`Script error: ${data}`) + const errorMsg = data.toString(); + stderrOutput += errorMsg; + log.error(`Script error: ${errorMsg}`) }) nodeProcess.on('close', (code) => { @@ -43,7 +47,8 @@ export function runInstallScript(scriptPath: string): Promise { resolve(true) } else { log.error(`Script exited with code ${code}`) - reject(false) + const errorMessage = stderrOutput.trim() || `Script exited with code ${code}`; + reject(new Error(errorMessage)) } }) }) diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 8df2906d8..7ef37abf1 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -68,6 +68,7 @@ contextBridge.exposeInMainWorld('electronAPI', { checkAndInstallDepsOnUpdate: () => ipcRenderer.invoke('install-dependencies'), checkInstallBrowser: () => ipcRenderer.invoke('check-install-browser'), getInstallationStatus: () => ipcRenderer.invoke('get-installation-status'), + restartBackend: () => ipcRenderer.invoke('restart-backend'), onInstallDependenciesStart: (callback: () => void) => { ipcRenderer.on('install-dependencies-start', callback); }, @@ -77,14 +78,17 @@ contextBridge.exposeInMainWorld('electronAPI', { onInstallDependenciesComplete: (callback: (data: { success: boolean, code?: number, error?: string }) => void) => { ipcRenderer.on('install-dependencies-complete', (event, data) => callback(data)); }, - onUpdateNotification: (callback: (data: { - type: string; - currentVersion: string; - previousVersion: string; - reason: string; + onUpdateNotification: (callback: (data: { + type: string; + currentVersion: string; + previousVersion: string; + reason: string; }) => void) => { ipcRenderer.on('update-notification', (event, data) => callback(data)); }, + onBackendReady: (callback: (data: { success: boolean, port?: number, error?: string }) => void) => { + ipcRenderer.on('backend-ready', (event, data) => callback(data)); + }, startBrowserImport: (args?: any) => ipcRenderer.invoke('start-browser-import', args), // remove listeners removeAllListeners: (channel: string) => { diff --git a/index.html b/index.html index 9c2d1457f..5c3481fab 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ Eigent diff --git a/package.json b/package.json index b70ec9258..f84e7612f 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "scripts": { "compile-babel": "cd backend && uv run pybabel compile -d lang", "clean-cache": "rimraf node_modules/.vite", - "dev": "npm run clean-cache && npm run compile-babel && vite", + "dev": "npm run clean-cache && vite", "build": "npm run compile-babel && tsc && vite build && electron-builder -- --publish always", "build:mac": "npm run compile-babel && tsc && vite build && electron-builder --mac", "build:win": "npm run compile-babel && tsc && vite build && electron-builder --win", diff --git a/resources/scripts/download.js b/resources/scripts/download.js index 0948791af..aeb6993ad 100644 --- a/resources/scripts/download.js +++ b/resources/scripts/download.js @@ -15,6 +15,25 @@ export async function downloadWithRedirects(url, destinationPath) { reject(new Error(`timeout(${timeoutMs / 1000} seconds)`)); }, timeoutMs); + // Use flag to prevent multiple resolve/reject calls + let settled = false; + + const safeReject = (error) => { + if (!settled) { + settled = true; + clearTimeout(timeout); + reject(error); + } + }; + + const safeResolve = () => { + if (!settled) { + settled = true; + clearTimeout(timeout); + resolve(); + } + }; + const request = (url) => { https .get(url, (response) => { @@ -23,58 +42,72 @@ export async function downloadWithRedirects(url, destinationPath) { return } if (response.statusCode !== 200) { - clearTimeout(timeout); - reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`)) + safeReject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`)) return } - + const file = fs.createWriteStream(destinationPath) let downloadedBytes = 0 const expectedBytes = parseInt(response.headers['content-length'] || '0') - + response.on('data', (chunk) => { downloadedBytes += chunk.length }) - + response.pipe(file) - + file.on('finish', () => { file.close(() => { - clearTimeout(timeout); - + // Don't proceed if already rejected (e.g., by error handler) + if (settled) return; + // Verify the download is complete if (expectedBytes > 0 && downloadedBytes !== expectedBytes) { - fs.unlinkSync(destinationPath) - reject(new Error(`Download incomplete: received ${downloadedBytes} bytes, expected ${expectedBytes}`)) + try { + if (fs.existsSync(destinationPath)) { + fs.unlinkSync(destinationPath) + } + } catch (err) { + console.error('Failed to delete incomplete file:', err); + } + safeReject(new Error(`Download incomplete: received ${downloadedBytes} bytes, expected ${expectedBytes}`)) return } - + // Check if file exists and has size > 0 try { - const stats = fs.statSync(destinationPath) - if (stats.size === 0) { - fs.unlinkSync(destinationPath) - reject(new Error('Downloaded file is empty')) - return + + if (fs.existsSync(destinationPath)) { + const stats = fs.statSync(destinationPath) + if (stats.size === 0) { + fs.unlinkSync(destinationPath) + safeReject(new Error('Downloaded file is empty')) + return + } + safeResolve() + } else { + safeReject(new Error('Downloaded file does not exist')) } - } catch (statError) { - reject(new Error(`Failed to check downloaded file: ${statError.message}`)) - return + } catch (err) { + safeReject(new Error(`Failed to verify download: ${err.message}`)) + } - - resolve() }) }) - + file.on('error', (err) => { - clearTimeout(timeout); - fs.unlinkSync(destinationPath) - reject(err) + try { + if (fs.existsSync(destinationPath)) { + fs.unlinkSync(destinationPath) + } + } catch (deleteErr) { + console.error('Failed to delete file after error:', deleteErr); + } + safeReject(err) }) }) .on('error', (err) => { - clearTimeout(timeout); - reject(err) + safeReject(err) }) } request(url) diff --git a/resources/scripts/install-bun.js b/resources/scripts/install-bun.js index 0b3df6d38..fa1ffb004 100644 --- a/resources/scripts/install-bun.js +++ b/resources/scripts/install-bun.js @@ -169,10 +169,7 @@ async function installBun() { const isInstalled = await downloadBunBinary(BUN_RELEASE_BASE_URL,platform, arch, version, isMusl, isBaseline) if(!isInstalled){ - // Wait for the file lock handle to be released - await new Promise(r => setTimeout(r, 200)) - console.log('Downloading bun from gitcode.com') - await downloadBunBinary('https://gitcode.com/CherryHQ/bun/releases/download',platform, arch, version, isMusl, isBaseline) + throw new Error(`Failed to download bun ${version} from default source`) } } diff --git a/resources/scripts/install-uv.js b/resources/scripts/install-uv.js index 9a8a1dcdf..057c28984 100644 --- a/resources/scripts/install-uv.js +++ b/resources/scripts/install-uv.js @@ -189,17 +189,7 @@ async function installUv() { isMusl ); if (!isInstalled) { - // Wait for the file lock handle to be released - await new Promise(r => setTimeout(r, 200)) - console.log("Downloading uv from gitcode.com"); - isInstalled = await downloadUvBinary( - "https://gitcode.com/CherryHQ/uv/releases/download", - platform, - arch, - version, - isMusl - ); - console.log("Downloading uv from gitcode.com ####", isInstalled); + throw new Error(`Failed to download uv ${version} from default source`); } } diff --git a/src/api/http.ts b/src/api/http.ts index 1a60855ad..6187a266f 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -2,6 +2,7 @@ import { getAuthStore } from '@/store/authStore' import { showCreditsToast } from '@/components/Toast/creditsToast'; import { showStorageToast } from '@/components/Toast/storageToast'; import { showTrafficToast } from '@/components/Toast/trafficToast'; + const defaultHeaders = { 'Content-Type': 'application/json', } @@ -250,3 +251,57 @@ export async function uploadFile(url: string, formData: FormData, headers?: Reco return handleResponse(fetch(fullUrl, options)) } + +// =============== Backend Health Check =============== + +/** + * Check if backend is ready by checking the health endpoint + * @returns Promise - true if backend is ready, false otherwise + */ +export async function checkBackendHealth(): Promise { + try { + const baseURL = await getBaseURL(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1000); + + const res = await fetch(`${baseURL}/health`, { + signal: controller.signal, + method: 'GET', + }); + + clearTimeout(timeoutId); + return res.ok; + } catch (error) { + console.log('[Backend Health Check] Not ready:', error); + return false; + } +} + +/** + * Simple backend health check with retries + * @param maxWaitMs - Maximum time to wait in milliseconds (default: 10000ms) + * @param retryIntervalMs - Interval between retries in milliseconds (default: 500ms) + * @returns Promise - true if backend becomes ready, false if timeout + */ +export async function waitForBackendReady( + maxWaitMs: number = 10000, + retryIntervalMs: number = 500 +): Promise { + const startTime = Date.now(); + console.log('[Backend Health Check] Waiting for backend to be ready...'); + + while (Date.now() - startTime < maxWaitMs) { + const isReady = await checkBackendHealth(); + + if (isReady) { + console.log(`[Backend Health Check] Backend is ready after ${Date.now() - startTime}ms`); + return true; + } + + console.log(`[Backend Health Check] Backend not ready, retrying... (${Date.now() - startTime}ms elapsed)`); + await new Promise(resolve => setTimeout(resolve, retryIntervalMs)); + } + + console.error(`[Backend Health Check] Backend failed to start within ${maxWaitMs}ms`); + return false; +} diff --git a/src/components/AddWorker/ToolSelect.tsx b/src/components/AddWorker/ToolSelect.tsx index 0b29ebd66..92922f92e 100644 --- a/src/components/AddWorker/ToolSelect.tsx +++ b/src/components/AddWorker/ToolSelect.tsx @@ -56,7 +56,7 @@ const ToolSelect = forwardRef< const [integrations, setIntegrations] = useState([]); const fetchIntegrationsData = (keyword?: string) => { proxyFetchGet("/api/config/info").then((res) => { - if (res && typeof res === "object") { + if (res && typeof res === "object" && !res.error) { const baseURL = getProxyBaseURL(); const list = Object.entries(res) @@ -187,7 +187,13 @@ const ToolSelect = forwardRef< }; }); setIntegrations(list); + } else { + console.error("Failed to fetch integrations:", res); + setIntegrations([]); } + }).catch((error) => { + console.error("Error fetching integrations:", error); + setIntegrations([]); }); }; @@ -217,7 +223,16 @@ const ToolSelect = forwardRef< page: 1, size: 100, }).then((res) => { - setAllMcpList(res.items); + // Add defensive check for API errors + if (res && res.items && Array.isArray(res.items)) { + setAllMcpList(res.items); + } else { + console.error("Failed to fetch MCPs:", res); + setAllMcpList([]); + } + }).catch((error) => { + console.error("Error fetching MCPs:", error); + setAllMcpList([]); }); }; @@ -228,7 +243,7 @@ const ToolSelect = forwardRef< if (Array.isArray(res)) { ids = res.map((item: any) => item.mcp_id); dataList = res; - } else if (Array.isArray(res.items)) { + } else if (res && Array.isArray(res.items)) { ids = res.items.map((item: any) => item.mcp_id); dataList = res.items; } @@ -236,14 +251,22 @@ const ToolSelect = forwardRef< const customMcpList = dataList.filter((item: any) => item.mcp_id === 0); setCustomMcpList(customMcpList); + }).catch((error) => { + console.error("Error fetching installed MCPs:", error); + setInstalledIds([]); + setCustomMcpList([]); }); }; // only surface installed MCPs from the market list useEffect(() => { - if (!installedIds.length) { + // Add defensive check and fix logic: should filter when installedIds has items + if (Array.isArray(allMcpList) && installedIds.length > 0) { const filtered = allMcpList.filter((item) => installedIds.includes(item.id)); setMcpList(filtered); + } else if (Array.isArray(allMcpList)) { + // If no installed IDs, show empty list instead of all + setMcpList([]); } }, [allMcpList, installedIds]); diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index 41c66f352..78cec6aa9 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -444,9 +444,17 @@ export default function ChatBox(): JSX.Element { }; // Edit query handler - const handleEditQuery = () => { + const handleEditQuery = async () => { const taskId = chatStore.activeTaskId as string; - fetchDelete(`/chat/${taskId}`); + const projectId = projectStore.activeProjectId; + + // Early validation + if (!projectId) { + console.error("No active project ID found for edit operation"); + return; + } + + // Get question and attachments before any deletions const messageIndex = chatStore.tasks[taskId].messages.findLastIndex( (item) => item.step === "to_sub_tasks" ); @@ -454,6 +462,28 @@ export default function ChatBox(): JSX.Element { const question = questionMessage.content; // Get the file attachments from the original user message (not from task.attaches which gets cleared after sending) const attachments = questionMessage.attaches || []; + + // Delete task from backend first + try { + await fetchDelete(`/chat/${taskId}`); + } catch (error) { + console.error("Failed to delete task from backend:", error); + // Continue with local cleanup even if backend fails + } + + // Delete chat history + const history_id = projectStore.getHistoryId(projectId); + if (history_id) { + try { + await proxyFetchDelete(`/api/chat/history/${history_id}`); + } catch(error) { + console.error(`Failed to delete chat history (ID: ${history_id}) for project ${projectId}:`, error); + } + } else { + console.warn(`No history ID found for project ${projectId} during edit operation`); + } + + // Create new task and clean up locally let id = chatStore.create(); chatStore.setHasMessages(id, true); // Copy the file attachments to the new task @@ -461,7 +491,6 @@ export default function ChatBox(): JSX.Element { chatStore.setAttaches(id, attachments); } chatStore.removeTask(taskId); - proxyFetchDelete(`/api/chat/history/${taskId}`); setMessage(question); }; diff --git a/src/components/Folder/index.tsx b/src/components/Folder/index.tsx index 7834bfc4e..b12816221 100644 --- a/src/components/Folder/index.tsx +++ b/src/components/Folder/index.tsx @@ -183,7 +183,23 @@ export default function Folder({ data }: { data?: Agent }) { setLoading(true); console.log("file", JSON.parse(JSON.stringify(file))); - // all files call open-file interface, the backend handles download and parsing + // For PDF files, use data URL instead of custom protocol + if (file.type === "pdf") { + window.ipcRenderer + .invoke("read-file-dataurl", file.path) + .then((dataUrl: string) => { + setSelectedFile({ ...file, content: dataUrl }); + chatStore.setSelectedFile(chatStore.activeTaskId as string, file); + setLoading(false); + }) + .catch((error) => { + console.error("read-file-dataurl error:", error); + setLoading(false); + }); + return; + } + + // all other files call open-file interface, the backend handles download and parsing window.ipcRenderer .invoke("open-file", file.type, file.path, isShowSourceCode) .then((res) => { @@ -539,10 +555,7 @@ export default function Folder({ data }: { data?: Agent }) { ) : selectedFile.type === "pdf" ? (