mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-19 16:31:36 +00:00
Merge branch 'hide_github_update_toast' of https://github.com/eigent-ai/eigent into hide_github_update_toast
This commit is contained in:
commit
8b4b4820a2
30 changed files with 800 additions and 466 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
"continue-with-google-login": "使用 Google 登录",
|
||||
"continue-with-github-login": "使用 Github 登录",
|
||||
"installation-failed": "安装失败",
|
||||
"backend-startup-failed": "后端启动失败",
|
||||
"projects": "项目",
|
||||
"mcp-tools": "MCP & 工具",
|
||||
"browser": "浏览器",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
9
src/types/electron.d.ts
vendored
9
src/types/electron.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue