Merge branch 'hide_github_update_toast' of https://github.com/eigent-ai/eigent into hide_github_update_toast

This commit is contained in:
Sun Tao 2025-11-21 02:25:11 +08:00
commit 8b4b4820a2
30 changed files with 800 additions and 466 deletions

View file

@ -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

View file

@ -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<void>((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();

View file

@ -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<an
...process.env,
SERVER_URL: "https://dev.eigent.ai/api",
PYTHONIOENCODING: 'utf-8',
PYTHONUNBUFFERED: '1',
UV_PROJECT_ENVIRONMENT: venvPath,
npm_config_cache: npmCacheDir,
}
//Redirect output
const displayFilteredLogs = (data: String) => {
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<an
started = true;
clearTimeout(startTimeout);
if (healthCheckInterval) clearInterval(healthCheckInterval);
node_process.kill();
killBackendProcess(node_process);
reject(new Error(`Backend health check failed: HTTP ${res.statusCode}`));
}
}
@ -242,7 +314,7 @@ export async function startBackend(setPort?: (port: number) => void): Promise<an
started = true;
clearTimeout(startTimeout);
if (healthCheckInterval) clearInterval(healthCheckInterval);
node_process.kill();
killBackendProcess(node_process);
reject(new Error('Backend health check failed: unable to connect'));
}
});
@ -254,7 +326,7 @@ export async function startBackend(setPort?: (port: number) => void): Promise<an
started = true;
clearTimeout(startTimeout);
if (healthCheckInterval) clearInterval(healthCheckInterval);
node_process.kill();
killBackendProcess(node_process);
reject(new Error('Backend health check timed out'));
}
});
@ -262,38 +334,48 @@ export async function startBackend(setPort?: (port: number) => void): Promise<an
};
node_process.stdout.on('data', (data) => {
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}`));
}
});
});

View file

@ -129,26 +129,43 @@ export async function installCommandTool(): Promise<PromiseReturnType> {
}
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<PromiseReturnType> {
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<PromiseRetur
const isInstalCommandTool = await installCommandTool()
if (!isInstalCommandTool.success) {
log.error('[DEPS INSTALL] Command tool installation failed:', isInstalCommandTool.message);
safeMainWindowSend('install-dependencies-complete', {
success: false,
code: 2,
error: isInstalCommandTool.message || 'Command tool installation failed'
});
resolve({ message: "Command tool installation failed", success: false })
return
}
@ -677,104 +707,4 @@ export async function installDependencies(version: string): Promise<PromiseRetur
resolve({ message: "Both default and mirror install failed", success: false })
}
})
}
let dependencyInstallationDetected = false;
let installationNotificationSent = false;
export function detectInstallationLogs(msg:string) {
// CRITICAL FIX: Use file system to check if installation is complete
// Don't rely on module variables as they can be reset during hot reload
// Check if dependencies are already installed
const isAlreadyInstalled = fs.existsSync(installedLockPath);
// If installed lock file exists, dependencies are already installed
// Skip all detection to avoid false positives
if (isAlreadyInstalled) {
// Dependencies are already installed, skip detection entirely
return;
}
// Also skip if notification was already sent (in current session)
if (installationNotificationSent) {
return;
}
// Check for UV dependency installation patterns
const installPatterns = [
"Resolved", // UV resolving dependencies
"Downloaded", // UV downloading packages
"Installing", // UV installing packages
"Built", // UV building packages
"Prepared", // UV preparing virtual environment
"Syncing", // UV sync process
"Creating virtualenv", // Virtual environment creation
"Updating", // UV updating packages
"× No solution found when resolving dependencies", // Dependency resolution issues
"Audited" // UV auditing dependencies
];
// Detect if UV is installing dependencies
if (!dependencyInstallationDetected && installPatterns.some(pattern =>
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'
});
}
}

View file

@ -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
})
}
/**

View file

@ -29,12 +29,16 @@ export function runInstallScript(scriptPath: string): Promise<boolean> {
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<boolean> {
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))
}
})
})

View file

@ -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) => {

View file

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.amplitude.com; worker-src 'self' blob:; child-src 'self' blob:;frame-src 'self' localfile: blob:;"
content="script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.amplitude.com; worker-src 'self' blob:; child-src 'self' blob:;frame-src 'self' localfile: blob: data:;"
/>
<script src="https://cdn.amplitude.com/libs/analytics-browser-2.11.1-min.js.gz"></script><script src="https://cdn.amplitude.com/libs/plugin-session-replay-browser-1.8.0-min.js.gz"></script><script>window.amplitude.add(window.sessionReplay.plugin({sampleRate: 1}));window.amplitude.init('87ce6adbb14b24ffe1703d18bf405e40', {"autocapture":{"elementInteractions":true}});</script>
<title>Eigent</title>

View file

@ -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",

View file

@ -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)

View file

@ -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`)
}
}

