diff --git a/backend/app/component/environment.py b/backend/app/component/environment.py index 3096d84c6..514ff9590 100644 --- a/backend/app/component/environment.py +++ b/backend/app/component/environment.py @@ -5,10 +5,36 @@ from fastapi import APIRouter, FastAPI from dotenv import load_dotenv import importlib from typing import Any, overload +import threading + +# Thread-local storage for user-specific environment +_thread_local = threading.local() + +# Default global environment path +default_env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env") +load_dotenv(dotenv_path=default_env_path) -env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env") -load_dotenv(dotenv_path=env_path) +def set_user_env_path(env_path: str | None = None): + """ + Set user-specific environment path for current thread. + If env_path is None, uses default global environment. + """ + if env_path and os.path.exists(env_path): + _thread_local.env_path = env_path + # Load user-specific environment variables + load_dotenv(dotenv_path=env_path, override=True) + else: + # Clear thread-local env_path to fall back to global + if hasattr(_thread_local, 'env_path'): + delattr(_thread_local, 'env_path') + + +def get_current_env_path() -> str: + """ + Get current environment path (either user-specific or default). + """ + return getattr(_thread_local, 'env_path', default_env_path) @overload @@ -24,6 +50,20 @@ def env(key: str, default: Any) -> Any: ... def env(key: str, default=None): + """ + Get environment variable. + First checks thread-local user-specific environment, + then falls back to global environment. + """ + # If we have a user-specific environment path, try to reload it to get latest values + if hasattr(_thread_local, 'env_path') and os.path.exists(_thread_local.env_path): + # Temporarily load user-specific env to get the latest value + from dotenv import dotenv_values + user_env_values = dotenv_values(_thread_local.env_path) + if key in user_env_values: + return user_env_values[key] or default + + # Fall back to global environment return os.getenv(key, default) diff --git a/backend/app/controller/chat_controller.py b/backend/app/controller/chat_controller.py index e5c248198..45bafd41e 100644 --- a/backend/app/controller/chat_controller.py +++ b/backend/app/controller/chat_controller.py @@ -20,6 +20,7 @@ from app.service.task import ( create_task_lock, get_task_lock, ) +from app.component.environment import set_user_env_path router = APIRouter(tags=["chat"]) @@ -33,6 +34,9 @@ chat_logger = traceroot.get_logger('chat_controller') async def post(data: Chat, request: Request): chat_logger.info(f"Starting new chat session for task_id: {data.task_id}, user: {data.email}") task_lock = create_task_lock(data.task_id) + + # Set user-specific environment path for this thread + set_user_env_path(data.env_path) load_dotenv(dotenv_path=data.env_path) # logger.debug(f"start chat: {data.model_dump_json()}") diff --git a/backend/app/controller/task_controller.py b/backend/app/controller/task_controller.py index 7a8a034b0..f8f104f1b 100644 --- a/backend/app/controller/task_controller.py +++ b/backend/app/controller/task_controller.py @@ -15,6 +15,7 @@ from app.service.task import ( task_locks, ) import asyncio +from app.component.environment import set_user_env_path router = APIRouter(tags=["task"]) @@ -49,6 +50,8 @@ def take_control(id: str, data: TakeControl): @router.post("/task/{id}/add-agent", name="add new agent") def add_agent(id: str, data: NewAgent): + # Set user-specific environment path for this thread + set_user_env_path(data.env_path) load_dotenv(dotenv_path=data.env_path) asyncio.run(get_task_lock(id).put_queue(ActionNewAgent(**data.model_dump()))) return Response(status_code=204) diff --git a/backend/app/utils/agent.py b/backend/app/utils/agent.py index 5d542b2b6..5dc1f4a5a 100644 --- a/backend/app/utils/agent.py +++ b/backend/app/utils/agent.py @@ -1,5 +1,6 @@ import asyncio import json +import os import platform from threading import Event import traceback @@ -1438,7 +1439,17 @@ async def get_mcp_tools(mcp_server: McpServers): traceroot_logger.info(f"Getting MCP tools for {len(mcp_server['mcpServers'])} servers") if len(mcp_server["mcpServers"]) == 0: return [] - mcp_toolkit = MCPToolkit(config_dict={**mcp_server}, timeout=180) + + # Ensure unified auth directory for all mcp-remote servers to avoid re-authentication on each task + config_dict = {**mcp_server} + for server_config in config_dict["mcpServers"].values(): + if "env" not in server_config: + server_config["env"] = {} + # Set global auth directory to persist authentication across tasks + if "MCP_REMOTE_CONFIG_DIR" not in server_config["env"]: + server_config["env"]["MCP_REMOTE_CONFIG_DIR"] = env("MCP_REMOTE_CONFIG_DIR", os.path.expanduser("~/.mcp-auth")) + + mcp_toolkit = MCPToolkit(config_dict=config_dict, timeout=20) try: await mcp_toolkit.connect() traceroot_logger.info(f"Successfully connected to MCP toolkit with {len(mcp_server['mcpServers'])} servers") diff --git a/electron/main/index.ts b/electron/main/index.ts index af7152835..9d87c76ac 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -312,6 +312,24 @@ function registerIpcHandlers() { }); ipcMain.handle('get-app-version', () => app.getVersion()); ipcMain.handle('get-backend-port', () => backendPort); + ipcMain.handle('restart-backend', async () => { + try { + if (backendPort) { + log.info('Restarting backend service...'); + await cleanupPythonProcess(); + await checkAndStartBackend(); + log.info('Backend restart completed successfully'); + return { success: true }; + } else { + log.warn('No backend port found, starting fresh backend'); + await checkAndStartBackend(); + return { success: true }; + } + } catch (error) { + log.error('Failed to restart backend:', error); + return { success: false, error: String(error) }; + } + }); ipcMain.handle('get-system-language', getSystemLanguage); ipcMain.handle('is-fullscreen', () => win?.isFullScreen() || false); ipcMain.handle('get-home-dir', () => { @@ -516,6 +534,15 @@ function registerIpcHandlers() { // ==================== MCP manage handler ==================== ipcMain.handle('mcp-install', async (event, name, mcp) => { + // Convert args from JSON string to array if needed + if (mcp.args && typeof mcp.args === 'string') { + try { + mcp.args = JSON.parse(mcp.args); + } catch (e) { + // If parsing fails, split by comma as fallback + mcp.args = mcp.args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== ''); + } + } addMcp(name, mcp); return { success: true }; }); @@ -526,6 +553,15 @@ function registerIpcHandlers() { }); ipcMain.handle('mcp-update', async (event, name, mcp) => { + // Convert args from JSON string to array if needed + if (mcp.args && typeof mcp.args === 'string') { + try { + mcp.args = JSON.parse(mcp.args); + } catch (e) { + // If parsing fails, split by comma as fallback + mcp.args = mcp.args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== ''); + } + } updateMcp(name, mcp); return { success: true }; }); diff --git a/electron/main/utils/mcpConfig.ts b/electron/main/utils/mcpConfig.ts index ca351f5c0..b3400cae9 100644 --- a/electron/main/utils/mcpConfig.ts +++ b/electron/main/utils/mcpConfig.ts @@ -8,6 +8,7 @@ const MCP_CONFIG_PATH = path.join(MCP_CONFIG_DIR, 'mcp.json'); type McpServerConfig = { command: string; args: string[]; + description?: string; env?: Record; } | { url: string; @@ -17,7 +18,7 @@ type McpServersConfig = { [name: string]: McpServerConfig; }; -type ConfigFile = { +export type ConfigFile = { mcpServers: McpServersConfig; }; @@ -42,6 +43,28 @@ export function readMcpConfig(): ConfigFile { if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') { return getDefaultConfig(); } + + // Normalize args field - ensure it's always an array + Object.keys(parsed.mcpServers).forEach(serverName => { + const server = parsed.mcpServers[serverName]; + if (server.args) { + const args = server.args as any; + if (typeof args === 'string') { + try { + // Try to parse as JSON string first + server.args = JSON.parse(args); + } catch (e) { + // If parsing fails, split by comma as fallback + server.args = args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== ''); + } + } + // Ensure it's always an array of strings + if (Array.isArray(server.args)) { + server.args = server.args.map((arg: any) => String(arg)); + } + } + }); + return parsed; } catch (e) { return getDefaultConfig(); @@ -58,7 +81,22 @@ export function writeMcpConfig(config: ConfigFile): void { export function addMcp(name: string, mcp: McpServerConfig): void { const config = readMcpConfig(); if (!config.mcpServers[name]) { - config.mcpServers[name] = mcp; + // Ensure args is an array before adding + const normalizedMcp = { ...mcp }; + if ('args' in normalizedMcp && normalizedMcp.args) { + const args = normalizedMcp.args as any; + if (typeof args === 'string') { + try { + normalizedMcp.args = JSON.parse(args); + } catch (e) { + normalizedMcp.args = args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== ''); + } + } + if (Array.isArray(normalizedMcp.args)) { + normalizedMcp.args = normalizedMcp.args.map((arg: any) => String(arg)); + } + } + config.mcpServers[name] = normalizedMcp; writeMcpConfig(config); } } @@ -74,6 +112,21 @@ export function removeMcp(name: string): void { export function updateMcp(name: string, mcp: McpServerConfig): void { const config = readMcpConfig(); - config.mcpServers[name] = mcp; + // Ensure args is an array before updating + const normalizedMcp = { ...mcp }; + if ('args' in normalizedMcp && normalizedMcp.args) { + const args = normalizedMcp.args as any; + if (typeof args === 'string') { + try { + normalizedMcp.args = JSON.parse(args); + } catch (e) { + normalizedMcp.args = args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== ''); + } + } + if (Array.isArray(normalizedMcp.args)) { + normalizedMcp.args = normalizedMcp.args.map((arg: any) => String(arg)); + } + } + config.mcpServers[name] = normalizedMcp; writeMcpConfig(config); } \ No newline at end of file diff --git a/electron/main/webview.ts b/electron/main/webview.ts index 20816fd1b..123d464d2 100644 --- a/electron/main/webview.ts +++ b/electron/main/webview.ts @@ -112,18 +112,23 @@ export class WebViewManager { } console.log(`Webview ${id} navigated to: ${navigationUrl}`) if (webViewInfo.isActive && webViewInfo.isShow && navigationUrl !== 'about:blank?use=0' && navigationUrl !== 'about:blank') { - console.log("did-navigate", id, url) - this.win?.webContents.send("url-updated", url); + console.log("did-navigate", id, navigationUrl) + this.win?.webContents.send("url-updated", navigationUrl); return } webViewInfo.view.setBounds({ x: -1919, y: -1079, width: 1920, height: 1080 }) const activeSize = this.getActiveWebview().length const allSize = Array.from(this.webViews.values()).length if (allSize - activeSize <= 3) { - const newId = Array.from(this.webViews.keys()).length + 2 - this.createWebview(newId.toString(), 'about:blank?use=0') - this.createWebview((newId + 1).toString(), 'about:blank?use=0') - this.createWebview((newId + 2).toString(), 'about:blank?use=0') + const existingKeys = Array.from(this.webViews.keys()).map(Number).filter(n => !isNaN(n)) + const maxId = existingKeys.length > 0 ? Math.max(...existingKeys) : 0 + const startId = maxId + 1 + + // Create webviews sequentially to avoid race conditions + for (let i = 0; i < 3; i++) { + const nextId = (startId + i).toString() + this.createWebview(nextId, 'about:blank?use=0') + } } // setTimeout(() => { @@ -242,8 +247,12 @@ export class WebViewManager { } } - public distroy() { - // TODO: Destroy all webviews + public destroy() { + // Destroy all webviews + Array.from(this.webViews.keys()).forEach(id => { + this.destroyWebview(id) + }) + this.webViews.clear() } } diff --git a/src/components/Dialog/CloseNotice.tsx b/src/components/Dialog/CloseNotice.tsx new file mode 100644 index 000000000..2e19ef298 --- /dev/null +++ b/src/components/Dialog/CloseNotice.tsx @@ -0,0 +1,39 @@ +import { useCallback } from "react"; +import { Button } from "../ui/button"; +import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger?: React.ReactNode; +} +export default function CloseNoticeDialog({open, onOpenChange, trigger}: Props) { + + const onSubmit = useCallback(() => { + window.electronAPI.closeWindow(true) + }, []) + + return + {trigger && {trigger}} + + + + Close notice + + +
+ A task is currently running. Exiting will terminate it. Are you sure you want to exit? +
+ + + + + + +
+
+} \ No newline at end of file diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 260b72ce5..685531bfe 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -6,10 +6,32 @@ import { useAuthStore } from "@/store/authStore"; import { useEffect, useState } from "react"; import { AnimationJson } from "@/components/AnimationJson"; import animationData from "@/assets/animation/onboarding_success.json"; +import CloseNoticeDialog from "../Dialog/CloseNotice"; +import { useChatStore } from "@/store/chatStore"; const Layout = () => { const { initState, setInitState, isFirstLaunch, setIsFirstLaunch } = useAuthStore(); const [isInstalling, setIsInstalling] = useState(false); + const [noticeOpen, setNoticeOpen] = useState(false); + const chatStore = useChatStore(); + + useEffect(() => { + const handleBeforeClose = () => { + const currentStatus = chatStore.tasks[chatStore.activeTaskId as string]?.status; + if(["pending", "running", "pause"].includes(currentStatus)) { + setNoticeOpen(true); + } else { + window.electronAPI.closeWindow(true); + } + }; + + window.ipcRenderer.on("before-close", handleBeforeClose); + + return () => { + window.ipcRenderer.removeAllListeners("before-close"); + }; + }, [chatStore.tasks, chatStore.activeTaskId]); + useEffect(() => { const checkToolInstalled = async () => { // in render process @@ -25,6 +47,7 @@ const Layout = () => { }; checkToolInstalled(); }, []); + return (
@@ -46,6 +69,10 @@ const Layout = () => { )} +
); diff --git a/src/pages/Setting/MCP.tsx b/src/pages/Setting/MCP.tsx index 23bf13732..ef1eabe14 100644 --- a/src/pages/Setting/MCP.tsx +++ b/src/pages/Setting/MCP.tsx @@ -20,6 +20,7 @@ import { getProxyBaseURL } from "@/lib"; import { useAuthStore } from "@/store/authStore"; import { toast } from "sonner"; +import { ConfigFile } from "electron/main/utils/mcpConfig"; export default function SettingMCP() { const navigate = useNavigate(); @@ -195,13 +196,28 @@ export default function SettingMCP() { setSaving(true); setErrorMsg(null); try { - await proxyFetchPut(`/api/mcp/users/${showConfig.id}`, { + const mcpData = { mcp_name: configForm.mcp_name, mcp_desc: configForm.mcp_desc, command: configForm.command, args: arrayToArgsJson(configForm.argsArr), env: configForm.env, - }); + } + await proxyFetchPut(`/api/mcp/users/${showConfig.id}`, mcpData); + + if (window.ipcRenderer) { + //Partial payload to empty env {} + const payload: any = { + description: configForm.mcp_desc, + command: configForm.command, + args: arrayToArgsJson(configForm.argsArr), + }; + if (configForm.env && Object.keys(configForm.env).length > 0) { + payload.env = configForm.env; + } + window.ipcRenderer.invoke("mcp-update", mcpData.mcp_name, payload); + } + setShowConfig(null); fetchList(); } catch (err: any) { @@ -236,9 +252,27 @@ export default function SettingMCP() { setInstalling(true); try { if (addType === "local") { - let data; + let data:ConfigFile; try { data = JSON.parse(localJson); + + // validate mcpServers structure + if (!data.mcpServers || typeof data.mcpServers !== "object") { + throw new Error("Invalid mcpServers"); + } + + // check for name conflicts with existing items + const serverNames = Object.keys(data.mcpServers); + const conflict = serverNames.find((name) => + items.some((d) => d.mcp_name === name) + ); + if (conflict) { + toast.error(`MCP server "${conflict}" already exists`, { + closeButton: true, + }); + setInstalling(false); + return; + } } catch (e) { toast.error("Invalid JSON", { closeButton: true }); setInstalling(false); @@ -252,19 +286,14 @@ export default function SettingMCP() { } if (window.ipcRenderer) { const mcpServers = data["mcpServers"]; - Object.entries(mcpServers).forEach(async ([key, value]) => { + for (const [key, value] of Object.entries(mcpServers)) { await window.ipcRenderer.invoke("mcp-install", key, value); - }); + } } } setShowAdd(false); setLocalJson(`{ - "mcp_id": 0, - "mcp_name": "", - "mcp_desc": "", - "command": "", - "args": "", - "env": {} + "mcpServers": {} }`); setRemoteName(""); setRemoteUrl(""); @@ -335,13 +364,13 @@ export default function SettingMCP() { {!isLoading && !error && items.length === 0 && (
No MCP servers
)} - + />} GPT-5 GPT-5 mini GPT-5 nano - - Claude Opus 4.1 - Claude Sonnet 4 diff --git a/src/pages/Setting/components/MCPConfigDialog.tsx b/src/pages/Setting/components/MCPConfigDialog.tsx index d58a36e77..4440f5231 100644 --- a/src/pages/Setting/components/MCPConfigDialog.tsx +++ b/src/pages/Setting/components/MCPConfigDialog.tsx @@ -34,7 +34,7 @@ export default function MCPConfigDialog({ open, form, mcp, onChange, onSave, onC
- onChange({ ...form, mcp_name: e.target.value })} disabled={loading} /> + onChange({ ...form, mcp_name: e.target.value })} disabled readOnly />
@@ -45,7 +45,7 @@ export default function MCPConfigDialog({ open, form, mcp, onChange, onSave, onC onChange({ ...form, command: e.target.value })} disabled={loading} />
- +