diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 919b12415..0ed8f3d11 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,6 +67,9 @@ jobs: - name: Check Electron Access Guard run: bash scripts/check-electron-access.sh + - name: Design tokens (engine + no hard-coded colors in UI) + run: npm run check:design-tokens + pytest: name: Run Python Tests runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 371f89626..a77e24f08 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ node-compile-cache node_modules dist +dist-web !package/**/dist dist-ssr dist-electron diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 3f024a1db..a9efd3733 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,6 +1,7 @@ { "*.{ts,tsx}": [ "eslint --fix --no-warn-ignored", + "node scripts/check-design-token-usage.mjs", "prettier --write", "node licenses/update_license.js" ], diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 0265148c8..66abb74a7 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -52,7 +52,11 @@ async def dispatch(self, request, call_next): response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" - if request.url.path.startswith("/files/preview/"): + path = request.url.path + is_file_preview = path.startswith( + "/files/preview/" + ) or path.startswith("/api/v1/files/preview/") + if is_file_preview: if "X-Frame-Options" in response.headers: del response.headers["X-Frame-Options"] response.headers["Content-Security-Policy"] = ( diff --git a/backend/app/agent/factory/browser.py b/backend/app/agent/factory/browser.py index 6f4bb401e..c6cb0e02e 100644 --- a/backend/app/agent/factory/browser.py +++ b/backend/app/agent/factory/browser.py @@ -37,12 +37,32 @@ from app.hands.interface import IHands from app.model.chat import Chat from app.service.task import Agents +from app.utils.browser_launcher import normalize_cdp_url from app.utils.file_utils import get_working_directory def _get_browser_port(browser: dict) -> int: """Extract port from a browser config dict, with fallback to env default.""" - return int(browser.get("port", env("browser_port", "9222"))) + raw_port = browser.get("port") + if raw_port is not None: + return int(raw_port) + + raw_endpoint = browser.get("endpoint") or browser.get("cdp_url") + if raw_endpoint: + _, _, port = normalize_cdp_url(str(raw_endpoint)) + return port + + return int(env("browser_port", "9222")) + + +def _get_browser_endpoint(browser: dict) -> str: + """Extract a normalized CDP endpoint from a browser config dict.""" + raw_endpoint = browser.get("endpoint") or browser.get("cdp_url") + if raw_endpoint: + endpoint, _, _ = normalize_cdp_url(str(raw_endpoint)) + return endpoint + + return f"http://localhost:{_get_browser_port(browser)}" class CdpBrowserPoolManager: @@ -181,6 +201,7 @@ def browser_agent( ) if selected_browser: selected_port = _get_browser_port(selected_browser) + cdp_url = _get_browser_endpoint(selected_browser) selected_is_external = selected_browser.get("isExternal", False) logger.info( f"Acquired CDP browser from pool (initial): " @@ -188,15 +209,14 @@ def browser_agent( f"session_id={toolkit_session_id}" ) else: - selected_port = _get_browser_port(options.cdp_browsers[0]) - selected_is_external = options.cdp_browsers[0].get( - "isExternal", False - ) + fallback_browser = options.cdp_browsers[0] + selected_port = _get_browser_port(fallback_browser) + cdp_url = _get_browser_endpoint(fallback_browser) + selected_is_external = fallback_browser.get("isExternal", False) logger.warning( f"No available browsers in pool (initial), using first: " f"port={selected_port}, session_id={toolkit_session_id}" ) - cdp_url = f"http://localhost:{selected_port}" elif use_browser: existing_cdp_url = env("EIGENT_CDP_URL", "").strip() selected_port = env("browser_port", "9222") diff --git a/backend/app/agent/listen_chat_agent.py b/backend/app/agent/listen_chat_agent.py index 3ff3aeb91..b272952b4 100644 --- a/backend/app/agent/listen_chat_agent.py +++ b/backend/app/agent/listen_chat_agent.py @@ -700,6 +700,7 @@ def clone(self, with_memory: bool = False) -> ChatAgent: # If this agent has CDP acquire callback, acquire CDP BEFORE cloning # tools so that HybridBrowserToolkit clones with the correct CDP port new_cdp_port = None + new_cdp_url = None new_cdp_session = None has_cdp = hasattr(self, "_cdp_acquire_callback") and callable( getattr(self, "_cdp_acquire_callback", None) @@ -721,12 +722,18 @@ def clone(self, with_memory: bool = False) -> ChatAgent: new_cdp_session, getattr(self, "_cdp_task_id", None), ) - from app.agent.factory.browser import _get_browser_port + from app.agent.factory.browser import ( + _get_browser_endpoint, + _get_browser_port, + ) if selected: new_cdp_port = _get_browser_port(selected) + new_cdp_url = _get_browser_endpoint(selected) else: - new_cdp_port = _get_browser_port(cdp_browsers[0]) + fallback_browser = cdp_browsers[0] + new_cdp_port = _get_browser_port(fallback_browser) + new_cdp_url = _get_browser_endpoint(fallback_browser) if need_cdp_clone: # Temporarily override the browser toolkit's CDP URL. @@ -738,7 +745,7 @@ def clone(self, with_memory: bool = False) -> ChatAgent: toolkit.config_loader.get_browser_config().cdp_url ) toolkit.config_loader.get_browser_config().cdp_url = ( - f"http://localhost:{new_cdp_port}" + new_cdp_url ) try: cloned_tools, toolkits_to_register = self._clone_tools() @@ -803,10 +810,13 @@ def clone(self, with_memory: bool = False) -> ChatAgent: # Set CDP info on cloned agent if new_cdp_port is not None and new_cdp_session is not None: new_agent._cdp_port = new_cdp_port + new_agent._cdp_url = new_cdp_url new_agent._cdp_session_id = new_cdp_session else: if hasattr(self, "_cdp_port"): new_agent._cdp_port = self._cdp_port + if hasattr(self, "_cdp_url"): + new_agent._cdp_url = self._cdp_url if hasattr(self, "_cdp_session_id"): new_agent._cdp_session_id = self._cdp_session_id diff --git a/backend/app/controller/chat_controller.py b/backend/app/controller/chat_controller.py index ba681233a..678ec49c3 100644 --- a/backend/app/controller/chat_controller.py +++ b/backend/app/controller/chat_controller.py @@ -55,8 +55,9 @@ task_locks, ) from app.utils.browser_launcher import ( - ensure_cdp_browser_available, + ensure_cdp_browser_endpoint, is_cdp_url_available, + normalize_cdp_url, ) router = APIRouter() @@ -98,6 +99,11 @@ async def _prepare_browser_for_request( is_cdp_url_available, existing_cdp_url ) if is_available: + normalized_endpoint, _, selected_port = normalize_cdp_url( + existing_cdp_url + ) + os.environ["EIGENT_CDP_URL"] = normalized_endpoint + os.environ["browser_port"] = str(selected_port) if request is not None: request.state.browser_available = True return True @@ -109,7 +115,7 @@ async def _prepare_browser_for_request( return True try: - launched = await asyncio.to_thread(ensure_cdp_browser_available, port) + endpoint = await asyncio.to_thread(ensure_cdp_browser_endpoint, port) except Exception as e: os.environ.pop("EIGENT_CDP_URL", None) chat_logger.warning( @@ -120,8 +126,10 @@ async def _prepare_browser_for_request( request.state.browser_available = False return False - if launched: - os.environ["EIGENT_CDP_URL"] = f"http://127.0.0.1:{port}" + if endpoint: + os.environ["EIGENT_CDP_URL"] = endpoint + _, _, selected_port = normalize_cdp_url(endpoint) + os.environ["browser_port"] = str(selected_port) if request is not None: request.state.browser_available = True return True diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py index 5d0cb7ffa..f2bcada9c 100644 --- a/backend/app/controller/health_controller.py +++ b/backend/app/controller/health_controller.py @@ -19,7 +19,7 @@ from pydantic import BaseModel from app.router_layer.hands_resolver import get_environment_hands -from app.utils.browser_launcher import _is_cdp_available +from app.utils.browser_launcher import _is_cdp_available, is_cdp_url_available logger = logging.getLogger("health_controller") @@ -41,11 +41,16 @@ async def health_check(detail: bool = Query(False)): if detail: hands = get_environment_hands() capabilities = hands.get_capability_manifest() - try: - browser_port = int(os.environ.get("browser_port", "9222")) - except ValueError: - browser_port = 9222 - capabilities["browser_cdp_reachable"] = _is_cdp_available(browser_port) + cdp_url = os.environ.get("EIGENT_CDP_URL", "").strip() + if cdp_url: + cdp_reachable = is_cdp_url_available(cdp_url) + else: + try: + browser_port = int(os.environ.get("browser_port", "9222")) + except ValueError: + browser_port = 9222 + cdp_reachable = _is_cdp_available(browser_port) + capabilities["browser_cdp_reachable"] = cdp_reachable response.capabilities = capabilities logger.debug( "Health check completed", diff --git a/backend/app/controller/tool_controller.py b/backend/app/controller/tool_controller.py index 76a217883..61d792c59 100644 --- a/backend/app/controller/tool_controller.py +++ b/backend/app/controller/tool_controller.py @@ -13,6 +13,7 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import asyncio +import inspect import logging import os import shutil @@ -30,7 +31,8 @@ DEFAULT_CDP_PORT, _is_cdp_available, _is_port_in_use, - ensure_cdp_browser_available, + ensure_cdp_browser_endpoint, + is_cdp_url_available, is_local_cdp_host, normalize_cdp_url, ) @@ -50,6 +52,7 @@ class LinkedInTokenRequest(BaseModel): logger = logging.getLogger("tool_controller") router = APIRouter() _web_cdp_browser_meta: dict | None = None +DEFAULT_LOGIN_BROWSER_CDP_PORT = 9323 class CdpBrowserConnectRequest(BaseModel): @@ -114,6 +117,37 @@ def _get_connected_cdp_port() -> int | None: return None +def _get_login_browser_cdp_port() -> int: + """Dedicated CDP port for the user-login cookie browser. + + Keep this outside the Browser Agent fallback range (9223-9299), otherwise + Cookie Management can mistake a managed task browser for the login window. + """ + raw_port = os.environ.get("EIGENT_LOGIN_BROWSER_CDP_PORT") + if not raw_port: + return DEFAULT_LOGIN_BROWSER_CDP_PORT + + try: + port = int(raw_port) + except ValueError: + logger.warning( + "Invalid EIGENT_LOGIN_BROWSER_CDP_PORT=%s; using default %s", + raw_port, + DEFAULT_LOGIN_BROWSER_CDP_PORT, + ) + return DEFAULT_LOGIN_BROWSER_CDP_PORT + + if port <= 0 or port > 65535: + logger.warning( + "Out-of-range EIGENT_LOGIN_BROWSER_CDP_PORT=%s; using default %s", + raw_port, + DEFAULT_LOGIN_BROWSER_CDP_PORT, + ) + return DEFAULT_LOGIN_BROWSER_CDP_PORT + + return port + + def _set_connected_cdp_browser( endpoint: str, *, @@ -123,8 +157,9 @@ def _set_connected_cdp_browser( managed_by: str = "local", ) -> dict: global _web_cdp_browser_meta - normalized_endpoint, _, _ = normalize_cdp_url(endpoint) + normalized_endpoint, _, port = normalize_cdp_url(endpoint) os.environ["EIGENT_CDP_URL"] = normalized_endpoint + os.environ["browser_port"] = str(port) _web_cdp_browser_meta = _build_web_cdp_browser( normalized_endpoint, is_external=is_external, @@ -163,29 +198,29 @@ def _list_connected_cdp_browsers() -> list[dict]: def _is_cdp_endpoint_available(endpoint: str) -> bool: - normalized_endpoint, host, port = normalize_cdp_url(endpoint) + _, host, port = normalize_cdp_url(endpoint) if is_local_cdp_host(host): return _is_cdp_available(port) - try: - import httpx - - response = httpx.get( - f"{normalized_endpoint}/json/version", - timeout=2.0, - ) - return response.status_code == 200 - except Exception: - return False + return is_cdp_url_available(endpoint) def _is_remote_browser_hands(hands) -> bool: if hands is None: return False + get_manifest = getattr(hands, "get_capability_manifest", None) + if get_manifest is None or inspect.iscoroutinefunction(get_manifest): + return False try: - manifest = hands.get_capability_manifest() + manifest = get_manifest() except Exception: return False + if inspect.isawaitable(manifest): + if hasattr(manifest, "close"): + manifest.close() + return False + if not isinstance(manifest, dict): + return False return manifest.get("deployment") == "remote_cluster" @@ -271,13 +306,12 @@ async def launch_cdp_browser(request: Request): "endpoint": browser.get("endpoint"), } - port = DEFAULT_CDP_PORT - launched = ensure_cdp_browser_available(port) - if not launched: - if _is_port_in_use(port): + endpoint = ensure_cdp_browser_endpoint(DEFAULT_CDP_PORT) + if not endpoint: + if _is_port_in_use(DEFAULT_CDP_PORT): return { "success": False, - "error": f"Port {port} is already in use and is not exposing CDP.", + "error": f"Port {DEFAULT_CDP_PORT} is already in use and is not exposing a compatible CDP browser.", } return { "success": False, @@ -285,7 +319,7 @@ async def launch_cdp_browser(request: Request): } browser = _set_connected_cdp_browser( - f"http://127.0.0.1:{port}", + endpoint, is_external=False, ) return { @@ -970,12 +1004,11 @@ async def open_browser_login(): Browser session information """ try: - import socket import subprocess # Use fixed profile name for persistent logins (no port suffix) session_id = "user_login" - cdp_port = 9223 + cdp_port = _get_login_browser_cdp_port() # IMPORTANT: Use dedicated profile for tool_controller browser # This is the SOURCE OF TRUTH for login data @@ -996,12 +1029,7 @@ async def open_browser_login(): f" at: {user_data_dir}" ) - # Check if browser is already running on this port - def is_port_in_use(port): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - return s.connect_ex(("localhost", port)) == 0 - - if is_port_in_use(cdp_port): + if _is_port_in_use(cdp_port): logger.info(f"Browser already running on port {cdp_port}") return { "success": True, @@ -1135,15 +1163,8 @@ def log_electron_output(): @router.get("/browser/status", name="browser status") async def browser_status(): """Check if the login browser is currently open.""" - import socket - - cdp_port = 9223 - - def is_port_in_use(port): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - return s.connect_ex(("localhost", port)) == 0 - - return {"is_open": is_port_in_use(cdp_port)} + cdp_port = _get_login_browser_cdp_port() + return {"is_open": _is_port_in_use(cdp_port), "cdp_port": cdp_port} @router.get("/browser/cookies", name="list cookie domains") diff --git a/backend/app/utils/browser_launcher.py b/backend/app/utils/browser_launcher.py index ec16a26b6..be0127068 100644 --- a/backend/app/utils/browser_launcher.py +++ b/backend/app/utils/browser_launcher.py @@ -20,6 +20,7 @@ In web mode, Brain launches Chrome/Chromium directly. """ +import json import logging import os import platform @@ -32,6 +33,8 @@ # Default CDP port (must match browser_port in Chat model) DEFAULT_CDP_PORT = 9222 +FALLBACK_CDP_PORT_START = 9223 +FALLBACK_CDP_PORT_END = 9299 LOCAL_CDP_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) @@ -49,7 +52,12 @@ def normalize_cdp_url( default_port: int = DEFAULT_CDP_PORT, ) -> tuple[str, str, int]: """Normalize a CDP endpoint into ``scheme://host:port`` form.""" - parsed = urlparse(cdp_url) + raw_url = cdp_url.strip() + if raw_url.isdigit(): + port = int(raw_url) + return f"http://{default_host}:{port}", default_host, port + + parsed = urlparse(raw_url if "://" in raw_url else f"http://{raw_url}") scheme = parsed.scheme or "http" host = parsed.hostname or default_host port = parsed.port or default_port @@ -66,7 +74,9 @@ def is_cdp_url_available(cdp_url: str) -> bool: import httpx r = httpx.get(f"{normalized}/json/version", timeout=2.0) - return r.status_code == 200 + if r.status_code != 200: + return False + return _is_supported_cdp_version(r.json(), normalized) except Exception: return False @@ -78,16 +88,101 @@ def _is_port_in_use(port: int) -> bool: def _is_cdp_available(port: int) -> bool: - """Check if a CDP-capable browser is listening on the port.""" + """Check if a Playwright-compatible CDP browser is listening.""" try: import httpx r = httpx.get(f"http://127.0.0.1:{port}/json/version", timeout=2.0) - return r.status_code == 200 + if r.status_code != 200: + return False + return _is_supported_cdp_version(r.json(), f"http://127.0.0.1:{port}") except Exception: return False +def _is_supported_cdp_version(data: dict, endpoint: str) -> bool: + """Reject CDP endpoints that Playwright cannot manage.""" + browser = str(data.get("Browser") or "") + user_agent = str(data.get("User-Agent") or "") + websocket_url = data.get("webSocketDebuggerUrl") + combined = f"{browser} {user_agent}" + + if not websocket_url: + logger.debug( + "[BROWSER LAUNCHER] CDP endpoint has no browser websocket" + ) + return False + + if "Electron" in combined: + logger.warning( + "[BROWSER LAUNCHER] Ignoring Electron DevTools endpoint at %s; " + "Browser Agent requires a managed Chrome/Chromium CDP browser.", + endpoint, + ) + return False + + if not any( + token in combined + for token in ("Chrome/", "Chromium", "HeadlessChrome/") + ): + logger.warning( + "[BROWSER LAUNCHER] Ignoring unsupported CDP endpoint at %s: %s", + endpoint, + browser or user_agent or "unknown browser", + ) + return False + + if not _supports_browser_context_management(str(websocket_url), endpoint): + return False + + return True + + +def _supports_browser_context_management( + websocket_url: str, + endpoint: str, +) -> bool: + """Probe the browser-level CDP socket for Playwright compatibility.""" + try: + from websockets.sync.client import connect + + command = { + "id": 1, + "method": "Browser.setDownloadBehavior", + "params": {"behavior": "default"}, + } + with connect(websocket_url, open_timeout=2, close_timeout=1) as ws: + ws.send(json.dumps(command)) + response = json.loads(ws.recv(timeout=2)) + except Exception as exc: + logger.warning( + "[BROWSER LAUNCHER] Could not validate CDP capabilities at %s: %s", + endpoint, + exc, + ) + return False + + error = response.get("error") + if error: + message = error.get("message") if isinstance(error, dict) else error + logger.warning( + "[BROWSER LAUNCHER] Ignoring CDP endpoint at %s; " + "it does not support browser context management: %s", + endpoint, + message, + ) + return False + + return True + + +def _candidate_ports(preferred_port: int): + yield preferred_port + for port in range(FALLBACK_CDP_PORT_START, FALLBACK_CDP_PORT_END + 1): + if port != preferred_port: + yield port + + def _find_chrome_executable() -> str | None: """Find Chrome or Chromium executable for launching with CDP.""" system = platform.system() @@ -252,3 +347,36 @@ def ensure_cdp_browser_available(port: int = DEFAULT_CDP_PORT) -> bool: "[BROWSER LAUNCHER] Browser launched but CDP not ready after 10s" ) return False + + +def ensure_cdp_browser_endpoint( + preferred_port: int = DEFAULT_CDP_PORT, +) -> str | None: + """ + Ensure a managed CDP browser exists and return its endpoint. + + If the preferred port is occupied by Electron's own DevTools endpoint, use + the next available local port instead of handing that endpoint to + Playwright. + """ + for port in _candidate_ports(preferred_port): + if _is_cdp_available(port): + return f"http://127.0.0.1:{port}" + + if _is_port_in_use(port): + logger.warning( + "[BROWSER LAUNCHER] Port %s is occupied by a non-managed " + "or unsupported CDP endpoint; trying another port.", + port, + ) + continue + + if ensure_cdp_browser_available(port): + return f"http://127.0.0.1:{port}" + + logger.error( + "[BROWSER LAUNCHER] No available CDP browser port in %s-%s", + preferred_port, + FALLBACK_CDP_PORT_END, + ) + return None diff --git a/backend/tests/app/agent/factory/test_browser.py b/backend/tests/app/agent/factory/test_browser.py index 0c26a90c5..6182600ad 100644 --- a/backend/tests/app/agent/factory/test_browser.py +++ b/backend/tests/app/agent/factory/test_browser.py @@ -144,3 +144,65 @@ def test_browser_agent_prefers_preconnected_cdp_url(sample_chat_data): assert mock_browser_toolkit.call_args.kwargs["cdp_url"] == ( "http://worker-17:9222" ) + + +def test_browser_agent_uses_cdp_browser_endpoint(sample_chat_data): + """Browser pool entries may point at remote Hands endpoints.""" + sample_chat_data["cdp_browsers"] = [ + { + "id": "remote-browser", + "port": 9222, + "endpoint": "http://worker-17:9222", + "isExternal": False, + "name": "Remote Browser", + } + ] + options = Chat(**sample_chat_data) + + from app.agent.factory import browser as browser_factory + from app.service.task import task_locks + + mock_task_lock = MagicMock() + task_locks[options.task_id] = mock_task_lock + + _mod = "app.agent.factory.browser" + try: + with ( + patch(f"{_mod}.agent_model") as mock_agent_model, + patch( + f"{_mod}.get_working_directory", + return_value="/tmp/test_workdir", + ), + patch("asyncio.create_task"), + patch(f"{_mod}.HumanToolkit") as mock_human_toolkit, + patch(f"{_mod}.HybridBrowserToolkit") as mock_browser_toolkit, + patch(f"{_mod}.TerminalToolkit") as mock_terminal_toolkit, + patch(f"{_mod}.NoteTakingToolkit") as mock_note_toolkit, + patch(f"{_mod}.ScreenshotToolkit") as mock_screenshot_toolkit, + patch(f"{_mod}.SearchToolkit") as mock_search_toolkit, + patch(f"{_mod}.ToolkitMessageIntegration"), + patch("uuid.uuid4") as mock_uuid, + ): + mock_human_toolkit.get_can_use_tools.return_value = [] + mock_browser_toolkit.return_value.get_tools.return_value = [] + mock_terminal_instance = MagicMock() + mock_terminal_instance.shell_exec = MagicMock() + mock_terminal_toolkit.return_value = mock_terminal_instance + mock_note_toolkit.return_value.get_tools.return_value = [] + mock_screenshot_toolkit.return_value.get_tools.return_value = [] + mock_search_toolkit.return_value = MagicMock() + mock_uuid.return_value.__getitem__ = ( + lambda self, key: "test_session" + ) + + mock_agent = MagicMock() + mock_agent_model.return_value = mock_agent + + browser_agent(options) + + assert mock_browser_toolkit.call_args.kwargs["cdp_url"] == ( + "http://worker-17:9222" + ) + finally: + browser_factory._cdp_pool_manager.release_by_task(options.task_id) + task_locks.pop(options.task_id, None) diff --git a/backend/tests/app/controller/test_chat_controller.py b/backend/tests/app/controller/test_chat_controller.py index 2356fa7d8..79efd560f 100644 --- a/backend/tests/app/controller/test_chat_controller.py +++ b/backend/tests/app/controller/test_chat_controller.py @@ -91,6 +91,10 @@ async def test_post_chat_sets_environment_variables( ) as mock_step_solve, patch("app.controller.chat_controller.load_dotenv"), patch("app.controller.chat_controller.set_current_task_id"), + patch( + "app.controller.chat_controller._prepare_browser_for_request", + return_value=True, + ), patch("pathlib.Path.mkdir"), patch("pathlib.Path.home", return_value=MagicMock()), patch.dict(os.environ, {}, clear=True), @@ -133,8 +137,8 @@ async def test_post_chat_sets_cdp_url_when_browser_ready( return_value=False, ), patch( - "app.controller.chat_controller.ensure_cdp_browser_available", - return_value=True, + "app.controller.chat_controller.ensure_cdp_browser_endpoint", + return_value="http://127.0.0.1:8080", ), patch("app.controller.chat_controller.load_dotenv"), patch("app.controller.chat_controller.set_current_task_id"), @@ -151,6 +155,7 @@ async def mock_generator(): await post(chat_data, mock_request) assert os.environ.get("EIGENT_CDP_URL") == "http://127.0.0.1:8080" + assert os.environ.get("browser_port") == "8080" assert mock_request.state.browser_available is True @pytest.mark.asyncio @@ -174,8 +179,8 @@ async def test_post_chat_clears_cdp_url_when_browser_unavailable( return_value=False, ), patch( - "app.controller.chat_controller.ensure_cdp_browser_available", - return_value=False, + "app.controller.chat_controller.ensure_cdp_browser_endpoint", + return_value=None, ), patch("app.controller.chat_controller.load_dotenv"), patch("app.controller.chat_controller.set_current_task_id"), @@ -218,7 +223,7 @@ async def test_post_chat_preserves_existing_cdp_url( return_value=True, ), patch( - "app.controller.chat_controller.ensure_cdp_browser_available", + "app.controller.chat_controller.ensure_cdp_browser_endpoint", ) as mock_ensure_browser, patch("app.controller.chat_controller.load_dotenv"), patch("app.controller.chat_controller.set_current_task_id"), @@ -239,6 +244,7 @@ async def mock_generator(): await post(chat_data, mock_request) assert os.environ.get("EIGENT_CDP_URL") == "http://worker-17:9222" + assert os.environ.get("browser_port") == "9222" assert mock_request.state.browser_available is True mock_ensure_browser.assert_not_called() diff --git a/backend/tests/app/controller/test_health_controller.py b/backend/tests/app/controller/test_health_controller.py new file mode 100644 index 000000000..514b597f6 --- /dev/null +++ b/backend/tests/app/controller/test_health_controller.py @@ -0,0 +1,51 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from unittest.mock import patch + +import pytest + +from app.controller import health_controller + +pytestmark = pytest.mark.unit + + +class _FakeHands: + def get_capability_manifest(self): + return {"deployment": "remote_cluster"} + + +@pytest.mark.asyncio +async def test_health_detail_prefers_configured_cdp_url(monkeypatch): + monkeypatch.setenv("EIGENT_CDP_URL", "http://worker-17:9222") + monkeypatch.setenv("browser_port", "9222") + + with ( + patch( + "app.controller.health_controller.get_environment_hands", + return_value=_FakeHands(), + ), + patch( + "app.controller.health_controller.is_cdp_url_available", + return_value=True, + ) as is_cdp_url_available, + patch( + "app.controller.health_controller._is_cdp_available" + ) as is_cdp_available, + ): + response = await health_controller.health_check(detail=True) + + assert response.capabilities["browser_cdp_reachable"] is True + is_cdp_url_available.assert_called_once_with("http://worker-17:9222") + is_cdp_available.assert_not_called() diff --git a/backend/tests/app/controller/test_tool_controller.py b/backend/tests/app/controller/test_tool_controller.py index c6652c4aa..f814870a9 100644 --- a/backend/tests/app/controller/test_tool_controller.py +++ b/backend/tests/app/controller/test_tool_controller.py @@ -169,9 +169,61 @@ def acquire_resource( assert response["browser"]["managedBy"] == "remote" assert response["browser"]["host"] == "worker-17" assert os.environ["EIGENT_CDP_URL"] == "http://worker-17:9222" + assert os.environ["browser_port"] == "9222" tool_controller._clear_connected_cdp_browser() + @pytest.mark.asyncio + async def test_open_browser_login_uses_dedicated_cookie_port_when_existing( + self, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.delenv("EIGENT_LOGIN_BROWSER_CDP_PORT", raising=False) + + with patch( + "app.controller.tool_controller._is_port_in_use", + return_value=True, + ): + response = await tool_controller.open_browser_login() + + assert response["success"] is True + assert response["cdp_port"] == 9323 + assert response["session_id"] == "user_login" + + @pytest.mark.asyncio + async def test_browser_status_uses_dedicated_cookie_port( + self, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.delenv("EIGENT_LOGIN_BROWSER_CDP_PORT", raising=False) + + with patch( + "app.controller.tool_controller._is_port_in_use", + return_value=True, + ) as is_port_in_use: + response = await tool_controller.browser_status() + + assert response == {"is_open": True, "cdp_port": 9323} + is_port_in_use.assert_called_once_with(9323) + + def test_remote_browser_hands_rejects_async_manifest(self): + class _AsyncManifestHands: + async def get_capability_manifest(self): + return {"deployment": "remote_cluster"} + + assert not tool_controller._is_remote_browser_hands( + _AsyncManifestHands() + ) + + def test_remote_cdp_endpoint_uses_shared_validation(self): + with patch( + "app.controller.tool_controller.is_cdp_url_available", + return_value=True, + ) as is_cdp_url_available: + assert tool_controller._is_cdp_endpoint_available( + "http://worker-17:9222" + ) + + is_cdp_url_available.assert_called_once_with("http://worker-17:9222") + @pytest.mark.integration class TestToolControllerIntegration: @@ -238,8 +290,8 @@ def test_launch_cdp_browser_endpoint_integration( tool_controller._web_cdp_browser_meta = None with patch( - "app.controller.tool_controller.ensure_cdp_browser_available", - return_value=True, + "app.controller.tool_controller.ensure_cdp_browser_endpoint", + return_value="http://127.0.0.1:9222", ): response = client.post("/browser/cdp/launch") diff --git a/backend/tests/app/utils/test_browser_launcher.py b/backend/tests/app/utils/test_browser_launcher.py new file mode 100644 index 000000000..732bd74e4 --- /dev/null +++ b/backend/tests/app/utils/test_browser_launcher.py @@ -0,0 +1,108 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import json + +import pytest + +from app.utils.browser_launcher import ( + _is_supported_cdp_version, + normalize_cdp_url, +) + + +class _FakeWebSocket: + def __init__(self, response: dict): + self.response = response + self.sent: dict | None = None + + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + def send(self, message: str): + self.sent = json.loads(message) + + def recv(self, timeout: int): + assert timeout == 2 + return json.dumps(self.response) + + +@pytest.mark.unit +def test_normalize_cdp_url_accepts_host_port_without_scheme(): + assert normalize_cdp_url("worker-17:9333") == ( + "http://worker-17:9333", + "worker-17", + 9333, + ) + + +@pytest.mark.unit +def test_normalize_cdp_url_accepts_bare_port(): + assert normalize_cdp_url("9333") == ( + "http://127.0.0.1:9333", + "127.0.0.1", + 9333, + ) + + +@pytest.mark.unit +def test_supported_cdp_version_requires_context_management(monkeypatch): + fake_ws = _FakeWebSocket({"id": 1, "result": {}}) + monkeypatch.setattr( + "websockets.sync.client.connect", + lambda *args, **kwargs: fake_ws, + ) + + assert _is_supported_cdp_version( + { + "Browser": "Chrome/147.0.7727.102", + "User-Agent": "Chrome/147.0.7727.102", + "webSocketDebuggerUrl": "ws://127.0.0.1:9223/devtools/browser/id", + }, + "http://127.0.0.1:9223", + ) + assert fake_ws.sent == { + "id": 1, + "method": "Browser.setDownloadBehavior", + "params": {"behavior": "default"}, + } + + +@pytest.mark.unit +def test_supported_cdp_version_rejects_context_management_error(monkeypatch): + fake_ws = _FakeWebSocket( + { + "id": 1, + "error": { + "code": -32000, + "message": "Browser context management is not supported.", + }, + } + ) + monkeypatch.setattr( + "websockets.sync.client.connect", + lambda *args, **kwargs: fake_ws, + ) + + assert not _is_supported_cdp_version( + { + "Browser": "Chrome/147.0.7727.102", + "User-Agent": "Chrome/147.0.7727.102", + "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/browser/id", + }, + "http://127.0.0.1:9222", + ) diff --git a/docs/troubleshooting/bug.md b/docs/troubleshooting/bug.md index 17cac16e6..8a190eac7 100644 --- a/docs/troubleshooting/bug.md +++ b/docs/troubleshooting/bug.md @@ -10,23 +10,23 @@ width="100%" height="auto" /> -### Step 1: Access the Bug Report Feature +### Step 1: Open Report a bug -1. Locate the **Bug Report** button in the top section of the desktop interface, positioned to the right of your project name -1. Click the **Bug Report** button to initiate the reporting process +1. In the top bar, click the **Support** (help) icon +1. Choose **Report a bug** -### Step 2: Download Log Files +### Step 2: Describe the issue and save diagnostics -- Upon clicking the Bug Report button, log files will be automatically downloaded to your device -- These log files contain technical information that helps our development team diagnose issues more effectively +1. Enter what went wrong. Optionally add **steps to reproduce** +1. Click **Save diagnostics and open email** +1. Choose where to save the **diagnostics ZIP** (it includes app logs and a `bug_report.txt` file) -### Step 3: Complete the Bug Report Form +Your default email app opens addressed to **info@eigent.ai** with a pre-filled message. -- A bug report web form will automatically open in your default browser -- Please provide the following information: - - **Bug Description**: Write a clear description of the issue you encountered - - **Screenshot Upload**: Attach relevant screenshots that demonstrate the problem - - **Log File Upload**: Upload the log files that were downloaded in Step 2 +### Step 3: Send the email + +1. **Attach the ZIP** you just saved to the message (the mail app cannot add this automatically) +1. Add screenshots or other files if they help, then send ### Step 4: Join Our Community for Real-time Support @@ -47,9 +47,11 @@ Developers and technical users are welcome to report issues directly through our - **Repository URL**: https://github.com/eigent-ai/eigent - Submit detailed issues with reproduction steps +**Optional:** In the same **Report a bug** dialog, use **Download logs** to save a single log file (without the full diagnostics ZIP). + ## Important Notes -- Always include log files when reporting bugs for faster resolution +- Always include the diagnostics ZIP (or log export) when reporting bugs for faster resolution - Provide as much detail as possible in your bug description - Screenshots help our team understand visual issues more quickly - Our community channels provide the fastest response times for urgent issues diff --git a/electron/main/index.ts b/electron/main/index.ts index 1eec43fc8..a1c4dc336 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -36,7 +36,6 @@ import os, { homedir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import kill from 'tree-kill'; -import * as unzipper from 'unzipper'; import { copyBrowserData } from './copy'; import { FileReader } from './fileReader'; import { @@ -60,7 +59,7 @@ import { removeEnvKey, updateEnvBlock, } from './utils/envUtil'; -import { zipFolder } from './utils/log'; +import { createDiagnosticsZip, zipFolder } from './utils/log'; import { addMcp, readMcpConfig, removeMcp, updateMcp } from './utils/mcpConfig'; import { checkVenvExistsForPreCheck, @@ -413,13 +412,8 @@ protocol.registerSchemesAsPrivileged([ process.env.APP_ROOT = MAIN_DIST; process.env.VITE_PUBLIC = VITE_PUBLIC; -// Respect system theme on Windows, keep light theme on macOS for consistency -const isWindows = process.platform === 'win32'; -if (isWindows) { - nativeTheme.themeSource = 'system'; // Respect Windows dark/light mode -} else { - nativeTheme.themeSource = 'light'; // Keep existing behavior for macOS -} +// Always follow OS appearance so renderer `prefers-color-scheme` stays accurate. +nativeTheme.themeSource = 'system'; // Set log level log.transports.console.level = 'info'; @@ -1117,6 +1111,100 @@ function registerIpcHandlers() { } }); + ipcMain.handle('get-diagnostics-info', async () => { + return { + version: app.getVersion(), + platform: process.platform, + arch: process.arch, + }; + }); + + ipcMain.handle( + 'export-diagnostics-zip', + async ( + _event, + payload: { description: string; steps?: string } | undefined + ) => { + try { + const description = + typeof payload?.description === 'string' + ? payload.description.trim() + : ''; + if (!description) { + return { success: false, error: 'Description is required' }; + } + const steps = + typeof payload?.steps === 'string' ? payload.steps.trim() : ''; + + const logFiles: { src: string; destName: string }[] = []; + if (fs.existsSync(logPath)) { + logFiles.push({ src: logPath, destName: 'electron-main.log' }); + } + const backupResolved = getBackupLogPath(); + if ( + fs.existsSync(backupResolved) && + path.resolve(backupResolved) !== path.resolve(logPath) + ) { + logFiles.push({ + src: backupResolved, + destName: 'electron-userdata-logs.log', + }); + } + if (logFiles.length === 0) { + return { success: false, error: 'no log file' }; + } + + const appVersion = app.getVersion(); + const platform = process.platform; + const arch = process.arch; + const bugReportText = [ + 'Eigent bug report', + '=================', + '', + `App version: ${appVersion}`, + `OS: ${platform} (${arch})`, + '', + 'Description', + '-----------', + description, + '', + ...(steps + ? ['Steps to reproduce', '-------------------', steps, ''] + : []), + ].join('\n'); + + const defaultFileName = `eigent-diagnostics-${appVersion}-${Date.now()}.zip`; + const { canceled, filePath } = await dialog.showSaveDialog({ + title: 'Save diagnostics', + defaultPath: defaultFileName, + filters: [{ name: 'ZIP archive', extensions: ['zip'] }], + }); + + if (canceled || !filePath) { + return { success: false, error: '' }; + } + + await createDiagnosticsZip(filePath, bugReportText, logFiles); + return { success: true, savedPath: filePath }; + } catch (error: any) { + log.error('export-diagnostics-zip failed:', error); + return { success: false, error: error.message }; + } + } + ); + + ipcMain.handle('open-mailto', async (_event, url: string) => { + try { + if (typeof url !== 'string' || !url.startsWith('mailto:')) { + return { success: false, error: 'Invalid mailto URL' }; + } + await shell.openExternal(url); + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } + }); + ipcMain.handle( 'upload-log', async ( @@ -2067,239 +2155,6 @@ async function seedDefaultSkillsIfEmpty(): Promise { } } -/** Truncate a single path component to fit within the 255-byte filesystem limit. */ -function safePathComponent(name: string, maxBytes = 200): string { - // 200 leaves headroom for suffixes the OS or future logic may add - if (Buffer.byteLength(name, 'utf-8') <= maxBytes) return name; - // Trim from the end, character by character, until it fits - let trimmed = name; - while (Buffer.byteLength(trimmed, 'utf-8') > maxBytes) { - trimmed = trimmed.slice(0, -1); - } - return trimmed.replace(/-+$/, '') || 'skill'; -} - -// Simple mutex to prevent concurrent skill imports -let _importLock: Promise = Promise.resolve(); -function withImportLock(fn: () => Promise): Promise { - let release: () => void; - const next = new Promise((resolve) => { - release = resolve; - }); - const prev = _importLock; - _importLock = next; - return prev.then(fn).finally(() => release!()); -} - -async function importSkillsFromZip( - zipPath: string, - replacements?: Set -): Promise<{ - success: boolean; - error?: string; - conflicts?: Array<{ folderName: string; skillName: string }>; -}> { - // Extract to a temp directory, then find SKILL.md files and copy their - // parent skill directories into SKILLS_ROOT. This handles any zip - // structure: wrapping directories, SKILL.md at root, or multiple skills. - const tempDir = path.join(os.tmpdir(), `eigent-skill-extract-${Date.now()}`); - try { - if (!existsSync(zipPath)) { - return { success: false, error: 'Zip file does not exist' }; - } - const ext = path.extname(zipPath).toLowerCase(); - if (ext !== '.zip') { - return { success: false, error: 'Only .zip files are supported' }; - } - if (!existsSync(SKILLS_ROOT)) { - await fsp.mkdir(SKILLS_ROOT, { recursive: true }); - } - - // Step 1: Extract zip into temp directory - await fsp.mkdir(tempDir, { recursive: true }); - const directory = await unzipper.Open.file(zipPath); - const resolvedTempDir = path.resolve(tempDir); - const comparePath = (value: string) => - process.platform === 'win32' ? value.toLowerCase() : value; - const resolvedTempDirCmp = comparePath(resolvedTempDir); - const resolvedTempDirWithSep = resolvedTempDirCmp.endsWith(path.sep) - ? resolvedTempDirCmp - : `${resolvedTempDirCmp}${path.sep}`; - for (const file of directory.files as any[]) { - if (file.type === 'Directory') continue; - const normalizedArchivePath = path - .normalize(String(file.path)) - .replace(/^([/\\])+/, ''); - const destPath = path.join(tempDir, normalizedArchivePath); - const resolvedDestPathCmp = comparePath(path.resolve(destPath)); - // Protect against zip-slip (e.g. entries containing ../) - if ( - !normalizedArchivePath || - (resolvedDestPathCmp !== resolvedTempDirCmp && - !resolvedDestPathCmp.startsWith(resolvedTempDirWithSep)) - ) { - return { success: false, error: 'Zip archive contains unsafe paths' }; - } - const destDir = path.dirname(destPath); - await fsp.mkdir(destDir, { recursive: true }); - const content = await file.buffer(); - await fsp.writeFile(destPath, content); - } - - // Step 2: Recursively find all SKILL.md files - const skillFiles: string[] = []; - async function findSkillMdFiles(dir: string) { - const entries = await fsp.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.name.startsWith('.')) continue; - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - await findSkillMdFiles(fullPath); - } else if (entry.name === SKILL_FILE) { - skillFiles.push(fullPath); - } - } - } - await findSkillMdFiles(tempDir); - - if (skillFiles.length === 0) { - return { - success: false, - error: 'No SKILL.md files found in zip archive', - }; - } - - // Step 3: Copy each skill directory into SKILLS_ROOT - - // Helper function to extract skill name from SKILL.md - async function getSkillName(skillFilePath: string): Promise { - try { - const raw = await fsp.readFile(skillFilePath, 'utf-8'); - const nameMatch = raw.match(/^\s*name\s*:\s*(.+)$/m); - const parsed = nameMatch?.[1]?.trim()?.replace(/^['"]|['"]$/g, ''); - return parsed || path.basename(path.dirname(skillFilePath)); - } catch { - return path.basename(path.dirname(skillFilePath)); - } - } - - // Helper: derive a safe folder name from a skill display name - function folderNameFromSkillName( - skillName: string, - fallback: string - ): string { - return safePathComponent( - skillName - .replace(/[\\/*?:"<>|\s]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') || fallback - ); - } - - // Step 3a: Scan existing skills to build a name→folderName map for - // name-based duplicate detection (case-insensitive). - const existingSkillNames = new Map(); // lower-case name → folder name on disk - if (existsSync(SKILLS_ROOT)) { - const rootEntries = await fsp.readdir(SKILLS_ROOT, { - withFileTypes: true, - }); - for (const entry of rootEntries) { - if (!entry.isDirectory() || entry.name.startsWith('.')) continue; - const existingSkillFile = path.join( - SKILLS_ROOT, - entry.name, - SKILL_FILE - ); - if (!existsSync(existingSkillFile)) continue; - try { - const raw = await fsp.readFile(existingSkillFile, 'utf-8'); - const nameMatch = raw.match(/^\s*name\s*:\s*(.+)$/m); - const name = nameMatch?.[1]?.trim()?.replace(/^['"]|['"]$/g, ''); - if (name) existingSkillNames.set(name.toLowerCase(), entry.name); - } catch { - // skip unreadable skill - } - } - } - - // Collect conflicts if replacements not provided - const conflicts: Array<{ folderName: string; skillName: string }> = []; - const replacementsSet = replacements || new Set(); - - for (const skillFilePath of skillFiles) { - const skillDir = path.dirname(skillFilePath); - - // Read the incoming skill's display name from SKILL.md frontmatter. - const incomingName = await getSkillName(skillFilePath); - const incomingNameLower = incomingName.toLowerCase(); - - // Determine where this skill will be written on disk. - // Both root-level and nested skills use the skill name to derive the - // folder, so that detection and storage are consistent. - const fallbackFolderName = - skillDir === tempDir - ? path.basename(zipPath, path.extname(zipPath)) - : path.basename(skillDir); - const destFolderName = folderNameFromSkillName( - incomingName, - fallbackFolderName - ); - const dest = path.join(SKILLS_ROOT, destFolderName); - - // Name-based duplicate detection: check if any existing skill already - // has this display name, regardless of what folder it lives in. - const existingFolder = existingSkillNames.get(incomingNameLower); - if (existingFolder) { - if (!replacements) { - // First pass — report conflict using the existing skill's folder as - // the key so the frontend can confirm the right replacement. - conflicts.push({ - folderName: existingFolder, - skillName: incomingName, - }); - continue; - } - if (replacementsSet.has(existingFolder)) { - // User confirmed — remove the existing skill folder before importing. - await fsp.rm(path.join(SKILLS_ROOT, existingFolder), { - recursive: true, - force: true, - }); - } else { - // User cancelled for this skill — skip it. - continue; - } - } - - // Import the skill (no conflict, or conflict was resolved). - await fsp.mkdir(dest, { recursive: true }); - if (skillDir === tempDir) { - // SKILL.md at zip root — copy all root-level entries. - await copyDirRecursive(tempDir, dest); - } else { - // SKILL.md inside a subdirectory — copy that directory. - await copyDirRecursive(skillDir, dest); - } - } - - // Return conflicts if any were found and replacements not provided - if (conflicts.length > 0 && !replacements) { - return { success: false, conflicts }; - } - - log.info( - `Imported ${skillFiles.length} skill(s) from zip into ~/.eigent/skills:`, - zipPath - ); - return { success: true }; - } catch (error: any) { - log.error('importSkillsFromZip failed', error); - return { success: false, error: error?.message || String(error) }; - } finally { - await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => {}); - } -} - // ==================== Shared backend startup logic ==================== // Starts backend after installation completes // Used by both initial startup and retry flows @@ -2322,6 +2177,7 @@ let installationLock: Promise = Promise.resolve({ // ==================== window create ==================== async function createWindow() { const isMac = process.platform === 'darwin'; + const isWindows = process.platform === 'win32'; // Ensure .eigent directories exist before anything else ensureEigentDirectories(); @@ -2348,10 +2204,10 @@ async function createWindow() { // Windows: native frame and solid background. macOS/Linux: frameless; macOS corner radius via native hook. win = new BrowserWindow({ title: 'Eigent', - width: 1200, - height: 800, - minWidth: 1050, - minHeight: 650, + width: 1280, + height: 960, + minWidth: 1100, + minHeight: 700, // Use native frame on Windows for better native integration frame: isWindows ? true : false, show: false, // Don't show until content is ready to avoid white screen diff --git a/electron/main/init.ts b/electron/main/init.ts index 20ed53bc0..90d0ec5a7 100644 --- a/electron/main/init.ts +++ b/electron/main/init.ts @@ -288,8 +288,7 @@ export async function startBackend( } // In dev mode, also check .env.development for SERVER_URL - if (!resolvedServerUrl && devServerUrl) { - const devEnvPath = path.join(app.getAppPath(), '.env.development'); + if (!resolvedServerUrl && fs.existsSync(devEnvPath)) { const devFileServerUrl = readEnvValue(devEnvPath, 'SERVER_URL'); if (devFileServerUrl) { resolvedServerUrl = devFileServerUrl; diff --git a/electron/main/utils/log.ts b/electron/main/utils/log.ts index bf7007874..495576cb4 100644 --- a/electron/main/utils/log.ts +++ b/electron/main/utils/log.ts @@ -12,7 +12,11 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { randomBytes } from 'node:crypto'; import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; // @ts-ignore import archiver from 'archiver'; import log from 'electron-log'; @@ -37,3 +41,42 @@ export function zipFolder( archive.finalize(); }); } + +export type DiagnosticsLogFile = { src: string; destName: string }; + +/** + * Stages log files and bug_report.txt into a temp directory, zips to outputZipPath, then removes the staging dir. + */ +export async function createDiagnosticsZip( + outputZipPath: string, + bugReportText: string, + logFiles: DiagnosticsLogFile[] +): Promise { + if (logFiles.length === 0) { + throw new Error('no log files to include'); + } + const id = randomBytes(8).toString('hex'); + const staging = path.join(os.tmpdir(), `eigent-diagnostics-${id}`); + await fsp.mkdir(staging, { recursive: true }); + try { + for (const f of logFiles) { + if (!fs.existsSync(f.src)) { + log.warn(`[diagnostics] skip missing log: ${f.src}`); + continue; + } + await fsp.copyFile(f.src, path.join(staging, f.destName)); + } + await fsp.writeFile( + path.join(staging, 'bug_report.txt'), + bugReportText, + 'utf-8' + ); + const entries = await fsp.readdir(staging); + if (entries.length === 0) { + throw new Error('no log files to include'); + } + await zipFolder(staging, outputZipPath); + } finally { + await fsp.rm(staging, { recursive: true, force: true }); + } +} diff --git a/electron/main/webview.ts b/electron/main/webview.ts index fda3bde0e..1575cf0bb 100644 --- a/electron/main/webview.ts +++ b/electron/main/webview.ts @@ -37,6 +37,20 @@ export class WebViewManager { private maxInactiveWebviews = 5; private lastCleanupTime = Date.now(); + private getHiddenBounds(id: string, width = 100, height = 100) { + const numericId = Number(id); + const idOffset = Number.isFinite(numericId) ? (numericId % 20) * 20 : 0; + const safeWidth = Math.max(width, 100); + const safeHeight = Math.max(height, 100); + + return { + x: -10000 - safeWidth - idOffset, + y: -10000 - safeHeight - idOffset, + width: safeWidth, + height: safeHeight, + }; + } + constructor(window: BrowserWindow) { this.win = window; } @@ -245,13 +259,7 @@ export class WebViewManager { // Set to muted state when created view.webContents.audioMuted = true; - let newId = Number(id); - view.setBounds({ - x: -9999 + newId * 100, - y: -9999 + newId * 100, - width: 100, - height: 100, - }); + view.setBounds(this.getHiddenBounds(id)); view.setBorderRadius(16); await view.webContents.loadURL(url); @@ -298,12 +306,7 @@ export class WebViewManager { this.win?.webContents.send('url-updated', navigationUrl); return; } - webViewInfo.view.setBounds({ - x: -1919, - y: -1079, - width: 1920, - height: 1080, - }); + webViewInfo.view.setBounds(this.getHiddenBounds(id, 1920, 1080)); const activeSize = this.getActiveWebview().length; const allSize = Array.from(this.webViews.values()).length; const inactiveSize = allSize - activeSize; @@ -374,13 +377,7 @@ export class WebViewManager { height: Math.max(height, 100), }); } else { - let newId = Number(id); - webViewInfo.view.setBounds({ - x: -9999 + newId * 100, - y: -9999 + newId * 100, - width: Math.max(width, 100), - height: Math.max(height, 100), - }); + webViewInfo.view.setBounds(this.getHiddenBounds(id, width, height)); } return { success: true }; @@ -395,13 +392,7 @@ export class WebViewManager { if (!webViewInfo) { return { success: false, error: `Webview with id ${id} not found` }; } - let newId = Number(id); - webViewInfo.view.setBounds({ - x: -9999 + newId * 100, - y: -9999 + newId * 100, - width: 100, - height: 100, - }); + webViewInfo.view.setBounds(this.getHiddenBounds(id)); webViewInfo.isShow = false; if ( @@ -415,13 +406,7 @@ export class WebViewManager { } public hideAllWebview() { this.webViews.forEach((webview) => { - let newId = Number(webview.id); - webview.view.setBounds({ - x: -9999 + newId * 100, - y: -9999 + newId * 100, - width: 100, - height: 100, - }); + webview.view.setBounds(this.getHiddenBounds(webview.id)); webview.isShow = false; if (webview.view.webContents && !webview.view.webContents.isDestroyed()) { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 2071116cd..2547c038a 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -76,6 +76,10 @@ contextBridge.exposeInMainWorld('electronAPI', { webviewDestroy: (webviewId: string) => ipcRenderer.invoke('webview-destroy', webviewId), exportLog: () => ipcRenderer.invoke('export-log'), + getDiagnosticsInfo: () => ipcRenderer.invoke('get-diagnostics-info'), + exportDiagnosticsZip: (payload: { description: string; steps?: string }) => + ipcRenderer.invoke('export-diagnostics-zip', payload), + openMailto: (url: string) => ipcRenderer.invoke('open-mailto', url), uploadLog: (email: string, taskId: string, baseUrl: string, token: string) => ipcRenderer.invoke('upload-log', email, taskId, baseUrl, token), // mcp diff --git a/package.json b/package.json index 75dc35552..53d0c2863 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,12 @@ "test:watch": "vitest", "test:e2e": "vitest run --config vitest.config.ts", "test:coverage": "vitest run --coverage", + "check:i18n": "node scripts/check-i18n-locale-parity.js", + "check:design-token-usage": "node scripts/check-design-token-usage.mjs", + "check:design-tokens": "npm run verify:theme && npm run check:design-token-usage", + "verify:theme": "vite-node scripts/verify-theme-tokens.ts", "type-check": "tsc -p tsconfig.build.json --noEmit", - "lint": "eslint . --no-warn-ignored", + "lint": "eslint . --no-warn-ignored && npm run check:design-token-usage", "lint:fix": "eslint . --fix --no-warn-ignored", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,css,md}\"", @@ -53,6 +57,7 @@ }, "dependencies": { "@electron/notarize": "^2.5.0", + "@emotion/is-prop-valid": "^1.3.1", "@fontsource/inter": "^5.2.5", "@gsap/react": "^2.1.2", "@microsoft/fetch-event-source": "^2.0.1", @@ -90,7 +95,6 @@ "dompurify": "^3.2.7", "electron-log": "^5.4.0", "electron-updater": "^6.3.9", - "@emotion/is-prop-valid": "^1.3.1", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", "framer-motion": "^12.17.0", @@ -99,12 +103,12 @@ "koffi": "^2.14.1", "lodash-es": "^4.17.21", "lottie-web": "^5.13.0", - "lucide-react": "^0.509.0", + "lucide-react": "^0.548.0", "mammoth": "^1.9.1", "marked": "^17.0.1", "mime": "^4.1.0", "monaco-editor": "^0.52.2", - "motion": "^12.23.24", + "motion": "^12.38.0", "next-themes": "^0.4.6", "papaparse": "^5.5.3", "postprocessing": "^6.37.8", diff --git a/scripts/check-design-token-usage.mjs b/scripts/check-design-token-usage.mjs new file mode 100644 index 000000000..26ae1260d --- /dev/null +++ b/scripts/check-design-token-usage.mjs @@ -0,0 +1,196 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +/** + * Fails if UI source contains hard-coded colors instead of design tokens + * (CSS variables such as var(--ds-...), var(--colors-...), component vars, or + * Tailwind classes that map to those vars — not raw #hex / rgb() / hsl()). + * + * Usage: + * node scripts/check-design-token-usage.mjs + * node scripts/check-design-token-usage.mjs src/a.tsx # lint-staged (one or more files) + * + * Exemptions: + * - End-of-line comment: // ds:allow-hardcoded-color + * - scripts/design-token-usage.allowlist — repo-relative paths, one per line (# comments ok) + */ + +import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, '..'); + +const EXT = new Set(['.ts', '.tsx', '.js', '.jsx']); + +const SKIP_PREFIXES = ['src/lib/themeTokens/']; + +const SKIP_FILE_RE = + /\.(test|spec)\.(ts|tsx|js|jsx)$|vite-env\.d\.ts$|\.stories\.(ts|tsx)$/; + +const HEX_RE = /#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g; + +const RGB_NUM_RE = /\brgba?\(\s*[\d.]/; +const HSL_NUM_RE = /\bhsla?\(\s*[\d.]/; + +const ARBITRARY_HEX_RE = + /\[[^\]]*#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b[^\]]*\]/; +const ARBITRARY_RGB_RE = /\[[^\]]*rgba?\([^\]]*\]/; + +function loadAllowlist() { + const path = join(REPO_ROOT, 'scripts/design-token-usage.allowlist'); + const set = new Set(); + if (!existsSync(path)) return set; + const raw = readFileSync(path, 'utf8'); + for (const line of raw.split('\n')) { + const t = line.trim(); + if (!t || t.startsWith('#')) continue; + set.add(t.replaceAll('\\', '/')); + } + return set; +} + +function shouldSkipPath(relPosix, allowlist) { + const norm = relPosix.replaceAll('\\', '/'); + if (allowlist.has(norm)) return true; + for (const prefix of SKIP_PREFIXES) { + if (norm.startsWith(prefix)) return true; + } + if (SKIP_FILE_RE.test(norm)) return true; + return false; +} + +function* walkSrcFiles(dir) { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const e of entries) { + const p = join(dir, e.name); + if (e.isDirectory()) { + if (e.name === 'node_modules' || e.name === 'dist') continue; + yield* walkSrcFiles(p); + } else { + const ext = e.name.slice(e.name.lastIndexOf('.')); + if (EXT.has(ext)) yield p; + } + } +} + +function stripTrailingLineComment(line) { + const idx = line.indexOf('//'); + if (idx === -1) return line; + return line.slice(0, idx); +} + +function lineHasExemption(line) { + return line.includes('//') && line.includes('ds:allow-hardcoded-color'); +} + +function checkLine(rawLine, lineNum, fileRel, out) { + if (lineHasExemption(rawLine)) return; + const line = stripTrailingLineComment(rawLine); + + if (RGB_NUM_RE.test(line) || HSL_NUM_RE.test(line)) { + out.push({ + file: fileRel, + line: lineNum, + message: + 'Use design tokens (e.g. var(--ds-...), Tailwind semantic colors) instead of raw rgb/hsl.', + snippet: rawLine.trim(), + }); + return; + } + + HEX_RE.lastIndex = 0; + let m; + while ((m = HEX_RE.exec(line)) !== null) { + const start = m.index; + const before = line.slice(Math.max(0, start - 4), start); + if (/url\s*\(\s*$/i.test(before)) continue; + out.push({ + file: fileRel, + line: lineNum, + message: `Hard-coded hex "${m[0]}" — use a design token or Tailwind color that maps to var(--...).`, + snippet: rawLine.trim(), + }); + return; + } + + if (ARBITRARY_HEX_RE.test(line) || ARBITRARY_RGB_RE.test(line)) { + out.push({ + file: fileRel, + line: lineNum, + message: + 'Tailwind arbitrary color value ([#...] / [rgb(...)]) — use ds-* or semantic utilities.', + snippet: rawLine.trim(), + }); + } +} + +function checkFile(absPath, allowlist) { + const rel = relative(REPO_ROOT, absPath); + const relPosix = rel.replaceAll('\\', '/'); + if (shouldSkipPath(relPosix, allowlist)) return []; + + const text = readFileSync(absPath, 'utf8'); + const lines = text.split(/\r?\n/); + const out = []; + lines.forEach((ln, i) => checkLine(ln, i + 1, relPosix, out)); + return out; +} + +function resolveCliFiles(argv) { + const files = []; + for (const a of argv) { + if (a.startsWith('-')) continue; + const abs = resolve(REPO_ROOT, a); + if (existsSync(abs) && statSync(abs).isFile()) files.push(abs); + } + return files; +} + +function main() { + const allowlist = loadAllowlist(); + const argv = process.argv.slice(2).filter((a) => a !== '--'); + const explicit = resolveCliFiles(argv); + + const targets = + explicit.length > 0 + ? explicit + : [...walkSrcFiles(join(REPO_ROOT, 'src'))]; + + const violations = []; + for (const abs of targets) { + violations.push(...checkFile(abs, allowlist)); + } + + if (violations.length === 0) { + console.log('check-design-token-usage: OK (no hard-coded colors found).'); + process.exit(0); + } + + console.error('check-design-token-usage: hard-coded colors detected:\n'); + for (const v of violations) { + console.error(` ${v.file}:${v.line}`); + console.error(` ${v.message}`); + console.error( + ` ${v.snippet.slice(0, 200)}${v.snippet.length > 200 ? '…' : ''}\n` + ); + } + console.error( + `Total: ${violations.length} finding(s). Fix or add // ds:allow-hardcoded-color on the line, or list the file in scripts/design-token-usage.allowlist (one path per line).` + ); + process.exit(1); +} + +main(); diff --git a/scripts/check-electron-access.sh b/scripts/check-electron-access.sh index 9736265e0..9c061ba20 100644 --- a/scripts/check-electron-access.sh +++ b/scripts/check-electron-access.sh @@ -6,8 +6,9 @@ set -euo pipefail # only src/host/createHost.ts may read window.electronAPI/window.ipcRenderer. violations="$( rg -n \ - -e 'window\.electronAPI' \ - -e 'window\.ipcRenderer' \ + -e 'window\s*(\?\.)?\s*\.\s*(electronAPI|ipcRenderer)' \ + -e '\(window\s+as\s+any\)\s*\.\s*(electronAPI|ipcRenderer)' \ + -e 'window\s*\[\s*["'\''](electronAPI|ipcRenderer)["'\'']\s*\]' \ --glob '*.{ts,tsx,js,jsx}' \ --glob '!src/host/createHost.ts' \ src || true diff --git a/scripts/check-i18n-locale-parity.js b/scripts/check-i18n-locale-parity.js new file mode 100644 index 000000000..b0e85e8b4 --- /dev/null +++ b/scripts/check-i18n-locale-parity.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +/* global console, process */ + +/** + * Ensures every locale has the same JSON keys as `en-us` for every namespace JSON under `src/i18n/locales//`. + * Run from repo root: `node scripts/check-i18n-locale-parity.js` + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..'); +const localesDir = path.join(projectRoot, 'src', 'i18n', 'locales'); +const referenceLocale = 'en-us'; + +function collectJsonFiles(dir) { + const out = []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + out.push(...collectJsonFiles(full)); + } else if (e.isFile() && e.name.endsWith('.json')) { + out.push(full); + } + } + return out; +} + +function flattenKeys(obj, prefix = '') { + const keys = []; + for (const [k, v] of Object.entries(obj)) { + const p = prefix ? `${prefix}.${k}` : k; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + keys.push(...flattenKeys(v, p)); + } else { + keys.push(p); + } + } + return keys; +} + +function main() { + const refPath = path.join(localesDir, referenceLocale); + if (!fs.existsSync(refPath)) { + console.error(`Reference locale not found: ${refPath}`); + process.exit(1); + } + + const refFiles = collectJsonFiles(refPath); + const refRelative = refFiles.map((f) => path.relative(refPath, f)); + + const localeDirs = fs + .readdirSync(localesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .filter((name) => name !== referenceLocale); + + let failed = false; + + for (const locale of localeDirs) { + const locPath = path.join(localesDir, locale); + for (const rel of refRelative) { + const refFile = path.join(refPath, rel); + const targetFile = path.join(locPath, rel); + if (!fs.existsSync(targetFile)) { + console.error(`Missing file [${locale}]: ${rel}`); + failed = true; + continue; + } + let refJson; + let tgtJson; + try { + refJson = JSON.parse(fs.readFileSync(refFile, 'utf8')); + tgtJson = JSON.parse(fs.readFileSync(targetFile, 'utf8')); + } catch (e) { + console.error(`Invalid JSON (${locale}/${rel}):`, e.message); + failed = true; + continue; + } + const refKeys = new Set(flattenKeys(refJson)); + const tgtKeys = new Set(flattenKeys(tgtJson)); + for (const k of refKeys) { + if (!tgtKeys.has(k)) { + console.error(`Missing key [${locale}] ${rel}: ${k}`); + failed = true; + } + } + for (const k of tgtKeys) { + if (!refKeys.has(k)) { + console.error(`Extra key [${locale}] ${rel}: ${k}`); + failed = true; + } + } + } + } + + if (failed) { + console.error('\ncheck-i18n-locale-parity: FAILED'); + process.exit(1); + } + console.log('check-i18n-locale-parity: OK'); +} + +main(); diff --git a/scripts/design-token-usage.allowlist b/scripts/design-token-usage.allowlist new file mode 100644 index 000000000..fa84573ba --- /dev/null +++ b/scripts/design-token-usage.allowlist @@ -0,0 +1,15 @@ +# Whole-file skips for scripts/check-design-token-usage.mjs (run: npm run check:design-token-usage). +# Prefer design tokens or a line-level // ds:allow-hardcoded-color comment. +# List a path only when the file must contain raw colors by design (whole module is the exception). +# +# WordCarousel — default marketing gradient uses fixed brand hex; pass `gradient` for token-based styling. +src/components/ui/WordCarousel/WordCarousel.tsx +# +# Terminal — @xterm/xterm ITheme requires hex strings for theme colors. +src/components/Terminal/index.tsx +# +# Install progress bar (src/components/ui/progress-install.tsx) and similar: use CSS vars only — no entry needed. +# +# Electron shell surfaces use fixed native colors in preload HTML and BrowserWindow options. +electron/main/index.ts +electron/preload/index.ts diff --git a/scripts/verify-theme-tokens.ts b/scripts/verify-theme-tokens.ts new file mode 100644 index 000000000..da70a1255 --- /dev/null +++ b/scripts/verify-theme-tokens.ts @@ -0,0 +1,195 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +// Standalone V2 design-token verification CLI. +// +// Runs the verifier over every registered theme/mode/contrast variant and +// prints a human-readable report. Exits with a non-zero code if any `error` +// findings are produced. Auxiliary contrast warnings do not fail the run +// unless `--strict` is passed. +// +// Usage: +// npm run verify:theme +// npm run verify:theme -- --strict # auxiliary warnings fail too +// npm run verify:theme -- --json # machine-readable output +// npm run verify:theme -- --contrast 0,50,100 + +import { + getDefaultContrastGrid, + listRegisteredThemes, + verifyThemeEngine, + type VerifyFinding, +} from '../src/lib/themeTokens/verifier'; + +type CliFlags = { + strict: boolean; + json: boolean; + contrastGrid: number[]; +}; + +function parseArgs(argv: string[]): CliFlags { + const flags: CliFlags = { + strict: false, + json: false, + contrastGrid: getDefaultContrastGrid(), + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--strict') flags.strict = true; + else if (arg === '--json') flags.json = true; + else if (arg === '--contrast') { + const raw = argv[++i]; + if (raw) { + flags.contrastGrid = raw + .split(',') + .map((s) => Number(s.trim())) + .filter((n) => Number.isFinite(n)); + } + } else if (arg === '--help' || arg === '-h') { + process.stdout.write( + [ + 'Usage: verify-theme-tokens [options]', + '', + 'Options:', + ' --strict Treat auxiliary contrast warnings as errors', + ' --json Emit JSON report on stdout', + ' --contrast a,b,c Override contrast grid (default: 0,25,43,75,100)', + ' -h, --help Show this help', + '', + ].join('\n') + ); + process.exit(0); + } + } + return flags; +} + +const COLORS = { + reset: '\x1b[0m', + red: '\x1b[31m', + yellow: '\x1b[33m', + green: '\x1b[32m', + dim: '\x1b[2m', + bold: '\x1b[1m', + cyan: '\x1b[36m', +}; + +function colorize(text: string, code: string): string { + if (!process.stdout.isTTY) return text; + return `${code}${text}${COLORS.reset}`; +} + +function groupFindings( + findings: VerifyFinding[] +): Map { + const groups = new Map(); + for (const f of findings) { + const key = `${f.mode} / ${f.themeId} / contrast=${f.contrast}`; + const bucket = groups.get(key); + if (bucket) bucket.push(f); + else groups.set(key, [f]); + } + return groups; +} + +function printHumanReport( + themes: Array<{ mode: string; id: string }>, + flags: CliFlags, + report: ReturnType +): void { + const { summary, findings } = report; + + process.stdout.write( + `\n${colorize('Design Token Engine Verification (V2)', COLORS.bold)}\n` + ); + process.stdout.write( + colorize( + ` Registered themes: ${themes.map((t) => `${t.mode}/${t.id}`).join(', ')}\n`, + COLORS.dim + ) + ); + process.stdout.write( + colorize( + ` Contrast grid: ${flags.contrastGrid.join(', ')}\n`, + COLORS.dim + ) + ); + process.stdout.write( + colorize(` Variants checked: ${summary.variantsChecked}\n\n`, COLORS.dim) + ); + + if (findings.length === 0) { + process.stdout.write( + `${colorize('PASS', COLORS.green)} No findings — engine is clean.\n\n` + ); + return; + } + + const groups = groupFindings(findings); + for (const [variant, bucket] of groups) { + process.stdout.write(`${colorize(variant, COLORS.cyan)}\n`); + for (const f of bucket) { + const badge = + f.severity === 'error' + ? colorize('ERROR', COLORS.red) + : colorize('WARN ', COLORS.yellow); + const ratioSuffix = + f.ratio !== undefined && f.threshold !== undefined + ? colorize( + ` (ratio ${f.ratio.toFixed(2)} / threshold ${f.threshold})`, + COLORS.dim + ) + : ''; + process.stdout.write( + ` ${badge} [${f.code}] ${f.message}${ratioSuffix}\n` + ); + if (f.tokenKey && f.value) { + process.stdout.write( + colorize(` ↳ ${f.tokenKey} = ${f.value}\n`, COLORS.dim) + ); + } + } + process.stdout.write('\n'); + } + + const errBadge = + summary.errors === 0 + ? colorize(`${summary.errors} errors`, COLORS.green) + : colorize(`${summary.errors} errors`, COLORS.red); + const warnBadge = + summary.warnings === 0 + ? colorize(`${summary.warnings} warnings`, COLORS.green) + : colorize(`${summary.warnings} warnings`, COLORS.yellow); + process.stdout.write(`Summary: ${errBadge}, ${warnBadge}\n\n`); +} + +function main() { + const flags = parseArgs(process.argv.slice(2)); + const themes = listRegisteredThemes(); + const report = verifyThemeEngine({ + contrastGrid: flags.contrastGrid, + strictAuxContrast: flags.strict, + }); + + if (flags.json) { + process.stdout.write(JSON.stringify({ themes, ...report }, null, 2) + '\n'); + } else { + printHumanReport(themes, flags, report); + } + + const failed = report.summary.errors > 0; + process.exit(failed ? 1 : 0); +} + +main(); diff --git a/server/app/domains/chat/api/share_controller.py b/server/app/domains/chat/api/share_controller.py index 674c72226..6877dde3f 100644 --- a/server/app/domains/chat/api/share_controller.py +++ b/server/app/domains/chat/api/share_controller.py @@ -70,6 +70,7 @@ async def event_generator(): "task_id": s.task_id, "step": s.step, "data": s.data, + "timestamp": s.timestamp, "created_at": s.created_at.isoformat() if s.created_at else None, } yield f"data: {json.dumps(step_data)}\n\n" diff --git a/server/app/domains/chat/api/step_controller.py b/server/app/domains/chat/api/step_controller.py index d14c84ad0..e8f58fbe7 100644 --- a/server/app/domains/chat/api/step_controller.py +++ b/server/app/domains/chat/api/step_controller.py @@ -83,6 +83,7 @@ async def event_generator(): "task_id": s.task_id, "step": s.step, "data": s.data, + "timestamp": s.timestamp, "created_at": s.created_at.isoformat() if s.created_at else None, } yield f"data: {json.dumps(step_data)}\n\n" diff --git a/src/api/http.ts b/src/api/http.ts index da84ed9f8..0c523df6e 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -15,6 +15,7 @@ import { showCreditsToast } from '@/components/Toast/creditsToast'; import { showStorageToast } from '@/components/Toast/storageToast'; import { showTrafficToast } from '@/components/Toast/trafficToast'; +import { createHost } from '@/host/createHost'; import { getAuthStore } from '@/store/authStore'; import { getConnectionConfig, @@ -87,7 +88,7 @@ export async function getBaseURL() { return cfg.brainEndpoint.replace(/\/$/, ''); } // Electron: get port from IPC - const port = await (window as any).ipcRenderer?.invoke('get-backend-port'); + const port = await createHost().ipcRenderer?.invoke('get-backend-port'); if (port && port > 0) { const resolved = `http://localhost:${port}`; setConnectionConfig({ brainEndpoint: resolved }); diff --git a/src/assets/dark.png b/src/assets/dark.png deleted file mode 100644 index d46a6a756..000000000 Binary files a/src/assets/dark.png and /dev/null differ diff --git a/src/assets/icon/cursor.svg b/src/assets/icon/cursor.svg new file mode 100644 index 000000000..2104885da --- /dev/null +++ b/src/assets/icon/cursor.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/src/assets/icon/github.svg b/src/assets/icon/github.svg new file mode 100644 index 000000000..bd1fe3b66 --- /dev/null +++ b/src/assets/icon/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icon/google_calendar.svg b/src/assets/icon/google_calendar.svg new file mode 100644 index 000000000..920991de6 --- /dev/null +++ b/src/assets/icon/google_calendar.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icon/google_gmail.svg b/src/assets/icon/google_gmail.svg new file mode 100644 index 000000000..fadeff8f6 --- /dev/null +++ b/src/assets/icon/google_gmail.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icon/linkedin.svg b/src/assets/icon/linkedin.svg new file mode 100644 index 000000000..2cf6f301a --- /dev/null +++ b/src/assets/icon/linkedin.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icon/notion.svg b/src/assets/icon/notion.svg new file mode 100644 index 000000000..070bcd78a --- /dev/null +++ b/src/assets/icon/notion.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icon/rag.svg b/src/assets/icon/rag.svg new file mode 100644 index 000000000..bee2d0fa1 --- /dev/null +++ b/src/assets/icon/rag.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icon/reddit.svg b/src/assets/icon/reddit.svg new file mode 100644 index 000000000..4e427bc7e --- /dev/null +++ b/src/assets/icon/reddit.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icon/slack.svg b/src/assets/icon/slack.svg index 73d69d013..53b24e829 100644 --- a/src/assets/icon/slack.svg +++ b/src/assets/icon/slack.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/src/assets/icon/vs-code.svg b/src/assets/icon/vs-code.svg new file mode 100644 index 000000000..24c1d67a9 --- /dev/null +++ b/src/assets/icon/vs-code.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icon/whatsapp.svg b/src/assets/icon/whatsapp.svg new file mode 100644 index 000000000..49f70f468 --- /dev/null +++ b/src/assets/icon/whatsapp.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icon/x.svg b/src/assets/icon/x.svg new file mode 100644 index 000000000..f414fb575 --- /dev/null +++ b/src/assets/icon/x.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/light.png b/src/assets/light.png deleted file mode 100644 index f0de3f5e5..000000000 Binary files a/src/assets/light.png and /dev/null differ diff --git a/src/assets/logo/icon_black.svg b/src/assets/logo/icon_black.svg index 752962fb5..a4a46348b 100644 --- a/src/assets/logo/icon_black.svg +++ b/src/assets/logo/icon_black.svg @@ -1,33 +1,32 @@ - - - - - - + + + + + + - + - - - - - - - - - + + + + + + + + - + diff --git a/src/assets/logo/icon_white.svg b/src/assets/logo/icon_white.svg index 4cefd0711..a66226908 100644 --- a/src/assets/logo/icon_white.svg +++ b/src/assets/logo/icon_white.svg @@ -1,33 +1,32 @@ - - - - - - + + + + + + - + - - - - - - - - - - + + + + + + + + + - - + + diff --git a/src/assets/transparent.png b/src/assets/transparent.png deleted file mode 100644 index 798523455..000000000 Binary files a/src/assets/transparent.png and /dev/null differ diff --git a/src/client/platform.ts b/src/client/platform.ts index 9c717066f..269d073e3 100644 --- a/src/client/platform.ts +++ b/src/client/platform.ts @@ -13,6 +13,8 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= // Client platform detection. See docs/design/04-client.md. +import { createHost } from '@/host/createHost'; + export type ClientType = | 'desktop' | 'web' @@ -26,11 +28,8 @@ export type ClientType = /** True when running inside Electron (desktop app). */ export function isElectron(): boolean { - return ( - typeof window !== 'undefined' && - !!(window as any).electronAPI && - !!(window as any).ipcRenderer - ); + const host = createHost(); + return !!host.electronAPI && !!host.ipcRenderer; } /** Current client type. Web build = 'web'; Electron = 'desktop'. */ diff --git a/src/components/AddWorker/ToolSelect.tsx b/src/components/AddWorker/ToolSelect.tsx index 35437aedb..4d3247dc4 100644 --- a/src/components/AddWorker/ToolSelect.tsx +++ b/src/components/AddWorker/ToolSelect.tsx @@ -20,30 +20,31 @@ import { proxyFetchPost, proxyFetchPut, } from '@/api/http'; -import githubIcon from '@/assets/github.svg'; -import IntegrationList from '@/components/IntegrationList'; +import IntegrationList from '@/components/Dashboard/IntegrationList'; import { Badge } from '@/components/ui/badge'; +import { + useIntegrationManagement, + type IntegrationItem, +} from '@/hooks/useIntegrationManagement'; import { useHost } from '@/host'; import { capitalizeFirstLetter, getProxyBaseURL } from '@/lib'; +import { cn } from '@/lib/utils'; import { useAuthStore } from '@/store/authStore'; -import { CircleAlert, Store, X } from 'lucide-react'; +import { CircleAlert, X } from 'lucide-react'; import { forwardRef, useCallback, useEffect, useImperativeHandle, + useMemo, useRef, useState, } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button } from '../ui/button'; +import { Checkbox } from '../ui/checkbox'; import { Textarea } from '../ui/textarea'; import { TooltipSimple } from '../ui/tooltip'; -import AnthropicIcon from '@/assets/mcp/Anthropic.svg?url'; -import CamelIcon from '@/assets/mcp/Camel.svg?url'; -import CommunityIcon from '@/assets/mcp/Community.svg?url'; -import OfficialIcon from '@/assets/mcp/Official.svg?url'; interface McpItem { id: number; name: string; @@ -73,22 +74,22 @@ const ToolSelect = forwardRef< const { t } = useTranslation(); // state management - remove internal selected state, use parent passed initialSelectedTools const [keyword, setKeyword] = useState(''); - const [mcpList, setMcpList] = useState([]); - const [allMcpList, setAllMcpList] = useState([]); - const [customMcpList, setCustomMcpList] = useState([]); - const [isOpen, setIsOpen] = useState(false); - const [installed, setInstalled] = useState<{ [id: number]: boolean }>({}); - const [installing, setInstalling] = useState<{ [id: number]: boolean }>({}); - const [installedIds, setInstalledIds] = useState([]); const { email } = useAuthStore(); - // add: integration service list const [integrations, setIntegrations] = useState([]); + const [userMcpList, setUserMcpList] = useState([]); + + const integrationItems = integrations as IntegrationItem[]; + const { installed: webInstalled } = + useIntegrationManagement(integrationItems); + + const inputRef = useRef(null); + const debounceTimerRef = useRef(null); + const containerRef = useRef(null); + // select management const addOption = useCallback( (item: McpItem, isLocal?: boolean) => { - setKeyword(''); const currentSelected = initialSelectedTools || []; - console.log(currentSelected.find((i) => i.id === item.id)); if (isLocal) { if (!currentSelected.find((i) => i.key === item.key)) { const newSelected = [...currentSelected, { ...item, isLocal }]; @@ -298,86 +299,23 @@ const ToolSelect = forwardRef< [addOption, t] ); - // Refs - const inputRef = useRef(null); - const debounceTimerRef = useRef(null); - const containerRef = useRef(null); - - // constants - const categoryIconMap: Record = { - anthropic: 'Anthropic', - community: 'Community', - official: 'Official', - camel: 'Camel', - }; - - const svgIcons: Record = { - Anthropic: AnthropicIcon, - Community: CommunityIcon, - Official: OfficialIcon, - Camel: CamelIcon, - }; - - // data fetching - const fetchData = useCallback((keyword?: string) => { - proxyFetchGet('/api/v1/mcps', { - keyword: keyword || '', - page: 1, - size: 100, - }) - .then((res) => { - // 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([]); - }); - }, []); - const fetchInstalledMcps = useCallback(() => { proxyFetchGet('/api/v1/mcp/users') .then((res) => { - let dataList = []; - let ids: number[] = []; + let dataList: any[] = []; if (Array.isArray(res)) { - ids = res.map((item: any) => item.mcp_id); dataList = res; } else if (res && Array.isArray(res.items)) { - ids = res.items.map((item: any) => item.mcp_id); dataList = res.items; } - setInstalledIds(ids); - - const customMcpList = dataList.filter((item: any) => item.mcp_id === 0); - setCustomMcpList(customMcpList); + setUserMcpList(dataList); }) .catch((error) => { console.error('Error fetching installed MCPs:', error); - setInstalledIds([]); - setCustomMcpList([]); + setUserMcpList([]); }); }, []); - // only surface installed MCPs from the market list - useEffect(() => { - // 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]); - // public save env/config logic const saveEnvAndConfig = async ( provider: string, @@ -634,11 +572,16 @@ const ToolSelect = forwardRef< } return; } - setInstalling((prev) => ({ ...prev, [id]: true })); try { await proxyFetchPost('/api/v1/mcp/install?mcp_id=' + id); - setInstalled((prev) => ({ ...prev, [id]: true })); - const installedMcp = mcpList.find((mcp) => mcp.id === id); + const listRes = await proxyFetchGet('/api/v1/mcps', { + page: 1, + size: 200, + keyword: '', + }); + const items = + listRes?.items && Array.isArray(listRes.items) ? listRes.items : []; + const installedMcp = items.find((mcp: McpItem) => mcp.id === id); if (installedMcp?.install_command) { const installCmd = { ...installedMcp.install_command }; if (envValue) { @@ -650,14 +593,11 @@ const ToolSelect = forwardRef< } await mcpInstall(installedMcp.key, installCmd); } - // after install successfully, automatically add to selected list if (installedMcp) { addOption(installedMcp); } } catch (e) { console.error('Failed to install MCP:', e); - } finally { - setInstalling((prev) => ({ ...prev, [id]: false })); } }; @@ -666,57 +606,81 @@ const ToolSelect = forwardRef< installMcp, })); - const checkEnv = (id: number) => { - const mcp = mcpList.find((mcp) => mcp.id === id); - if (mcp && Object.keys(mcp?.install_command?.env || {}).length > 0) { - if (onShowEnvConfig) { - onShowEnvConfig(mcp); - } - } else { - installMcp(id); - } - }; - - const removeOption = (item: McpItem) => { - const currentSelected = initialSelectedTools || []; - const newSelected = currentSelected.filter((i) => i.id !== item.id); - onSelectedToolsChange?.(newSelected); - }; - - // tool functions - const getCategoryIcon = (categoryName?: string) => { - if (!categoryName) return ; + const removeOption = useCallback( + (item: McpItem) => { + const currentSelected = initialSelectedTools || []; + const newSelected = currentSelected.filter((i) => i.id !== item.id); + onSelectedToolsChange?.(newSelected); + }, + [initialSelectedTools, onSelectedToolsChange] + ); - const normalizedName = categoryName.toLowerCase(); - const iconKey = categoryIconMap[normalizedName]; - const iconUrl = iconKey ? svgIcons[iconKey] : undefined; + const buildLocalToolFromIntegration = useCallback( + (item: IntegrationItem): McpItem => { + const normalizedToolkit = + item.name === 'Notion' ? 'notion_mcp_toolkit' : item.toolkit; + return { + id: 0, + key: item.key, + name: item.name, + description: typeof item.desc === 'string' ? item.desc : '', + toolkit: normalizedToolkit, + isLocal: true, + }; + }, + [] + ); - return iconUrl ? ( - {categoryName} - ) : ( - - ); - }; + const isIntegrationInAgentSelection = useCallback( + (item: IntegrationItem) => + !!(initialSelectedTools || []).find( + (s) => s.isLocal && s.key === item.key + ), + [initialSelectedTools] + ); - const getGithubRepoName = (homePage?: string) => { - if (!homePage || !homePage.startsWith('https://github.com/')) return null; - const parts = homePage.split('/'); - return parts.length > 4 ? parts[4] : homePage; - }; + const handleToggleIntegrationForAgent = useCallback( + (item: IntegrationItem, selected: boolean) => { + if (selected) { + addOption(buildLocalToolFromIntegration(item), true); + } else { + const found = (initialSelectedTools || []).find( + (s) => s.isLocal && s.key === item.key + ); + if (found) removeOption(found); + } + }, + [ + addOption, + buildLocalToolFromIntegration, + initialSelectedTools, + removeOption, + ] + ); - const getInstallButtonText = (itemId: number) => { - if (installedIds.includes(itemId)) return t('layout.installed'); - if (installing[itemId]) return t('layout.installing'); - if (installed[itemId]) return t('layout.installed'); - return t('layout.install'); - }; + const handleToggleUserMcp = useCallback( + (row: any, selected: boolean) => { + if (selected) { + addOption({ + id: row.id, + key: row.mcp_key || String(row.id), + name: row.mcp_name || row.mcp_key, + description: String(row.mcp_desc || ''), + mcp_name: row.mcp_name, + } as McpItem); + } else { + const found = (initialSelectedTools || []).find((i) => i.id === row.id); + if (found) removeOption(found); + } + }, + [addOption, initialSelectedTools, removeOption] + ); // Effects useEffect(() => { - fetchData(); fetchIntegrationsData(); fetchInstalledMcps(); - }, [fetchData, fetchIntegrationsData, fetchInstalledMcps]); + }, [fetchIntegrationsData, fetchInstalledMcps]); useEffect(() => { if (debounceTimerRef.current) { @@ -724,7 +688,6 @@ const ToolSelect = forwardRef< } debounceTimerRef.current = setTimeout(() => { - fetchData(keyword); fetchIntegrationsData(keyword); }, 500); @@ -733,23 +696,56 @@ const ToolSelect = forwardRef< clearTimeout(debounceTimerRef.current); } }; - }, [keyword, fetchData, fetchIntegrationsData]); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); + }, [keyword, fetchIntegrationsData]); + + const webConnectedItems = useMemo(() => { + const kw = keyword.trim().toLowerCase(); + return integrations + .filter((i: IntegrationItem) => webInstalled[i.key]) + .filter((i: IntegrationItem) => { + if (!kw) return true; + const descStr = typeof i.desc === 'string' ? i.desc.toLowerCase() : ''; + return ( + (i.key || '').toLowerCase().includes(kw) || + (i.name || '').toLowerCase().includes(kw) || + descStr.includes(kw) + ); + }); + }, [integrations, webInstalled, keyword]); + + const webNotConnectedItems = useMemo(() => { + const kw = keyword.trim().toLowerCase(); + return integrations + .filter((i: IntegrationItem) => !webInstalled[i.key]) + .filter((i: IntegrationItem) => { + if (!kw) return true; + const descStr = typeof i.desc === 'string' ? i.desc.toLowerCase() : ''; + return ( + (i.key || '').toLowerCase().includes(kw) || + (i.name || '').toLowerCase().includes(kw) || + descStr.includes(kw) + ); + }); + }, [integrations, webInstalled, keyword]); + + const ownPicks = useMemo(() => { + const kw = keyword.trim().toLowerCase(); + return userMcpList.filter((opt) => { + if (!kw) return true; + const name = String(opt.mcp_name || '').toLowerCase(); + const desc = String(opt.mcp_desc || '').toLowerCase(); + const key = String(opt.mcp_key || '').toLowerCase(); + return name.includes(kw) || desc.includes(kw) || key.includes(kw); + }); + }, [userMcpList, keyword]); + + const listHasItems = + webConnectedItems.length > 0 || + webNotConnectedItems.length > 0 || + ownPicks.length > 0; + + const showSearchPlaceholder = + keyword.length === 0 && (initialSelectedTools?.length ?? 0) === 0; // render functions const renderSelectedItems = () => ( @@ -757,12 +753,14 @@ const ToolSelect = forwardRef< {(initialSelectedTools || []).map((item: any) => ( {item.name || item.mcp_name || item.key || `tool_${item.id}`} -
+
removeOption(item)} />
@@ -771,160 +769,132 @@ const ToolSelect = forwardRef< ); - const renderMcpItem = (item: McpItem) => ( -
{ - // check if already installed - const isAlreadyInstalled = - installedIds.includes(item.id) || installed[item.id]; - - if (isAlreadyInstalled) { - // if already installed, add to selected list directly - addOption(item); - setKeyword(''); - } else { - // if not installed, first check environment configuration, then install and add to selected list - checkEnv(item.id); - } - }} - className="flex cursor-pointer justify-between px-3 py-2 hover:bg-surface-hover-subtle" - > -
- {getCategoryIcon(item.category?.name)} -
- {item.name} -
- - e.stopPropagation()} - /> - -
-
- {getGithubRepoName(item.home_page) && ( -
- github - - {getGithubRepoName(item.home_page)} - -
- )} - + onClick={(e) => e.stopPropagation()} + aria-label={String(item.mcp_name || item.mcp_key || '')} + /> + + {capitalizeFirstLetter(item.mcp_name || '')} +
-
- ); + ); + }; - const renderCustomMcpItem = (item: any) => ( -
{ - addOption(item); - setKeyword(''); - }} - className="flex cursor-pointer justify-between px-3 py-2 hover:bg-surface-hover-subtle" - > -
- {/* {getCategoryIcon(item.category?.name)} */} -
- {item.mcp_name} -
- - e.stopPropagation()} - /> - -
-
- -
-
- ); return ( -
-
-
+
+
+
{t('workforce.agent-tool')} - +
{ - inputRef.current?.focus(); - setIsOpen(true); - }} - className="flex max-h-[120px] min-h-[60px] w-full flex-wrap justify-start gap-1 overflow-y-auto rounded-lg border border-solid border-input-border-default bg-input-bg-default px-[6px] py-1" + onMouseDown={() => inputRef.current?.focus()} + className={cn( + 'focus-within:ring-ds-border-brand-default-default/35 gap-1.5 rounded-lg border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default min-w-0 px-2 py-1.5 flex max-h-[120px] min-h-[40px] w-full flex-wrap content-center items-center justify-start border border-solid focus-within:ring-2' + )} > {renderSelectedItems()}