View file

@ -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`);
}
}

View file

@ -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<boolean> - true if backend is ready, false otherwise
*/
export async function checkBackendHealth(): Promise<boolean> {
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<boolean> - true if backend becomes ready, false if timeout
*/
export async function waitForBackendReady(
maxWaitMs: number = 10000,
retryIntervalMs: number = 500
): Promise<boolean> {
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;
}

View file

@ -56,7 +56,7 @@ const ToolSelect = forwardRef<
const [integrations, setIntegrations] = useState<any[]>([]);
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]);

View file

@ -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);
};

View file

@ -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 }) {
</div>
) : selectedFile.type === "pdf" ? (
<iframe
src={
"localfile://" +
encodeURIComponent(selectedFile.content as string)
}
src={selectedFile.content as string}
className="w-full h-full border-0"
title={selectedFile.name}
/>

View file

@ -335,10 +335,12 @@ export default function ProjectGroup({
<span>{project.total_tokens ? project.total_tokens.toLocaleString() : "0"}</span>
</Tag>
<Tag variant="default" size="sm" className="min-w-10">
<Pin />
<span>{project.task_count}</span>
</Tag>
<TooltipSimple content={t("layout.tasks")}>
<Tag variant="default" size="sm" className="min-w-10">
<Pin />
<span>{project.task_count}</span>
</Tag>
</TooltipSimple>
</div>
{/* End: Status and menu */}
@ -401,4 +403,4 @@ export default function ProjectGroup({
/>
</div>
);
}
}

View file

@ -88,7 +88,9 @@ export default function TaskItem({
`}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Pin className="w-4 h-4 text-icon-primary" />
<TooltipSimple content={t("layout.tasks")}>
<Pin className="w-4 h-4 text-icon-primary" />
</TooltipSimple>
<div className="flex flex-col gap-1 flex-1 min-w-0">
<TooltipSimple
@ -206,4 +208,4 @@ export default function TaskItem({
</div>
</div>
);
}
}

View file

@ -19,6 +19,7 @@ export const CarouselStep: React.FC = () => {
const [currentSlide, setCurrentSlide] = useState(0);
const [isHovered, setIsHovered] = useState(false);
const [api, setApi] = useState<any>(null);
const [isDismissed, setIsDismissed] = useState(false);
const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
// listen to carousel change
useEffect(() => {
@ -93,6 +94,14 @@ export const CarouselStep: React.FC = () => {
}
}
}, [currentSlide, api]);
// If carousel is dismissed, don't show anything
// The actual transition to 'done' will be handled by useInstallationSetup
// when both installation and backend are ready
if (isDismissed) {
return null;
}
return (
<div className="flex flex-col gap-lg w-[1120px] max-lg:w-[100%]">
<div className="flex flex-col gap-md ">
@ -136,7 +145,7 @@ export const CarouselStep: React.FC = () => {
</CarouselContent>
</Carousel>
</div>
<div className="flex justify-between items-center gap-sm">
<div className="flex justify-center items-center gap-sm">
<div className="flex justify-center items-center gap-6">
{carouselItems.map((item, index) => (
<div
@ -150,29 +159,6 @@ export const CarouselStep: React.FC = () => {
></div>
))}
</div>
<div className="flex justify-center items-center gap-sm">
<Button
onClick={() => setInitState("done")}
variant="ghost"
size="sm"
>
skip
</Button>
<Button
onClick={() => {
if (currentSlide < carouselItems.length - 1) {
api?.scrollNext(); // not last page, switch to next page
} else {
setInitState("done"); // last page, execute done logic
}
}}
variant="primary"
size="sm"
>
<div>Next</div>
<ArrowRight size={24} className="text-white-100%" />
</Button>
</div>
</div>
</div>
);

View file

@ -1,33 +1,18 @@
import React from "react";
import { useAuthStore } from "@/store/authStore";
import { ProgressInstall } from "@/components/ui/progress-install";
import { FileDown, RefreshCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Permissions } from "@/components/InstallStep/Permissions";
import { CarouselStep } from "@/components/InstallStep/Carousel";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { useTranslation } from "react-i18next";
import { useInstallationUI } from "@/store/installationStore";
import { TooltipSimple } from "../ui/tooltip";
export const InstallDependencies: React.FC = () => {
const { initState } = useAuthStore();
const {t} = useTranslation();
const {
progress,
latestLog,
error,
isInstalling,
retryInstallation,
exportLog,
installationState,
} = useInstallationUI();
return (
@ -37,54 +22,22 @@ export const InstallDependencies: React.FC = () => {
{/* {isInstalling.toString()} */}
<div>
<ProgressInstall
value={isInstalling ? progress : 100}
value={isInstalling || installationState === 'waiting-backend' ? progress : 100}
className="w-full"
/>
<div className="flex items-center gap-2 justify-between">
<div className="text-text-label text-xs font-normal leading-tight ">
{isInstalling ? "System Installing ..." : ""}
{isInstalling ? "System Installing ..." : installationState === 'waiting-backend' ? "Starting backend service..." : ""}
<span className="pl-2">{latestLog?.data}</span>
</div>
<TooltipSimple content={`Cannot retry because state is ${error}`} hidden={true}>
<Button
size="icon"
variant="outline"
className="mt-1"
onClick={retryInstallation}
>
<RefreshCcw className="w-4 h-4" />
</Button>
</TooltipSimple>
</div>
</div>
</div>
<div>
{initState === "permissions" && <Permissions />}
{initState === "carousel" && <CarouselStep />}
{initState === "carousel" && installationState !== 'waiting-backend' && <CarouselStep />}
</div>
</div>
{/* error dialog */}
<Dialog open={status === "error"}>
<DialogContent className="bg-white-100%">
<DialogHeader>
<DialogTitle>{t("layout.installation-failed")}</DialogTitle>
</DialogHeader>
<DialogFooter>
<Button
onClick={exportLog}
variant="outline"
size="xs"
className="mr-2 no-drag leading-tight"
>
<FileDown className="w-4 h-4" />
{t("layout.report-bug")}
</Button>
<Button size="sm" onClick={retryInstallation}>
{t("layout.retry")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

View file

@ -12,17 +12,43 @@ import React from "react";
interface InstallationErrorDialogProps {
error: string;
backendError?: string;
installationState: string;
latestLog: any;
retryInstallation: () => void;
retryBackend?: () => void;
}
const InstallationErrorDialog = ({
error,
backendError,
installationState,
latestLog,
retryInstallation,
retryBackend,
}:InstallationErrorDialogProps) => {
if (backendError) {
return (
<Dialog open={true}>
<DialogContent className="bg-white-100%">
<DialogHeader>
<DialogTitle>{t("layout.backend-startup-failed")}</DialogTitle>
</DialogHeader>
<div className="text-text-label text-xs font-normal leading-tight mb-4">
<div className="mb-1">
<span className="text-text-label/60">
{backendError}
</span>
</div>
</div>
<DialogFooter>
<Button onClick={retryBackend}>{t("layout.retry")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={installationState == "error"}>
<DialogContent className="bg-white-100%">
@ -30,14 +56,11 @@ const InstallationErrorDialog = ({
<DialogTitle>{t("layout.installation-failed")}</DialogTitle>
</DialogHeader>
<div className="text-text-label text-xs font-normal leading-tight mb-4">
{
<div className="mb-1">
<span className="text-text-label/60">
Error: {error} <br />
Log: {latestLog?.data}
</span>
</div>
}
<div className="mb-1">
<span className="text-text-label/60">
{error}
</span>
</div>
</div>
<DialogFooter>
<Button onClick={retryInstallation}>{t("layout.retry")}</Button>

View file

@ -16,11 +16,12 @@ import useChatStoreAdapter from "@/hooks/useChatStoreAdapter";
const Layout = () => {
const { initState, isFirstLaunch, setIsFirstLaunch, setInitState } = useAuthStore();
const [noticeOpen, setNoticeOpen] = useState(false);
//Get Chatstore for the active project's task
const { chatStore } = useChatStoreAdapter();
if (!chatStore) {
if (!chatStore) {
console.log(chatStore);
return <div>Loading...</div>;
}
@ -28,33 +29,15 @@ const Layout = () => {
installationState,
latestLog,
error,
backendError,
isInstalling,
shouldShowInstallScreen,
retryInstallation,
retryBackend,
} = useInstallationUI();
// Setup installation IPC listeners and state synchronization
useInstallationSetup();
// Additional check: If initState is carousel but tools are installed, skip to done
useEffect(() => {
const checkAndSkipCarousel = async () => {
if (initState === 'carousel' && !isInstalling) {
try {
const result = await window.ipcRenderer.invoke("check-tool-installed");
if (result.success && result.isInstalled) {
console.log('[Layout] Tools installed, skipping carousel and setting initState to done');
setInitState('done');
}
} catch (error) {
console.error('[Layout] Failed to check tool installation:', error);
}
}
};
checkAndSkipCarousel();
}, [initState, isInstalling, setInitState]);
useEffect(() => {
const handleBeforeClose = () => {
const currentStatus = chatStore.tasks[chatStore.activeTaskId as string]?.status;
@ -74,10 +57,8 @@ const Layout = () => {
// Determine what to show based on states
const shouldShowOnboarding = initState === "done" && isFirstLaunch && !isInstalling;
// Show install screen if either:
// 1. The installation store says to show it (isVisible && not completed)
// 2. OR if initState is not 'done' (meaning permissions or carousel should show)
const actualShouldShowInstallScreen = shouldShowInstallScreen || initState !== 'done';
const actualShouldShowInstallScreen = shouldShowInstallScreen || initState !== 'done' || installationState === 'waiting-backend';
const shouldShowMainContent = !actualShouldShowInstallScreen;
return (
@ -103,13 +84,16 @@ const Layout = () => {
</>
)}
{(error != "" && error !=undefined) &&
<InstallationErrorDialog
error={error}
installationState={installationState}
latestLog={latestLog}
retryInstallation={retryInstallation}/>
}
{(backendError || (error && installationState === "error")) && (
<InstallationErrorDialog
error={error || ""}
backendError={backendError}
installationState={installationState}
latestLog={latestLog}
retryInstallation={retryInstallation}
retryBackend={retryBackend}
/>
)}
<CloseNoticeDialog
onOpenChange={setNoticeOpen}

View file

@ -9,19 +9,18 @@ import { useAuthStore } from '@/store/authStore';
export const useInstallationSetup = () => {
const { initState, setInitState } = useAuthStore();
// Use ref to track if initial check is done to prevent repeated checks
const hasCheckedOnMount = useRef(false);
// Extract only the functions we need to avoid dependency issues
const installationCompleted = useRef(false);
const backendReady = useRef(false);
const startInstallation = useInstallationStore(state => state.startInstallation);
const performInstallation = useInstallationStore(state => state.performInstallation);
const addLog = useInstallationStore(state => state.addLog);
const setSuccess = useInstallationStore(state => state.setSuccess);
const setError = useInstallationStore(state => state.setError);
const setBackendError = useInstallationStore(state => state.setBackendError);
const setWaitingBackend = useInstallationStore(state => state.setWaitingBackend);
// Check tool installation status on mount - but only during setup phase
useEffect(() => {
// Only run this check once on initial mount
if (hasCheckedOnMount.current) {
return;
}
@ -32,17 +31,15 @@ export const useInstallationSetup = () => {
try {
const result = await window.ipcRenderer.invoke("check-tool-installed");
// Only perform tool check during setup phase (permissions or carousel)
// Once user is in 'done' state (main app), don't check again
// This prevents unexpected navigation away from the main app
if (initState !== 'done') {
if (result.success) {
if (result.isInstalled && initState === "carousel") {
// If tools ARE installed and we're in carousel state, go to done
console.log('[useInstallationSetup] Tools installed but initState is carousel, setting to done');
setInitState("done");
} else if (!result.isInstalled && initState === "permissions") {
// If tools are NOT installed and we're in permissions state, set to carousel
if (result.success) {
if (result.isInstalled) {
console.log('[useInstallationSetup] Tools already installed, waiting for backend');
installationCompleted.current = true;
setWaitingBackend();
}
if (initState !== 'done') {
if (!result.isInstalled && initState === "permissions") {
console.log('[useInstallationSetup] Tools not installed and initState is permissions, setting to carousel');
setInitState("carousel");
}
@ -57,13 +54,11 @@ export const useInstallationSetup = () => {
const checkBackendStatus = async(toolResult?: any) => {
try {
// Also check if installation is currently in progress
const installationStatus = await window.electronAPI.getInstallationStatus();
if (installationStatus.success && installationStatus.isInstalling) {
startInstallation();
} else if (initState !== 'done' && toolResult) {
// Use the tool result from the previous check to avoid duplicate API calls
if (toolResult.success && !toolResult.isInstalled) {
console.log('[useInstallationSetup] Tools missing and not installing. Starting installation...');
try {
@ -78,7 +73,6 @@ export const useInstallationSetup = () => {
}
}
// Run checks sequentially to avoid race conditions and duplicate API calls
const runInitialChecks = async () => {
const toolResult = await checkToolInstalled();
await checkBackendStatus(toolResult);
@ -88,10 +82,19 @@ export const useInstallationSetup = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Setup Electron IPC listeners (only once)
useEffect(() => {
// Electron IPC event handlers
const checkAndSetDone = () => {
console.log('[useInstallationSetup] Checking readiness - Installation:', installationCompleted.current, 'Backend:', backendReady.current);
if (installationCompleted.current && backendReady.current) {
console.log('[useInstallationSetup] Both installation and backend are ready, setting initState to done');
setInitState('done');
}
};
const handleInstallStart = () => {
installationCompleted.current = false;
backendReady.current = false;
startInstallation();
};
@ -104,26 +107,45 @@ export const useInstallationSetup = () => {
};
const handleInstallComplete = (data: { success: boolean; code?: number; error?: string }) => {
console.log('[useInstallationSetup] Installation complete event received:', data);
if (data.success) {
setSuccess();
setInitState('done');
installationCompleted.current = true;
console.log('[useInstallationSetup] Installation marked as completed');
// setSuccess() will be called in handleBackendReady to prevent premature state change
checkAndSetDone();
} else {
setError(data.error || 'Installation failed');
}
};
// Register Electron IPC listeners
const handleBackendReady = (data: { success: boolean; port?: number; error?: string }) => {
console.log('[useInstallationSetup] Backend ready event received:', data);
if (data.success && data.port) {
console.log(`[useInstallationSetup] Backend is ready on port ${data.port}`);
backendReady.current = true;
console.log('[useInstallationSetup] Backend marked as ready');
setSuccess();
checkAndSetDone();
} else {
console.error('[useInstallationSetup] Backend failed to start:', data.error);
setBackendError(data.error || 'Backend startup failed');
}
};
window.electronAPI.onInstallDependenciesStart(handleInstallStart);
window.electronAPI.onInstallDependenciesLog(handleInstallLog);
window.electronAPI.onInstallDependenciesComplete(handleInstallComplete);
window.electronAPI.onBackendReady(handleBackendReady);
// Cleanup listeners on unmount
return () => {
window.electronAPI.removeAllListeners('install-dependencies-start');
window.electronAPI.removeAllListeners('install-dependencies-log');
window.electronAPI.removeAllListeners('install-dependencies-complete');
window.electronAPI.removeAllListeners('backend-ready');
};
}, [startInstallation, addLog, setSuccess, setError, setInitState]);
}, [startInstallation, addLog, setSuccess, setError, setBackendError, setInitState]);
};

View file

@ -29,6 +29,7 @@
"continue-with-google-login": "Continue with Google",
"continue-with-github-login": "Continue with Github",
"installation-failed": "Installation Failed",
"backend-startup-failed": "Backend Startup Failed",
"projects": "Projects",
"mcp-tools": "MCP & Tools",
"browser": "Browser",

View file

@ -31,6 +31,7 @@
"continue-with-google-login": "使用 Google 登录",
"continue-with-github-login": "使用 Github 登录",
"installation-failed": "安装失败",
"backend-startup-failed": "后端启动失败",
"projects": "项目",
"mcp-tools": "MCP & 工具",
"browser": "浏览器",

View file

@ -709,8 +709,6 @@ const [errors, setErrors] = useState<
? t("setting.gpt-5")
: cloud_model_type === "gpt-5-mini"
? t("setting.gpt-5-mini")
: cloud_model_type === "gpt-5-nano"
? t("setting.gpt-5-nano")
: t("setting.gemini-2.5-pro")}
</span>
</TooltipContent>
@ -732,7 +730,6 @@ const [errors, setErrors] = useState<
<SelectItem value="gpt-4.1">GPT-4.1</SelectItem>
<SelectItem value="gpt-5">GPT-5</SelectItem>
<SelectItem value="gpt-5-mini">GPT-5 mini</SelectItem>
<SelectItem value="gpt-5-nano">GPT-5 nano</SelectItem>
<SelectItem value="claude-sonnet-4-5">
Claude Sonnet 4-5
</SelectItem>

View file

@ -4,7 +4,7 @@ import { persist } from 'zustand/middleware';
// type definition
type InitState = 'permissions' | 'carousel' | 'done';
type ModelType = 'cloud' | 'local' | 'custom';
type CloudModelType = 'gemini/gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-3-pro-preview' | 'gpt-4.1-mini' | 'gpt-4.1' | 'claude-sonnet-4-5' | 'claude-sonnet-4-20250514' | 'claude-3-5-haiku-20241022' | 'gpt-5' | 'gpt-5-mini' | 'gpt-5-nano';
type CloudModelType = 'gemini/gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-3-pro-preview' | 'gpt-4.1-mini' | 'gpt-4.1' | 'claude-sonnet-4-5' | 'claude-sonnet-4-20250514' | 'claude-3-5-haiku-20241022' | 'gpt-5' | 'gpt-5-mini';
// auth info interface
interface AuthInfo {

View file

@ -1,4 +1,4 @@
import { fetchPost, fetchPut, getBaseURL, proxyFetchPost, proxyFetchPut, proxyFetchGet, uploadFile, fetchDelete } from '@/api/http';
import { fetchPost, fetchPut, getBaseURL, proxyFetchPost, proxyFetchPut, proxyFetchGet, uploadFile, fetchDelete, waitForBackendReady } from '@/api/http';
import { fetchEventSource } from '@microsoft/fetch-event-source';
import { createStore } from 'zustand';
import { generateUniqueId, uploadLog } from "@/lib";
@ -199,6 +199,24 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
})
},
startTask: async (taskId: string, type?: string, shareToken?: string, delayTime?: number, messageContent?: string, messageAttaches?: File[]) => {
// ✅ Wait for backend to be ready before starting task (except for replay/share)
if (!type || type === 'normal') {
console.log('[startTask] Checking if backend is ready...');
const isBackendReady = await waitForBackendReady(15000, 500); // Wait up to 15 seconds
if (!isBackendReady) {
console.error('[startTask] Backend is not ready, cannot start task');
const { addMessages } = get();
addMessages(taskId, {
id: generateUniqueId(),
role: 'system',
content: '❌ Backend service is not ready. Please wait a moment and try again, or restart the application if the problem persists.',
});
return;
}
console.log('[startTask] Backend is ready, proceeding with task...');
}
const { token, language, modelType, cloud_model_type, email } = getAuthStore()
const workerList = useWorkerList();
const { getLastUserMessage, setDelayTime, setType } = get();
@ -438,8 +456,6 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
language: systemLanguage,
allow_local_system: true,
attaches: (messageAttaches || targetChatStore.getState().tasks[newTaskId]?.attaches || []).map(f => f.filePath),
bun_mirror: systemLanguage === 'zh-cn' ? 'https://registry.npmmirror.com' : '',
uvx_mirror: systemLanguage === 'zh-cn' ? 'http://mirrors.aliyun.com/pypi/simple/' : '',
summary_prompt: ``,
new_agents: [...addWorkers],
browser_port: browser_port,
@ -1598,7 +1614,21 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
},
onerror(err) {
console.error("Error:", err);
console.error("[fetchEventSource] Error:", err);
// Allow automatic retry for connection errors
// TypeError usually means network/connection issues
if (err instanceof TypeError ||
err?.message?.includes('Failed to fetch') ||
err?.message?.includes('ECONNREFUSED') ||
err?.message?.includes('NetworkError')) {
console.warn('[fetchEventSource] Connection error detected, will retry automatically...');
// Don't throw - let fetchEventSource auto-retry
return;
}
// For other errors, log and throw to stop retrying
console.error('[fetchEventSource] Fatal error, stopping connection:', err);
throw err;
},

View file

@ -2,11 +2,12 @@ import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
// Define all possible installation states
export type InstallationState =
export type InstallationState =
| 'idle'
| 'checking-permissions'
| 'showing-carousel'
| 'showing-carousel'
| 'installing'
| 'waiting-backend' // New state: tools installed, waiting for backend to be ready
| 'error'
| 'completed';
@ -24,19 +25,23 @@ interface InstallationStoreState {
progress: number;
logs: InstallationLog[];
error?: string;
backendError?: string; // Separate error for backend startup failures
isVisible: boolean;
// Actions
startInstallation: () => void;
addLog: (log: InstallationLog) => void;
setSuccess: () => void;
setError: (error: string) => void;
setBackendError: (error: string) => void;
setWaitingBackend: () => void;
retryInstallation: () => void;
retryBackend: () => Promise<void>;
completeSetup: () => void;
updateProgress: (progress: number) => void;
setVisible: (visible: boolean) => void;
reset: () => void;
// Async actions
performInstallation: () => Promise<void>;
exportLog: () => Promise<void>;
@ -48,6 +53,7 @@ const initialState = {
progress: 20,
logs: [] as InstallationLog[],
error: undefined,
backendError: undefined,
isVisible: false,
};
@ -96,7 +102,20 @@ export const useInstallationStore = create<InstallationStoreState>()(
},
],
})),
setWaitingBackend: () =>
set({
state: 'waiting-backend',
progress: 80,
isVisible: true,
}),
setBackendError: (error: string) =>
set({
backendError: error,
state: 'error',
}),
retryInstallation: () => {
set({
...initialState,
@ -105,7 +124,33 @@ export const useInstallationStore = create<InstallationStoreState>()(
});
get().performInstallation();
},
retryBackend: async () => {
try {
// Clear backend error and go back to waiting-backend state
set({
backendError: undefined,
state: 'waiting-backend',
progress: 80,
});
// Call restart-backend via electronAPI
const result = await window.electronAPI.restartBackend();
if (!result.success) {
set({
backendError: result.error || 'Failed to restart backend',
state: 'error',
});
}
} catch (error) {
set({
backendError: error instanceof Error ? error.message : 'Unknown error',
state: 'error',
});
}
},
completeSetup: () =>
set({
state: 'completed',
@ -124,16 +169,14 @@ export const useInstallationStore = create<InstallationStoreState>()(
// Async actions
performInstallation: async () => {
const { startInstallation, setSuccess, setError } = get();
try {
startInstallation();
const result = await window.electronAPI.checkAndInstallDepsOnUpdate();
if (result.success) {
// initState will be set to 'done' by useInstallationSetup after both installation and backend are ready
setSuccess();
// Update auth store
const { useAuthStore } = await import('./authStore');
useAuthStore.getState().setInitState('done');
} else {
setError('Installation failed');
}
@ -196,21 +239,25 @@ export const useInstallationUI = () => {
const progress = useInstallationStore(state => state.progress);
const logs = useInstallationStore(state => state.logs);
const error = useInstallationStore(state => state.error);
const backendError = useInstallationStore(state => state.backendError);
const isVisible = useInstallationStore(state => state.isVisible);
const performInstallation = useInstallationStore(state => state.performInstallation);
const retryInstallation = useInstallationStore(state => state.retryInstallation);
const retryBackend = useInstallationStore(state => state.retryBackend);
const exportLog = useInstallationStore(state => state.exportLog);
return {
installationState: state,
progress,
latestLog: logs[logs.length - 1],
error,
backendError,
isInstalling: state === 'installing',
shouldShowInstallScreen: isVisible && state !== 'completed',
canRetry: state === 'error',
performInstallation,
retryInstallation,
retryBackend,
exportLog,
};
};

View file

@ -47,14 +47,15 @@ interface ElectronAPI {
executeCommand: (command: string,email:string) => Promise<{ success: boolean; stdout?: string; stderr?: string; error?: string }>;
checkAndInstallDepsOnUpdate: () => Promise<{ success: boolean; error?: string }>;
checkInstallBrowser: () => Promise<{ data:any[] }>;
getInstallationStatus: () => Promise<{
success: boolean;
isInstalling?: boolean;
getInstallationStatus: () => Promise<{
success: boolean;
isInstalling?: boolean;
hasLockFile?: boolean;
installedExists?: boolean;
timestamp?: number;
error?: string
error?: string
}>;
restartBackend: () => Promise<{ success: boolean; error?: string }>;
onInstallDependenciesStart: (callback: () => void) => void;
onInstallDependenciesLog: (callback: (data: { type: string; data: string }) => void) => void;
onInstallDependenciesComplete: (callback: (data: { success: boolean; code?: number; error?: string }) => void) => void;