From cfe92bbd2dd1f0014223f3be43b70f4ecc01fa0f Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Thu, 2 Apr 2026 23:30:43 +0800 Subject: [PATCH 01/10] WIP: refactor --- .github/workflows/build.yml | 2 +- .github/workflows/test.yml | 57 ++ backend/README.md | 35 ++ backend/app/__init__.py | 57 +- backend/app/agent/factory/browser.py | 186 ++++-- backend/app/agent/factory/developer.py | 50 +- backend/app/agent/factory/document.py | 85 +-- backend/app/agent/factory/multi_modal.py | 58 +- backend/app/agent/listen_chat_agent.py | 2 +- backend/app/agent/prompt.py | 19 +- backend/app/agent/toolkit/human_toolkit.py | 14 +- .../agent/toolkit/hybrid_browser_toolkit.py | 10 +- .../app/agent/toolkit/screenshot_toolkit.py | 86 +++ backend/app/agent/tools.py | 54 +- backend/app/auth/__init__.py | 17 + backend/app/auth/interface.py | 47 ++ backend/app/channels/__init__.py | 17 + backend/app/channels/interface.py | 44 ++ backend/app/controller/chat_controller.py | 107 +++- backend/app/controller/file_controller.py | 312 ++++++++++ backend/app/controller/health_controller.py | 24 +- backend/app/controller/mcp_controller.py | 63 ++ backend/app/controller/message_controller.py | 109 ++++ backend/app/controller/skill_controller.py | 199 +++++++ backend/app/controller/task_controller.py | 31 +- backend/app/file_access/__init__.py | 19 + backend/app/file_access/interface.py | 54 ++ backend/app/file_access/local_file_access.py | 57 ++ backend/app/file_access/upload_file_access.py | 72 +++ backend/app/hands/__init__.py | 46 ++ backend/app/hands/capabilities.py | 236 ++++++++ backend/app/hands/cluster_config.py | 321 ++++++++++ backend/app/hands/cluster_interface.py | 41 ++ backend/app/hands/environment_hands.py | 110 ++++ backend/app/hands/full_hands.py | 84 +++ backend/app/hands/http_hands_cluster.py | 154 +++++ backend/app/hands/interface.py | 75 +++ backend/app/hands/remote_hands.py | 136 +++++ backend/app/hands/routed_hands_cluster.py | 122 ++++ backend/app/hands/sandbox_hands.py | 94 +++ backend/app/hardware/__init__.py | 18 + backend/app/hardware/interface.py | 55 ++ backend/app/hardware/null_bridge.py | 42 ++ backend/app/model/chat.py | 3 + backend/app/router.py | 24 + backend/app/router_layer/__init__.py | 39 ++ backend/app/router_layer/hands_resolver.py | 206 +++++++ backend/app/router_layer/interface.py | 72 +++ backend/app/router_layer/message_router.py | 190 ++++++ backend/app/router_layer/middleware.py | 117 ++++ backend/app/router_layer/session_store.py | 87 +++ backend/app/service/chat_service.py | 153 +++-- backend/app/service/mcp_config.py | 105 ++++ backend/app/service/skill_config_service.py | 131 ++++ backend/app/service/skill_service.py | 297 +++++++++ backend/app/service/task.py | 12 + backend/app/utils/browser_launcher.py | 216 +++++++ backend/app/utils/event_loop_utils.py | 3 + backend/app/utils/listen/toolkit_listen.py | 2 +- backend/app/utils/server/sync_step.py | 121 +++- .../app/utils/telemetry/workforce_metrics.py | 19 + backend/app/utils/workforce.py | 10 +- backend/config/hands_clusters.example.toml | 26 + backend/main.py | 142 ++++- .../app/controller/test_chat_controller.py | 79 +++ .../app/controller/test_message_controller.py | 63 ++ .../tests/app/hands/test_cluster_config.py | 133 +++++ .../app/hands/test_http_hands_cluster.py | 103 ++++ .../tests/app/hands/test_path_scope_checks.py | 62 ++ backend/tests/app/hands/test_remote_hands.py | 93 +++ .../app/hands/test_routed_hands_cluster.py | 88 +++ .../app/router_layer/test_message_router.py | 109 ++++ electron/main/index.ts | 410 +------------ electron/main/init.ts | 19 +- electron/main/webview.ts | 49 +- electron/preload/index.ts | 27 +- eslint.config.js | 31 + index.html | 2 +- package.json | 4 + scripts/check-electron-access.sh | 22 + scripts/smoke-web-local-brain.sh | 130 ++++ src/App.tsx | 12 +- src/api/brain.ts | 179 ++++++ src/api/http.ts | 211 ++++++- src/client/desktop/README.md | 6 + src/client/index.ts | 23 + src/client/platform.ts | 49 ++ src/client/web/README.md | 7 + src/components/AddWorker/ToolSelect.tsx | 22 +- src/components/AddWorker/index.tsx | 9 +- .../BrowserAgentWorkspace/index.tsx | 16 +- src/components/ChatBox/BottomBox/InputBox.tsx | 40 +- .../ChatBox/MessageItem/AgentMessageCard.tsx | 5 +- .../ChatBox/MessageItem/MarkDown.tsx | 12 +- .../ChatBox/MessageItem/UserMessageCard.tsx | 23 +- src/components/ChatBox/TaskBox/TaskCard.tsx | 5 +- src/components/ChatBox/UserQueryGroup.tsx | 2 + src/components/ChatBox/index.tsx | 69 ++- src/components/Dialog/CloseNotice.tsx | 7 +- src/components/Folder/index.tsx | 563 ++++++++++++++---- src/components/Layout/index.tsx | 13 +- .../TerminalAgentWorkspace/index.tsx | 5 +- src/components/TopBar/index.tsx | 29 +- src/components/WindowControls/index.tsx | 20 +- src/components/WorkFlow/node.tsx | 7 +- src/components/WorkspaceMenu/index.tsx | 14 +- src/components/update/index.tsx | 37 +- src/context/ConnectionContext.tsx | 111 ++++ src/hooks/useInstallationSetup.ts | 152 +++-- src/hooks/useIntegrationManagement.ts | 28 +- src/host/README.md | 26 + src/host/context.tsx | 35 ++ src/host/createHost.ts | 27 + src/host/index.ts | 17 + src/host/types.ts | 20 + src/lib/env.ts | 14 + src/lib/fileUtils.ts | 67 ++- src/lib/htmlFontStyles.ts | 7 +- src/lib/index.ts | 4 +- src/lib/oauth.ts | 9 +- src/lib/skillToolkit.ts | 9 +- src/main.tsx | 31 +- .../Agents/components/SkillUploadDialog.tsx | 16 +- src/pages/Browser/CDP.tsx | 31 +- src/pages/Browser/Cookies.tsx | 7 +- src/pages/Connectors/MCP.tsx | 32 +- src/pages/Connectors/components/MCPMarket.tsx | 15 +- src/pages/Home.tsx | 83 +-- src/pages/Login.tsx | 43 +- src/pages/Project/Workspace.tsx | 75 +-- src/pages/Setting/General.tsx | 21 +- src/pages/SignUp.tsx | 21 +- src/store/chatStore.ts | 262 +++++--- src/store/connectionStore.ts | 104 ++++ src/store/installationStore.ts | 17 +- src/store/skillsStore.ts | 88 +-- src/types/chatbox.d.ts | 2 + src/types/electron.d.ts | 63 +- src/vite-env.d.ts | 6 +- test/unit/api/http.test.ts | 93 +++ test/unit/lib/htmlFontStyles.test.ts | 1 + tsconfig.node.json | 2 +- vite.config.ts | 15 +- vite.config.web.ts | 52 ++ 144 files changed, 8351 insertions(+), 1460 deletions(-) create mode 100644 backend/app/auth/__init__.py create mode 100644 backend/app/auth/interface.py create mode 100644 backend/app/channels/__init__.py create mode 100644 backend/app/channels/interface.py create mode 100644 backend/app/controller/file_controller.py create mode 100644 backend/app/controller/mcp_controller.py create mode 100644 backend/app/controller/message_controller.py create mode 100644 backend/app/controller/skill_controller.py create mode 100644 backend/app/file_access/__init__.py create mode 100644 backend/app/file_access/interface.py create mode 100644 backend/app/file_access/local_file_access.py create mode 100644 backend/app/file_access/upload_file_access.py create mode 100644 backend/app/hands/__init__.py create mode 100644 backend/app/hands/capabilities.py create mode 100644 backend/app/hands/cluster_config.py create mode 100644 backend/app/hands/cluster_interface.py create mode 100644 backend/app/hands/environment_hands.py create mode 100644 backend/app/hands/full_hands.py create mode 100644 backend/app/hands/http_hands_cluster.py create mode 100644 backend/app/hands/interface.py create mode 100644 backend/app/hands/remote_hands.py create mode 100644 backend/app/hands/routed_hands_cluster.py create mode 100644 backend/app/hands/sandbox_hands.py create mode 100644 backend/app/hardware/__init__.py create mode 100644 backend/app/hardware/interface.py create mode 100644 backend/app/hardware/null_bridge.py create mode 100644 backend/app/router_layer/__init__.py create mode 100644 backend/app/router_layer/hands_resolver.py create mode 100644 backend/app/router_layer/interface.py create mode 100644 backend/app/router_layer/message_router.py create mode 100644 backend/app/router_layer/middleware.py create mode 100644 backend/app/router_layer/session_store.py create mode 100644 backend/app/service/mcp_config.py create mode 100644 backend/app/service/skill_config_service.py create mode 100644 backend/app/service/skill_service.py create mode 100644 backend/app/utils/browser_launcher.py create mode 100644 backend/config/hands_clusters.example.toml create mode 100644 backend/tests/app/controller/test_message_controller.py create mode 100644 backend/tests/app/hands/test_cluster_config.py create mode 100644 backend/tests/app/hands/test_http_hands_cluster.py create mode 100644 backend/tests/app/hands/test_path_scope_checks.py create mode 100644 backend/tests/app/hands/test_remote_hands.py create mode 100644 backend/tests/app/hands/test_routed_hands_cluster.py create mode 100644 backend/tests/app/router_layer/test_message_router.py create mode 100644 scripts/check-electron-access.sh create mode 100644 scripts/smoke-web-local-brain.sh create mode 100644 src/api/brain.ts create mode 100644 src/client/desktop/README.md create mode 100644 src/client/index.ts create mode 100644 src/client/platform.ts create mode 100644 src/client/web/README.md create mode 100644 src/context/ConnectionContext.tsx create mode 100644 src/host/README.md create mode 100644 src/host/context.tsx create mode 100644 src/host/createHost.ts create mode 100644 src/host/index.ts create mode 100644 src/host/types.ts create mode 100644 src/lib/env.ts create mode 100644 src/store/connectionStore.ts create mode 100644 test/unit/api/http.test.ts create mode 100644 vite.config.web.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6a5c88896..5f4615bb1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -347,7 +347,7 @@ jobs: files: | gh-release-assets/* - # Extract version from tag (e.g., v0.0.85 -> 0.0.89) + # Extract version from tag (e.g., v0.0.89 -> 0.0.89) - name: Extract version if: startsWith(github.ref, 'refs/tags/') id: version diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36c729b0a..31519ebdc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,63 @@ permissions: contents: read jobs: + web-local-smoke: + name: Run Web + Local Brain Smoke + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install frontend dependencies + run: npm ci --ignore-scripts + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.11 + + - name: Install backend dependencies + run: | + cd backend + uv sync + + - name: Run web + local brain smoke + run: bash scripts/smoke-web-local-brain.sh + + frontend-quality: + name: Run Frontend Guardrails + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install frontend dependencies + run: npm ci --ignore-scripts + + - name: Run type check + run: npm run type-check + + - name: Check Electron Access Guard + run: bash scripts/check-electron-access.sh + pytest: name: Run Python Tests runs-on: ubuntu-latest diff --git a/backend/README.md b/backend/README.md index 237ae8dde..04df6d441 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,5 +1,40 @@ ```bash +# Option 1: Start with uvicorn directly uv run uvicorn main:api --port 5001 + +# Option 2: Standalone mode (no Electron dependency) +uv run python main.py + +# Option 3: If uv run hangs, delete lock files and retry, or use venv directly: +.venv/bin/python main.py +# or +.venv/bin/uvicorn main:api --port 5001 --host 0.0.0.0 + +# If uv hangs, delete lock files first: rm -f uv_installing.lock uv_installed.lock +``` + +### Environment Variables (Standalone) + +| Variable | Default | Description | +|----------|---------|-------------| +| `EIGENT_BRAIN_PORT` | 5001 | Listening port | +| `EIGENT_BRAIN_HOST` | 0.0.0.0 | Listening address | +| `EIGENT_DEBUG` | - | Set to 1/true to enable reload | +| `EIGENT_WORKSPACE` | ~/.eigent/workspace | Working directory | +| `EIGENT_DEPLOYMENT_TYPE` | (auto) | `local` / `cloud_vm` / `sandbox` / `docker`; determines Hands capabilities (see ADR-0006) | +| `EIGENT_HANDS_MODE` | - | Set to `remote` to enable `RemoteHands` (remote cluster resource mode) | +| `EIGENT_HANDS_CLUSTER_CONFIG_FILE` | - | Path to `RemoteHands` config file (TOML); **recommended** | +| `EIGENT_HANDS_TERMINAL` | - | Override terminal hand: `1`/`true`/`yes` or `0`/`false`/`no` | +| `EIGENT_HANDS_BROWSER` | - | Override browser hand | +| `EIGENT_HANDS_FILESYSTEM` | - | Override filesystem scope: `full` / `workspace_only` | +| `EIGENT_HANDS_MCP` | - | Override MCP mode: `all` / `allowlist` | + +RemoteHands config file example: + +```bash +cp backend/config/hands_clusters.example.toml ~/.eigent/hands_clusters.toml +export EIGENT_HANDS_MODE=remote +export EIGENT_HANDS_CLUSTER_CONFIG_FILE=~/.eigent/hands_clusters.toml ``` i18n operation process: https://github.com/Anbarryprojects/fastapi-babel diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 06f0364bf..0265148c8 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -12,17 +12,68 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import os + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.base import BaseHTTPMiddleware # Initialize FastAPI with title api = FastAPI(title="Eigent Multi-Agent System API") -# Add CORS middleware + +@api.get("/") +def root(): + """Root endpoint - confirms this is the Brain backend.""" + return {"service": "eigent-brain", "docs": "/docs", "health": "/health"} + + +_cors_raw = os.environ.get("EIGENT_CORS_ORIGINS", "") +_allowed_origins = [o.strip() for o in _cors_raw.split(",") if o.strip()] +_default_frame_ancestors = [ + "'self'", + "http://localhost:*", + "http://127.0.0.1:*", + "https://localhost:*", + "https://127.0.0.1:*", +] +_frame_ancestors = " ".join( + dict.fromkeys( + [ + *_default_frame_ancestors, + *[origin for origin in _allowed_origins if origin != "*"], + ] + ) +) + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + 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/"): + if "X-Frame-Options" in response.headers: + del response.headers["X-Frame-Options"] + response.headers["Content-Security-Policy"] = ( + f"frame-ancestors {_frame_ancestors};" + ) + else: + response.headers["X-Frame-Options"] = "DENY" + return response + + api.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, + allow_origins=_allowed_origins or ["*"], + allow_credentials=bool(_allowed_origins), allow_methods=["*"], allow_headers=["*"], + expose_headers=["X-Session-ID"], ) +api.add_middleware(SecurityHeadersMiddleware) + +# Phase 2: Channel/Session header parsing (X-Channel, X-Session-ID, X-User-ID) +from app.router_layer import ChannelSessionMiddleware + +api.add_middleware(ChannelSessionMiddleware) diff --git a/backend/app/agent/factory/browser.py b/backend/app/agent/factory/browser.py index 6eb501da0..b0550d092 100644 --- a/backend/app/agent/factory/browser.py +++ b/backend/app/agent/factory/browser.py @@ -33,6 +33,7 @@ from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.utils import NOW_STR from app.component.environment import env +from app.hands.interface import IHands from app.model.chat import Chat from app.service.task import Agents from app.utils.file_utils import get_working_directory @@ -148,7 +149,10 @@ def get_occupied_ports(self) -> list[int]: _cdp_pool_manager = CdpBrowserPoolManager() -def browser_agent(options: Chat): +def browser_agent( + options: Chat, + hands: IHands | None = None, +): working_directory = get_working_directory(options) logger.info( f"Creating browser agent for project: {options.project_id} " @@ -160,12 +164,16 @@ def browser_agent(options: Chat): ).send_message_to_user ) - # Acquire CDP browser from pool or use default port + use_browser = hands is None or hands.can_use_browser() + use_terminal = hands is None or hands.can_execute_terminal() + + # Acquire CDP browser from pool or use default port (only when browser enabled) toolkit_session_id = str(uuid.uuid4())[:8] selected_port = None selected_is_external = False + cdp_url = None - if options.cdp_browsers: + if use_browser and options.cdp_browsers: selected_browser = _cdp_pool_manager.acquire_browser( options.cdp_browsers, toolkit_session_id, options.task_id ) @@ -186,52 +194,70 @@ def browser_agent(options: Chat): f"No available browsers in pool (initial), using first: " f"port={selected_port}, session_id={toolkit_session_id}" ) - else: + cdp_url = f"http://localhost:{selected_port}" + elif use_browser: selected_port = env("browser_port", "9222") + cdp_url = f"http://localhost:{selected_port}" + if hands is not None: + try: + cdp_url = hands.acquire_resource( + "browser", toolkit_session_id, port=selected_port + ) + except (NotImplementedError, ValueError): + cdp_url = f"http://localhost:{selected_port}" + + # Web mode (no Electron): cdp_keep_current_page=False so toolkit can create + # pages when browser has 0 tabs. Electron mode: True to reuse user's page. + cdp_keep_current = bool(options.cdp_browsers) if use_browser else False + default_start_url = None if cdp_keep_current else "about:blank" + + web_toolkit_custom = None + web_toolkit_for_agent_registration = None + if use_browser: + web_toolkit_custom = HybridBrowserToolkit( + options.project_id, + cdp_keep_current_page=cdp_keep_current, + default_start_url=default_start_url, + headless=False, + browser_log_to_file=True, + stealth=True, + session_id=toolkit_session_id, + cdp_url=cdp_url, + enabled_tools=[ + "browser_click", + "browser_type", + "browser_back", + "browser_forward", + "browser_select", + "browser_console_exec", + "browser_console_view", + "browser_switch_tab", + "browser_enter", + "browser_visit_page", + "browser_scroll", + "browser_sheet_read", + "browser_sheet_input", + "browser_get_page_snapshot", + "browser_open", + ], + ) + web_toolkit_for_agent_registration = web_toolkit_custom + web_toolkit_custom = message_integration.register_toolkits( + web_toolkit_custom + ) - web_toolkit_custom = HybridBrowserToolkit( - options.project_id, - cdp_keep_current_page=True, - headless=False, - browser_log_to_file=True, - stealth=True, - session_id=toolkit_session_id, - cdp_url=f"http://localhost:{selected_port}", - enabled_tools=[ - "browser_click", - "browser_type", - "browser_back", - "browser_forward", - "browser_select", - "browser_console_exec", - "browser_console_view", - "browser_switch_tab", - "browser_enter", - "browser_visit_page", - "browser_scroll", - "browser_sheet_read", - "browser_sheet_input", - "browser_get_page_snapshot", - "browser_open", - ], - ) - - # Save reference before registering for toolkits_to_register_agent - web_toolkit_for_agent_registration = web_toolkit_custom - web_toolkit_custom = message_integration.register_toolkits( - web_toolkit_custom - ) - - terminal_toolkit = TerminalToolkit( - options.project_id, - Agents.browser_agent, - working_directory=working_directory, - safe_mode=True, - clone_current_env=True, - ) - terminal_toolkit = message_integration.register_functions( - [terminal_toolkit.shell_exec] - ) + terminal_toolkit = None + if use_terminal: + terminal_toolkit = TerminalToolkit( + options.project_id, + Agents.browser_agent, + working_directory=working_directory, + safe_mode=True, + clone_current_env=True, + ) + terminal_toolkit = message_integration.register_functions( + [terminal_toolkit.shell_exec] + ) note_toolkit = NoteTakingToolkit( options.project_id, @@ -270,13 +296,33 @@ def browser_agent(options: Chat): *HumanToolkit.get_can_use_tools( options.project_id, Agents.browser_agent ), - *web_toolkit_custom.get_tools(), - *terminal_toolkit, *note_toolkit.get_tools(), *screenshot_toolkit.get_tools(), *search_tools, *skill_toolkit.get_tools(), ] + tool_names = [ + SearchToolkit.toolkit_name(), + HumanToolkit.toolkit_name(), + NoteTakingToolkit.toolkit_name(), + ScreenshotToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), + ] + if use_browser and web_toolkit_custom: + tools = [ + *HumanToolkit.get_can_use_tools( + options.project_id, Agents.browser_agent + ), + *web_toolkit_custom.get_tools(), + *note_toolkit.get_tools(), + *screenshot_toolkit.get_tools(), + *search_tools, + *skill_toolkit.get_tools(), + ] + tool_names.insert(1, HybridBrowserToolkit.toolkit_name()) + if use_terminal and terminal_toolkit: + tools.extend(terminal_toolkit) + tool_names.append(TerminalToolkit.toolkit_name()) # Build external browser notice external_browser_notice = "" @@ -308,18 +354,14 @@ def browser_agent(options: Chat): options, tools, prune_tool_calls_from_memory=True, - tool_names=[ - SearchToolkit.toolkit_name(), - HybridBrowserToolkit.toolkit_name(), - HumanToolkit.toolkit_name(), - NoteTakingToolkit.toolkit_name(), - TerminalToolkit.toolkit_name(), - ScreenshotToolkit.toolkit_name(), - SkillToolkit.toolkit_name(), - ], + tool_names=tool_names, toolkits_to_register_agent=[ - web_toolkit_for_agent_registration, - screenshot_toolkit_for_agent_registration, + t + for t in ( + web_toolkit_for_agent_registration, + screenshot_toolkit_for_agent_registration, + ) + if t is not None ], enable_snapshot_clean=True, ) @@ -349,16 +391,34 @@ def release_cdp_from_agent(agent_instance): """Release CDP browser back to pool.""" port = getattr(agent_instance, "_cdp_port", None) session_id = getattr(agent_instance, "_cdp_session_id", None) - if port is not None and session_id is not None: + if ( + port is not None + and session_id is not None + and options.cdp_browsers + ): _cdp_pool_manager.release_browser(port, session_id) logger.info( f"Released CDP for agent {agent_instance.agent_id}: " f"port={port}, session={session_id}" ) + elif session_id is not None and hands is not None: + try: + hands.release_resource("browser", session_id) + except Exception as exc: + logger.warning( + "Failed to release browser resource for session %s: %s", + session_id, + exc, + ) - agent._cdp_acquire_callback = acquire_cdp_for_agent - agent._cdp_release_callback = release_cdp_from_agent + agent._cdp_acquire_callback = ( + acquire_cdp_for_agent if use_browser else None + ) + agent._cdp_release_callback = ( + release_cdp_from_agent if use_browser else None + ) agent._cdp_port = selected_port + agent._cdp_url = cdp_url agent._cdp_session_id = toolkit_session_id agent._cdp_task_id = options.task_id agent._cdp_options = options diff --git a/backend/app/agent/factory/developer.py b/backend/app/agent/factory/developer.py index c52ce3e14..811c26622 100644 --- a/backend/app/agent/factory/developer.py +++ b/backend/app/agent/factory/developer.py @@ -30,12 +30,16 @@ from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.toolkit.web_deploy_toolkit import WebDeployToolkit from app.agent.utils import NOW_STR +from app.hands.interface import IHands from app.model.chat import Chat from app.service.task import Agents from app.utils.file_utils import get_working_directory -async def developer_agent(options: Chat): +async def developer_agent( + options: Chat, + hands: IHands | None = None, +): working_directory = get_working_directory(options) logger.info( f"Creating developer agent for project: {options.project_id} " @@ -66,16 +70,6 @@ async def developer_agent(options: Chat): screenshot_toolkit = message_integration.register_toolkits( screenshot_toolkit ) - - terminal_toolkit = TerminalToolkit( - options.project_id, - Agents.developer_agent, - working_directory=working_directory, - safe_mode=True, - clone_current_env=True, - ) - terminal_toolkit = message_integration.register_toolkits(terminal_toolkit) - skill_toolkit = SkillToolkit( options.project_id, Agents.developer_agent, @@ -98,11 +92,33 @@ async def developer_agent(options: Chat): ), *note_toolkit.get_tools(), *web_deploy_toolkit.get_tools(), - *terminal_toolkit.get_tools(), *screenshot_toolkit.get_tools(), *skill_toolkit.get_tools(), *search_tools, ] + tool_names = [ + HumanToolkit.toolkit_name(), + NoteTakingToolkit.toolkit_name(), + WebDeployToolkit.toolkit_name(), + ScreenshotToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), + ] + if search_tools: + tool_names.append(SearchToolkit.toolkit_name()) + if hands is None or hands.can_execute_terminal(): + terminal_toolkit = TerminalToolkit( + options.project_id, + Agents.developer_agent, + working_directory=working_directory, + safe_mode=True, + clone_current_env=True, + ) + terminal_toolkit = message_integration.register_toolkits( + terminal_toolkit + ) + tools.extend(terminal_toolkit.get_tools()) + tool_names.append(TerminalToolkit.toolkit_name()) + system_message = DEVELOPER_SYS_PROMPT.format( platform_system=platform.system(), platform_machine=platform.machine(), @@ -118,15 +134,7 @@ async def developer_agent(options: Chat): ), options, tools, - tool_names=[ - HumanToolkit.toolkit_name(), - TerminalToolkit.toolkit_name(), - NoteTakingToolkit.toolkit_name(), - WebDeployToolkit.toolkit_name(), - ScreenshotToolkit.toolkit_name(), - SkillToolkit.toolkit_name(), - SearchToolkit.toolkit_name(), - ], + tool_names=tool_names, toolkits_to_register_agent=[ screenshot_toolkit_for_agent_registration, ], diff --git a/backend/app/agent/factory/document.py b/backend/app/agent/factory/document.py index edabefc70..0e1827808 100644 --- a/backend/app/agent/factory/document.py +++ b/backend/app/agent/factory/document.py @@ -33,12 +33,16 @@ from app.agent.toolkit.skill_toolkit import SkillToolkit from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.utils import NOW_STR +from app.hands.interface import IHands from app.model.chat import Chat from app.service.task import Agents from app.utils.file_utils import get_working_directory -async def document_agent(options: Chat): +async def document_agent( + options: Chat, + hands: IHands | None = None, +): working_directory = get_working_directory(options) logger.info( f"Creating document agent for project: {options.project_id} " @@ -82,14 +86,39 @@ async def document_agent(options: Chat): screenshot_toolkit ) - terminal_toolkit = TerminalToolkit( - options.project_id, - Agents.document_agent, - working_directory=working_directory, - safe_mode=True, - clone_current_env=True, - ) - terminal_toolkit = message_integration.register_toolkits(terminal_toolkit) + tools = [ + *file_write_toolkit.get_tools(), + *pptx_toolkit.get_tools(), + *HumanToolkit.get_can_use_tools( + options.project_id, Agents.document_agent + ), + *mark_it_down_toolkit.get_tools(), + *excel_toolkit.get_tools(), + *note_toolkit.get_tools(), + *screenshot_toolkit.get_tools(), + ] + tool_names = [ + FileToolkit.toolkit_name(), + PPTXToolkit.toolkit_name(), + HumanToolkit.toolkit_name(), + MarkItDownToolkit.toolkit_name(), + ExcelToolkit.toolkit_name(), + NoteTakingToolkit.toolkit_name(), + ScreenshotToolkit.toolkit_name(), + ] + if hands is None or hands.can_execute_terminal(): + terminal_toolkit = TerminalToolkit( + options.project_id, + Agents.document_agent, + working_directory=working_directory, + safe_mode=True, + clone_current_env=True, + ) + terminal_toolkit = message_integration.register_toolkits( + terminal_toolkit + ) + tools.extend(terminal_toolkit.get_tools()) + tool_names.append(TerminalToolkit.toolkit_name()) google_drive_tools = await GoogleDriveMCPToolkit.get_can_use_tools( options.project_id, options.get_bun_env() @@ -102,30 +131,20 @@ async def document_agent(options: Chat): user_id=options.skill_config_user_id(), ) skill_toolkit = message_integration.register_toolkits(skill_toolkit) - + tools.extend(google_drive_tools) + if google_drive_tools: + tool_names.append(GoogleDriveMCPToolkit.toolkit_name()) + tools.extend(skill_toolkit.get_tools()) + tool_names.append(SkillToolkit.toolkit_name()) search_tools = SearchToolkit.get_can_use_tools( options.project_id, agent_name=Agents.document_agent ) if search_tools: search_tools = message_integration.register_functions(search_tools) + tools.extend(search_tools) + tool_names.append(SearchToolkit.toolkit_name()) else: search_tools = [] - - tools = [ - *file_write_toolkit.get_tools(), - *pptx_toolkit.get_tools(), - *HumanToolkit.get_can_use_tools( - options.project_id, Agents.document_agent - ), - *mark_it_down_toolkit.get_tools(), - *excel_toolkit.get_tools(), - *note_toolkit.get_tools(), - *terminal_toolkit.get_tools(), - *screenshot_toolkit.get_tools(), - *google_drive_tools, - *skill_toolkit.get_tools(), - *search_tools, - ] system_message = DOCUMENT_SYS_PROMPT.format( platform_system=platform.system(), platform_machine=platform.machine(), @@ -141,19 +160,7 @@ async def document_agent(options: Chat): ), options, tools, - tool_names=[ - FileToolkit.toolkit_name(), - PPTXToolkit.toolkit_name(), - HumanToolkit.toolkit_name(), - MarkItDownToolkit.toolkit_name(), - ExcelToolkit.toolkit_name(), - NoteTakingToolkit.toolkit_name(), - TerminalToolkit.toolkit_name(), - ScreenshotToolkit.toolkit_name(), - GoogleDriveMCPToolkit.toolkit_name(), - SkillToolkit.toolkit_name(), - SearchToolkit.toolkit_name(), - ], + tool_names=tool_names, toolkits_to_register_agent=[ screenshot_toolkit_for_agent_registration, ], diff --git a/backend/app/agent/factory/multi_modal.py b/backend/app/agent/factory/multi_modal.py index f01756f57..00329c24d 100644 --- a/backend/app/agent/factory/multi_modal.py +++ b/backend/app/agent/factory/multi_modal.py @@ -33,12 +33,16 @@ from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.toolkit.video_download_toolkit import VideoDownloaderToolkit from app.agent.utils import NOW_STR +from app.hands.interface import IHands from app.model.chat import Chat from app.service.task import Agents from app.utils.file_utils import get_working_directory -def multi_modal_agent(options: Chat): +def multi_modal_agent( + options: Chat, + hands: IHands | None = None, +): working_directory = get_working_directory(options) logger.info( f"Creating multi-modal agent for project: {options.project_id} " @@ -67,15 +71,6 @@ def multi_modal_agent(options: Chat): screenshot_toolkit ) - terminal_toolkit = TerminalToolkit( - options.project_id, - agent_name=Agents.multi_modal_agent, - working_directory=working_directory, - safe_mode=True, - clone_current_env=True, - ) - terminal_toolkit = message_integration.register_toolkits(terminal_toolkit) - note_toolkit = NoteTakingToolkit( options.project_id, Agents.multi_modal_agent, @@ -105,13 +100,33 @@ def multi_modal_agent(options: Chat): *HumanToolkit.get_can_use_tools( options.project_id, Agents.multi_modal_agent ), - *terminal_toolkit.get_tools(), *note_toolkit.get_tools(), *skill_toolkit.get_tools(), *search_tools, ] + tool_names = [ + VideoDownloaderToolkit.toolkit_name(), + ScreenshotToolkit.toolkit_name(), + HumanToolkit.toolkit_name(), + NoteTakingToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), + ] + if search_tools: + tool_names.append(SearchToolkit.toolkit_name()) + if hands is None or hands.can_execute_terminal(): + terminal_toolkit = TerminalToolkit( + options.project_id, + agent_name=Agents.multi_modal_agent, + working_directory=working_directory, + safe_mode=True, + clone_current_env=True, + ) + terminal_toolkit = message_integration.register_toolkits( + terminal_toolkit + ) + tools.extend(terminal_toolkit.get_tools()) + tool_names.append(TerminalToolkit.toolkit_name()) if options.is_cloud(): - # TODO: check llm has this model open_ai_image_toolkit = OpenAIImageToolkit( options.project_id, model="dall-e-3", @@ -125,10 +140,8 @@ def multi_modal_agent(options: Chat): open_ai_image_toolkit = message_integration.register_toolkits( open_ai_image_toolkit ) - tools = [ - *tools, - *open_ai_image_toolkit.get_tools(), - ] + tools.extend(open_ai_image_toolkit.get_tools()) + tool_names.append(OpenAIImageToolkit.toolkit_name()) # Convert string model_platform to enum for comparison try: model_platform_enum = ModelPlatformType(options.model_platform.lower()) @@ -148,6 +161,7 @@ def multi_modal_agent(options: Chat): audio_analysis_toolkit ) tools.extend(audio_analysis_toolkit.get_tools()) + tool_names.append(AudioAnalysisToolkit.toolkit_name()) system_message = MULTI_MODAL_SYS_PROMPT.format( platform_system=platform.system(), @@ -164,17 +178,7 @@ def multi_modal_agent(options: Chat): ), options, tools, - tool_names=[ - VideoDownloaderToolkit.toolkit_name(), - AudioAnalysisToolkit.toolkit_name(), - ScreenshotToolkit.toolkit_name(), - OpenAIImageToolkit.toolkit_name(), - HumanToolkit.toolkit_name(), - TerminalToolkit.toolkit_name(), - NoteTakingToolkit.toolkit_name(), - SearchToolkit.toolkit_name(), - SkillToolkit.toolkit_name(), - ], + tool_names=tool_names, toolkits_to_register_agent=[ screenshot_toolkit_for_agent_registration, ], diff --git a/backend/app/agent/listen_chat_agent.py b/backend/app/agent/listen_chat_agent.py index 7fbaddc6a..3ff3aeb91 100644 --- a/backend/app/agent/listen_chat_agent.py +++ b/backend/app/agent/listen_chat_agent.py @@ -709,7 +709,7 @@ def clone(self, with_memory: bool = False) -> ChatAgent: if has_cdp and hasattr(self, "_cdp_options"): options = self._cdp_options cdp_browsers = getattr(options, "cdp_browsers", []) - if cdp_browsers and hasattr(self, "_browser_toolkit"): + if cdp_browsers and getattr(self, "_browser_toolkit", None): need_cdp_clone = True import uuid as _uuid diff --git a/backend/app/agent/prompt.py b/backend/app/agent/prompt.py index 40f8d1bc0..074198b1b 100644 --- a/backend/app/agent/prompt.py +++ b/backend/app/agent/prompt.py @@ -152,6 +152,7 @@ message_description parameters when calling tools. These optional parameters are available on all tools and will automatically notify the user of your progress. + @@ -178,7 +179,6 @@ - Image Analysis & Understanding: - Use `read_image` to analyze images from local file paths - - Use `take_screenshot_and_read_image` to capture and analyze the screen - Generate detailed descriptions of image content - Answer specific questions about images - Identify objects, text, people, and scenes in images @@ -292,6 +292,9 @@ Your capabilities include: - You can use ScreenshotToolkit to read image with given path. +- When verifying generated image files (PNG/JPG/etc.), you MUST use + `read_image` on the saved file path. Do NOT capture the desktop screen + for this purpose. - **Skills System (Highest Priority Workflow)**: Skills are your primary execution source for specialized tasks. - Trigger: If a task explicitly references a skill with double curly braces @@ -451,11 +454,15 @@ summary of your work and the outcome, presented in a clear, detailed, and easy-to-read format. Avoid using markdown tables for presenting data; use plain text formatting instead. + Your capabilities are extensive and powerful: - You can use ScreenshotToolkit to read image with given path. +- When verifying generated image files (PNG/JPG/etc.), you MUST use + `read_image` on the saved file path. Do NOT capture the desktop screen + for this purpose. - **Skills System (Highest Priority Workflow)**: Skills are your primary execution source for specialized tasks. - Trigger: If a task explicitly references a skill with double curly braces @@ -486,8 +493,6 @@ `chmod`. - **Networking & Web**: `curl`, `wget` for web requests; `ssh` for remote access. -- **Screen Observation**: You can take screenshots to analyze GUIs and visual - context, enabling you to perform tasks that require sight. - **Desktop Automation**: You can control desktop applications programmatically. - **On macOS**, you MUST prioritize using **AppleScript** for its robust @@ -629,6 +634,12 @@ MUST be sourced from the web using the available tools. If you don't know something, find it out using your tools. +- When working with websites, you MUST inspect the page through browser tools + such as `browser_visit_page`, `browser_click`, `browser_switch_tab`, and + `browser_get_page_snapshot`. Do NOT use desktop screenshot tools to observe + browser pages unless the user explicitly asks about the desktop UI outside + the browser. + - When you complete your task, your final response must be a comprehensive summary of your findings, presented in a clear, detailed, and easy-to-read format. Avoid using markdown tables for presenting data; @@ -638,6 +649,8 @@ Your capabilities include: - You can use ScreenshotToolkit to read image with given path. +- For saved browser/file images, use `read_image` with the file path. Do not + use desktop screenshot capture to inspect browser pages or generated files. - **Skills System (Highest Priority Workflow)**: Skills are your primary execution source for specialized tasks. - Trigger: If a task explicitly references a skill with double curly braces diff --git a/backend/app/agent/toolkit/human_toolkit.py b/backend/app/agent/toolkit/human_toolkit.py index 731ca939a..e06fdabdf 100644 --- a/backend/app/agent/toolkit/human_toolkit.py +++ b/backend/app/agent/toolkit/human_toolkit.py @@ -12,6 +12,7 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import asyncio import logging from camel.toolkits.base import BaseToolkit @@ -19,6 +20,7 @@ from app.agent.toolkit.abstract_toolkit import AbstractToolkit from app.service.task import ( + TASK_LOCK_CLEANUP_SENTINEL, Action, ActionAskData, ActionNoticeData, @@ -60,7 +62,6 @@ async def ask_human_via_gui(self, question: str) -> str: credentials, file paths). - Ask for a decision when there are multiple viable options. - Seek help when you encounter an error you cannot resolve on your own. - Args: question (str): The question to ask the user. @@ -80,6 +81,17 @@ async def ask_human_via_gui(self, question: str) -> str: ) reply = await task_lock.get_human_input(self.agent_name) + if reply == TASK_LOCK_CLEANUP_SENTINEL: + logger.info( + "Human input wait interrupted by task cleanup", + extra={ + "task_id": self.api_task_id, + "agent": self.agent_name, + }, + ) + raise asyncio.CancelledError( + "Task cleanup interrupted human input wait" + ) logger.info(f"User reply: {reply}") return reply diff --git a/backend/app/agent/toolkit/hybrid_browser_toolkit.py b/backend/app/agent/toolkit/hybrid_browser_toolkit.py index 74209fe42..0b49f9782 100644 --- a/backend/app/agent/toolkit/hybrid_browser_toolkit.py +++ b/backend/app/agent/toolkit/hybrid_browser_toolkit.py @@ -568,6 +568,12 @@ def clone_for_new_session( # Use the same session_id to share the same browser instance # This ensures all clones use the same WebSocket connection and browser + # When cdp_keep_current_page=True, default_start_url must be None (CAMEL constraint) + cdp_keep = ( + self.config_loader.get_browser_config().cdp_keep_current_page + ) + clone_start_url = None if cdp_keep else self._default_start_url + return HybridBrowserToolkit( self.api_task_id, headless=self._headless, @@ -578,9 +584,7 @@ def clone_for_new_session( browser_log_to_file=self._browser_log_to_file, log_dir=self.config_loader.get_toolkit_config().log_dir, session_id=new_session_id, - default_start_url=None - if self.config_loader.get_browser_config().cdp_keep_current_page - else self._default_start_url, + default_start_url=clone_start_url, default_timeout=self._default_timeout, short_timeout=self._short_timeout, navigation_timeout=self._navigation_timeout, diff --git a/backend/app/agent/toolkit/screenshot_toolkit.py b/backend/app/agent/toolkit/screenshot_toolkit.py index eb6942702..77f6aa29b 100644 --- a/backend/app/agent/toolkit/screenshot_toolkit.py +++ b/backend/app/agent/toolkit/screenshot_toolkit.py @@ -13,8 +13,12 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import os +from pathlib import Path +from camel.agents import ChatAgent +from camel.messages import BaseMessage from camel.toolkits import ScreenshotToolkit as BaseScreenshotToolkit +from PIL import Image from app.agent.toolkit.abstract_toolkit import AbstractToolkit from app.component.environment import env @@ -31,11 +35,93 @@ def __init__( agent_name: str, working_directory: str | None = None, timeout: float | None = None, + enable_desktop_capture: bool = False, ): self.api_task_id = api_task_id self.agent_name = agent_name + self.enable_desktop_capture = enable_desktop_capture if working_directory is None: working_directory = env( "file_save_path", os.path.expanduser("~/Downloads") ) super().__init__(working_directory, timeout) + + def read_image( + self, + image_path: str, + instruction: str = "", + ) -> str: + """Analyze an image without recursively calling the current agent. + + CAMEL's base ScreenshotToolkit uses `self.agent.step(...)` directly. + When this toolkit itself is being invoked through a tool call, that + creates a nested step on the same agent and can corrupt tool-call + memory (`tool_call_id` mismatch). Use a short-lived vision agent with + the same model backend instead. + """ + if self.agent is None: + return ( + "Error: No agent registered. Please pass this toolkit to " + "ChatAgent via toolkits_to_register_agent parameter." + ) + + try: + image_path = str(Path(image_path).absolute()) + if not os.path.exists(image_path): + return f"Error: Screenshot file not found: {image_path}" + + img = Image.open(image_path) + message = BaseMessage.make_user_message( + role_name="User", + content=instruction, + image_list=[img], + ) + + vision_agent = ChatAgent( + system_message=( + "You are a careful visual assistant. Answer only from the " + "provided image and user instruction." + ), + model=self.agent.model_backend, + tools=[], + toolkits_to_register_agent=None, + external_tools=None, + step_timeout=getattr(self.agent, "step_timeout", 1800), + ) + response = vision_agent.step(message) + if getattr(response, "msg", None) is not None: + return response.msg.content + if getattr(response, "msgs", None): + return response.msgs[0].content + return "Error reading screenshot: empty response" + except Exception as e: + return f"Error reading screenshot: {e}" + + def take_screenshot_and_read_image( + self, + filename: str, + save_to_file: bool = True, + read_image: bool = True, + instruction: str | None = None, + ) -> str: + if not self.enable_desktop_capture: + return ( + "Error: Desktop screenshot capture is disabled for this agent. " + "Use read_image with an existing image file path instead." + ) + + return super().take_screenshot_and_read_image( + filename=filename, + save_to_file=save_to_file, + read_image=read_image, + instruction=instruction, + ) + + def get_tools(self): + tools = super().get_tools() + if self.enable_desktop_capture: + return tools + + return [ + tool for tool in tools if tool.get_function_name() == "read_image" + ] diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py index c0880b13a..877c584eb 100644 --- a/backend/app/agent/tools.py +++ b/backend/app/agent/tools.py @@ -42,12 +42,26 @@ from app.agent.toolkit.video_download_toolkit import VideoDownloaderToolkit from app.agent.toolkit.whatsapp_toolkit import WhatsAppToolkit from app.component.environment import env +from app.hands.interface import IHands from app.model.chat import McpServers logger = logging.getLogger(__name__) +# Toolkits depending on terminal hand +TERMINAL_DEPENDENT_TOOLKITS = frozenset({"terminal_toolkit"}) -async def get_toolkits(tools: list[str], agent_name: str, api_task_id: str): +# Toolkits depending on browser hand +BROWSER_DEPENDENT_TOOLKITS = ( + frozenset() +) # hybrid_browser not in get_toolkits dict + + +async def get_toolkits( + tools: list[str], + agent_name: str, + api_task_id: str, + hands: IHands | None = None, +): logger.info( f"Getting toolkits for agent: {agent_name}, " f"task: {api_task_id}, tools: {tools}" @@ -79,6 +93,24 @@ async def get_toolkits(tools: list[str], agent_name: str, api_task_id: str): res = [] for item in tools: if item in toolkits: + # Filter by Brain capabilities + if hands is not None: + if ( + item in TERMINAL_DEPENDENT_TOOLKITS + and not hands.can_execute_terminal() + ): + logger.info( + f"Skipping {item} for {agent_name}: no terminal hand" + ) + continue + if ( + item in BROWSER_DEPENDENT_TOOLKITS + and not hands.can_use_browser() + ): + logger.info( + f"Skipping {item} for {agent_name}: no browser hand" + ) + continue toolkit: AbstractToolkit = toolkits[item] toolkit.agent_name = agent_name toolkit_tools = toolkit.get_can_use_tools(api_task_id) @@ -93,13 +125,31 @@ async def get_toolkits(tools: list[str], agent_name: str, api_task_id: str): return res -async def get_mcp_tools(mcp_server: McpServers): +async def get_mcp_tools( + mcp_server: McpServers, + hands: IHands | None = None, +): logger.info( f"Getting MCP tools for {len(mcp_server['mcpServers'])} servers" ) if len(mcp_server["mcpServers"]) == 0: return [] + # Filter by mcp hand capability + mcp_servers = mcp_server["mcpServers"] + if hands is not None: + filtered = { + name: cfg + for name, cfg in mcp_servers.items() + if hands.can_use_mcp(name) + } + if len(filtered) == 0: + logger.info( + "No MCP servers allowed by mcp hand, skipping MCP tools" + ) + return [] + mcp_server = {**mcp_server, "mcpServers": filtered} + # Ensure unified auth directory for all mcp-remote servers to avoid # re-authentication on each task config_dict = {**mcp_server} diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 000000000..9b24bfb1f --- /dev/null +++ b/backend/app/auth/__init__.py @@ -0,0 +1,17 @@ +# ========= 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 app.auth.interface import IAuthProvider, NoneAuth + +__all__ = ["IAuthProvider", "NoneAuth"] diff --git a/backend/app/auth/interface.py b/backend/app/auth/interface.py new file mode 100644 index 000000000..741103069 --- /dev/null +++ b/backend/app/auth/interface.py @@ -0,0 +1,47 @@ +# ========= 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 abc import ABC, abstractmethod +from typing import Any + + +class IAuthProvider(ABC): + """ + Auth provider interface. + + This round only provides a no-op local implementation (NoneAuth). + Future modes (API key / JWT / tenant-aware auth) can implement this + interface without changing router/middleware call sites. + """ + + @abstractmethod + async def authenticate(self, scope: dict[str, Any]) -> dict[str, str]: + """ + Authenticate request context. + + Returns: + {"user_id": "", "tenant_id": ""} + """ + ... + + +class NoneAuth(IAuthProvider): + """ + Local deployment default auth provider. + Trusts inbound requests and emits a fixed local identity. + """ + + async def authenticate(self, scope: dict[str, Any]) -> dict[str, str]: + _ = scope + return {"user_id": "local", "tenant_id": "default"} diff --git a/backend/app/channels/__init__.py b/backend/app/channels/__init__.py new file mode 100644 index 000000000..bdae9210a --- /dev/null +++ b/backend/app/channels/__init__.py @@ -0,0 +1,17 @@ +# ========= 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 app.channels.interface import IChannelAdapter + +__all__ = ["IChannelAdapter"] diff --git a/backend/app/channels/interface.py b/backend/app/channels/interface.py new file mode 100644 index 000000000..c01042bbe --- /dev/null +++ b/backend/app/channels/interface.py @@ -0,0 +1,44 @@ +# ========= 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 abc import ABC, abstractmethod + + +class IChannelAdapter(ABC): + """ + Channel Adapter interface. + + This round defines the extension contract only. + Concrete adapters (Slack/WhatsApp/etc.) are out of scope. + """ + + @abstractmethod + async def start(self) -> None: + """Start channel listener.""" + ... + + @abstractmethod + async def stop(self) -> None: + """Stop channel listener.""" + ... + + @abstractmethod + async def send_message(self, session_id: str, content: str) -> None: + """Push outbound message to a channel session.""" + ... + + @abstractmethod + def get_channel_type(self) -> str: + """Channel identifier (e.g. 'slack', 'telegram').""" + ... diff --git a/backend/app/controller/chat_controller.py b/backend/app/controller/chat_controller.py index 87e42a70a..396ea589a 100644 --- a/backend/app/controller/chat_controller.py +++ b/backend/app/controller/chat_controller.py @@ -49,9 +49,11 @@ delete_task_lock, get_or_create_task_lock, get_task_lock, + get_task_lock_if_exists, set_current_task_id, task_locks, ) +from app.utils.browser_launcher import ensure_cdp_browser_available router = APIRouter() @@ -164,8 +166,11 @@ async def timeout_stream_wrapper( raise -@router.post("/chat", name="start chat") -async def post(data: Chat, request: Request): +async def start_chat_stream(data: Chat, request: Request): + """ + Setup and start chat stream. Used by POST /chat and Message Router. + Returns async generator of SSE chunks. + """ chat_logger.info( "Starting new chat session", extra={ @@ -184,8 +189,36 @@ async def post(data: Chat, request: Request): if safe_env_path: load_dotenv(dotenv_path=safe_env_path) + # TODO(multi-tenant): os.environ is global – concurrent sessions overwrite + # each other's API keys, file paths, and browser ports. Pass these values + # through Chat / request context instead of mutating the process environment. os.environ["file_save_path"] = data.file_save_path() os.environ["browser_port"] = str(data.browser_port) + # Web mode: when no CDP browsers from Electron, Brain launches Chrome directly + if not data.cdp_browsers: + try: + launched = await asyncio.to_thread( + ensure_cdp_browser_available, data.browser_port + ) + if launched: + os.environ["EIGENT_CDP_URL"] = ( + f"http://127.0.0.1:{data.browser_port}" + ) + request.state.browser_available = True + else: + request.state.browser_available = False + os.environ.pop("EIGENT_CDP_URL", None) + chat_logger.warning( + "CDP browser not available after ensure attempt", + extra={"port": data.browser_port}, + ) + except Exception as e: + request.state.browser_available = False + os.environ.pop("EIGENT_CDP_URL", None) + chat_logger.warning( + "Could not ensure CDP browser for web mode", + extra={"error": str(e), "port": data.browser_port}, + ) os.environ["OPENAI_API_KEY"] = data.api_key os.environ["OPENAI_API_BASE_URL"] = ( data.api_url or "https://api.openai.com/v1" @@ -242,10 +275,16 @@ async def post(data: Chat, request: Request): "log_dir": str(camel_log), }, ) + return timeout_stream_wrapper( + step_solve(data, request, task_lock), task_lock=task_lock + ) + + +@router.post("/chat", name="start chat") +async def post(data: Chat, request: Request): + stream = await start_chat_stream(data, request) return StreamingResponse( - timeout_stream_wrapper( - step_solve(data, request, task_lock), task_lock=task_lock - ), + stream, media_type="text/event-stream", ) @@ -258,6 +297,22 @@ def improve(id: str, data: SupplementChat): ) task_lock = get_task_lock(id) + # Web mode: ensure CDP browser is available for follow-up tasks + # (browser may have been closed/crashed since last session) + try: + port = int(os.environ.get("browser_port", "9222")) + launched = ensure_cdp_browser_available(port) + if launched: + os.environ["EIGENT_CDP_URL"] = f"http://127.0.0.1:{port}" + else: + os.environ.pop("EIGENT_CDP_URL", None) + except Exception as e: + os.environ.pop("EIGENT_CDP_URL", None) + chat_logger.warning( + "Could not ensure CDP browser for supplement", + extra={"error": str(e)}, + ) + # Allow continuing conversation even after task is done # This supports multi-turn conversation after complex task completion if task_lock.status == Status.done: @@ -374,8 +429,8 @@ def stop(id: str): ) chat_logger.info(f"[STOP-BUTTON] project_id/task_id: {id}") chat_logger.info("=" * 80) - try: - task_lock = get_task_lock(id) + task_lock = get_task_lock_if_exists(id) + if task_lock is not None: chat_logger.info( "[STOP-BUTTON] Task lock retrieved," f" task_lock.id: {task_lock.id}," @@ -386,20 +441,24 @@ def stop(id: str): " ActionStopData(Action.stop)" " to task_lock queue" ) - asyncio.run(task_lock.put_queue(ActionStopData(action=Action.stop))) - chat_logger.info( - "[STOP-BUTTON] ActionStopData queued" - " successfully, this will trigger" - " workforce.stop_gracefully()" - ) - except Exception as e: - # Task lock may not exist if task is already - # finished or never started + try: + asyncio.run( + task_lock.put_queue(ActionStopData(action=Action.stop)) + ) + chat_logger.info( + "[STOP-BUTTON] ActionStopData queued" + " successfully, this will trigger" + " workforce.stop_gracefully()" + ) + except Exception as e: + chat_logger.warning( + "[STOP-BUTTON] Failed to queue ActionStopData", + extra={"task_id": id, "error": str(e)}, + ) + else: chat_logger.warning( - "[STOP-BUTTON] Task lock not found" - " or already stopped," - f" task_id: {id}," - f" error: {str(e)}" + "[STOP-BUTTON] Task lock not found, task may already be stopped", + extra={"task_id": id}, ) return Response(status_code=204) @@ -515,7 +574,13 @@ def skip_task(project_id: str): ) chat_logger.info(f"[STOP-BUTTON] project_id: {project_id}") chat_logger.info("=" * 80) - task_lock = get_task_lock(project_id) + task_lock = get_task_lock_if_exists(project_id) + if task_lock is None: + chat_logger.warning( + "[STOP-BUTTON] Task lock not found, task may already be stopped", + extra={"project_id": project_id}, + ) + return Response(status_code=204) chat_logger.info( "[STOP-BUTTON] Task lock retrieved," f" task_lock.id: {task_lock.id}," diff --git a/backend/app/controller/file_controller.py b/backend/app/controller/file_controller.py new file mode 100644 index 000000000..143d3212a --- /dev/null +++ b/backend/app/controller/file_controller.py @@ -0,0 +1,312 @@ +# ========= 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 logging +import mimetypes +import re +import time +from pathlib import Path +from typing import Annotated +from urllib.parse import quote + +from fastapi import APIRouter, File, Header, HTTPException, Query, UploadFile +from fastapi.responses import FileResponse + +from app.component.environment import env +from app.utils.file_utils import list_files, resolve_under_base + +router = APIRouter() +file_logger = logging.getLogger("file_controller") + +# Config +MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 # 50MB +MAX_FILES_PER_SESSION = 20 +WORKSPACE_ROOT = env("EIGENT_WORKSPACE", "~/.eigent/workspace") +SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") + + +def _get_eigent_root() -> Path: + """Base root for eigent storage (~/eigent). Do NOT use env file_save_path + here: chat overwrites it to task path, which would break list/stream.""" + eigent = Path.home() / "eigent" + if eigent.exists(): + return eigent + dot_eigent = Path.home() / ".eigent" + if dot_eigent.exists(): + return dot_eigent + return eigent # default to ~/eigent + + +def _get_workspace_root() -> Path: + return Path(WORKSPACE_ROOT).expanduser() + + +def _validate_session_id(session_id: str) -> str: + normalized = (session_id or "").strip() + if not SESSION_ID_PATTERN.fullmatch(normalized): + raise ValueError("Invalid X-Session-ID") + return normalized + + +def _get_session_uploads_dir(session_id: str) -> Path: + root = _get_workspace_root().resolve() + validated = _validate_session_id(session_id) + uploads_dir = (root / validated / "uploads").resolve() + try: + uploads_dir.relative_to(root) + except ValueError as exc: + raise ValueError("Invalid X-Session-ID") from exc + return uploads_dir + + +def _count_session_uploads(session_id: str) -> int: + uploads_dir = _get_session_uploads_dir(session_id) + if not uploads_dir.exists(): + return 0 + return len(list(uploads_dir.iterdir())) + + +@router.post("/files") +async def upload_file( + file: Annotated[UploadFile, File()], + x_session_id: Annotated[str | None, Header(alias="X-Session-ID")] = None, +) -> dict: + """ + Upload file. Requires X-Session-ID header. + Returns file_id for message attachments reference. + """ + if not x_session_id: + raise HTTPException( + status_code=400, + detail="X-Session-ID header is required for file upload", + ) + try: + validated_session_id = _validate_session_id(x_session_id) + except ValueError as exc: + raise HTTPException( + status_code=400, detail="Invalid X-Session-ID" + ) from exc + + # Check session file count limit + count = _count_session_uploads(validated_session_id) + if count >= MAX_FILES_PER_SESSION: + raise HTTPException( + status_code=429, + detail=f"Maximum {MAX_FILES_PER_SESSION} files per session", + ) + + # Read and validate size + content = await file.read() + if len(content) > MAX_FILE_SIZE_BYTES: + raise HTTPException( + status_code=413, + detail=f"File size exceeds {MAX_FILE_SIZE_BYTES // (1024 * 1024)}MB limit", + ) + + # Generate safe filename + timestamp = int(time.time() * 1000) + safe_name = "".join( + c if c.isalnum() or c in "._-" else "_" + for c in (file.filename or "file") + ) + stored_name = f"{safe_name}_{timestamp}" + + # Write to disk + uploads_dir = _get_session_uploads_dir(validated_session_id) + uploads_dir.mkdir(parents=True, exist_ok=True) + target_path = uploads_dir / stored_name + target_path.write_bytes(content) + + file_id = f"upload://{stored_name}" + file_logger.info( + f"File uploaded: session={validated_session_id}, file_id={file_id}, size={len(content)}" + ) + + return { + "file_id": file_id, + "filename": file.filename or "file", + "size": len(content), + } + + +def _sanitize_email(email: str) -> str: + """Sanitize email for use in path (match chat_controller logic).""" + return re.sub(r'[\\/*?:"<>|\s]', "_", email.split("@")[0]).strip(".") + + +def _get_project_root(email: str, project_id: str) -> Path: + """Get project root path: ~/eigent/{email}/project_{project_id}/.""" + root = _get_eigent_root() + email_sanitized = _sanitize_email(email) + return root / email_sanitized / f"project_{project_id}" + + +def _resolve_project_root(email: str, project_id: str) -> Path: + """ + Resolve project root, preferring the email-scoped path but falling back to + any local project_{project_id} directory when the stored email differs from + the current login identity. + """ + preferred = _get_project_root(email, project_id) + if preferred.exists(): + return preferred + + root = _get_eigent_root() + candidate_name = f"project_{project_id}" + try: + for child in root.iterdir(): + if not child.is_dir(): + continue + candidate = child / candidate_name + if candidate.exists(): + file_logger.info( + "Resolved project root via fallback lookup: %s -> %s", + preferred, + candidate, + ) + return candidate + except FileNotFoundError: + pass + except Exception as e: + file_logger.warning("project root fallback lookup failed: %s", e) + + return preferred + + +@router.get("/files") +async def list_project_files( + project_id: str = Query(..., description="Project ID"), + email: str = Query(..., description="User email"), + task_id: str | None = Query( + None, description="Optional task ID to scope listing" + ), +) -> list[dict]: + """ + List files in project working directory (Brain storage). + Used by Web mode when ipcRenderer is unavailable. + Returns [{filename, url}] where url can be used to fetch file content. + """ + if not project_id or not email: + raise HTTPException( + status_code=400, + detail="project_id and email are required", + ) + project_root = _resolve_project_root(email, project_id) + list_dir = str(project_root) + if task_id: + list_dir = str(project_root / f"task_{task_id}") + if not Path(list_dir).exists(): + file_logger.debug( + "list_project_files: path does not exist: %s", + list_dir, + ) + return [] + base_path = str(project_root.resolve()) + try: + paths = list_files(list_dir, base=base_path, max_entries=500) + except Exception as e: + file_logger.warning("list_project_files failed: %s", e) + return [] + result: list[dict] = [] + for abs_path in paths: + try: + rel = str(Path(abs_path).relative_to(base_path)) + # URL-encode the relative path for stream endpoint + path_param = quote(rel, safe="") + result.append( + { + "filename": Path(abs_path).name, + "url": f"/files/stream?path={path_param}&project_id={quote(project_id)}&email={quote(email)}", + "relativePath": rel, + } + ) + except (ValueError, OSError): + continue + return result + + +@router.get("/files/stream") +async def stream_file( + path: str = Query(..., description="Relative path from project root"), + project_id: str = Query(..., description="Project ID"), + email: str = Query(..., description="User email"), +): + """ + Stream file content. Path must be relative to project root. + Used by Web mode to fetch file content for display. + """ + if not path or not project_id or not email: + raise HTTPException( + status_code=400, + detail="path, project_id and email are required", + ) + project_root = _resolve_project_root(email, project_id) + # Resolve path and ensure it stays under project root (security) + try: + resolved = resolve_under_base(path, str(project_root.resolve())) + except Exception as e: + file_logger.warning("stream_file path validation failed: %s", e) + raise HTTPException(status_code=400, detail="Invalid path") from e + p = Path(resolved) + if not p.is_file(): + raise HTTPException(status_code=404, detail="File not found") + media_type, _ = mimetypes.guess_type(str(p)) + if not media_type: + media_type = "application/octet-stream" + # content_disposition_type=inline: display in iframe instead of triggering download + return FileResponse( + path=str(p), + filename=p.name, + media_type=media_type, + content_disposition_type="inline", + ) + + +@router.get("/files/preview/{email}/{project_id}/{file_path:path}") +async def preview_file( + email: str, + project_id: str, + file_path: str, +): + """ + Preview file content with a path-based URL so relative references inside + HTML/CSS/JS resolve against the project directory structure. + """ + if not file_path or not project_id or not email: + raise HTTPException( + status_code=400, + detail="file_path, project_id and email are required", + ) + + project_root = _resolve_project_root(email, project_id) + try: + resolved = resolve_under_base(file_path, str(project_root.resolve())) + except Exception as e: + file_logger.warning("preview_file path validation failed: %s", e) + raise HTTPException(status_code=400, detail="Invalid path") from e + + p = Path(resolved) + if not p.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + media_type, _ = mimetypes.guess_type(str(p)) + if not media_type: + media_type = "application/octet-stream" + + return FileResponse( + path=str(p), + filename=p.name, + media_type=media_type, + content_disposition_type="inline", + ) diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py index 1ee53719e..5d0cb7ffa 100644 --- a/backend/app/controller/health_controller.py +++ b/backend/app/controller/health_controller.py @@ -13,10 +13,14 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import logging +import os -from fastapi import APIRouter +from fastapi import APIRouter, Query from pydantic import BaseModel +from app.router_layer.hands_resolver import get_environment_hands +from app.utils.browser_launcher import _is_cdp_available + logger = logging.getLogger("health_controller") router = APIRouter(tags=["Health"]) @@ -25,16 +29,30 @@ class HealthResponse(BaseModel): status: str service: str + capabilities: dict | None = None @router.get("/health", name="health check", response_model=HealthResponse) -async def health_check(): +async def health_check(detail: bool = Query(False)): """Health check endpoint for verifying backend is ready to accept requests.""" logger.debug("Health check requested") response = HealthResponse(status="ok", service="eigent") + 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) + response.capabilities = capabilities logger.debug( "Health check completed", - extra={"status": response.status, "service": response.service}, + extra={ + "status": response.status, + "service": response.service, + "detail": detail, + }, ) return response diff --git a/backend/app/controller/mcp_controller.py b/backend/app/controller/mcp_controller.py new file mode 100644 index 000000000..52e1dc0ea --- /dev/null +++ b/backend/app/controller/mcp_controller.py @@ -0,0 +1,63 @@ +# ========= 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 logging + +from fastapi import APIRouter, HTTPException + +from app.service.mcp_config import ( + add_mcp, + read_mcp_config, + remove_mcp, + update_mcp, +) + +router = APIRouter() +mcp_logger = logging.getLogger("mcp_controller") + + +@router.get("/mcp/list") +def mcp_list() -> dict: + """List all MCP servers (global config).""" + return read_mcp_config() + + +@router.post("/mcp/install") +def mcp_install(body: dict) -> dict: + """Install/add MCP server to global config. Body: { name, mcp }.""" + name = body.get("name") + mcp = body.get("mcp") + if not name: + raise HTTPException(status_code=400, detail="name is required") + if not mcp or not isinstance(mcp, dict): + raise HTTPException(status_code=400, detail="mcp object is required") + add_mcp(str(name).strip(), mcp) + mcp_logger.info("MCP installed: %s", name) + return {"success": True} + + +@router.delete("/mcp/{name}") +def mcp_remove(name: str) -> dict: + """Remove MCP server from global config.""" + remove_mcp(name) + mcp_logger.info("MCP removed: %s", name) + return {"success": True} + + +@router.put("/mcp/{name}") +def mcp_update(name: str, mcp: dict) -> dict: + """Update MCP server in global config. Body is the mcp config object.""" + update_mcp(name, mcp) + mcp_logger.info("MCP updated: %s", name) + return {"success": True} diff --git a/backend/app/controller/message_controller.py b/backend/app/controller/message_controller.py new file mode 100644 index 000000000..16ba233f9 --- /dev/null +++ b/backend/app/controller/message_controller.py @@ -0,0 +1,109 @@ +# ========= 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. ========= + +"""Message Router HTTP endpoint for Phase 2.""" + +import inspect +import json +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, StreamingResponse + +from app.router_layer.interface import InboundMessage +from app.router_layer.message_router import DefaultMessageRouter + +router = APIRouter() +message_logger = logging.getLogger("message_controller") + +# Singleton router instance +_message_router: DefaultMessageRouter | None = None + + +def get_message_router() -> DefaultMessageRouter: + global _message_router + if _message_router is None: + _message_router = DefaultMessageRouter() + return _message_router + + +@router.post("/messages", name="send message via router") +async def post_message(request: Request): + """ + Accept message per docs/design/06-protocol.md §2.1. + Uses X-Channel, X-Session-ID, X-User-ID from ChannelSessionMiddleware. + Returns SSE stream for chat, or JSON for non-streaming. + """ + body = ( + await request.json() + if request.headers.get("content-type", "").startswith( + "application/json" + ) + else {} + ) + if not isinstance(body, dict): + body = {} + + channel = getattr(request.state, "channel", None) or "desktop" + session_id = getattr(request.state, "session_id", None) + user_id = getattr(request.state, "user_id", None) + + mr = get_message_router() + resolved_session_id = await mr.resolve_session( + channel, session_id, user_id + ) + + headers_dict = {} + for k, v in request.headers.items(): + headers_dict[k] = v + + msg = InboundMessage( + session_id=resolved_session_id, + channel=channel, + user_id=user_id, + payload=body, + headers=headers_dict, + ) + + result = mr.route_in(msg, request=request) + + if not inspect.isasyncgen(result): + message_logger.error( + "message_router.route_in returned non-stream result: %r", + type(result), + ) + return JSONResponse( + { + "code": -1, + "text": "Internal router contract error", + "data": {}, + }, + status_code=500, + headers={"X-Session-ID": resolved_session_id}, + ) + + async def stream(): + async for out in result: + raw = out.payload.get("raw") + if raw: + yield raw + elif not out.stream: + # Non-streaming error: yield as SSE event + yield f"data: {json.dumps(out.payload, ensure_ascii=False)}\n\n" + + return StreamingResponse( + stream(), + media_type="text/event-stream", + headers={"X-Session-ID": resolved_session_id}, + ) diff --git a/backend/app/controller/skill_controller.py b/backend/app/controller/skill_controller.py new file mode 100644 index 000000000..d54e37d4f --- /dev/null +++ b/backend/app/controller/skill_controller.py @@ -0,0 +1,199 @@ +# ========= 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 logging +from typing import Annotated + +from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile + +from app.service.skill_config_service import ( + skill_config_delete, + skill_config_init, + skill_config_load, + skill_config_toggle, + skill_config_update, +) +from app.service.skill_service import ( + skill_delete, + skill_get_path_by_name, + skill_import_zip, + skill_list_files, + skill_read, + skill_write, + skills_scan, +) + +router = APIRouter() +skill_logger = logging.getLogger("skill_controller") + + +# --- Skill config (must be before /skills/{skill_dir_name} to avoid path conflict) --- + + +@router.get("/skills/config") +def skill_config_get(user_id: str = Query(..., description="User ID")) -> dict: + """Load skills config for user.""" + config = skill_config_load(user_id) + return {"success": True, "config": config} + + +@router.post("/skills/config/init") +def skill_config_init_endpoint(body: dict) -> dict: + """Initialize skills config for user (merge default if present).""" + user_id = body.get("user_id") + if not user_id: + raise HTTPException(status_code=400, detail="user_id is required") + config = skill_config_init(user_id) + return {"success": True, "config": config} + + +@router.put("/skills/config/{skill_name}") +def skill_config_update_endpoint(skill_name: str, body: dict) -> dict: + """Update config for a skill.""" + user_id = body.get("user_id") + if not user_id: + raise HTTPException(status_code=400, detail="user_id is required") + skill_config = {k: v for k, v in body.items() if k != "user_id"} + skill_config_update(user_id, skill_name, skill_config) + return {"success": True} + + +@router.delete("/skills/config/{skill_name}") +def skill_config_delete_endpoint( + skill_name: str, user_id: str = Query(..., description="User ID") +) -> dict: + """Remove skill from config.""" + skill_config_delete(user_id, skill_name) + return {"success": True} + + +@router.post("/skills/config/{skill_name}/toggle") +def skill_config_toggle_endpoint(skill_name: str, body: dict) -> dict: + """Toggle skill enabled state.""" + user_id = body.get("user_id") + enabled = body.get("enabled") + if not user_id: + raise HTTPException(status_code=400, detail="user_id is required") + if enabled is None: + raise HTTPException(status_code=400, detail="enabled is required") + result = skill_config_toggle(user_id, skill_name, bool(enabled)) + return {"success": True, "config": result} + + +# --- Skills CRUD --- + + +@router.post("/skills/import") +async def skill_import_endpoint( + file: Annotated[ + UploadFile, File(description="Zip file containing SKILL.md") + ], + replacements: Annotated[ + str | None, Form(description="Comma-separated folder names to replace") + ] = None, +) -> dict: + """Import skills from a zip archive. Returns {success, error?, conflicts?}.""" + if not file.filename or not file.filename.lower().endswith(".zip"): + raise HTTPException( + status_code=400, detail="File must be a .zip archive" + ) + try: + zip_bytes = await file.read() + except Exception as e: + raise HTTPException( + status_code=400, detail=f"Failed to read file: {e}" + ) + repl_list = ( + [s for s in (s.strip() for s in replacements.split(",")) if s] + if replacements + else None + ) + result = skill_import_zip(zip_bytes, repl_list) + if not result.get("success") and "conflicts" not in result: + raise HTTPException( + status_code=400, + detail=result.get("error", "Import failed"), + ) + return result + + +@router.get("/skills/path") +def skill_get_path( + name: str = Query(..., description="Skill display name"), +) -> dict: + """Get absolute directory path for a skill by name. For reveal-in-folder.""" + path_val = skill_get_path_by_name(name) + if path_val is None: + raise HTTPException(status_code=404, detail=f"Skill not found: {name}") + return {"path": path_val} + + +@router.get("/skills") +def skills_list() -> dict: + """Scan and list all skills.""" + skills = skills_scan() + return {"success": True, "skills": skills} + + +@router.post("/skills/{skill_dir_name}") +def skill_create(skill_dir_name: str, body: dict) -> dict: + """Create or overwrite skill. Body: { content }.""" + content = body.get("content", "") + try: + skill_write(skill_dir_name, content) + skill_logger.info("Skill written: %s", skill_dir_name) + return {"success": True} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) + + +@router.get("/skills/{skill_dir_name}") +def skill_get(skill_dir_name: str) -> dict: + """Read skill content.""" + try: + content = skill_read(skill_dir_name) + return {"success": True, "content": content} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Skill not found") + + +@router.delete("/skills/{skill_dir_name}") +def skill_remove(skill_dir_name: str) -> dict: + """Delete skill.""" + try: + skill_delete(skill_dir_name) + skill_logger.info("Skill deleted: %s", skill_dir_name) + return {"success": True} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) + + +@router.get("/skills/{skill_dir_name}/files") +def skill_files(skill_dir_name: str) -> dict: + """List files in skill directory.""" + try: + files = skill_list_files(skill_dir_name) + return {"success": True, "files": files} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) diff --git a/backend/app/controller/task_controller.py b/backend/app/controller/task_controller.py index 8924218cd..0b15df35e 100644 --- a/backend/app/controller/task_controller.py +++ b/backend/app/controller/task_controller.py @@ -30,6 +30,7 @@ ActionTakeControl, ActionUpdateTaskData, get_task_lock, + get_task_lock_if_exists, task_locks, ) @@ -38,6 +39,17 @@ router = APIRouter() +@router.post("/v1/tasks", name="dispatch task placeholder") +def create_dispatch_task(): + return Response(status_code=501, content="Not implemented yet") + + +@router.get("/v1/tasks/{task_id}", name="dispatch task status placeholder") +def get_dispatch_task(task_id: str): + _ = task_id + return Response(status_code=501, content="Not implemented yet") + + @router.post("/task/{id}/start", name="start task") def start(id: str): task_lock = get_task_lock(id) @@ -68,16 +80,27 @@ def put(id: str, data: UpdateData): class TakeControl(BaseModel): - action: Literal[Action.pause, Action.resume] + action: Literal[Action.pause, Action.resume, Action.stop] -@router.put("/task/{id}/take-control", name="take control pause or resume") +@router.put( + "/task/{id}/take-control", name="take control pause, resume or stop" +) def take_control(id: str, data: TakeControl): logger.info( "Task control action", extra={"task_id": id, "action": data.action} ) - task_lock = get_task_lock(id) - asyncio.run(task_lock.put_queue(ActionTakeControl(action=data.action))) + task_lock = get_task_lock_if_exists(id) + if task_lock is None: + logger.warning( + "Task lock not found for take-control, may already be stopped", + extra={"task_id": id}, + ) + return Response(status_code=204) + if data.action == Action.stop: + asyncio.run(task_lock.put_queue(ActionStopData(action=Action.stop))) + else: + asyncio.run(task_lock.put_queue(ActionTakeControl(action=data.action))) logger.info( "Task control action completed", extra={"task_id": id, "action": data.action}, diff --git a/backend/app/file_access/__init__.py b/backend/app/file_access/__init__.py new file mode 100644 index 000000000..1f21f1989 --- /dev/null +++ b/backend/app/file_access/__init__.py @@ -0,0 +1,19 @@ +# ========= 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 app.file_access.interface import IFileAccess +from app.file_access.local_file_access import LocalFileAccess +from app.file_access.upload_file_access import UploadFileAccess + +__all__ = ["IFileAccess", "LocalFileAccess", "UploadFileAccess"] diff --git a/backend/app/file_access/interface.py b/backend/app/file_access/interface.py new file mode 100644 index 000000000..ab860af15 --- /dev/null +++ b/backend/app/file_access/interface.py @@ -0,0 +1,54 @@ +# ========= 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 abc import ABC, abstractmethod + + +class IFileAccess(ABC): + """File access abstraction, implementation selected by client type""" + + @abstractmethod + def read_file(self, path: str) -> str: + """Read file content, return text""" + pass + + @abstractmethod + def read_file_binary(self, path: str) -> bytes: + """Read file as binary""" + pass + + @abstractmethod + def write_file(self, path: str, content: str | bytes) -> None: + """Write file""" + pass + + @abstractmethod + def exists(self, path: str) -> bool: + """Check if path exists""" + pass + + @abstractmethod + def list_dir(self, path: str) -> list[str]: + """List directory contents""" + pass + + @abstractmethod + def get_working_directory(self, session_id: str) -> str: + """Return session working directory absolute path""" + pass + + @abstractmethod + def resolve_path(self, path_or_id: str, session_id: str) -> str: + """Resolve path or file_id to actual path""" + pass diff --git a/backend/app/file_access/local_file_access.py b/backend/app/file_access/local_file_access.py new file mode 100644 index 000000000..2a63e4d89 --- /dev/null +++ b/backend/app/file_access/local_file_access.py @@ -0,0 +1,57 @@ +# ========= 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 pathlib import Path + +from app.file_access.interface import IFileAccess + + +class LocalFileAccess(IFileAccess): + """Direct local filesystem access (Desktop/CLI)""" + + def __init__(self, workspace_root: str = "~/.eigent/workspace") -> None: + self.workspace_root = Path(workspace_root).expanduser() + + def read_file(self, path: str) -> str: + return Path(path).read_text(encoding="utf-8") + + def read_file_binary(self, path: str) -> bytes: + return Path(path).read_bytes() + + def write_file(self, path: str, content: str | bytes) -> None: + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, str): + p.write_text(content, encoding="utf-8") + else: + p.write_bytes(content) + + def exists(self, path: str) -> bool: + return Path(path).exists() + + def list_dir(self, path: str) -> list[str]: + return [p.name for p in Path(path).iterdir()] + + def get_working_directory(self, session_id: str) -> str: + return str(self.workspace_root / session_id) + + def resolve_path(self, path_or_id: str, session_id: str) -> str: + if path_or_id.startswith("upload://"): + return self._resolve_upload_id(path_or_id, session_id) + return path_or_id + + def _resolve_upload_id(self, path_or_id: str, session_id: str) -> str: + """Resolve upload://xxx to workspace/{session_id}/uploads/xxx""" + file_id = path_or_id.removeprefix("upload://") + return str(self.workspace_root / session_id / "uploads" / file_id) diff --git a/backend/app/file_access/upload_file_access.py b/backend/app/file_access/upload_file_access.py new file mode 100644 index 000000000..b571e3726 --- /dev/null +++ b/backend/app/file_access/upload_file_access.py @@ -0,0 +1,72 @@ +# ========= 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 pathlib import Path + +from app.file_access.interface import IFileAccess + + +class UploadFileAccess(IFileAccess): + """Only handle uploaded files, path restricted to workspace/{session_id}/ (Web/Channel)""" + + def __init__(self, workspace_root: str = "~/.eigent/workspace") -> None: + self.workspace_root = Path(workspace_root).expanduser() + + def read_file(self, path: str) -> str: + resolved = self._ensure_in_workspace(path) + return resolved.read_text(encoding="utf-8") + + def read_file_binary(self, path: str) -> bytes: + resolved = self._ensure_in_workspace(path) + return resolved.read_bytes() + + def write_file(self, path: str, content: str | bytes) -> None: + resolved = self._ensure_in_workspace(path) + resolved.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, str): + resolved.write_text(content, encoding="utf-8") + else: + resolved.write_bytes(content) + + def exists(self, path: str) -> bool: + try: + resolved = self._ensure_in_workspace(path) + return resolved.exists() + except PermissionError: + return False + + def list_dir(self, path: str) -> list[str]: + resolved = self._ensure_in_workspace(path) + return [p.name for p in resolved.iterdir()] + + def get_working_directory(self, session_id: str) -> str: + return str(self.workspace_root / session_id) + + def resolve_path(self, path_or_id: str, session_id: str) -> str: + if not path_or_id.startswith("upload://"): + raise PermissionError("Only uploaded files are accessible") + return self._resolve_upload_id(path_or_id, session_id) + + def _resolve_upload_id(self, path_or_id: str, session_id: str) -> str: + file_id = path_or_id.removeprefix("upload://") + return str(self.workspace_root / session_id / "uploads" / file_id) + + def _ensure_in_workspace(self, path: str) -> Path: + p = Path(path).resolve() + root = self.workspace_root.resolve() + try: + p.relative_to(root) + except ValueError: + raise PermissionError("Path outside workspace") + return p diff --git a/backend/app/hands/__init__.py b/backend/app/hands/__init__.py new file mode 100644 index 000000000..a48442a51 --- /dev/null +++ b/backend/app/hands/__init__.py @@ -0,0 +1,46 @@ +# ========= 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 app.hands.capabilities import BrainCapabilities, detect_capabilities +from app.hands.cluster_config import ( + ClusterEndpointConfig, + HandsClusterConfigError, + HandsClusterRoutingConfig, + load_hands_cluster_config, +) +from app.hands.cluster_interface import IHandsCluster +from app.hands.environment_hands import EnvironmentHands +from app.hands.full_hands import FullHands +from app.hands.http_hands_cluster import HttpHandsCluster +from app.hands.interface import IHands +from app.hands.remote_hands import RemoteHands +from app.hands.routed_hands_cluster import RoutedHandsCluster +from app.hands.sandbox_hands import SandboxHands + +__all__ = [ + "BrainCapabilities", + "ClusterEndpointConfig", + "EnvironmentHands", + "FullHands", + "HandsClusterConfigError", + "HandsClusterRoutingConfig", + "HttpHandsCluster", + "IHandsCluster", + "IHands", + "RemoteHands", + "RoutedHandsCluster", + "SandboxHands", + "detect_capabilities", + "load_hands_cluster_config", +] diff --git a/backend/app/hands/capabilities.py b/backend/app/hands/capabilities.py new file mode 100644 index 000000000..fc63ee4cc --- /dev/null +++ b/backend/app/hands/capabilities.py @@ -0,0 +1,236 @@ +# ========= 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. ========= + +""" +BrainCapabilities — Brain capability set, determined by deployment env. Everything revolves around what the Brain can operate. + +Hand Types (capability dimensions — what the Brain can reach and control): +- filesystem: operate local files (scope: full | workspace_only | none) +- terminal: execute shell commands +- browser: control browser (CDP) +- mcp: use MCP tool protocol (all | allowlist) + +Design principles: +- Brain on local/cloud VM -> full capabilities (extensible: smart home, router, car, etc.) +- Brain in sandbox/Docker -> limited capabilities +- Channel only affects message display format (Markdown/plain/Block Kit), does not determine Brain capabilities +""" + +import logging +import os +import shutil +from dataclasses import dataclass, field +from pathlib import Path + +from app.component.environment import env + +logger = logging.getLogger("hands.capabilities") + +# Deployment determines Brain capabilities +DEPLOYMENT_FULL = ("local", "cloud_vm", "") # full capabilities +DEPLOYMENT_SANDBOX = ("sandbox", "docker", "container") # limited capabilities + + +def _is_running_in_docker() -> bool: + """Detect if Brain runs inside Docker/container.""" + if Path("/.dockerenv").exists(): + return True + try: + cgroup = Path("/proc/1/cgroup").read_text() + return ( + "docker" in cgroup + or "containerd" in cgroup + or "kubepods" in cgroup + ) + except (OSError, FileNotFoundError): + return False + + +def _probe_cdp_browser() -> bool: + """Check if CDP browser is configured/available.""" + if os.environ.get("EIGENT_CDP_URL"): + return True + cdp_json = Path.home() / ".eigent" / "cdp.json" + if cdp_json.exists(): + return True + # Electron persists CDP pool here; if present, browser capability is likely available. + cdp_pool = Path.home() / ".eigent" / "cdp-browsers.json" + return cdp_pool.exists() + + +def _is_electron_runtime() -> bool: + """Detect whether Brain is launched by Electron desktop host.""" + return env("EIGENT_RUNTIME", "").lower().strip() == "electron" + + +def _can_launch_local_cdp_browser() -> bool: + """Check if local runtime can provision a CDP browser on demand.""" + if os.environ.get("EIGENT_BRAIN_LAUNCH_BROWSER", "true").lower() in ( + "false", + "0", + "no", + ): + return False + try: + from app.utils.browser_launcher import _find_chrome_executable + + return _find_chrome_executable() is not None + except Exception as e: + logger.debug(f"Could not probe local browser executable: {e}") + return False + + +@dataclass +class BrainCapabilities: + """ + Brain capability set (detected + config), determined at startup, global singleton. + + Each field maps to a Hand Type: what the Brain can operate. + """ + + has_terminal: bool = True + """terminal hand: can execute shell""" + + has_browser: bool = False + """browser hand: can control CDP browser""" + + filesystem_scope: str = "full" + """filesystem hand: full | workspace_only | none""" + + mcp_mode: str = "all" + """mcp hand: all | allowlist""" + + mcp_allowlist: list[str] = field(default_factory=list) + """used when mcp_mode=allowlist""" + + workspace_root: str = "~/.eigent/workspace" + """workspace root path""" + + deployment_type: str = "local" + """deployment type (for logging): local | cloud_vm | sandbox | docker""" + + @property + def mode(self) -> str: + """capability tier: full | sandbox — for IHands.mode compatibility""" + return "full" if self._is_full else "sandbox" + + @property + def _is_full(self) -> bool: + return self.filesystem_scope == "full" and self.has_terminal + + +def detect_capabilities(config: dict | None = None) -> BrainCapabilities: + """ + Detect Brain capabilities, two-layer decision: + 1. Deployment env: EIGENT_DEPLOYMENT_TYPE / Docker auto-detect + 2. Env var overrides: EIGENT_HANDS_* + """ + cfg = config or {} + + # 1. Deployment env determines base capabilities + deployment = env("EIGENT_DEPLOYMENT_TYPE") or "" + deployment = deployment.lower().strip() + + if deployment in DEPLOYMENT_FULL: + # local/cloud VM -> full capabilities + in_docker = _is_running_in_docker() + if in_docker: + logger.info("Brain running in Docker, using limited capabilities") + deployment = "docker" + caps = BrainCapabilities( + has_terminal=shutil.which("bash") is not None, + has_browser=False, + filesystem_scope="workspace_only", + mcp_mode="all", # MCP available in all deployment modes + workspace_root=env("EIGENT_WORKSPACE", "~/.eigent/workspace"), + deployment_type="docker", + ) + else: + # local/desktop: browser hand when CDP is configured/reachable, + # Electron host is present, or local browser can be provisioned. + has_browser = _probe_cdp_browser() + if not has_browser and _is_electron_runtime(): + has_browser = True + if not has_browser: + has_browser = _can_launch_local_cdp_browser() + if not has_browser: + logger.warning( + "Browser capability disabled: no CDP config, " + "not running under Electron host, and no launchable browser found." + ) + caps = BrainCapabilities( + has_terminal=shutil.which("bash") is not None, + has_browser=has_browser, + filesystem_scope="full", + mcp_mode="all", + workspace_root=env("EIGENT_WORKSPACE", "~/.eigent/workspace"), + deployment_type="cloud_vm" + if deployment == "cloud_vm" + else "local", + ) + else: + # sandbox / docker / container -> limited capabilities + caps = BrainCapabilities( + has_terminal=shutil.which("bash") is not None, + has_browser=False, + filesystem_scope="workspace_only", + mcp_mode="all", # MCP available in all deployment modes + workspace_root=env("EIGENT_WORKSPACE", "~/.eigent/workspace"), + deployment_type=deployment or "sandbox", + ) + + # 2. Env var overrides + if env("EIGENT_HANDS_TERMINAL") is not None: + caps.has_terminal = env("EIGENT_HANDS_TERMINAL", "true").lower() in ( + "1", + "true", + "yes", + ) + if env("EIGENT_HANDS_BROWSER") is not None: + caps.has_browser = env("EIGENT_HANDS_BROWSER", "false").lower() in ( + "1", + "true", + "yes", + ) + if env("EIGENT_HANDS_FILESYSTEM") is not None: + caps.filesystem_scope = env("EIGENT_HANDS_FILESYSTEM", "full") + if env("EIGENT_HANDS_MCP") is not None: + caps.mcp_mode = env("EIGENT_HANDS_MCP", "all") + if env("EIGENT_CDP_URL"): + caps.has_browser = True + + # 3. Config file overrides + if "terminal" in cfg: + caps.has_terminal = bool(cfg["terminal"]) + if "browser" in cfg: + caps.has_browser = bool(cfg["browser"]) + if "filesystem" in cfg: + caps.filesystem_scope = str(cfg["filesystem"]) + if "mcp" in cfg: + caps.mcp_mode = str(cfg["mcp"]) + if "mcp_allowlist" in cfg: + caps.mcp_allowlist = list(cfg["mcp_allowlist"]) + + logger.info( + "BrainCapabilities detected", + extra={ + "deployment": caps.deployment_type, + "mode": caps.mode, + "terminal": caps.has_terminal, + "browser": caps.has_browser, + "filesystem": caps.filesystem_scope, + "mcp": caps.mcp_mode, + }, + ) + return caps diff --git a/backend/app/hands/cluster_config.py b/backend/app/hands/cluster_config.py new file mode 100644 index 000000000..d1dc2505b --- /dev/null +++ b/backend/app/hands/cluster_config.py @@ -0,0 +1,321 @@ +# ========= 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 __future__ import annotations + +import tomllib +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +class HandsClusterConfigError(ValueError): + """Raised when cluster config file is invalid.""" + + +@dataclass(frozen=True, slots=True) +class ClusterEndpointConfig: + name: str + base_url: str + timeout_seconds: float + verify_tls: bool + acquire_path: str + release_path: str + health_path: str + auth_token: str | None + + +@dataclass(frozen=True, slots=True) +class HandsClusterRoutingConfig: + source_path: str + route_to_cluster: dict[str, ClusterEndpointConfig] + + +def load_hands_cluster_config( + config_file: str, + read_env: Callable[[str], str | None] | None = None, +) -> HandsClusterRoutingConfig: + env_reader = read_env or _default_read_env + source_path = _resolve_config_path(config_file) + data = _read_toml(source_path) + + defaults = _read_defaults(data.get("defaults")) + clusters = _read_clusters(data.get("clusters"), defaults, env_reader) + routes = _read_routes(data.get("routes"), clusters) + + return HandsClusterRoutingConfig( + source_path=str(source_path), + route_to_cluster=routes, + ) + + +def _default_read_env(name: str) -> str | None: + from app.component.environment import env + + return env(name) + + +def _resolve_config_path(config_file: str) -> Path: + raw = (config_file or "").strip() + if not raw: + raise HandsClusterConfigError("config file path is empty") + path = Path(raw).expanduser() + if not path.is_absolute(): + path = Path.cwd() / path + if not path.exists(): + raise HandsClusterConfigError( + f"cluster config file does not exist: {path}" + ) + if not path.is_file(): + raise HandsClusterConfigError( + f"cluster config path is not a file: {path}" + ) + return path + + +def _read_toml(path: Path) -> dict[str, Any]: + try: + with path.open("rb") as handle: + parsed = tomllib.load(handle) + except tomllib.TOMLDecodeError as exc: + raise HandsClusterConfigError( + f"invalid TOML in cluster config: {path}: {exc}" + ) from exc + except OSError as exc: + raise HandsClusterConfigError( + f"unable to read cluster config file: {path}: {exc}" + ) from exc + if not isinstance(parsed, dict): + raise HandsClusterConfigError( + f"cluster config root must be an object: {path}" + ) + return parsed + + +def _read_defaults(raw: Any) -> dict[str, Any]: + if raw is None: + raw = {} + if not isinstance(raw, dict): + raise HandsClusterConfigError("[defaults] must be a TOML table/object") + return { + "timeout_seconds": _as_float( + raw.get("timeout_seconds"), "defaults.timeout_seconds", 10.0 + ), + "verify_tls": _as_bool( + raw.get("verify_tls"), "defaults.verify_tls", True + ), + "acquire_path": _as_path_segment( + raw.get("acquire_path"), "defaults.acquire_path", "/acquire" + ), + "release_path": _as_path_segment( + raw.get("release_path"), "defaults.release_path", "/release" + ), + "health_path": _as_path_segment( + raw.get("health_path"), "defaults.health_path", "/health" + ), + "auth_token": _as_optional_str(raw.get("auth_token")), + "auth_token_env": _as_optional_str(raw.get("auth_token_env")), + } + + +def _read_clusters( + raw: Any, + defaults: dict[str, Any], + read_env: Callable[[str], str | None], +) -> dict[str, ClusterEndpointConfig]: + if not isinstance(raw, dict) or not raw: + raise HandsClusterConfigError( + "[clusters] must be a non-empty TOML table/object" + ) + + clusters: dict[str, ClusterEndpointConfig] = {} + for raw_name, item in raw.items(): + if not isinstance(raw_name, str) or not raw_name.strip(): + raise HandsClusterConfigError( + "cluster name must be a non-empty string" + ) + name = raw_name.strip().lower() + if not isinstance(item, dict): + raise HandsClusterConfigError( + f"[clusters.{raw_name}] must be a TOML table/object" + ) + + base_url = _as_optional_str(item.get("base_url")) or _as_optional_str( + item.get("api") + ) + if not base_url: + raise HandsClusterConfigError( + f"[clusters.{raw_name}] requires base_url (or api)" + ) + + timeout_seconds = _as_float( + item.get("timeout_seconds"), + f"clusters.{raw_name}.timeout_seconds", + defaults["timeout_seconds"], + ) + verify_tls = _as_bool( + item.get("verify_tls"), + f"clusters.{raw_name}.verify_tls", + defaults["verify_tls"], + ) + acquire_path = _as_path_segment( + item.get("acquire_path"), + f"clusters.{raw_name}.acquire_path", + defaults["acquire_path"], + ) + release_path = _as_path_segment( + item.get("release_path"), + f"clusters.{raw_name}.release_path", + defaults["release_path"], + ) + health_path = _as_path_segment( + item.get("health_path"), + f"clusters.{raw_name}.health_path", + defaults["health_path"], + ) + auth_token = _resolve_auth_token( + item=item, + defaults=defaults, + read_env=read_env, + ) + + clusters[name] = ClusterEndpointConfig( + name=name, + base_url=base_url.strip(), + timeout_seconds=timeout_seconds, + verify_tls=verify_tls, + acquire_path=acquire_path, + release_path=release_path, + health_path=health_path, + auth_token=auth_token, + ) + return clusters + + +def _read_routes( + raw: Any, + clusters: dict[str, ClusterEndpointConfig], +) -> dict[str, ClusterEndpointConfig]: + if raw is None: + if len(clusters) == 1: + only = next(iter(clusters.values())) + return {"default": only} + return dict(clusters) + + if not isinstance(raw, dict): + raise HandsClusterConfigError("[routes] must be a TOML table/object") + + route_to_cluster: dict[str, ClusterEndpointConfig] = {} + for raw_route, raw_cluster in raw.items(): + if not isinstance(raw_route, str) or not raw_route.strip(): + raise HandsClusterConfigError( + "route key must be a non-empty string" + ) + if not isinstance(raw_cluster, str) or not raw_cluster.strip(): + raise HandsClusterConfigError( + f"route '{raw_route}' target must be a non-empty string" + ) + + route_key = _normalize_route_key(raw_route) + cluster_name = raw_cluster.strip().lower() + cluster = clusters.get(cluster_name) + if cluster is None: + raise HandsClusterConfigError( + f"route '{raw_route}' references unknown cluster '{raw_cluster}'" + ) + route_to_cluster[route_key] = cluster + + if not route_to_cluster: + raise HandsClusterConfigError("[routes] must not be empty") + return route_to_cluster + + +def _resolve_auth_token( + item: dict[str, Any], + defaults: dict[str, Any], + read_env: Callable[[str], str | None], +) -> str | None: + direct = _as_optional_str(item.get("auth_token")) + if direct: + return direct + + env_name = _as_optional_str(item.get("auth_token_env")) + if env_name: + return _as_optional_str(read_env(env_name)) + + default_direct = _as_optional_str(defaults.get("auth_token")) + if default_direct: + return default_direct + + default_env_name = _as_optional_str(defaults.get("auth_token_env")) + if default_env_name: + return _as_optional_str(read_env(default_env_name)) + + return None + + +def _normalize_route_key(raw: str) -> str: + key = raw.strip().lower() + if key in ("*", "fallback"): + return "default" + return key + + +def _as_optional_str(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, str): + s = value.strip() + return s if s else None + return str(value).strip() or None + + +def _as_float(value: Any, field: str, default: float) -> float: + if value is None: + return default + try: + parsed = float(value) + except (TypeError, ValueError) as exc: + raise HandsClusterConfigError( + f"{field} must be a number, got {value!r}" + ) from exc + if parsed <= 0: + raise HandsClusterConfigError(f"{field} must be > 0, got {parsed!r}") + return parsed + + +def _as_bool(value: Any, field: str, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in ("1", "true", "yes", "on"): + return True + if lowered in ("0", "false", "no", "off"): + return False + raise HandsClusterConfigError(f"{field} must be a boolean, got {value!r}") + + +def _as_path_segment(value: Any, field: str, default: str) -> str: + if value is None: + return default + if not isinstance(value, str): + raise HandsClusterConfigError(f"{field} must be a string path") + s = value.strip() + if not s: + raise HandsClusterConfigError(f"{field} must not be empty") + return s if s.startswith("/") else f"/{s}" diff --git a/backend/app/hands/cluster_interface.py b/backend/app/hands/cluster_interface.py new file mode 100644 index 000000000..de53d45e7 --- /dev/null +++ b/backend/app/hands/cluster_interface.py @@ -0,0 +1,41 @@ +# ========= 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 abc import ABC, abstractmethod +from typing import Any + + +class IHandsCluster(ABC): + """Remote Hands worker cluster interface placeholder.""" + + @abstractmethod + async def acquire( + self, + resource_type: str, + session_id: str, + tenant_id: str = "default", + **kwargs, + ) -> dict[str, Any]: + """Acquire a worker resource and return its endpoint metadata.""" + ... + + @abstractmethod + async def release(self, session_id: str) -> None: + """Release the worker resource bound to the session.""" + ... + + @abstractmethod + async def health(self) -> dict[str, Any]: + """Return cluster health and pool availability.""" + ... diff --git a/backend/app/hands/environment_hands.py b/backend/app/hands/environment_hands.py new file mode 100644 index 000000000..d6d420cc5 --- /dev/null +++ b/backend/app/hands/environment_hands.py @@ -0,0 +1,110 @@ +# ========= 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. ========= + +""" +EnvironmentHands — IHands implementation driven by BrainCapabilities. + +Brain deployment env determines capability set; all Channels share one instance. +Channel only handles message display format adaptation. +""" + +from pathlib import Path + +from app.hands.capabilities import BrainCapabilities +from app.hands.interface import IHands + + +class EnvironmentHands(IHands): + """ + IHands implementation based on BrainCapabilities. + Initialized at Brain startup from deployment env; globally reused. + """ + + def __init__(self, caps: BrainCapabilities) -> None: + self._caps = caps + self.workspace_root = Path(caps.workspace_root).expanduser() + + @property + def mode(self) -> str: + """Capability tier: full | sandbox""" + return self._caps.mode + + def can_execute_terminal(self) -> bool: + return self._caps.has_terminal + + def can_access_filesystem(self, path: str) -> bool: + if self._caps.filesystem_scope == "full": + try: + resolved = Path(path).expanduser().resolve() + home = Path.home() + workspace = self.workspace_root.resolve() + try: + resolved.relative_to(home) + return True + except ValueError: + pass + try: + resolved.relative_to(workspace) + return True + except ValueError: + return False + except (OSError, RuntimeError): + return False + if self._caps.filesystem_scope == "workspace_only": + try: + resolved = Path(path).expanduser().resolve() + workspace = self.workspace_root.resolve() + resolved.relative_to(workspace) + return True + except ValueError: + return False + except (OSError, RuntimeError): + return False + return False # none + + def can_use_mcp(self, mcp_name: str) -> bool: + if self._caps.mcp_mode == "all": + return True + return mcp_name in self._caps.mcp_allowlist + + def can_use_browser(self) -> bool: + return self._caps.has_browser + + def get_working_directory( + self, session_id: str, tenant_id: str = "default" + ) -> str: + return str(self.workspace_root / session_id) + + def get_capability_manifest(self) -> dict[str, str | bool | list[str]]: + return { + "mode": self.mode, + "terminal": self._caps.has_terminal, + "browser": self._caps.has_browser, + "filesystem": self._caps.filesystem_scope, + "mcp": self._caps.mcp_mode, + "mcp_allowlist": list(self._caps.mcp_allowlist), + "deployment": self._caps.deployment_type, + "workspace_root": str(self.workspace_root), + } + + def acquire_resource( + self, resource_type: str, session_id: str, **kwargs + ) -> str: + if resource_type == "browser": + port = kwargs.get("port", 9222) + return f"http://localhost:{port}" + raise ValueError(f"Unknown resource type: {resource_type}") + + def release_resource(self, resource_type: str, session_id: str) -> None: + return None diff --git a/backend/app/hands/full_hands.py b/backend/app/hands/full_hands.py new file mode 100644 index 000000000..c5af52b90 --- /dev/null +++ b/backend/app/hands/full_hands.py @@ -0,0 +1,84 @@ +# ========= 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 pathlib import Path + +from app.hands.interface import IHands + + +class FullHands(IHands): + """Full capabilities: terminal, filesystem, browser, MCP all available""" + + def __init__(self, workspace_root: str = "~/.eigent/workspace") -> None: + self.workspace_root = Path(workspace_root).expanduser() + + @property + def mode(self) -> str: + return "full" + + def can_execute_terminal(self) -> bool: + return True + + def can_access_filesystem(self, path: str) -> bool: + # Allow ~/ and workspace + try: + resolved = Path(path).expanduser().resolve() + home = Path.home() + workspace = self.workspace_root.resolve() + try: + resolved.relative_to(home) + return True + except ValueError: + pass + try: + resolved.relative_to(workspace) + return True + except ValueError: + return False + except (OSError, RuntimeError): + return False + + def can_use_mcp(self, mcp_name: str) -> bool: + return True + + def can_use_browser(self) -> bool: + return True + + def get_working_directory( + self, session_id: str, tenant_id: str = "default" + ) -> str: + return str(self.workspace_root / session_id) + + def get_capability_manifest(self) -> dict[str, str | bool | list[str]]: + return { + "mode": self.mode, + "terminal": True, + "browser": True, + "filesystem": "full", + "mcp": "all", + "mcp_allowlist": [], + "deployment": "override_full", + "workspace_root": str(self.workspace_root), + } + + def acquire_resource( + self, resource_type: str, session_id: str, **kwargs + ) -> str: + if resource_type == "browser": + port = kwargs.get("port", 9222) + return f"http://localhost:{port}" + raise ValueError(f"Unknown resource type: {resource_type}") + + def release_resource(self, resource_type: str, session_id: str) -> None: + return None diff --git a/backend/app/hands/http_hands_cluster.py b/backend/app/hands/http_hands_cluster.py new file mode 100644 index 000000000..5ae92d642 --- /dev/null +++ b/backend/app/hands/http_hands_cluster.py @@ -0,0 +1,154 @@ +# ========= 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 logging +from typing import Any + +import httpx + +from app.hands.cluster_interface import IHandsCluster + +logger = logging.getLogger("hands.cluster.http") + + +class HttpHandsCluster(IHandsCluster): + """HTTP-backed Hands cluster client.""" + + def __init__( + self, + base_url: str, + timeout_seconds: float = 10.0, + auth_token: str | None = None, + acquire_path: str = "/acquire", + release_path: str = "/release", + health_path: str = "/health", + verify_tls: bool = True, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + normalized = base_url.strip().rstrip("/") + if not normalized: + raise ValueError("base_url must not be empty") + self._base_url = normalized + self._timeout_seconds = timeout_seconds + self._auth_token = auth_token + self._acquire_path = acquire_path + self._release_path = release_path + self._health_path = health_path + self._verify_tls = verify_tls + self._transport = transport + + async def acquire( + self, + resource_type: str, + session_id: str, + tenant_id: str = "default", + **kwargs, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "type": resource_type, + "resource_type": resource_type, + "session_id": session_id, + "tenant_id": tenant_id, + } + payload.update(kwargs) + body = await self._request_json( + method="POST", + url=self._build_url(self._acquire_path), + payload=payload, + ) + data = self._unwrap_response(body) + endpoint = ( + data.get("endpoint") or data.get("cdp_url") or data.get("url") + ) + if not endpoint: + raise RuntimeError( + "Hands cluster acquire response missing endpoint" + ) + data["endpoint"] = str(endpoint) + return data + + async def release(self, session_id: str) -> None: + payload = {"session_id": session_id} + try: + await self._request_json( + method="POST", + url=self._build_url(self._release_path), + payload=payload, + ) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + logger.warning( + "Hands cluster release returned 404 for session_id=%s", + session_id, + ) + return + raise + + async def health(self) -> dict[str, Any]: + body = await self._request_json( + method="GET", + url=self._build_url(self._health_path), + payload=None, + ) + return self._unwrap_response(body) + + def _build_url(self, path: str) -> str: + p = path.strip() + if p.startswith("http://") or p.startswith("https://"): + return p + if not p.startswith("/"): + p = f"/{p}" + return f"{self._base_url}{p}" + + def _headers(self) -> dict[str, str]: + headers = {"Accept": "application/json"} + if self._auth_token: + headers["Authorization"] = f"Bearer {self._auth_token}" + return headers + + async def _request_json( + self, + method: str, + url: str, + payload: dict[str, Any] | None, + ) -> dict[str, Any]: + request_kwargs: dict[str, Any] = {"headers": self._headers()} + if payload is not None: + request_kwargs["json"] = payload + + async with httpx.AsyncClient( + timeout=self._timeout_seconds, + verify=self._verify_tls, + transport=self._transport, + ) as client: + response = await client.request(method, url, **request_kwargs) + response.raise_for_status() + + if not response.content: + return {} + + try: + body = response.json() + except ValueError: + return {} + if isinstance(body, dict): + return body + return {"result": body} + + def _unwrap_response(self, body: dict[str, Any]) -> dict[str, Any]: + if isinstance(body.get("data"), dict): + return dict(body["data"]) + if isinstance(body.get("result"), dict): + return dict(body["result"]) + return dict(body) diff --git a/backend/app/hands/interface.py b/backend/app/hands/interface.py new file mode 100644 index 000000000..77cdf7d5e --- /dev/null +++ b/backend/app/hands/interface.py @@ -0,0 +1,75 @@ +# ========= 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 abc import ABC, abstractmethod +from typing import Any + + +class IHands(ABC): + """ + Brain capability interface — what the Brain can operate (Hand Types). + + mode: full | sandbox (capability tier) + can_*: whether each Hand Type is available + """ + + @property + @abstractmethod + def mode(self) -> str: + """Capability tier: full | sandbox""" + pass + + @abstractmethod + def can_execute_terminal(self) -> bool: + """terminal hand: can execute shell""" + pass + + @abstractmethod + def can_access_filesystem(self, path: str) -> bool: + """filesystem hand: whether path is within accessible scope""" + pass + + @abstractmethod + def can_use_mcp(self, mcp_name: str) -> bool: + """mcp hand: whether this MCP is available""" + pass + + @abstractmethod + def can_use_browser(self) -> bool: + """browser hand: can control CDP browser""" + pass + + @abstractmethod + def get_working_directory( + self, session_id: str, tenant_id: str = "default" + ) -> str: + """Return session working directory""" + pass + + @abstractmethod + def get_capability_manifest(self) -> dict[str, Any]: + """Return a serializable capability manifest for clients.""" + pass + + @abstractmethod + def acquire_resource( + self, resource_type: str, session_id: str, **kwargs + ) -> str: + """Acquire a resource endpoint for the requested hand type.""" + pass + + @abstractmethod + def release_resource(self, resource_type: str, session_id: str) -> None: + """Release a previously acquired resource.""" + pass diff --git a/backend/app/hands/remote_hands.py b/backend/app/hands/remote_hands.py new file mode 100644 index 000000000..37834b597 --- /dev/null +++ b/backend/app/hands/remote_hands.py @@ -0,0 +1,136 @@ +# ========= 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 asyncio +from pathlib import Path +from typing import Any + +from app.hands.cluster_interface import IHandsCluster +from app.hands.interface import IHands + + +class RemoteHands(IHands): + """ + Remote/cluster-backed Hands placeholder. + + This class is intentionally minimal for this refactor stage: + - exposes a concrete IHands implementation for remote cluster mode + - supports browser resource acquire/release via IHandsCluster when provided + - keeps safe local fallback endpoint when cluster is not wired yet + """ + + def __init__( + self, + cluster: IHandsCluster | None = None, + workspace_root: str = "~/.eigent/workspace", + ) -> None: + self._cluster = cluster + self.workspace_root = Path(workspace_root).expanduser() + self._acquired: dict[str, dict[str, Any]] = {} + + @property + def mode(self) -> str: + return "full" + + def can_execute_terminal(self) -> bool: + return True + + def can_access_filesystem(self, path: str) -> bool: + try: + resolved = Path(path).expanduser().resolve() + workspace = self.workspace_root.resolve() + resolved.relative_to(workspace) + return True + except ValueError: + return False + except (OSError, RuntimeError): + return False + + def can_use_mcp(self, mcp_name: str) -> bool: + _ = mcp_name + return True + + def can_use_browser(self) -> bool: + return True + + def get_working_directory( + self, session_id: str, tenant_id: str = "default" + ) -> str: + _ = tenant_id + return str(self.workspace_root / session_id) + + def get_capability_manifest(self) -> dict[str, str | bool | list[str]]: + return { + "mode": self.mode, + "terminal": True, + "browser": True, + "filesystem": "workspace_only", + "mcp": "all", + "mcp_allowlist": [], + "deployment": "remote_cluster", + "workspace_root": str(self.workspace_root), + } + + def acquire_resource( + self, resource_type: str, session_id: str, **kwargs + ) -> str: + if self._cluster is None: + if resource_type == "browser": + port = int(kwargs.get("port", 9222)) + return f"http://localhost:{port}" + raise ValueError( + f"Unknown resource type without cluster configured: {resource_type}" + ) + + # IHands interface is sync; bridge to async cluster API here. + try: + _ = asyncio.get_running_loop() + except RuntimeError: + pass + else: + raise RuntimeError( + "Cannot synchronously acquire remote resource while event loop is running" + ) + + acquired = asyncio.run( + self._cluster.acquire( + resource_type=resource_type, + session_id=session_id, + **kwargs, + ) + ) + self._acquired[session_id] = acquired + endpoint = acquired.get("endpoint") + if not endpoint: + raise RuntimeError( + "Remote cluster acquire() did not return endpoint" + ) + return str(endpoint) + + def release_resource(self, resource_type: str, session_id: str) -> None: + _ = resource_type + self._acquired.pop(session_id, None) + if self._cluster is None: + return + + # Best-effort release for sync interface. + try: + asyncio.run(self._cluster.release(session_id)) + except RuntimeError: + # If called from a running loop, schedule best-effort release. + try: + loop = asyncio.get_running_loop() + loop.create_task(self._cluster.release(session_id)) + except RuntimeError: + return diff --git a/backend/app/hands/routed_hands_cluster.py b/backend/app/hands/routed_hands_cluster.py new file mode 100644 index 000000000..28b8a7184 --- /dev/null +++ b/backend/app/hands/routed_hands_cluster.py @@ -0,0 +1,122 @@ +# ========= 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 logging +from typing import Any + +from app.hands.cluster_interface import IHandsCluster + +logger = logging.getLogger("hands.cluster.routed") + + +class RoutedHandsCluster(IHandsCluster): + """ + Route resource requests to different cluster clients by resource type. + + Example keys: + - "browser" + - "terminal" + - "model" + - "default" + """ + + def __init__( + self, + clusters: dict[str, IHandsCluster], + default_key: str = "default", + ) -> None: + normalized = { + k.strip().lower(): v + for k, v in clusters.items() + if k and isinstance(k, str) + } + if not normalized: + raise ValueError("clusters must not be empty") + self._clusters = normalized + self._default_key = ( + default_key if default_key in self._clusters else None + ) + self._session_cluster_key: dict[str, str] = {} + + async def acquire( + self, + resource_type: str, + session_id: str, + tenant_id: str = "default", + **kwargs, + ) -> dict[str, Any]: + key = self._select_cluster_key(resource_type) + cluster = self._clusters[key] + acquired = await cluster.acquire( + resource_type=resource_type, + session_id=session_id, + tenant_id=tenant_id, + **kwargs, + ) + self._session_cluster_key[session_id] = key + if "cluster_key" not in acquired: + acquired["cluster_key"] = key + return acquired + + async def release(self, session_id: str) -> None: + key = self._session_cluster_key.pop(session_id, None) + if key is not None and key in self._clusters: + await self._clusters[key].release(session_id) + return + + if self._default_key is not None: + await self._clusters[self._default_key].release(session_id) + return + + last_error: Exception | None = None + for cluster_key, cluster in self._clusters.items(): + try: + await cluster.release(session_id) + return + except Exception as exc: # pragma: no cover - best effort log path + last_error = exc + logger.warning( + "Release attempt failed on cluster key %s for session_id=%s: %s", + cluster_key, + session_id, + exc, + ) + if last_error is not None: + raise last_error + + async def health(self) -> dict[str, Any]: + clusters_health: dict[str, Any] = {} + for key, cluster in self._clusters.items(): + try: + clusters_health[key] = await cluster.health() + except Exception as exc: + clusters_health[key] = {"error": str(exc)} + return { + "mode": "routed", + "default_key": self._default_key, + "clusters": clusters_health, + } + + def _select_cluster_key(self, resource_type: str) -> str: + wanted = (resource_type or "").strip().lower() + if wanted in self._clusters: + return wanted + if self._default_key is not None: + return self._default_key + if len(self._clusters) == 1: + return next(iter(self._clusters.keys())) + raise ValueError( + "No cluster configured for " + f"resource_type={resource_type!r} and no default cluster" + ) diff --git a/backend/app/hands/sandbox_hands.py b/backend/app/hands/sandbox_hands.py new file mode 100644 index 000000000..1e5460815 --- /dev/null +++ b/backend/app/hands/sandbox_hands.py @@ -0,0 +1,94 @@ +# ========= 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 pathlib import Path + +from app.hands.interface import IHands + +# Terminal command allowlist for sandbox +SANDBOX_TERMINAL_ALLOWLIST = frozenset( + {"ls", "cat", "python", "node", "pwd", "echo"} +) + + +class SandboxHands(IHands): + """Limited capabilities: workspace only, MCP allowed, no browser""" + + def __init__( + self, + workspace_root: str = "~/.eigent/workspace", + allowed_mcps: frozenset[str] | None = None, + ) -> None: + self.workspace_root = Path(workspace_root).expanduser() + # None = allow all MCP (default for debug override); frozenset() = allow none + self.allowed_mcps = allowed_mcps + + @property + def mode(self) -> str: + return "sandbox" + + def can_execute_terminal(self) -> bool: + return ( + True # Enabled; toolkit layer validates against command allowlist + ) + + def can_access_filesystem(self, path: str) -> bool: + try: + resolved = Path(path).expanduser().resolve() + workspace = self.workspace_root.resolve() + resolved.relative_to(workspace) + return True + except ValueError: + return False + except (OSError, RuntimeError): + return False + + def can_use_mcp(self, mcp_name: str) -> bool: + if self.allowed_mcps is None: + return True # MCP available in all cases + if not self.allowed_mcps: + return False + return mcp_name in self.allowed_mcps + + def can_use_browser(self) -> bool: + return False # No browser hand in limited mode + + def get_working_directory( + self, session_id: str, tenant_id: str = "default" + ) -> str: + return str(self.workspace_root / session_id) + + def get_capability_manifest(self) -> dict[str, str | bool | list[str]]: + return { + "mode": self.mode, + "terminal": self.can_execute_terminal(), + "browser": False, + "filesystem": "workspace_only", + "mcp": "all" if self.allowed_mcps is None else "allowlist", + "mcp_allowlist": [] + if self.allowed_mcps is None + else list(self.allowed_mcps), + "deployment": "override_sandbox", + "workspace_root": str(self.workspace_root), + } + + def acquire_resource( + self, resource_type: str, session_id: str, **kwargs + ) -> str: + raise ValueError( + f"Resource type {resource_type!r} is not available in sandbox mode" + ) + + def release_resource(self, resource_type: str, session_id: str) -> None: + return None diff --git a/backend/app/hardware/__init__.py b/backend/app/hardware/__init__.py new file mode 100644 index 000000000..9bd7e8fef --- /dev/null +++ b/backend/app/hardware/__init__.py @@ -0,0 +1,18 @@ +# ========= 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 app.hardware.interface import IHardwareBridge +from app.hardware.null_bridge import NullHardwareBridge + +__all__ = ["IHardwareBridge", "NullHardwareBridge"] diff --git a/backend/app/hardware/interface.py b/backend/app/hardware/interface.py new file mode 100644 index 000000000..e51f30219 --- /dev/null +++ b/backend/app/hardware/interface.py @@ -0,0 +1,55 @@ +# ========= 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 abc import ABC, abstractmethod +from typing import Any + + +class IHardwareBridge(ABC): + """Hardware capability bridge. Only Desktop has implementation, others use NullBridge""" + + @abstractmethod + def get_cdp_browsers(self) -> list[dict]: + """Get available CDP browser list""" + pass + + @abstractmethod + def add_cdp_browser(self, browser_id: str, **kwargs: Any) -> dict: + """Add CDP browser""" + pass + + @abstractmethod + def remove_cdp_browser(self, browser_id: str) -> bool: + """Remove CDP browser""" + pass + + @abstractmethod + def create_webview(self, id: str, url: str) -> None: + """Create WebView (Electron)""" + pass + + @abstractmethod + def hide_webview(self, id: str) -> None: + """Hide WebView""" + pass + + @abstractmethod + def show_webview(self, id: str) -> None: + """Show WebView""" + pass + + @abstractmethod + def set_webview_size(self, id: str, size: dict) -> None: + """Set WebView size""" + pass diff --git a/backend/app/hardware/null_bridge.py b/backend/app/hardware/null_bridge.py new file mode 100644 index 000000000..df9c232d4 --- /dev/null +++ b/backend/app/hardware/null_bridge.py @@ -0,0 +1,42 @@ +# ========= 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 typing import Any + +from app.hardware.interface import IHardwareBridge + + +class NullHardwareBridge(IHardwareBridge): + """Null implementation when no hardware. All methods return empty or no-op""" + + def get_cdp_browsers(self) -> list[dict]: + return [] + + def add_cdp_browser(self, browser_id: str, **kwargs: Any) -> dict: + raise NotImplementedError("CDP not available in this environment") + + def remove_cdp_browser(self, browser_id: str) -> bool: + return False + + def create_webview(self, id: str, url: str) -> None: + raise NotImplementedError("WebView not available in this environment") + + def hide_webview(self, id: str) -> None: + pass + + def show_webview(self, id: str) -> None: + pass + + def set_webview_size(self, id: str, size: dict) -> None: + pass diff --git a/backend/app/model/chat.py b/backend/app/model/chat.py index 8f13a5aa4..a0780f457 100644 --- a/backend/app/model/chat.py +++ b/backend/app/model/chat.py @@ -78,6 +78,9 @@ class Chat(BaseModel): search_config: dict[str, str] | None = None # User identifier for user-specific skill configurations user_id: str | None = None + # Direct server API base URL (for example http://localhost:3001/api/v1) + # used by standalone Brain to sync replay steps without Electron env injection. + server_url: str | None = None @field_validator("model_type") @classmethod diff --git a/backend/app/router.py b/backend/app/router.py index 2909eaae0..fc58f25d6 100644 --- a/backend/app/router.py +++ b/backend/app/router.py @@ -23,8 +23,12 @@ from app.controller import ( chat_controller, + file_controller, health_controller, + mcp_controller, + message_controller, model_controller, + skill_controller, task_controller, tool_controller, ) @@ -51,11 +55,31 @@ def register_routers(app: FastAPI, prefix: str = "") -> None: "tags": ["Health"], "description": "Health check endpoint for service readiness", }, + { + "router": file_controller.router, + "tags": ["Files"], + "description": "File upload for Web/Channel clients", + }, + { + "router": mcp_controller.router, + "tags": ["MCP"], + "description": "MCP config (list, install, remove, update)", + }, + { + "router": skill_controller.router, + "tags": ["Skills"], + "description": "Skills scan, write, read, delete", + }, { "router": chat_controller.router, "tags": ["chat"], "description": "Chat session management, improvements, and human interactions", }, + { + "router": message_controller.router, + "tags": ["Message Router"], + "description": "Phase 2 Message Router - /messages endpoint (prefix-aware)", + }, { "router": model_controller.router, "tags": ["model"], diff --git a/backend/app/router_layer/__init__.py b/backend/app/router_layer/__init__.py new file mode 100644 index 000000000..84962ae14 --- /dev/null +++ b/backend/app/router_layer/__init__.py @@ -0,0 +1,39 @@ +# ========= 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. ========= + +"""Message Router layer: Channel/Session binding, Hands selection.""" + +from app.router_layer.hands_resolver import ( + get_environment_hands, + get_hands_for_channel, + init_environment_hands, +) +from app.router_layer.interface import ( + InboundMessage, + IRouter, + OutboundMessage, +) +from app.router_layer.message_router import DefaultMessageRouter +from app.router_layer.middleware import ChannelSessionMiddleware + +__all__ = [ + "ChannelSessionMiddleware", + "DefaultMessageRouter", + "get_environment_hands", + "get_hands_for_channel", + "InboundMessage", + "init_environment_hands", + "IRouter", + "OutboundMessage", +] diff --git a/backend/app/router_layer/hands_resolver.py b/backend/app/router_layer/hands_resolver.py new file mode 100644 index 000000000..d72f3d739 --- /dev/null +++ b/backend/app/router_layer/hands_resolver.py @@ -0,0 +1,206 @@ +# ========= 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. ========= + +""" +Hands = Brain capabilities, driven by deployment env, not by Channel. + +- Brain on local/cloud VM -> full capabilities (extensible: smart home, router, car, etc.) +- Brain in sandbox/Docker -> limited capabilities +- Channel only affects message display format +- MCP is available in all deployment modes +""" + +import logging + +from app.component.environment import env +from app.hands import ( + FullHands, + HandsClusterConfigError, + HandsClusterRoutingConfig, + HttpHandsCluster, + IHands, + IHandsCluster, + RemoteHands, + RoutedHandsCluster, + SandboxHands, + load_hands_cluster_config, +) +from app.hands.capabilities import detect_capabilities +from app.hands.environment_hands import EnvironmentHands + +logger = logging.getLogger("hands.resolver") + +# Global EnvironmentHands singleton, initialized at startup +_environment_hands: IHands | None = None + + +def _is_truthy(raw: str | None) -> bool: + if raw is None: + return False + return raw.strip().lower() in ("1", "true", "yes", "on") + + +def _new_http_cluster( + cluster_api: str, + timeout_seconds: float, + verify_tls: bool, + auth_token: str | None, + acquire_path: str, + release_path: str, + health_path: str, +) -> HttpHandsCluster: + logger.info( + "Configured HttpHandsCluster", + extra={ + "cluster_api": cluster_api, + "acquire_path": acquire_path, + "release_path": release_path, + "health_path": health_path, + "verify_tls": verify_tls, + "has_auth_token": bool(auth_token), + "timeout_seconds": timeout_seconds, + }, + ) + return HttpHandsCluster( + base_url=cluster_api, + timeout_seconds=timeout_seconds, + verify_tls=verify_tls, + auth_token=auth_token, + acquire_path=acquire_path, + release_path=release_path, + health_path=health_path, + ) + + +def _build_remote_cluster() -> IHandsCluster | None: + config_file = env("EIGENT_HANDS_CLUSTER_CONFIG_FILE", "").strip() + if not config_file: + return None + + try: + routing = load_hands_cluster_config(config_file) + except HandsClusterConfigError as exc: + logger.warning( + "Failed to load hands cluster config file %r: %s", + config_file, + exc, + ) + return None + + logger.info( + "Loaded hands cluster config file", + extra={ + "config_file": routing.source_path, + "routes": sorted(routing.route_to_cluster.keys()), + }, + ) + return _build_cluster_from_routing(routing) + + +def _build_cluster_from_routing( + routing: HandsClusterRoutingConfig, +) -> IHandsCluster: + clusters_by_name: dict[str, IHandsCluster] = {} + route_clients: dict[str, IHandsCluster] = {} + + for route_key, endpoint in routing.route_to_cluster.items(): + client = clusters_by_name.get(endpoint.name) + if client is None: + client = _new_http_cluster( + endpoint.base_url, + timeout_seconds=endpoint.timeout_seconds, + verify_tls=endpoint.verify_tls, + auth_token=endpoint.auth_token, + acquire_path=endpoint.acquire_path, + release_path=endpoint.release_path, + health_path=endpoint.health_path, + ) + clusters_by_name[endpoint.name] = client + route_clients[route_key] = client + + if len(route_clients) == 1 and "default" in route_clients: + return route_clients["default"] + return RoutedHandsCluster(clusters=route_clients) + + +def _create_remote_hands(workspace_root: str) -> RemoteHands: + cluster = _build_remote_cluster() + if cluster is None: + logger.warning( + "RemoteHands enabled but EIGENT_HANDS_CLUSTER_CONFIG_FILE is missing/invalid; " + "browser resource acquisition will fallback to localhost endpoint" + ) + return RemoteHands(cluster=cluster, workspace_root=workspace_root) + + +def init_environment_hands(config: dict | None = None) -> IHands: + """Initialize global EnvironmentHands (capability set) at Brain startup""" + global _environment_hands + mode = env("EIGENT_HANDS_MODE", "").strip().lower() + remote_enabled = _is_truthy(env("EIGENT_HANDS_REMOTE", "false")) + + if mode == "remote" or remote_enabled: + workspace_root = env("EIGENT_WORKSPACE", "~/.eigent/workspace") + logger.info( + "Initializing RemoteHands from env switch", + extra={"mode": mode, "remote_enabled": remote_enabled}, + ) + _environment_hands = _create_remote_hands(workspace_root) + return _environment_hands + + caps = detect_capabilities(config) + _environment_hands = EnvironmentHands(caps) + return _environment_hands + + +def get_environment_hands() -> IHands: + """Return global EnvironmentHands, shared by all Channels. Auto-detect if not initialized.""" + global _environment_hands + if _environment_hands is None: + init_environment_hands() + return _environment_hands + + +def _reset_environment_hands_for_testing() -> None: + """Testing only: reset global Hands so it can be re-initialized with new env.""" + global _environment_hands + _environment_hands = None + + +def get_hands_for_channel( + _channel: str, + hands_override: str | None = None, + workspace_root: str | None = None, +) -> IHands: + """ + Return Hands (Brain capability) instance. Capabilities driven by deployment env; Channel not involved. + + - _channel: Kept for API compatibility; not used (Hands are env-driven per ADR-0006) + - hands_override: For debugging; force full/sandbox/remote + - workspace_root: Override workspace root (optional) + """ + root = workspace_root or env("EIGENT_WORKSPACE", "~/.eigent/workspace") + + if hands_override: + if hands_override in ("full", "sandbox", "remote"): + if hands_override == "remote": + return _create_remote_hands(root) + cls = {"full": FullHands, "sandbox": SandboxHands}[hands_override] + return cls(workspace_root=root) + logger.warning( + "Ignoring invalid X-Hands-Override: %r, expected full, sandbox or remote", + hands_override, + ) + + return get_environment_hands() diff --git a/backend/app/router_layer/interface.py b/backend/app/router_layer/interface.py new file mode 100644 index 000000000..3eac0c418 --- /dev/null +++ b/backend/app/router_layer/interface.py @@ -0,0 +1,72 @@ +# ========= 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. ========= + +"""IRouter interface and message types for Phase 2 Message Router.""" + +from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from fastapi import Request + + +@dataclass +class InboundMessage: + """Standardized inbound message format.""" + + session_id: str + channel: str # desktop | web | cli | whatsapp | telegram | slack | ... + user_id: str | None + payload: dict[str, Any] + headers: dict[str, str] + + +@dataclass +class OutboundMessage: + """Outbound message for routing back to Client.""" + + session_id: str + payload: dict[str, Any] + stream: bool = False + + +class IRouter(ABC): + """Message Router interface.""" + + @abstractmethod + def route_in( + self, + msg: InboundMessage, + *, + request: "Request | None" = None, + ) -> AsyncGenerator[OutboundMessage, None]: + """Inbound: dispatch to Core and return streaming outbound messages.""" + pass + + @abstractmethod + async def route_out(self, session_id: str, msg: OutboundMessage) -> None: + """Outbound: route back to Client (for WebSocket push).""" + pass + + @abstractmethod + async def resolve_session( + self, + channel: str, + session_id: str | None, + user_id: str | None, + ) -> str: + """Resolve or create Session, return session_id.""" + pass diff --git a/backend/app/router_layer/message_router.py b/backend/app/router_layer/message_router.py new file mode 100644 index 000000000..8875c92cd --- /dev/null +++ b/backend/app/router_layer/message_router.py @@ -0,0 +1,190 @@ +# ========= 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. ========= + +"""Default Message Router implementation for Phase 2.""" + +import logging +import os +import time +import uuid +from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING + +from app.router_layer.interface import ( + InboundMessage, + IRouter, + OutboundMessage, +) +from app.router_layer.session_store import ISessionStore, MemorySessionStore + +if TYPE_CHECKING: + from fastapi import Request + +logger = logging.getLogger("router_layer") + +# Session TTL: 24 hours per docs/design/06-protocol.md §1.2 +SESSION_TTL_SECONDS = 86400 + + +def _now_ts() -> float: + return time.time() + + +class DefaultMessageRouter(IRouter): + """ + Default Message Router with in-memory session store. + Implements resolve_session per docs/design/06-protocol.md §1.5. + """ + + def __init__( + self, + session_ttl: int = SESSION_TTL_SECONDS, + session_store: ISessionStore | None = None, + ): + self._session_ttl = session_ttl + self._session_store: ISessionStore = ( + session_store or MemorySessionStore() + ) + + async def resolve_session( + self, + channel: str, + session_id: str | None, + user_id: str | None, + ) -> str: + """ + Resolve or create Session per docs/design/06-protocol.md §1.5. + Uses channel isolation: same user_id on different channels get different sessions. + """ + # 1. If session_id provided, check store + if session_id: + entry = await self._session_store.get(session_id) + if ( + isinstance(entry, dict) + and entry.get("channel") == channel + and not self._is_expired(entry) + ): + entry["last_activity"] = _now_ts() + await self._session_store.set( + session_id, entry, ttl=self._session_ttl + ) + return session_id + # Expired or not found → treat as new, fall through + await self._session_store.delete(session_id) + + # 2. Generate new session_id + new_id = f"sess_{uuid.uuid4().hex[:16]}" + + # 3. Channel isolation: always create new session (per §1.3 recommendation) + now = _now_ts() + entry = { + "session_id": new_id, + "channel": channel, + "user_id": user_id, + "created_at": now, + "last_activity": now, + } + await self._session_store.set(new_id, entry, ttl=self._session_ttl) + + return new_id + + def _is_expired(self, entry: dict) -> bool: + last_activity = entry.get("last_activity") + if not isinstance(last_activity, (int, float)): + return True + return (_now_ts() - float(last_activity)) > self._session_ttl + + async def route_in( + self, + msg: InboundMessage, + *, + request: "Request | None" = None, + ) -> AsyncGenerator[OutboundMessage, None]: + """ + Inbound: dispatch to Core. + For chat payload (content present), forwards to step_solve. + request is required for chat dispatch (disconnect detection, hands). + """ + payload = msg.payload + content = payload.get("content") if isinstance(payload, dict) else None + + if content is not None and request is not None: + # Chat message: build Chat and stream from step_solve + async for out in self._route_chat(msg, request): + yield out + else: + # Unknown or unsupported payload + yield OutboundMessage( + session_id=msg.session_id, + payload={ + "code": -1, + "text": "Unsupported message type or missing content", + "data": {}, + }, + stream=False, + ) + + async def _route_chat( + self, + msg: InboundMessage, + request: "Request", + ) -> AsyncGenerator[OutboundMessage, None]: + """Dispatch chat payload to step_solve.""" + from app.controller.chat_controller import start_chat_stream + from app.model.chat import Chat + + payload = msg.payload or {} + project_id = payload.get("project_id") or msg.session_id + task_id = payload.get("task_id") or str(uuid.uuid4()) + content = payload.get("content", "") + attachments = payload.get("attachments") or [] + # Map design doc attachments [{type, file_id}] -> attaches (paths/ids) + attaches = [] + for a in attachments: + if isinstance(a, dict) and "file_id" in a: + attaches.append(a["file_id"]) + elif isinstance(a, str): + attaches.append(a) + + user_id = msg.user_id or "user" + email = f"{user_id}@local" if "@" not in user_id else user_id + + # Build Chat with defaults + chat = Chat( + task_id=task_id, + project_id=project_id, + question=content, + email=email, + attaches=attaches, + model_platform=payload.get("model_platform") or "openai", + model_type=payload.get("model_type") or "gpt-4o", + # TODO(multi-tenant): falling back to os.environ inherits whatever + # the last /chat request wrote – unsafe under concurrent sessions. + api_key=payload.get("api_key") + or os.environ.get("OPENAI_API_KEY", ""), + api_url=payload.get("api_url"), + user_id=msg.user_id, + ) + + stream = await start_chat_stream(chat, request) + async for sse_chunk in stream: + yield OutboundMessage( + session_id=msg.session_id, + payload={"raw": sse_chunk}, + stream=True, + ) + + async def route_out(self, session_id: str, msg: OutboundMessage) -> None: + """Outbound: route back to Client. Empty for now (WebSocket push later).""" + pass diff --git a/backend/app/router_layer/middleware.py b/backend/app/router_layer/middleware.py new file mode 100644 index 000000000..39ac2580f --- /dev/null +++ b/backend/app/router_layer/middleware.py @@ -0,0 +1,117 @@ +# ========= 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. ========= + +"""Channel/Session header middleware for Phase 2 Message Router.""" + +import logging +import uuid + +from app.component.environment import env +from app.router_layer.hands_resolver import get_hands_for_channel + +logger = logging.getLogger("router_layer") + +DEFAULT_CHANNEL = "desktop" +CHANNELS = frozenset( + { + "desktop", + "web", + "cli", + "whatsapp", + "telegram", + "slack", + "discord", + "lark", + "browser_extension", + } +) + + +def _is_truthy(raw: str | None) -> bool: + if raw is None: + return False + return raw.strip().lower() in ("1", "true", "yes", "on") + + +def _get_header( + scope: dict, name: str, default: str | None = None +) -> str | None: + name_lower = name.lower().encode() + for k, v in scope.get("headers", []): + if k.lower() == name_lower: + return v.decode() if v else default + return default + + +class ChannelSessionMiddleware: + """ + Parse X-Channel, X-Session-ID, X-User-ID headers and store in request.state. + Add X-Session-ID to response for clients. + Uses plain ASGI for reliable response header injection. + """ + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + channel = ( + _get_header(scope, "X-Channel", DEFAULT_CHANNEL) or DEFAULT_CHANNEL + ) + if channel not in CHANNELS: + logger.warning( + "Invalid X-Channel header %r, falling back to %r", + channel, + DEFAULT_CHANNEL, + ) + channel = DEFAULT_CHANNEL + + session_id = _get_header(scope, "X-Session-ID") + user_id = _get_header(scope, "X-User-ID") + hands_override = _get_header(scope, "X-Hands-Override") + debug_override_enabled = _is_truthy(env("EIGENT_DEBUG", "false")) + + if hands_override and not debug_override_enabled: + logger.warning( + "Ignoring X-Hands-Override because EIGENT_DEBUG is disabled" + ) + hands_override = None + + if not session_id: + session_id = f"sess_{uuid.uuid4().hex[:16]}" + + hands = get_hands_for_channel(channel, hands_override) + + if "state" not in scope: + scope["state"] = {} + scope["state"]["channel"] = channel + scope["state"]["session_id"] = session_id + scope["state"]["user_id"] = user_id + scope["state"]["hands_override"] = hands_override + scope["state"]["hands"] = hands + + session_id_bytes = session_id.encode() + + async def send_wrapper(message): + if message["type"] == "http.response.start" and session_id: + headers = list(message.get("headers", [])) + if not any(h[0].lower() == b"x-session-id" for h in headers): + headers.append((b"x-session-id", session_id_bytes)) + message["headers"] = headers + await send(message) + + await self.app(scope, receive, send_wrapper) diff --git a/backend/app/router_layer/session_store.py b/backend/app/router_layer/session_store.py new file mode 100644 index 000000000..c8ac4ba87 --- /dev/null +++ b/backend/app/router_layer/session_store.py @@ -0,0 +1,87 @@ +# ========= 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 time +from abc import ABC, abstractmethod +from typing import Any + + +class ISessionStore(ABC): + @abstractmethod + async def get(self, session_id: str) -> dict[str, Any] | None: ... + + @abstractmethod + async def set( + self, session_id: str, entry: dict[str, Any], ttl: int = 86400 + ) -> None: ... + + @abstractmethod + async def delete(self, session_id: str) -> None: ... + + +class MemorySessionStore(ISessionStore): + _CLEANUP_INTERVAL_SET_CALLS = 128 + _CLEANUP_BATCH_SIZE = 512 + + def __init__(self) -> None: + self._sessions: dict[str, dict[str, Any]] = {} + self._expires_at: dict[str, float] = {} + self._set_calls = 0 + + def _is_expired(self, session_id: str) -> bool: + expires_at = self._expires_at.get(session_id) + if expires_at is None: + return False + return time.time() > expires_at + + def _cleanup_if_expired(self, session_id: str) -> None: + if self._is_expired(session_id): + self._sessions.pop(session_id, None) + self._expires_at.pop(session_id, None) + + def _cleanup_expired_entries(self, max_entries: int) -> None: + if not self._expires_at: + return + now = time.time() + cleaned = 0 + for session_id, expires_at in list(self._expires_at.items()): + if expires_at > now: + continue + self._sessions.pop(session_id, None) + self._expires_at.pop(session_id, None) + cleaned += 1 + if cleaned >= max_entries: + break + + async def get(self, session_id: str) -> dict[str, Any] | None: + self._cleanup_if_expired(session_id) + return self._sessions.get(session_id) + + async def set( + self, session_id: str, entry: dict[str, Any], ttl: int = 86400 + ) -> None: + self._set_calls += 1 + if ( + self._set_calls % self._CLEANUP_INTERVAL_SET_CALLS == 0 + ): # lazy GC for never-read sessions + self._cleanup_expired_entries(self._CLEANUP_BATCH_SIZE) + self._sessions[session_id] = entry + if ttl > 0: + self._expires_at[session_id] = time.time() + ttl + else: + self._expires_at.pop(session_id, None) + + async def delete(self, session_id: str) -> None: + self._sessions.pop(session_id, None) + self._expires_at.pop(session_id, None) diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py index b0ea4e606..c59768f54 100644 --- a/backend/app/service/chat_service.py +++ b/backend/app/service/chat_service.py @@ -16,6 +16,7 @@ import datetime import logging import platform +from functools import partial from pathlib import Path from typing import Any @@ -43,6 +44,7 @@ from app.agent.toolkit.skill_toolkit import SkillToolkit from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.tools import get_mcp_tools, get_toolkits +from app.hands.interface import IHands from app.model.chat import Chat, NewAgent, Status, TaskContent, sse_json from app.service.task import ( Action, @@ -335,6 +337,9 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): event_loop = asyncio.get_running_loop() sub_tasks: list[Task] = [] + # Phase 4: hands from ChannelSessionMiddleware (desktop=full, web=sandbox, etc.) + hands = getattr(request.state, "hands", None) + logger.info("=" * 80) logger.info( "🚀 [LIFECYCLE] step_solve STARTED", @@ -533,10 +538,13 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): ) try: - simple_resp = question_agent.step(simple_answer_prompt) - if simple_resp and simple_resp.msgs: - answer_content = simple_resp.msgs[0].content - else: + simple_resp = await question_agent.astep( + simple_answer_prompt + ) + answer_content = _extract_agent_response_content( + simple_resp + ) + if not answer_content: answer_content = ( "I understand your " "question, but I'm " @@ -633,11 +641,15 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): logger.info( "[NEW-QUESTION] Creating NEW workforce instance" ) - (workforce, mcp) = await construct_workforce(options) + (workforce, mcp) = await construct_workforce( + options, hands=hands + ) for new_agent in options.new_agents: workforce.add_single_agent_worker( format_agent_description(new_agent), - await new_agent_model(new_agent, options), + await new_agent_model( + new_agent, options, hands=hands + ), ) task_lock.status = Status.confirmed @@ -1224,14 +1236,15 @@ async def run_decomposition(): ) try: - simple_resp = question_agent.step( + simple_resp = await question_agent.astep( simple_answer_prompt ) - if simple_resp and simple_resp.msgs: - answer_content = simple_resp.msgs[ - 0 - ].content - else: + answer_content = ( + _extract_agent_response_content( + simple_resp + ) + ) + if not answer_content: answer_content = ( "I understand your " "question, but I'm " @@ -1563,7 +1576,7 @@ def on_stream_text(chunk): workforce.pause() workforce.add_single_agent_worker( format_agent_description(item), - await new_agent_model(item, options), + await new_agent_model(item, options, hands=hands), ) workforce.resume() elif item.action == Action.timeout: @@ -1958,18 +1971,12 @@ async def question_confirm( Is this a complex task? (yes/no):""" try: - resp = agent.step(full_prompt) + resp = await agent.astep(full_prompt) - if not resp or not resp.msgs or len(resp.msgs) == 0: - logger.warning( - "No response from agent, defaulting to complex task" - ) - return True - - content = resp.msgs[0].content + content = _extract_agent_response_content(resp) if not content: logger.warning( - "Empty content from agent, defaulting to complex task" + "No response from agent, defaulting to complex task" ) return True @@ -2004,8 +2011,8 @@ async def summary_task(agent: ListenChatAgent, task: Task) -> str: """ logger.debug("Generating task summary", extra={"task_id": task.id}) try: - res = agent.step(prompt) - summary = res.msgs[0].content + res = await agent.astep(prompt) + summary = _extract_agent_response_content(res) or "" logger.info("Task summary generated", extra={"summary": summary}) return summary except Exception as e: @@ -2056,8 +2063,8 @@ async def summary_subtasks_result(agent: ListenChatAgent, task: Task) -> str: Summary: """ - res = agent.step(prompt) - summary = res.msgs[0].content + res = await agent.astep(prompt) + summary = _extract_agent_response_content(res) or "" logger.info( "Generated subtasks summary for " @@ -2068,6 +2075,26 @@ async def summary_subtasks_result(agent: ListenChatAgent, task: Task) -> str: return summary +def _extract_agent_response_content(resp) -> str | None: + if resp is None: + return None + + msg = getattr(resp, "msg", None) + if msg is not None: + content = getattr(msg, "content", None) + if content: + return content + + msgs = getattr(resp, "msgs", None) + if msgs: + first = msgs[0] + content = getattr(first, "content", None) + if content: + return content + + return None + + async def get_task_result_with_optional_summary( task: Task, options: Chat ) -> str: @@ -2083,6 +2110,24 @@ async def get_task_result_with_optional_summary( """ result = str(task.result or "") + def _is_failed_state(state) -> bool: + if state is None: + return False + state_name = getattr(state, "name", str(state)) + return "fail" in str(state_name).lower() + + has_failed_subtask = any( + _is_failed_state(getattr(subtask, "state", None)) + for subtask in (task.subtasks or []) + ) + + if has_failed_subtask: + logger.info( + "Task %s has failed subtasks, skipping LLM summary to finish quickly", + task.id, + ) + return result + if task.subtasks and len(task.subtasks) > 1: logger.info( f"Task {task.id} has " @@ -2110,12 +2155,16 @@ async def get_task_result_with_optional_summary( async def construct_workforce( options: Chat, + hands: IHands | None = None, ) -> tuple[Workforce, ListenChatAgent]: """Construct a workforce with all required agents. This function creates all agents in PARALLEL to minimize startup time. Sync functions are run in thread pool, async functions are awaited concurrently. + + When hands is passed, base agents add tools based on Brain capabilities: + hands.can_execute_terminal(), hands.can_use_browser(), etc. determine whether terminal/browser hands are enabled. """ logger.debug( "construct_workforce started", @@ -2226,10 +2275,12 @@ def _create_new_worker_agent() -> ListenChatAgent: results = await asyncio.gather( asyncio.to_thread(_create_coordinator_and_task_agents), asyncio.to_thread(_create_new_worker_agent), - asyncio.to_thread(browser_agent, options), - developer_agent(options), - document_agent(options), - asyncio.to_thread(multi_modal_agent, options), + asyncio.to_thread(partial(browser_agent, options, hands=hands)), + developer_agent(options, hands=hands), + document_agent(options, hands=hands), + asyncio.to_thread( + partial(multi_modal_agent, options, hands=hands) + ), mcp_agent(options), ) except Exception as e: @@ -2237,13 +2288,6 @@ def _create_new_worker_agent() -> ListenChatAgent: f"Failed to create agents in parallel: {e}", exc_info=True ) raise - finally: - # Always clear event loop reference after - # parallel agent creation completes. - # This prevents stale references and - # potential cross-request interference - set_main_event_loop(None) - # Unpack results ( coord_task_agents, @@ -2347,7 +2391,11 @@ def format_agent_description(agent_data: NewAgent | ActionNewAgent) -> str: return " ".join(description_parts) -async def new_agent_model(data: NewAgent | ActionNewAgent, options: Chat): +async def new_agent_model( + data: NewAgent | ActionNewAgent, + options: Chat, + hands: IHands | None = None, +): logger.info( "Creating new agent", extra={ @@ -2361,21 +2409,26 @@ async def new_agent_model(data: NewAgent | ActionNewAgent, options: Chat): ) working_directory = get_working_directory(options) tool_names = [] - tools = [*await get_toolkits(data.tools, data.name, options.project_id)] + tools = [ + *await get_toolkits( + data.tools, data.name, options.project_id, hands=hands + ) + ] for item in data.tools: tool_names.append(titleize(item)) - # Always include terminal_toolkit with proper working directory - terminal_toolkit = TerminalToolkit( - options.project_id, - agent_name=data.name, - working_directory=working_directory, - safe_mode=True, - clone_current_env=True, - ) - tools.extend(terminal_toolkit.get_tools()) - tool_names.append(titleize("terminal_toolkit")) + # Add terminal_toolkit when terminal hand is available + if hands is None or hands.can_execute_terminal(): + terminal_toolkit = TerminalToolkit( + options.project_id, + agent_name=data.name, + working_directory=working_directory, + safe_mode=True, + clone_current_env=True, + ) + tools.extend(terminal_toolkit.get_tools()) + tool_names.append(titleize("terminal_toolkit")) if data.mcp_tools is not None: - tools = [*tools, *await get_mcp_tools(data.mcp_tools)] + tools = [*tools, *await get_mcp_tools(data.mcp_tools, hands=hands)] for item in data.mcp_tools["mcpServers"].keys(): tool_names.append(titleize(item)) for item in tools: diff --git a/backend/app/service/mcp_config.py b/backend/app/service/mcp_config.py new file mode 100644 index 000000000..8658dae30 --- /dev/null +++ b/backend/app/service/mcp_config.py @@ -0,0 +1,105 @@ +# ========= 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 logging +from pathlib import Path + +logger = logging.getLogger("mcp_config") + +MCP_CONFIG_DIR = Path.home() / ".eigent" +MCP_CONFIG_PATH = MCP_CONFIG_DIR / "mcp.json" + + +def _normalize_args(args) -> list[str]: + """Normalize args to list of strings.""" + if args is None: + return [] + if isinstance(args, str): + try: + parsed = json.loads(args) + return ( + [str(x) for x in parsed] + if isinstance(parsed, list) + else [args] + ) + except json.JSONDecodeError: + return [x.strip() for x in args.split(",") if x.strip()] + if isinstance(args, list): + return [str(x) for x in args] + return [] + + +def _normalize_mcp(mcp: dict) -> dict: + """Normalize MCP server config.""" + out = dict(mcp) + if "args" in out: + out["args"] = _normalize_args(out["args"]) + return out + + +def get_mcp_config_path() -> Path: + return MCP_CONFIG_PATH + + +def read_mcp_config() -> dict: + """Read MCP config from ~/.eigent/mcp.json.""" + if not MCP_CONFIG_PATH.exists(): + default = {"mcpServers": {}} + write_mcp_config(default) + return default + try: + data = MCP_CONFIG_PATH.read_text(encoding="utf-8") + parsed = json.loads(data) + if not isinstance(parsed.get("mcpServers"), dict): + return {"mcpServers": {}} + for name, server in parsed["mcpServers"].items(): + if isinstance(server, dict): + parsed["mcpServers"][name] = _normalize_mcp(server) + return parsed + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to read MCP config: %s, using default", e) + return {"mcpServers": {}} + + +def write_mcp_config(config: dict) -> None: + """Write MCP config to ~/.eigent/mcp.json.""" + MCP_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + MCP_CONFIG_PATH.write_text( + json.dumps(config, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + +def add_mcp(name: str, mcp: dict) -> None: + """Add MCP server to config.""" + config = read_mcp_config() + if name not in config["mcpServers"]: + config["mcpServers"][name] = _normalize_mcp(mcp) + write_mcp_config(config) + + +def remove_mcp(name: str) -> None: + """Remove MCP server from config.""" + config = read_mcp_config() + if name in config["mcpServers"]: + del config["mcpServers"][name] + write_mcp_config(config) + + +def update_mcp(name: str, mcp: dict) -> None: + """Update MCP server in config.""" + config = read_mcp_config() + config["mcpServers"][name] = _normalize_mcp(mcp) + write_mcp_config(config) diff --git a/backend/app/service/skill_config_service.py b/backend/app/service/skill_config_service.py new file mode 100644 index 000000000..933256856 --- /dev/null +++ b/backend/app/service/skill_config_service.py @@ -0,0 +1,131 @@ +# ========= 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. ========= + +"""Skills config: ~/.eigent//skills-config.json.""" + +import json +import logging +import time +from pathlib import Path + +logger = logging.getLogger("skill_config") + +EIGENT_ROOT = Path.home() / ".eigent" + + +def _config_path(user_id: str) -> Path: + return EIGENT_ROOT / str(user_id) / "skills-config.json" + + +def _load_config(user_id: str) -> dict: + path = _config_path(user_id) + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + default = {"version": 1, "skills": {}} + path.write_text(json.dumps(default, indent=2, ensure_ascii=False)) + return default + try: + return json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to load skill config: %s", e) + return {"version": 1, "skills": {}} + + +def _save_config(user_id: str, config: dict) -> None: + path = _config_path(user_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(config, indent=2, ensure_ascii=False)) + + +def _ensure_skills_key(config: dict) -> dict: + if "skills" not in config: + config["skills"] = {} + return config + + +def skill_config_load(user_id: str) -> dict: + """Load skills config for user.""" + config = _load_config(user_id) + return _ensure_skills_key(config) + + +def skill_config_init(user_id: str) -> dict: + """Load or create config, merge default from example-skills if present.""" + config = _load_config(user_id) + config = _ensure_skills_key(config) + + # Try to merge default-config.json from example-skills (same as Electron) + try: + backend_root = Path(__file__).resolve().parent.parent.parent + default_path = ( + backend_root.parent + / "resources" + / "example-skills" + / "default-config.json" + ) + if default_path.exists(): + default = json.loads(default_path.read_text(encoding="utf-8")) + if default.get("skills"): + for skill_name, skill_cfg in default["skills"].items(): + if skill_name not in config["skills"]: + config["skills"][skill_name] = { + **skill_cfg, + "addedAt": int(time.time() * 1000), + } + logger.info( + "Initialized config for example skill: %s", + skill_name, + ) + _save_config(user_id, config) + except Exception as e: + logger.warning("Failed to merge default config: %s", e) + + return config + + +def skill_config_update( + user_id: str, skill_name: str, skill_config: dict +) -> None: + """Update config for a skill (merge with existing, don't replace entirely).""" + config = _load_config(user_id) + config = _ensure_skills_key(config) + existing = config["skills"].get(skill_name, {}) + config["skills"][skill_name] = {**existing, **skill_config} + _save_config(user_id, config) + + +def skill_config_delete(user_id: str, skill_name: str) -> None: + """Remove skill from config.""" + config = _load_config(user_id) + config = _ensure_skills_key(config) + if skill_name in config["skills"]: + del config["skills"][skill_name] + _save_config(user_id, config) + + +def skill_config_toggle(user_id: str, skill_name: str, enabled: bool) -> dict: + """Toggle skill enabled state.""" + config = _load_config(user_id) + config = _ensure_skills_key(config) + if skill_name not in config["skills"]: + config["skills"][skill_name] = { + "enabled": enabled, + "scope": {"isGlobal": True, "selectedAgents": []}, + "addedAt": int(time.time() * 1000), + "isExample": False, + } + else: + config["skills"][skill_name]["enabled"] = enabled + _save_config(user_id, config) + return config["skills"][skill_name] diff --git a/backend/app/service/skill_service.py b/backend/app/service/skill_service.py new file mode 100644 index 000000000..3a1c3282c --- /dev/null +++ b/backend/app/service/skill_service.py @@ -0,0 +1,297 @@ +# ========= 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 re +import shutil +import tempfile +import zipfile +from pathlib import Path + +SKILLS_ROOT = Path.home() / ".eigent" / "skills" +SKILL_FILE = "SKILL.md" + + +def _parse_skill_frontmatter(content: str) -> dict | None: + """Parse name and description from SKILL.md frontmatter.""" + if not content.startswith("---"): + return None + end = content.find("\n---", 3) + block = content[4:end] if end > 0 else content[4:] + name_match = re.search(r"^\s*name\s*:\s*(.+)$", block, re.MULTILINE) + desc_match = re.search(r"^\s*description\s*:\s*(.+)$", block, re.MULTILINE) + name = name_match.group(1).strip().strip("'\"") if name_match else None + desc = desc_match.group(1).strip().strip("'\"") if desc_match else None + if name and desc: + return {"name": name, "description": desc} + return None + + +def _assert_under_skills_root(target: Path) -> Path: + """Ensure path is under SKILLS_ROOT (security).""" + root = SKILLS_ROOT.resolve() + resolved = target.resolve() + try: + resolved.relative_to(root) + except ValueError: + raise PermissionError("Path is outside skills directory") + return resolved + + +def skills_scan() -> list[dict]: + """Scan skills directory and return list of skills with metadata.""" + if not SKILLS_ROOT.exists(): + return [] + skills = [] + for entry in SKILLS_ROOT.iterdir(): + if not entry.is_dir() or entry.name.startswith("."): + continue + skill_path = entry / SKILL_FILE + try: + raw = skill_path.read_text(encoding="utf-8") + meta = _parse_skill_frontmatter(raw) + if meta: + skills.append( + { + "name": meta["name"], + "description": meta["description"], + "path": str(skill_path), + "scope": "user", + "skillDirName": entry.name, + } + ) + except (OSError, UnicodeDecodeError): + pass + return skills + + +def skill_get_path_by_name(skill_name: str) -> str | None: + """Return the absolute directory path for a skill by its display name, or None if not found.""" + if not SKILLS_ROOT.exists(): + return None + name_lower = (skill_name or "").strip().lower() + if not name_lower: + return None + for entry in SKILLS_ROOT.iterdir(): + if not entry.is_dir() or entry.name.startswith("."): + continue + skill_path = entry / SKILL_FILE + if not skill_path.exists(): + continue + try: + meta = _parse_skill_frontmatter( + skill_path.read_text(encoding="utf-8") + ) + if meta and meta.get("name", "").lower().strip() == name_lower: + return str(entry.resolve()) + except (OSError, UnicodeDecodeError): + pass + return None + + +def skill_write(skill_dir_name: str, content: str) -> None: + """Write SKILL.md for a skill.""" + name = (skill_dir_name or "").strip() + if not name: + raise ValueError("Skill folder name is required") + dir_path = _assert_under_skills_root(SKILLS_ROOT / name) + dir_path.mkdir(parents=True, exist_ok=True) + (dir_path / SKILL_FILE).write_text(content, encoding="utf-8") + + +def skill_read(skill_dir_name: str) -> str: + """Read SKILL.md content.""" + name = (skill_dir_name or "").strip() + if not name: + raise ValueError("Skill folder name is required") + skill_path = _assert_under_skills_root(SKILLS_ROOT / name / SKILL_FILE) + return skill_path.read_text(encoding="utf-8") + + +def skill_delete(skill_dir_name: str) -> None: + """Delete skill directory.""" + name = (skill_dir_name or "").strip() + if not name: + raise ValueError("Skill folder name is required") + dir_path = _assert_under_skills_root(SKILLS_ROOT / name) + if dir_path.exists(): + import shutil + + shutil.rmtree(dir_path) + + +def skill_list_files(skill_dir_name: str) -> list[str]: + """List files in skill directory.""" + name = (skill_dir_name or "").strip() + if not name: + raise ValueError("Skill folder name is required") + dir_path = _assert_under_skills_root(SKILLS_ROOT / name) + if not dir_path.exists(): + return [] + return [e.name for e in dir_path.iterdir()] + + +def _get_skill_name_from_file(skill_file_path: Path) -> str: + """Extract skill name from SKILL.md frontmatter.""" + try: + raw = skill_file_path.read_text(encoding="utf-8") + name_match = re.search(r"^\s*name\s*:\s*(.+)$", raw, re.MULTILINE) + parsed = ( + name_match.group(1).strip().strip("'\"") if name_match else None + ) + return parsed or skill_file_path.parent.name + except (OSError, UnicodeDecodeError): + return skill_file_path.parent.name + + +def _folder_name_from_skill_name(skill_name: str, fallback: str) -> str: + """Derive safe folder name from skill display name.""" + cleaned = ( + (skill_name or "") + .replace("\\", "-") + .replace("/", "-") + .replace("*", "-") + .replace("?", "-") + .replace(":", "-") + .replace('"', "-") + .replace("<", "-") + .replace(">", "-") + .replace("|", "-") + .replace(" ", "-") + ) + cleaned = re.sub(r"-+", "-", cleaned).strip("-") + return cleaned or fallback + + +def skill_import_zip( + zip_bytes: bytes, + replacements: list[str] | None = None, +) -> dict: + """ + Import skills from a zip archive. + Returns {success, error?, conflicts?} matching Electron IPC contract. + """ + replacements_set = set(replacements or []) + temp_dir = Path(tempfile.mkdtemp(prefix="eigent-skill-extract-")) + try: + SKILLS_ROOT.mkdir(parents=True, exist_ok=True) + + # Step 1: Extract zip into temp directory + with zipfile.ZipFile(__import__("io").BytesIO(zip_bytes), "r") as zf: + for info in zf.infolist(): + if info.is_dir(): + continue + name = info.filename.replace("\\", "/").lstrip("/") + if ".." in name or name.startswith("/"): + return { + "success": False, + "error": "Zip archive contains unsafe paths", + } + dest = temp_dir / name + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(zf.read(info)) + + # Step 2: Find all SKILL.md files + skill_files: list[Path] = [] + + def find_skill_md(d: Path) -> None: + for entry in d.iterdir(): + if entry.name.startswith("."): + continue + if entry.is_dir(): + find_skill_md(entry) + elif entry.name == SKILL_FILE: + skill_files.append(entry) + + find_skill_md(temp_dir) + + if not skill_files: + return { + "success": False, + "error": "No SKILL.md files found in zip archive", + } + + # Step 3: Build existing skill names map + existing_names: dict[str, str] = {} + if SKILLS_ROOT.exists(): + for entry in SKILLS_ROOT.iterdir(): + if not entry.is_dir() or entry.name.startswith("."): + continue + skill_file = entry / SKILL_FILE + if not skill_file.exists(): + continue + try: + meta = _parse_skill_frontmatter( + skill_file.read_text(encoding="utf-8") + ) + if meta and meta.get("name"): + existing_names[meta["name"].lower()] = entry.name + except (OSError, UnicodeDecodeError): + pass + + conflicts: list[dict] = [] + + for skill_file in skill_files: + skill_dir = skill_file.parent + incoming_name = _get_skill_name_from_file(skill_file) + incoming_lower = incoming_name.lower() + + fallback = ( + "imported-skill" if skill_dir == temp_dir else skill_dir.name + ) + dest_folder = _folder_name_from_skill_name(incoming_name, fallback) + dest_path = SKILLS_ROOT / dest_folder + + existing_folder = existing_names.get(incoming_lower) + if existing_folder: + if replacements is None: + conflicts.append( + { + "folderName": existing_folder, + "skillName": incoming_name, + } + ) + continue + if existing_folder in replacements_set: + shutil.rmtree( + SKILLS_ROOT / existing_folder, ignore_errors=True + ) + else: + continue + + dest_path.mkdir(parents=True, exist_ok=True) + if skill_dir == temp_dir: + for item in temp_dir.iterdir(): + dest_item = dest_path / item.name + if item.is_dir(): + shutil.copytree(item, dest_item, dirs_exist_ok=True) + else: + shutil.copy2(item, dest_item) + else: + for item in skill_dir.iterdir(): + dest_item = dest_path / item.name + if item.is_dir(): + shutil.copytree(item, dest_item, dirs_exist_ok=True) + else: + shutil.copy2(item, dest_item) + + if conflicts and replacements is None: + return {"success": False, "conflicts": conflicts} + + return {"success": True} + except zipfile.BadZipFile as e: + return {"success": False, "error": f"Invalid zip file: {e}"} + except Exception as e: + return {"success": False, "error": str(e)} + finally: + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/backend/app/service/task.py b/backend/app/service/task.py index 604fbc717..1696535f3 100644 --- a/backend/app/service/task.py +++ b/backend/app/service/task.py @@ -36,6 +36,8 @@ logger = logging.getLogger("task_service") +TASK_LOCK_CLEANUP_SENTINEL = "__task_lock_cleanup__" + class Action(str, Enum): improve = "improve" # user -> backend @@ -444,6 +446,16 @@ async def cleanup(self): pass self.background_tasks.clear() + # Unblock agents waiting on human input so shutdown can proceed. + for agent, queue in self.human_input.items(): + try: + queue.put_nowait(TASK_LOCK_CLEANUP_SENTINEL) + except asyncio.QueueFull: + logger.debug( + "Human input queue already full during cleanup", + extra={"task_id": self.id, "agent": agent}, + ) + # Clean up registered toolkits (e.g., remove TerminalToolkit venvs) for toolkit in self.registered_toolkits: try: diff --git a/backend/app/utils/browser_launcher.py b/backend/app/utils/browser_launcher.py new file mode 100644 index 000000000..a061eaeeb --- /dev/null +++ b/backend/app/utils/browser_launcher.py @@ -0,0 +1,216 @@ +# ========= 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. ========= + +""" +Browser launcher for Web mode: ensures a CDP-capable browser is running +when Electron is not available (e.g. web + brain mode). + +Previously, Electron provided the CDP browser via remote-debugging-port. +In web mode, Brain launches Chrome/Chromium directly. +""" + +import logging +import os +import platform +import socket +import subprocess +from pathlib import Path + +logger = logging.getLogger("browser_launcher") + +# Default CDP port (must match browser_port in Chat model) +DEFAULT_CDP_PORT = 9222 + + +def _is_port_in_use(port: int) -> bool: + """Check if a port is in use.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + return s.connect_ex(("127.0.0.1", port)) == 0 + + +def _is_cdp_available(port: int) -> bool: + """Check if a CDP-capable browser is listening on the port.""" + try: + import httpx + + r = httpx.get(f"http://127.0.0.1:{port}/json/version", timeout=2.0) + return r.status_code == 200 + except Exception: + return False + + +def _find_chrome_executable() -> str | None: + """Find Chrome or Chromium executable for launching with CDP.""" + system = platform.system() + + # 1. Try Playwright's Chromium (most reliable, cross-platform) + try: + from playwright.sync_api import sync_playwright + + with sync_playwright() as p: + path = p.chromium.executable_path + if path and Path(path).exists(): + logger.debug(f"Using Playwright Chromium: {path}") + return path + except Exception as e: + logger.debug(f"Playwright Chromium not available: {e}") + + # 2. Platform-specific paths + if system == "Darwin": + candidates = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", + ] + elif system == "Linux": + candidates = [ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ] + elif system == "Windows": + candidates = [ + os.path.expandvars( + r"%ProgramFiles%\Google\Chrome\Application\chrome.exe" + ), + os.path.expandvars( + r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe" + ), + ] + else: + candidates = [] + + for path in candidates: + if path and Path(path).exists(): + logger.debug(f"Using system Chrome: {path}") + return path + + # 3. Try executable from PATH + for name in ("google-chrome", "chromium", "chromium-browser", "chrome"): + exe = _which(name) + if exe: + return exe + + return None + + +def _which(name: str) -> str | None: + """Find executable in PATH.""" + for path in os.environ.get("PATH", "").split(os.pathsep): + exe = Path(path) / name + if exe.exists(): + return str(exe) + return None + + +def _launch_browser( + executable: str, port: int, user_data_dir: str +) -> subprocess.Popen | None: + """Launch browser with CDP enabled. Returns process or None on failure.""" + profile_dir = Path(user_data_dir).expanduser() + profile_dir.mkdir(parents=True, exist_ok=True) + + args = [ + executable, + f"--remote-debugging-port={port}", + f"--user-data-dir={profile_dir}", + "--no-first-run", + "--no-default-browser-check", + "--disable-blink-features=AutomationControlled", + "about:blank", + ] + + try: + proc = subprocess.Popen( + args, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + logger.info( + f"[BROWSER LAUNCHER] Launched browser on port {port} (PID={proc.pid})" + ) + return proc + except Exception as e: + logger.error( + f"[BROWSER LAUNCHER] Failed to launch: {e}", exc_info=True + ) + return None + + +def ensure_cdp_browser_available(port: int = DEFAULT_CDP_PORT) -> bool: + """ + Ensure a CDP-capable browser is running on the given port. + + If no browser is listening, attempts to launch Chrome/Chromium. + Used in web mode when Electron is not available to provide CDP. + + Returns: + True if CDP is available (either already running or newly launched), + False otherwise. + """ + # Check if auto-launch is disabled + if os.environ.get("EIGENT_BRAIN_LAUNCH_BROWSER", "true").lower() in ( + "false", + "0", + "no", + ): + logger.debug("[BROWSER LAUNCHER] Auto-launch disabled by env") + return _is_cdp_available(port) + + # Already available + if _is_cdp_available(port): + logger.debug( + f"[BROWSER LAUNCHER] CDP already available on port {port}" + ) + return True + + # Port in use but not CDP (e.g. another service) + if _is_port_in_use(port): + logger.warning( + f"[BROWSER LAUNCHER] Port {port} in use but not CDP. " + "Another service may be using it." + ) + return False + + # Launch browser + executable = _find_chrome_executable() + if not executable: + logger.error( + "[BROWSER LAUNCHER] No Chrome/Chromium found. " + "Run: playwright install chromium" + ) + return False + + user_data_dir = os.path.expanduser( + f"~/.eigent/browser_profiles/cdp_brain_{port}" + ) + proc = _launch_browser(executable, port, user_data_dir) + if not proc: + return False + + # Poll for readiness (max 10s) + import time + + for _ in range(20): + time.sleep(0.5) + if _is_cdp_available(port): + logger.info(f"[BROWSER LAUNCHER] CDP ready on port {port}") + return True + + logger.warning( + "[BROWSER LAUNCHER] Browser launched but CDP not ready after 10s" + ) + return False diff --git a/backend/app/utils/event_loop_utils.py b/backend/app/utils/event_loop_utils.py index 13207054e..b8d3e2f9d 100644 --- a/backend/app/utils/event_loop_utils.py +++ b/backend/app/utils/event_loop_utils.py @@ -63,6 +63,9 @@ def _schedule_async_task(coro): asyncio.run_coroutine_threadsafe(coro, main_loop) else: # This should not happen in normal operation - log error and skip + close = getattr(coro, "close", None) + if callable(close): + close() logging.error( "No event loop available for async task scheduling, task skipped. " "Ensure set_main_event_loop() is called before parallel agent creation." diff --git a/backend/app/utils/listen/toolkit_listen.py b/backend/app/utils/listen/toolkit_listen.py index 1c2c9bbc6..f9db611d4 100644 --- a/backend/app/utils/listen/toolkit_listen.py +++ b/backend/app/utils/listen/toolkit_listen.py @@ -231,7 +231,7 @@ def run_in_thread(): logger.error(f"[SAFE_PUT_QUEUE] Thread failed: {e}") result_queue.put(("error", e)) - thread = threading.Thread(target=run_in_thread, daemon=False) + thread = threading.Thread(target=run_in_thread, daemon=True) thread.start() # Wait briefly for completion diff --git a/backend/app/utils/server/sync_step.py b/backend/app/utils/server/sync_step.py index ec2d8f514..aec4a6d98 100644 --- a/backend/app/utils/server/sync_step.py +++ b/backend/app/utils/server/sync_step.py @@ -18,14 +18,13 @@ High-frequency events (decompose_text) are batched to reduce API calls. Config (~/.eigent/.env): - SERVER_URL=https://dev.eigent.ai/api + SERVER_URL=https://dev.eigent.ai/api/v1 """ import asyncio import json import logging import time -from functools import lru_cache import httpx @@ -39,27 +38,54 @@ # Buffer storage: task_id -> accumulated text _text_buffers: dict[str, str] = {} +_warned_missing_auth_projects: set[str] = set() +_warned_missing_server_url_projects: set[str] = set() +_logged_sync_targets: set[str] = set() +_logged_first_sync_tasks: set[str] = set() -@lru_cache(maxsize=1) -def _get_config(): - server_url = env("SERVER_URL", "") +def _normalize_server_url(server_url: str | None) -> str: + if not server_url: + return "" + + trimmed = server_url.rstrip("/") + if trimmed.endswith("/api/v1"): + return trimmed + return f"{trimmed}/api/v1" + + +def _get_config(args): + server_url = ( + getattr(args[0], "server_url", None) + if args and hasattr(args[0], "server_url") + else None + ) + + if not server_url: + server_url = env("SERVER_URL", "") + + server_url = _normalize_server_url(server_url) if not server_url: return None - return f"{server_url.rstrip('/')}/chat/steps" + return f"{server_url}/chat/steps" def sync_step(func): async def wrapper(*args, **kwargs): - config = _get_config() + config = _get_config(args) if not config: + _warn_missing_server_url(args) async for value in func(*args, **kwargs): yield value return + if config not in _logged_sync_targets: + _logged_sync_targets.add(config) + logger.info("Cloud step sync enabled: %s", config) + async for value in func(*args, **kwargs): _try_sync(args, value, config) yield value @@ -76,18 +102,23 @@ def _try_sync(args, value, sync_url): if not task_id: return + headers = _get_auth_headers(args) + if headers is None: + _warn_missing_auth(args) + return + step = data.get("step") # Batch decompose_text events to reduce API calls if step == "decompose_text": _buffer_text(task_id, data["data"].get("content", "")) if _should_flush(task_id): - _flush_buffer(task_id, sync_url) + _flush_buffer(task_id, sync_url, headers) return # Flush any buffered text before sending other events (preserves order) if task_id in _text_buffers: - _flush_buffer(task_id, sync_url) + _flush_buffer(task_id, sync_url, headers) payload = { "task_id": task_id, @@ -96,7 +127,16 @@ def _try_sync(args, value, sync_url): "timestamp": time.time_ns() / 1_000_000_000, } - asyncio.create_task(_send(sync_url, payload)) + if task_id not in _logged_first_sync_tasks: + _logged_first_sync_tasks.add(task_id) + logger.info( + "Scheduling first cloud step sync: task_id=%s, step=%s, url=%s", + task_id, + step, + sync_url, + ) + + asyncio.create_task(_send(sync_url, payload, headers)) def _buffer_text(task_id: str, content: str): @@ -113,7 +153,11 @@ def _should_flush(task_id: str) -> bool: return word_count >= BATCH_WORD_THRESHOLD -def _flush_buffer(task_id: str, sync_url: str): +def _flush_buffer( + task_id: str, + sync_url: str, + headers: dict[str, str], +): """Send buffered text and clear buffer.""" text = _text_buffers.pop(task_id, "") if not text: @@ -126,7 +170,7 @@ def _flush_buffer(task_id: str, sync_url: str): "timestamp": time.time_ns() / 1_000_000_000, } - asyncio.create_task(_send(sync_url, payload)) + asyncio.create_task(_send(sync_url, payload, headers)) def _parse_value(value): @@ -162,9 +206,58 @@ def _get_task_id(args): return chat.task_id -async def _send(url, data): +def _get_auth_headers(args) -> dict[str, str] | None: + if len(args) < 2: + return None + + request = args[1] + headers = getattr(request, "headers", None) + if not headers: + return None + + auth_header = headers.get("authorization") + if not auth_header: + return None + + return {"Authorization": auth_header} + + +def _warn_missing_auth(args) -> None: + project_id = getattr(args[0], "project_id", None) if args else None + if not project_id or project_id in _warned_missing_auth_projects: + return + + _warned_missing_auth_projects.add(project_id) + logger.info( + "Skipping cloud step sync because Authorization header is missing " + "for project_id=%s. Replay will be unavailable for this run.", + project_id, + ) + + +def _warn_missing_server_url(args) -> None: + project_id = getattr(args[0], "project_id", None) if args else None + if not project_id or project_id in _warned_missing_server_url_projects: + return + + _warned_missing_server_url_projects.add(project_id) + logger.info( + "Skipping cloud step sync because SERVER_URL is empty for " + "project_id=%s. Replay will be unavailable for this run.", + project_id, + ) + + +async def _send(url, data, headers: dict[str, str]): try: async with httpx.AsyncClient(timeout=5.0) as client: - await client.post(url, json=data) + response = await client.post(url, json=data, headers=headers) + if response.is_error: + logger.error( + "Failed to sync step to %s: HTTP %s: %s", + url, + response.status_code, + response.text[:500], + ) except Exception as e: logger.error(f"Failed to sync step to {url}: {type(e).__name__}: {e}") diff --git a/backend/app/utils/telemetry/workforce_metrics.py b/backend/app/utils/telemetry/workforce_metrics.py index 550357789..6944e6acf 100644 --- a/backend/app/utils/telemetry/workforce_metrics.py +++ b/backend/app/utils/telemetry/workforce_metrics.py @@ -183,6 +183,25 @@ def get_tracer_provider() -> TracerProvider | None: return _GLOBAL_TRACER_PROVIDER +def shutdown_tracer_provider() -> None: + """Shutdown the global TracerProvider and release background threads. + + Call during FastAPI shutdown to ensure BatchSpanProcessor worker threads + are properly released, preventing process hang on exit. + """ + global _GLOBAL_TRACER_PROVIDER + if _GLOBAL_TRACER_PROVIDER is None: + return + try: + _GLOBAL_TRACER_PROVIDER.force_flush(timeout_millis=2000) + _GLOBAL_TRACER_PROVIDER.shutdown() + logger.info("TracerProvider shutdown completed") + except Exception as e: + logger.warning(f"TracerProvider shutdown failed: {e}") + finally: + _GLOBAL_TRACER_PROVIDER = None + + def _create_langfuse_endpoint(base_url: str) -> str: """Create Langfuse OTLP endpoint URL. diff --git a/backend/app/utils/workforce.py b/backend/app/utils/workforce.py index e8d749b51..ed1c0eeaf 100644 --- a/backend/app/utils/workforce.py +++ b/backend/app/utils/workforce.py @@ -52,6 +52,7 @@ get_camel_task, get_task_lock, ) +from app.utils.event_loop_utils import _schedule_async_task from app.utils.single_agent_worker import SingleAgentWorker from app.utils.telemetry.workforce_metrics import WorkforceMetricsCallback @@ -790,6 +791,10 @@ async def _handle_failed_task(self, task: Task) -> bool: if task.failure_count < max_retries: return result + # Keep parent.subtasks in sync for terminal failures as well so + # downstream result summarization can see that a subtask failed. + self._sync_subtask_to_parent(task) + error_message = "" # Use proper CAMEL pattern for metrics logging metrics_callbacks = [ @@ -907,8 +912,9 @@ def stop(self) -> None: f"new state: {self._state.name}" ) task_lock = get_task_lock(self.api_task_id) - task = asyncio.create_task(task_lock.put_queue(ActionEndData())) - task_lock.add_background_task(task) + # Use thread-safe scheduling: stop() may be called from worker thread + # when task fails (e.g. max retries exceeded) + _schedule_async_task(task_lock.put_queue(ActionEndData())) logger.info("[WF-LIFECYCLE] ✅ ActionEndData queued") def stop_gracefully(self) -> None: diff --git a/backend/config/hands_clusters.example.toml b/backend/config/hands_clusters.example.toml new file mode 100644 index 000000000..a93388b01 --- /dev/null +++ b/backend/config/hands_clusters.example.toml @@ -0,0 +1,26 @@ +[defaults] +timeout_seconds = 10 +verify_tls = true +acquire_path = "/acquire" +release_path = "/release" +health_path = "/health" +# auth_token_env = "EIGENT_HANDS_CLUSTER_AUTH_TOKEN" + +[routes] +browser = "browser_pool" +terminal = "terminal_pool" +model = "model_pool" +default = "gateway" + +[clusters.gateway] +base_url = "http://hands-gateway:8080" + +[clusters.browser_pool] +base_url = "http://browser-cluster:8080" +# auth_token_env = "EIGENT_BROWSER_CLUSTER_TOKEN" + +[clusters.terminal_pool] +base_url = "http://terminal-cluster:8080" + +[clusters.model_pool] +base_url = "http://model-cluster:8080" diff --git a/backend/main.py b/backend/main.py index 92843bf1e..e2d9dc3ab 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,6 +18,7 @@ import pathlib import signal import sys +import threading # Add project root to Python path to import shared utils _project_root = pathlib.Path(__file__).parent.parent @@ -41,6 +42,7 @@ from app import api from app.component.environment import env from app.router import register_routers +from app.utils.event_loop_utils import set_main_event_loop os.environ["PYTHONIOENCODING"] = "utf-8" @@ -53,6 +55,9 @@ prefix = env("url_prefix", "") app_logger.info(f"Loading routers with prefix: '{prefix}'") +app_logger.info( + f"MCP will be at: {prefix}/mcp/list, health at: {prefix}/health" +) register_routers(api, prefix) app_logger.info("All routers loaded successfully") @@ -99,9 +104,16 @@ async def write_pid_file(): @api.on_event("startup") async def startup_event(): global pid_task + set_main_event_loop(asyncio.get_running_loop()) pid_task = asyncio.create_task(write_pid_file()) app_logger.info("PID write task created") + # Initialize EnvironmentHands from Brain deployment (full on local/cloud_vm, sandbox in Docker) + from app.router_layer.hands_resolver import init_environment_hands + + hands = init_environment_hands() + app_logger.info(f"EnvironmentHands initialized: mode={hands.mode}") + # Initialize telemetry tracer provider from app.utils.telemetry.workforce_metrics import ( initialize_tracer_provider, @@ -111,8 +123,10 @@ async def startup_event(): app_logger.info("Telemetry tracer provider initialized") -# Graceful shutdown handler -shutdown_event = asyncio.Event() +@api.on_event("shutdown") +async def shutdown_event_handler(): + r"""Run cleanup when uvicorn receives SIGINT/SIGTERM and shuts down.""" + await cleanup_resources() async def cleanup_resources(): @@ -141,18 +155,43 @@ async def cleanup_resources(): if pid_file.exists(): pid_file.unlink() - app_logger.info("All resources cleaned up successfully") + # Shutdown OpenTelemetry tracer (releases BatchSpanProcessor worker threads) + try: + from app.utils.telemetry.workforce_metrics import ( + shutdown_tracer_provider, + ) + shutdown_tracer_provider() + except Exception as e: + app_logger.warning(f"Telemetry shutdown failed: {e}") -def signal_handler(signum, frame): - r"""Handle shutdown signals""" - app_logger.warning(f"Received shutdown signal: {signum}") - asyncio.create_task(cleanup_resources()) - shutdown_event.set() + # Shutdown TerminalToolkit thread pool (prevents non-daemon threads blocking exit) + try: + from app.agent.toolkit.terminal_toolkit import TerminalToolkit + if TerminalToolkit._thread_pool is not None: + TerminalToolkit._thread_pool.shutdown(wait=False) + TerminalToolkit._thread_pool = None + except Exception as e: + app_logger.warning(f"TerminalToolkit shutdown failed: {e}") -signal.signal(signal.SIGTERM, signal_handler) -signal.signal(signal.SIGINT, signal_handler) + # Best-effort close Browser toolkit WebSocket/Node connections. + # Use a timeout so shutdown stays responsive even if a wrapper is stuck. + try: + from app.agent.toolkit.hybrid_browser_toolkit import ( + websocket_connection_pool, + ) + + await asyncio.wait_for( + websocket_connection_pool.close_all(), timeout=3.0 + ) + except TimeoutError: + app_logger.warning("Browser WebSocket pool shutdown timed out") + except Exception as e: + app_logger.warning(f"Browser WebSocket pool shutdown failed: {e}") + + set_main_event_loop(None) + app_logger.info("All resources cleaned up successfully") # Register cleanup on exit with safe synchronous wrapper @@ -172,3 +211,86 @@ def sync_cleanup(): # Log successful initialization app_logger.info("Application initialization completed successfully") + + +def run_standalone(): + """Run Brain in standalone mode (no Electron dependency).""" + import uvicorn + + port = int(env("EIGENT_BRAIN_PORT", "5001")) + host = env("EIGENT_BRAIN_HOST", "0.0.0.0") # nosec B104 - bind all for Docker/dev + reload = os.environ.get("EIGENT_DEBUG", "").lower() in ("1", "true", "yes") + + app_logger.info( + f"Starting Brain in standalone mode: {host}:{port} (reload={reload})" + ) + if reload: + uvicorn.run( + "main:api", + host=host, + port=port, + reload=reload, + timeout_graceful_shutdown=5, + ) + return + + config = uvicorn.Config( + "main:api", + host=host, + port=port, + reload=False, + timeout_graceful_shutdown=5, + ) + server = uvicorn.Server(config) + server.install_signal_handlers = lambda: None + + force_exit_timer = None + signal_count = {"count": 0} + old_sigint = signal.getsignal(signal.SIGINT) + old_sigterm = signal.getsignal(signal.SIGTERM) + + def _force_exit(signum: int): + signame = signal.Signals(signum).name + app_logger.error( + "Force exiting Brain after %s because graceful shutdown did not finish", + signame, + ) + os._exit(128 + signum) + + def _handle_signal(signum, _frame): + nonlocal force_exit_timer + signame = signal.Signals(signum).name + signal_count["count"] += 1 + + if signal_count["count"] == 1: + app_logger.warning( + "%s received, requesting graceful shutdown. Press Ctrl+C again to force exit.", + signame, + ) + server.should_exit = True + if force_exit_timer is None: + force_exit_timer = threading.Timer( + 5.0, _force_exit, args=(signum,) + ) + force_exit_timer.daemon = True + force_exit_timer.start() + return + + app_logger.error( + "%s received again, force exiting Brain immediately", signame + ) + _force_exit(signum) + + signal.signal(signal.SIGINT, _handle_signal) + signal.signal(signal.SIGTERM, _handle_signal) + try: + server.run() + finally: + if force_exit_timer is not None: + force_exit_timer.cancel() + signal.signal(signal.SIGINT, old_sigint) + signal.signal(signal.SIGTERM, old_sigterm) + + +if __name__ == "__main__": + run_standalone() diff --git a/backend/tests/app/controller/test_chat_controller.py b/backend/tests/app/controller/test_chat_controller.py index 5366b6dc3..692c0f49e 100644 --- a/backend/tests/app/controller/test_chat_controller.py +++ b/backend/tests/app/controller/test_chat_controller.py @@ -13,6 +13,7 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import os +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -111,6 +112,84 @@ async def mock_generator(): assert os.environ.get("CAMEL_MODEL_LOG_ENABLED") == "true" assert os.environ.get("browser_port") == "8080" + @pytest.mark.asyncio + async def test_post_chat_sets_cdp_url_when_browser_ready( + self, sample_chat_data, mock_request, mock_task_lock + ): + """Web mode should set EIGENT_CDP_URL after successful browser ensure.""" + chat_data = Chat(**sample_chat_data) + mock_request.state = SimpleNamespace() + + with ( + patch( + "app.controller.chat_controller.get_or_create_task_lock", + return_value=mock_task_lock, + ), + patch( + "app.controller.chat_controller.step_solve" + ) as mock_step_solve, + patch( + "app.controller.chat_controller.ensure_cdp_browser_available", + return_value=True, + ), + patch("app.controller.chat_controller.load_dotenv"), + patch("app.controller.chat_controller.set_current_task_id"), + patch("pathlib.Path.mkdir"), + patch("pathlib.Path.home", return_value=MagicMock()), + patch.dict(os.environ, {}, clear=True), + ): + + async def mock_generator(): + yield "data: test_response\n\n" + + mock_step_solve.return_value = mock_generator() + + await post(chat_data, mock_request) + + assert os.environ.get("EIGENT_CDP_URL") == "http://127.0.0.1:8080" + assert mock_request.state.browser_available is True + + @pytest.mark.asyncio + async def test_post_chat_clears_cdp_url_when_browser_unavailable( + self, sample_chat_data, mock_request, mock_task_lock + ): + """Web mode should mark browser unavailable and clear EIGENT_CDP_URL.""" + chat_data = Chat(**sample_chat_data) + mock_request.state = SimpleNamespace() + + with ( + patch( + "app.controller.chat_controller.get_or_create_task_lock", + return_value=mock_task_lock, + ), + patch( + "app.controller.chat_controller.step_solve" + ) as mock_step_solve, + patch( + "app.controller.chat_controller.ensure_cdp_browser_available", + return_value=False, + ), + patch("app.controller.chat_controller.load_dotenv"), + patch("app.controller.chat_controller.set_current_task_id"), + patch("pathlib.Path.mkdir"), + patch("pathlib.Path.home", return_value=MagicMock()), + patch.dict( + os.environ, + {"EIGENT_CDP_URL": "http://127.0.0.1:9222"}, + clear=True, + ), + ): + + async def mock_generator(): + yield "data: test_response\n\n" + + mock_step_solve.return_value = mock_generator() + + await post(chat_data, mock_request) + + assert "EIGENT_CDP_URL" not in os.environ + assert mock_request.state.browser_available is False + def test_improve_chat_success(self, mock_task_lock): """Test successful chat improvement.""" task_id = "test_task_123" diff --git a/backend/tests/app/controller/test_message_controller.py b/backend/tests/app/controller/test_message_controller.py new file mode 100644 index 000000000..451381253 --- /dev/null +++ b/backend/tests/app/controller/test_message_controller.py @@ -0,0 +1,63 @@ +# ========= 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 pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.router import register_routers +from app.router_layer import ChannelSessionMiddleware + + +def _build_app(prefix: str) -> FastAPI: + app = FastAPI() + register_routers(app, prefix=prefix) + app.add_middleware(ChannelSessionMiddleware) + return app + + +@pytest.mark.unit +def test_message_endpoint_is_prefix_aware(): + app = _build_app("/api/v1") + paths = {route.path for route in app.routes if hasattr(route, "path")} + assert "/api/v1/messages" in paths + assert "/api/v1/v1/messages" not in paths + + +@pytest.mark.unit +def test_message_endpoint_streams_via_start_chat_stream(monkeypatch): + async def fake_start_chat_stream(_chat, _request): + async def _stream(): + yield 'data: {"text":"ok"}\n\n' + + return _stream() + + monkeypatch.setattr( + "app.controller.chat_controller.start_chat_stream", + fake_start_chat_stream, + ) + + app = _build_app("/api/v1") + with TestClient(app) as client: + with client.stream( + "POST", + "/api/v1/messages", + json={"content": "hello"}, + headers={"X-Channel": "web"}, + ) as response: + assert response.status_code == 200 + body = "".join(response.iter_text()) + assert '"text":"ok"' in body + sid = response.headers.get("x-session-id") + assert sid and sid.startswith("sess_") diff --git a/backend/tests/app/hands/test_cluster_config.py b/backend/tests/app/hands/test_cluster_config.py new file mode 100644 index 000000000..d84b16604 --- /dev/null +++ b/backend/tests/app/hands/test_cluster_config.py @@ -0,0 +1,133 @@ +# ========= 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 pathlib import Path + +import pytest + +from app.hands.cluster_config import ( + HandsClusterConfigError, + load_hands_cluster_config, +) + + +@pytest.mark.unit +def test_load_cluster_config_with_routes_and_env_token(tmp_path: Path): + config = tmp_path / "hands_clusters.toml" + config.write_text( + """ +[defaults] +timeout_seconds = 12 +verify_tls = true +acquire_path = "/acquire" +release_path = "/release" +health_path = "/health" + +[routes] +browser = "browser_pool" +terminal = "terminal_pool" +default = "gateway" + +[clusters.gateway] +base_url = "http://hands-gateway.local" + +[clusters.browser_pool] +base_url = "http://browser-cluster.local" +auth_token_env = "BROWSER_CLUSTER_TOKEN" + +[clusters.terminal_pool] +api = "http://terminal-cluster.local" +verify_tls = false +""".strip(), + encoding="utf-8", + ) + routing = load_hands_cluster_config( + str(config), + read_env=lambda name: "token_browser" + if name == "BROWSER_CLUSTER_TOKEN" + else None, + ) + + assert set(routing.route_to_cluster.keys()) == { + "browser", + "terminal", + "default", + } + assert ( + routing.route_to_cluster["browser"].base_url + == "http://browser-cluster.local" + ) + assert routing.route_to_cluster["browser"].auth_token == "token_browser" + assert routing.route_to_cluster["terminal"].verify_tls is False + assert routing.route_to_cluster["default"].timeout_seconds == 12 + + +@pytest.mark.unit +def test_load_cluster_config_without_routes_single_cluster_defaults_to_default( + tmp_path: Path, +): + config = tmp_path / "hands_clusters.toml" + config.write_text( + """ +[clusters.default] +base_url = "http://hands-gateway.local" +""".strip(), + encoding="utf-8", + ) + routing = load_hands_cluster_config(str(config)) + assert set(routing.route_to_cluster.keys()) == {"default"} + assert ( + routing.route_to_cluster["default"].base_url + == "http://hands-gateway.local" + ) + + +@pytest.mark.unit +def test_load_cluster_config_normalizes_fallback_route(tmp_path: Path): + config = tmp_path / "hands_clusters.toml" + config.write_text( + """ +[routes] +fallback = "gateway" + +[clusters.gateway] +base_url = "http://hands-gateway.local" +""".strip(), + encoding="utf-8", + ) + routing = load_hands_cluster_config(str(config)) + assert set(routing.route_to_cluster.keys()) == {"default"} + + +@pytest.mark.unit +def test_load_cluster_config_invalid_route_target_raises(tmp_path: Path): + config = tmp_path / "hands_clusters.toml" + config.write_text( + """ +[routes] +browser = "missing_cluster" + +[clusters.gateway] +base_url = "http://hands-gateway.local" +""".strip(), + encoding="utf-8", + ) + with pytest.raises(HandsClusterConfigError): + load_hands_cluster_config(str(config)) + + +@pytest.mark.unit +def test_load_cluster_config_missing_file_raises(): + with pytest.raises(HandsClusterConfigError): + load_hands_cluster_config("/tmp/non-existent-hands-cluster.toml") diff --git a/backend/tests/app/hands/test_http_hands_cluster.py b/backend/tests/app/hands/test_http_hands_cluster.py new file mode 100644 index 000000000..8bbb3a403 --- /dev/null +++ b/backend/tests/app/hands/test_http_hands_cluster.py @@ -0,0 +1,103 @@ +# ========= 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 asyncio +import json + +import httpx +import pytest + +from app.hands.http_hands_cluster import HttpHandsCluster + + +@pytest.mark.unit +def test_http_hands_cluster_acquire_with_wrapped_data(): + def _handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert str(request.url) == "http://hands-cluster.local/acquire" + assert request.headers.get("authorization") == "Bearer token_abc" + payload = json.loads(request.content.decode("utf-8")) + assert payload["type"] == "browser" + assert payload["resource_type"] == "browser" + assert payload["session_id"] == "sess_11" + assert payload["tenant_id"] == "tenant_1" + assert payload["port"] == 9444 + return httpx.Response( + 200, + json={ + "data": { + "endpoint": "http://worker-11:9222", + "container_id": "abc123", + } + }, + ) + + cluster = HttpHandsCluster( + base_url="http://hands-cluster.local", + auth_token="token_abc", + transport=httpx.MockTransport(_handler), + ) + acquired = asyncio.run( + cluster.acquire( + resource_type="browser", + session_id="sess_11", + tenant_id="tenant_1", + port=9444, + ) + ) + assert acquired["endpoint"] == "http://worker-11:9222" + assert acquired["container_id"] == "abc123" + + +@pytest.mark.unit +def test_http_hands_cluster_release_404_is_ignored(): + def _handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert str(request.url) == "http://hands-cluster.local/release" + payload = json.loads(request.content.decode("utf-8")) + assert payload["session_id"] == "sess_missing" + return httpx.Response(404, json={"detail": "not found"}) + + cluster = HttpHandsCluster( + base_url="http://hands-cluster.local", + transport=httpx.MockTransport(_handler), + ) + asyncio.run(cluster.release("sess_missing")) + + +@pytest.mark.unit +def test_http_hands_cluster_health_with_custom_path(): + def _handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert str(request.url) == "http://hands-cluster.local/api/v1/healthz" + return httpx.Response( + 200, + json={ + "browser_workers": {"total": 3, "available": 2, "in_use": 1} + }, + ) + + cluster = HttpHandsCluster( + base_url="http://hands-cluster.local", + health_path="/api/v1/healthz", + transport=httpx.MockTransport(_handler), + ) + health = asyncio.run(cluster.health()) + assert health["browser_workers"]["available"] == 2 + + +@pytest.mark.unit +def test_http_hands_cluster_requires_base_url(): + with pytest.raises(ValueError): + HttpHandsCluster(base_url=" ") diff --git a/backend/tests/app/hands/test_path_scope_checks.py b/backend/tests/app/hands/test_path_scope_checks.py new file mode 100644 index 000000000..60de91cee --- /dev/null +++ b/backend/tests/app/hands/test_path_scope_checks.py @@ -0,0 +1,62 @@ +# ========= 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 pytest + +from app.file_access.upload_file_access import UploadFileAccess +from app.hands.full_hands import FullHands +from app.hands.sandbox_hands import SandboxHands + + +@pytest.mark.unit +def test_sandbox_hands_blocks_workspace_prefix_bypass(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + sibling = tmp_path / "workspace_evil" + sibling.mkdir() + inside = workspace / "safe.txt" + inside.write_text("safe", encoding="utf-8") + outside = sibling / "evil.txt" + outside.write_text("evil", encoding="utf-8") + + hands = SandboxHands(workspace_root=str(workspace)) + assert hands.can_access_filesystem(str(inside)) is True + assert hands.can_access_filesystem(str(outside)) is False + + +@pytest.mark.unit +def test_full_hands_blocks_workspace_prefix_bypass(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + sibling = tmp_path / "workspace_evil" + sibling.mkdir() + outside = sibling / "evil.txt" + outside.write_text("evil", encoding="utf-8") + + hands = FullHands(workspace_root=str(workspace)) + assert hands.can_access_filesystem(str(outside)) is False + + +@pytest.mark.unit +def test_upload_file_access_blocks_workspace_prefix_bypass(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + sibling = tmp_path / "workspace_evil" + sibling.mkdir() + outside = sibling / "evil.txt" + outside.write_text("evil", encoding="utf-8") + + file_access = UploadFileAccess(workspace_root=str(workspace)) + with pytest.raises(PermissionError): + file_access.read_file(str(outside)) diff --git a/backend/tests/app/hands/test_remote_hands.py b/backend/tests/app/hands/test_remote_hands.py new file mode 100644 index 000000000..6432617ba --- /dev/null +++ b/backend/tests/app/hands/test_remote_hands.py @@ -0,0 +1,93 @@ +# ========= 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 pytest + +from app.hands.remote_hands import RemoteHands + + +class _FakeCluster: + def __init__(self) -> None: + self.released: list[str] = [] + self.acquired: list[tuple[str, str]] = [] + + async def acquire( + self, + resource_type: str, + session_id: str, + tenant_id: str = "default", + **kwargs, + ) -> dict: + _ = (tenant_id, kwargs) + self.acquired.append((resource_type, session_id)) + return {"endpoint": "http://worker-17:9222", "container_id": "abc123"} + + async def release(self, session_id: str) -> None: + self.released.append(session_id) + + async def health(self) -> dict: + return {"browser_workers": {"total": 1, "available": 1, "in_use": 0}} + + +@pytest.mark.unit +def test_remote_hands_browser_fallback_endpoint(): + hands = RemoteHands(cluster=None) + endpoint = hands.acquire_resource("browser", "sess_1", port=9444) + assert endpoint == "http://localhost:9444" + + +@pytest.mark.unit +def test_remote_hands_cluster_acquire_and_release(): + cluster = _FakeCluster() + hands = RemoteHands(cluster=cluster) + + endpoint = hands.acquire_resource("browser", "sess_2") + assert endpoint == "http://worker-17:9222" + assert ("browser", "sess_2") in cluster.acquired + + hands.release_resource("browser", "sess_2") + assert "sess_2" in cluster.released + + +@pytest.mark.unit +def test_remote_hands_cluster_allows_non_browser_resource(): + cluster = _FakeCluster() + hands = RemoteHands(cluster=cluster) + + endpoint = hands.acquire_resource("terminal", "sess_terminal") + assert endpoint == "http://worker-17:9222" + assert ("terminal", "sess_terminal") in cluster.acquired + + +@pytest.mark.unit +def test_remote_hands_unknown_resource_raises(): + hands = RemoteHands(cluster=None) + with pytest.raises(ValueError): + hands.acquire_resource("terminal", "sess_3") + + +@pytest.mark.unit +def test_remote_hands_workspace_prefix_bypass_blocked(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + sibling = tmp_path / "workspace_evil" + sibling.mkdir() + inside = workspace / "ok.txt" + inside.write_text("ok", encoding="utf-8") + outside = sibling / "evil.txt" + outside.write_text("evil", encoding="utf-8") + + hands = RemoteHands(cluster=None, workspace_root=str(workspace)) + assert hands.can_access_filesystem(str(inside)) is True + assert hands.can_access_filesystem(str(outside)) is False diff --git a/backend/tests/app/hands/test_routed_hands_cluster.py b/backend/tests/app/hands/test_routed_hands_cluster.py new file mode 100644 index 000000000..caaffc2b3 --- /dev/null +++ b/backend/tests/app/hands/test_routed_hands_cluster.py @@ -0,0 +1,88 @@ +# ========= 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 asyncio + +import pytest + +from app.hands.routed_hands_cluster import RoutedHandsCluster + + +class _FakeCluster: + def __init__(self, endpoint: str) -> None: + self.endpoint = endpoint + self.acquired: list[tuple[str, str]] = [] + self.released: list[str] = [] + + async def acquire( + self, + resource_type: str, + session_id: str, + tenant_id: str = "default", + **kwargs, + ) -> dict: + _ = (tenant_id, kwargs) + self.acquired.append((resource_type, session_id)) + return {"endpoint": self.endpoint} + + async def release(self, session_id: str) -> None: + self.released.append(session_id) + + async def health(self) -> dict: + return {"endpoint": self.endpoint} + + +@pytest.mark.unit +def test_routed_cluster_routes_by_resource_type(): + browser = _FakeCluster("http://browser-cluster:9222") + terminal = _FakeCluster("http://terminal-cluster:7001") + routed = RoutedHandsCluster({"browser": browser, "terminal": terminal}) + + acquired = asyncio.run( + routed.acquire("terminal", "sess_terminal", tenant_id="default") + ) + assert acquired["endpoint"] == "http://terminal-cluster:7001" + assert acquired["cluster_key"] == "terminal" + assert ("terminal", "sess_terminal") in terminal.acquired + assert browser.acquired == [] + + +@pytest.mark.unit +def test_routed_cluster_release_uses_same_cluster_as_acquire(): + browser = _FakeCluster("http://browser-cluster:9222") + terminal = _FakeCluster("http://terminal-cluster:7001") + routed = RoutedHandsCluster({"browser": browser, "terminal": terminal}) + + asyncio.run(routed.acquire("browser", "sess_browser")) + asyncio.run(routed.release("sess_browser")) + assert "sess_browser" in browser.released + assert terminal.released == [] + + +@pytest.mark.unit +def test_routed_cluster_uses_default_when_resource_missing(): + default_cluster = _FakeCluster("http://default-cluster:8100") + routed = RoutedHandsCluster( + {"default": default_cluster, "browser": _FakeCluster("http://b:1")} + ) + + acquired = asyncio.run(routed.acquire("model", "sess_model")) + assert acquired["endpoint"] == "http://default-cluster:8100" + assert acquired["cluster_key"] == "default" + + +@pytest.mark.unit +def test_routed_cluster_requires_non_empty_mapping(): + with pytest.raises(ValueError): + RoutedHandsCluster({}) diff --git a/backend/tests/app/router_layer/test_message_router.py b/backend/tests/app/router_layer/test_message_router.py new file mode 100644 index 000000000..32e7925a0 --- /dev/null +++ b/backend/tests/app/router_layer/test_message_router.py @@ -0,0 +1,109 @@ +# ========= 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 pytest + +from app.router_layer.message_router import DefaultMessageRouter +from app.router_layer.session_store import MemorySessionStore + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_resolve_session_reuses_valid_session(monkeypatch): + current = {"now": 1000.0} + monkeypatch.setattr( + "app.router_layer.message_router._now_ts", lambda: current["now"] + ) + + router = DefaultMessageRouter(session_ttl=60) + session_id = await router.resolve_session("web", None, "alice") + + current["now"] = 1020.0 + reused = await router.resolve_session("web", session_id, "alice") + + assert reused == session_id + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_resolve_session_channel_mismatch_creates_new(monkeypatch): + current = {"now": 1000.0} + monkeypatch.setattr( + "app.router_layer.message_router._now_ts", lambda: current["now"] + ) + + router = DefaultMessageRouter(session_ttl=60) + session_id = await router.resolve_session("web", None, "alice") + + current["now"] = 1010.0 + new_session = await router.resolve_session("desktop", session_id, "alice") + + assert new_session != session_id + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_resolve_session_expired_creates_new(monkeypatch): + current = {"now": 1000.0} + monkeypatch.setattr( + "app.router_layer.message_router._now_ts", lambda: current["now"] + ) + + router = DefaultMessageRouter(session_ttl=10) + session_id = await router.resolve_session("web", None, "alice") + + current["now"] = 1015.0 + new_session = await router.resolve_session("web", session_id, "alice") + + assert new_session != session_id + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_memory_session_store_respects_ttl(monkeypatch): + current = {"now": 2000.0} + monkeypatch.setattr( + "app.router_layer.session_store.time.time", + lambda: current["now"], + ) + + store = MemorySessionStore() + await store.set("sess_1", {"session_id": "sess_1"}, ttl=10) + assert await store.get("sess_1") == {"session_id": "sess_1"} + + current["now"] = 2011.0 + assert await store.get("sess_1") is None + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_memory_session_store_lazy_cleanup_on_set(monkeypatch): + current = {"now": 1000.0} + monkeypatch.setattr( + "app.router_layer.session_store.time.time", + lambda: current["now"], + ) + + store = MemorySessionStore() + for i in range(127): + await store.set( + f"sess_old_{i}", {"session_id": f"sess_old_{i}"}, ttl=1 + ) + + current["now"] = 2000.0 + await store.set("sess_live", {"session_id": "sess_live"}, ttl=60) + + # GC should run on the 128th set call and purge expired entries even if never read. + assert len(store._sessions) == 1 + assert await store.get("sess_live") == {"session_id": "sess_live"} diff --git a/electron/main/index.ts b/electron/main/index.ts index 1b9d5da40..fd3848a3f 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1390,413 +1390,10 @@ function registerIpcHandlers() { } }); - // ======================== skills ======================== - // SKILLS_ROOT, SKILL_FILE, seedDefaultSkillsIfEmpty are defined at module level (used at startup too). - function parseSkillFrontmatter( - content: string - ): { name: string; description: string } | null { - if (!content.startsWith('---')) return null; - const end = content.indexOf('\n---', 3); - const block = end > 0 ? content.slice(4, end) : content.slice(4); - const nameMatch = block.match(/^\s*name\s*:\s*(.+)$/m); - const descMatch = block.match(/^\s*description\s*:\s*(.+)$/m); - const name = nameMatch?.[1]?.trim()?.replace(/^['"]|['"]$/g, ''); - const desc = descMatch?.[1]?.trim()?.replace(/^['"]|['"]$/g, ''); - if (name && desc) return { name, description: desc }; - return null; - } - - const normalizePathForCompare = (value: string) => - process.platform === 'win32' ? value.toLowerCase() : value; - - function assertPathUnderSkillsRoot(targetPath: string): string { - const resolvedRoot = path.resolve(SKILLS_ROOT); - const resolvedTarget = path.resolve(targetPath); - const rootCmp = normalizePathForCompare(resolvedRoot); - const targetCmp = normalizePathForCompare(resolvedTarget); - const rootWithSep = rootCmp.endsWith(path.sep) - ? rootCmp - : `${rootCmp}${path.sep}`; - if (targetCmp !== rootCmp && !targetCmp.startsWith(rootWithSep)) { - throw new Error('Path is outside skills directory'); - } - return resolvedTarget; - } - - function resolveSkillDirPath(skillDirName: string): string { - const name = String(skillDirName || '').trim(); - if (!name) { - throw new Error('Skill folder name is required'); - } - return assertPathUnderSkillsRoot(path.join(SKILLS_ROOT, name)); - } - - ipcMain.handle('get-skills-dir', async () => { - try { - if (!existsSync(SKILLS_ROOT)) { - await fsp.mkdir(SKILLS_ROOT, { recursive: true }); - } - await seedDefaultSkillsIfEmpty(); - return { success: true, path: SKILLS_ROOT }; - } catch (error: any) { - log.error('get-skills-dir failed', error); - return { success: false, error: error?.message }; - } - }); - - ipcMain.handle('skills-scan', async () => { - try { - if (!existsSync(SKILLS_ROOT)) { - return { success: true, skills: [] }; - } - await seedDefaultSkillsIfEmpty(); - const entries = await fsp.readdir(SKILLS_ROOT, { withFileTypes: true }); - const exampleSkillsDir = getExampleSkillsSourceDir(); - const skills: Array<{ - name: string; - description: string; - path: string; - scope: string; - skillDirName: string; - isExample: boolean; - }> = []; - for (const e of entries) { - if (!e.isDirectory() || e.name.startsWith('.')) continue; - const skillPath = path.join(SKILLS_ROOT, e.name, SKILL_FILE); - try { - const raw = await fsp.readFile(skillPath, 'utf-8'); - const meta = parseSkillFrontmatter(raw); - if (meta) { - const isExample = existsSync( - path.join(exampleSkillsDir, e.name, SKILL_FILE) - ); - skills.push({ - name: meta.name, - description: meta.description, - path: skillPath, - scope: 'user', - skillDirName: e.name, - isExample, - }); - } - } catch (_) { - // skip invalid or unreadable skill - } - } - return { success: true, skills }; - } catch (error: any) { - log.error('skills-scan failed', error); - return { success: false, error: error?.message, skills: [] }; - } - }); - - ipcMain.handle( - 'skill-write', - async (_event, skillDirName: string, content: string) => { - try { - const dir = resolveSkillDirPath(skillDirName); - await fsp.mkdir(dir, { recursive: true }); - await fsp.writeFile(path.join(dir, SKILL_FILE), content, 'utf-8'); - return { success: true }; - } catch (error: any) { - log.error('skill-write failed', error); - return { success: false, error: error?.message }; - } - } - ); - - ipcMain.handle('skill-delete', async (_event, skillDirName: string) => { - try { - const dir = resolveSkillDirPath(skillDirName); - if (!existsSync(dir)) return { success: true }; - await fsp.rm(dir, { recursive: true, force: true }); - return { success: true }; - } catch (error: any) { - log.error('skill-delete failed', error); - return { success: false, error: error?.message }; - } - }); - - ipcMain.handle('skill-read', async (_event, filePath: string) => { - try { - const fullPath = path.isAbsolute(filePath) - ? assertPathUnderSkillsRoot(filePath) - : assertPathUnderSkillsRoot( - path.join(SKILLS_ROOT, filePath, SKILL_FILE) - ); - const content = await fsp.readFile(fullPath, 'utf-8'); - return { success: true, content }; - } catch (error: any) { - log.error('skill-read failed', error); - return { success: false, error: error?.message }; - } - }); - - ipcMain.handle('skill-list-files', async (_event, skillDirName: string) => { - try { - const dir = resolveSkillDirPath(skillDirName); - if (!existsSync(dir)) - return { success: false, error: 'Skill folder not found', files: [] }; - const entries = await fsp.readdir(dir, { withFileTypes: true }); - const files = entries.map((e) => - e.isDirectory() ? `${e.name}/` : e.name - ); - return { success: true, files }; - } catch (error: any) { - log.error('skill-list-files failed', error); - return { success: false, error: error?.message, files: [] }; - } - }); - - ipcMain.handle('open-skill-folder', async (_event, skillName: string) => { - try { - const name = String(skillName || '').trim(); - if (!name) return { success: false, error: 'Skill name is required' }; - if (!existsSync(SKILLS_ROOT)) - return { success: false, error: 'Skills dir not found' }; - const entries = await fsp.readdir(SKILLS_ROOT, { withFileTypes: true }); - const nameLower = name.toLowerCase(); - for (const e of entries) { - if (!e.isDirectory() || e.name.startsWith('.')) continue; - const skillPath = path.join(SKILLS_ROOT, e.name, SKILL_FILE); - try { - const raw = await fsp.readFile(skillPath, 'utf-8'); - const meta = parseSkillFrontmatter(raw); - if (meta && meta.name.toLowerCase().trim() === nameLower) { - const dirPath = path.join(SKILLS_ROOT, e.name); - await shell.openPath(dirPath); - return { success: true }; - } - } catch (_) { - continue; - } - } - return { success: false, error: `Skill not found: ${name}` }; - } catch (error: any) { - log.error('open-skill-folder failed', error); - return { success: false, error: error?.message }; - } - }); - - // ======================== skills-config.json handlers ======================== - - function getSkillConfigPath(userId: string): string { - return path.join(os.homedir(), '.eigent', userId, 'skills-config.json'); - } - - async function loadSkillConfig(userId: string): Promise { - const configPath = getSkillConfigPath(userId); - - // Auto-create config file if it doesn't exist - if (!existsSync(configPath)) { - const defaultConfig = { version: 1, skills: {} }; - try { - await fsp.mkdir(path.dirname(configPath), { recursive: true }); - await fsp.writeFile( - configPath, - JSON.stringify(defaultConfig, null, 2), - 'utf-8' - ); - log.info(`Auto-created skills config at ${configPath}`); - return defaultConfig; - } catch (error) { - log.error('Failed to create default skills config', error); - return defaultConfig; - } - } - - try { - const content = await fsp.readFile(configPath, 'utf-8'); - return JSON.parse(content); - } catch (error) { - log.error('Failed to load skill config', error); - return { version: 1, skills: {} }; - } - } - - async function saveSkillConfig(userId: string, config: any): Promise { - const configPath = getSkillConfigPath(userId); - await fsp.mkdir(path.dirname(configPath), { recursive: true }); - await fsp.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8'); - } - - ipcMain.handle('skill-config-load', async (_event, userId: string) => { - try { - const config = await loadSkillConfig(userId); - return { success: true, config }; - } catch (error: any) { - log.error('skill-config-load failed', error); - return { success: false, error: error?.message }; - } - }); - - ipcMain.handle( - 'skill-config-toggle', - async (_event, userId: string, skillName: string, enabled: boolean) => { - try { - const config = await loadSkillConfig(userId); - if (!config.skills[skillName]) { - // Use SkillScope object format - config.skills[skillName] = { - enabled, - scope: { - isGlobal: true, - selectedAgents: [], - }, - addedAt: Date.now(), - isExample: false, - }; - } else { - config.skills[skillName].enabled = enabled; - } - await saveSkillConfig(userId, config); - return { success: true, config: config.skills[skillName] }; - } catch (error: any) { - log.error('skill-config-toggle failed', error); - return { success: false, error: error?.message }; - } - } - ); - - ipcMain.handle( - 'skill-config-update', - async (_event, userId: string, skillName: string, skillConfig: any) => { - try { - const config = await loadSkillConfig(userId); - config.skills[skillName] = { ...skillConfig }; - await saveSkillConfig(userId, config); - return { success: true }; - } catch (error: any) { - log.error('skill-config-update failed', error); - return { success: false, error: error?.message }; - } - } - ); - - ipcMain.handle( - 'skill-config-delete', - async (_event, userId: string, skillName: string) => { - try { - const config = await loadSkillConfig(userId); - delete config.skills[skillName]; - await saveSkillConfig(userId, config); - return { success: true }; - } catch (error: any) { - log.error('skill-config-delete failed', error); - return { success: false, error: error?.message }; - } - } - ); - - // Initialize skills config for a user (ensures config file exists) - ipcMain.handle('skill-config-init', async (_event, userId: string) => { - try { - log.info(`[SKILLS-CONFIG] Initializing config for user: ${userId}`); - const config = await loadSkillConfig(userId); - - try { - const exampleSkillsDir = getExampleSkillsSourceDir(); - const defaultConfigPath = path.join( - exampleSkillsDir, - 'default-config.json' - ); - - if (existsSync(defaultConfigPath)) { - const defaultConfigContent = await fsp.readFile( - defaultConfigPath, - 'utf-8' - ); - const defaultConfig = JSON.parse(defaultConfigContent); - - if (defaultConfig.skills) { - let addedCount = 0; - // Merge default skills config with user's existing config - for (const [skillName, skillConfig] of Object.entries( - defaultConfig.skills - )) { - if (!config.skills[skillName]) { - // Add new skill config with current timestamp - config.skills[skillName] = { - ...(skillConfig as any), - addedAt: Date.now(), - }; - addedCount++; - log.info( - `[SKILLS-CONFIG] Initialized config for example skill: ${skillName}` - ); - } - } - - if (addedCount > 0) { - await saveSkillConfig(userId, config); - log.info( - `[SKILLS-CONFIG] Added ${addedCount} example skill configs` - ); - } - } - } else { - log.warn( - `[SKILLS-CONFIG] Default config not found at: ${defaultConfigPath}` - ); - } - } catch (err) { - log.error( - '[SKILLS-CONFIG] Failed to load default config template:', - err - ); - // Continue anyway - user config is still valid - } - - log.info( - `[SKILLS-CONFIG] Config initialized with ${Object.keys(config.skills || {}).length} skills` - ); - return { success: true, config }; - } catch (error: any) { - log.error('skill-config-init failed', error); - return { success: false, error: error?.message }; - } - }); - - ipcMain.handle( - 'skill-import-zip', - async ( - _event, - zipPathOrBuffer: string | Buffer | ArrayBuffer | Uint8Array, - replacements?: string[] - ) => - withImportLock(async () => { - // Use typeof check instead of instanceof to handle cross-realm objects - // from Electron IPC (instanceof can fail across context boundaries) - const replacementsSet = replacements - ? new Set(replacements) - : undefined; - const isBufferLike = typeof zipPathOrBuffer !== 'string'; - if (isBufferLike) { - const buf = Buffer.isBuffer(zipPathOrBuffer) - ? zipPathOrBuffer - : Buffer.from( - zipPathOrBuffer instanceof ArrayBuffer - ? zipPathOrBuffer - : (zipPathOrBuffer as any) - ); - const tempPath = path.join( - os.tmpdir(), - `eigent-skill-import-${Date.now()}.zip` - ); - try { - await fsp.writeFile(tempPath, buf); - const result = await importSkillsFromZip(tempPath, replacementsSet); - return result; - } finally { - await fsp.unlink(tempPath).catch(() => {}); - } - } - return importSkillsFromZip(zipPathOrBuffer as string, replacementsSet); - }) - ); + // Skills: all operations via Brain REST API (backend). No IPC. // ==================== read file handler ==================== - ipcMain.handle('read-file', async (event, filePath: string) => { + ipcMain.handle('read-file', async (_event, filePath: string) => { try { log.info('Reading file:', filePath); @@ -1805,15 +1402,12 @@ function registerIpcHandlers() { log.error('File does not exist:', filePath); return { success: false, error: 'File does not exist' }; } - - // Check if it's a directory const stats = await fsp.stat(filePath); if (stats.isDirectory()) { log.error('Path is a directory, not a file:', filePath); return { success: false, error: 'Path is a directory, not a file' }; } - // Read file content const fileContent = await fsp.readFile(filePath); log.info('File read successfully:', filePath); diff --git a/electron/main/init.ts b/electron/main/init.ts index 57fb26c87..e6d5aa60f 100644 --- a/electron/main/init.ts +++ b/electron/main/init.ts @@ -40,7 +40,7 @@ import { const execAsync = promisify(exec); -const DEFAULT_SERVER_URL = 'https://dev.eigent.ai/api'; +const DEFAULT_SERVER_URL = 'https://dev.eigent.ai'; function readEnvValue(filePath: string, key: string): string | undefined { try { @@ -81,9 +81,8 @@ function buildLocalServerUrl(proxyUrl: string | undefined): string | undefined { if (!proxyUrl) return undefined; const trimmed = proxyUrl.trim().replace(/\/+$/, ''); if (!trimmed) return undefined; - // Avoid double /api suffix - if (trimmed.endsWith('/api')) return trimmed; - return `${trimmed}/api`; + // Keep SERVER_URL as host/base only; API version belongs to concrete endpoints. + return trimmed.replace(/\/api\/v[12]$/i, ''); } // helper function to get main window @@ -271,9 +270,8 @@ export async function startBackend( } } - const devServerUrl = process.env.VITE_DEV_SERVER_URL; - if (!resolvedServerUrl && devServerUrl) { - const devEnvPath = path.join(app.getAppPath(), '.env.development'); + const devEnvPath = path.join(app.getAppPath(), '.env.development'); + if (!resolvedServerUrl && fs.existsSync(devEnvPath)) { const devProxyEnabled = readEnvValue(devEnvPath, 'VITE_USE_LOCAL_PROXY') === 'true'; const devProxyUrl = readEnvValue(devEnvPath, 'VITE_PROXY_URL'); @@ -329,6 +327,7 @@ export async function startBackend( ...uvEnv, ...proxyEnv, SERVER_URL: serverUrl, + EIGENT_RUNTIME: 'electron', PYTHONIOENCODING: 'utf-8', PYTHONUNBUFFERED: '1', npm_config_cache: npmCacheDir, @@ -354,8 +353,10 @@ export async function startBackend( }; const pythonPath = getVenvPythonPath(venvPath); - // Dev mode: use uv run (ensures sync); Packaged: use venv's python directly (prebuilt has deps) - const useDirectPython = app.isPackaged; + // Use venv's python directly when venv exists (avoids uv run hang in some terminals/Electron spawn). + // Packaged: always direct. Dev: use direct when venv exists, else uv run for first-time sync. + const venvExists = fs.existsSync(path.join(venvPath, 'pyvenv.cfg')); + const useDirectPython = app.isPackaged || venvExists; return new Promise(async (resolve, reject) => { const spawnCmd = useDirectPython diff --git a/electron/main/webview.ts b/electron/main/webview.ts index d7ddb02e5..fda3bde0e 100644 --- a/electron/main/webview.ts +++ b/electron/main/webview.ts @@ -45,11 +45,52 @@ export class WebViewManager { // IPC handlers should be registered once in the main process public async captureWebview(webviewId: string) { - const webContents = this.webViews.get(webviewId); - if (!webContents) return null; + const webViewInfo = this.webViews.get(webviewId); + if (!webViewInfo) return null; - const image = await webContents.view.webContents.capturePage(); - const jpegBuffer = image.toJPEG(10); + const targetContents = webViewInfo.view.webContents; + if (!targetContents || targetContents.isDestroyed()) { + return null; + } + + const debuggerApi = targetContents.debugger; + let attachedHere = false; + + try { + if (!debuggerApi.isAttached()) { + debuggerApi.attach('1.3'); + attachedHere = true; + } + + const result = (await debuggerApi.sendCommand('Page.captureScreenshot', { + format: 'jpeg', + quality: 60, + fromSurface: true, + })) as { data?: string }; + + if (result?.data) { + return 'data:image/jpeg;base64,' + result.data; + } + } catch (error) { + console.warn( + `CDP screenshot failed for webview ${webviewId}, falling back to capturePage:`, + error + ); + } finally { + if (attachedHere && debuggerApi.isAttached()) { + try { + debuggerApi.detach(); + } catch (detachError) { + console.warn( + `Failed to detach debugger for webview ${webviewId}:`, + detachError + ); + } + } + } + + const image = await targetContents.capturePage(); + const jpegBuffer = image.toJPEG(60); return 'data:image/jpeg;base64,' + jpegBuffer.toString('base64'); } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 910a670d7..2071116cd 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -175,32 +175,7 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.off(channel, listener); }; }, - // Skills - getSkillsDir: () => ipcRenderer.invoke('get-skills-dir'), - skillsScan: () => ipcRenderer.invoke('skills-scan'), - skillWrite: (skillDirName: string, content: string) => - ipcRenderer.invoke('skill-write', skillDirName, content), - skillDelete: (skillDirName: string) => - ipcRenderer.invoke('skill-delete', skillDirName), - skillRead: (filePath: string) => ipcRenderer.invoke('skill-read', filePath), - skillListFiles: (skillDirName: string) => - ipcRenderer.invoke('skill-list-files', skillDirName), - skillImportZip: ( - zipPathOrBuffer: string | ArrayBuffer, - replacements?: string[] - ) => ipcRenderer.invoke('skill-import-zip', zipPathOrBuffer, replacements), - openSkillFolder: (skillName: string) => - ipcRenderer.invoke('open-skill-folder', skillName), - skillConfigInit: (userId: string) => - ipcRenderer.invoke('skill-config-init', userId), - skillConfigLoad: (userId: string) => - ipcRenderer.invoke('skill-config-load', userId), - skillConfigToggle: (userId: string, skillName: string, enabled: boolean) => - ipcRenderer.invoke('skill-config-toggle', userId, skillName, enabled), - skillConfigUpdate: (userId: string, skillName: string, skillConfig: any) => - ipcRenderer.invoke('skill-config-update', userId, skillName, skillConfig), - skillConfigDelete: (userId: string, skillName: string) => - ipcRenderer.invoke('skill-config-delete', userId, skillName), + // Skills: all operations via Brain REST API, no IPC }); // --------- Preload scripts loading --------- diff --git a/eslint.config.js b/eslint.config.js index fa8839466..66f196871 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -58,6 +58,7 @@ export default [ '.vite/**', // Config files 'vite.config.ts', + 'vite.config.*.ts', 'vitest.config.ts', 'tailwind.config.js', 'postcss.config.cjs', @@ -70,6 +71,8 @@ export default [ '**/.venv/**', // Prebuilt resources 'resources/prebuilt/**', + // Archive (pre-refactor snapshots) + 'archive/**', ], }, @@ -140,6 +143,34 @@ export default [ ], }, }, + // Guardrail: in src code, always use Host abstraction instead of direct window Electron APIs + { + files: ['src/**/*.{ts,tsx,js,jsx}'], + rules: { + 'no-restricted-properties': [ + 'error', + { + object: 'window', + property: 'electronAPI', + message: + 'Use Host abstraction (useHost/createHost) instead of window.electronAPI', + }, + { + object: 'window', + property: 'ipcRenderer', + message: + 'Use Host abstraction (useHost/createHost) instead of window.ipcRenderer', + }, + ], + }, + }, + // Single allowed bridge for reading global Electron APIs + { + files: ['src/host/createHost.ts'], + rules: { + 'no-restricted-properties': 'off', + }, + }, // Prettier config (must be last to override conflicting rules) prettier, ]; diff --git a/index.html b/index.html index e000c9cd4..bde6b479d 100644 --- a/index.html +++ b/index.html @@ -46,7 +46,7 @@ https://registry.npmmirror.com; worker-src 'self' blob:; child-src 'self' blob:; - frame-src 'self' localfile: blob: data:; + frame-src 'self' localfile: blob: data: http://localhost:5001 http://127.0.0.1:5001; " /> diff --git a/package.json b/package.json index 264deabbc..8d14d5f32 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "prepare": "husky", "clean-cache": "rimraf node_modules/.vite", "dev": "npm run clean-cache && vite", + "dev:web": "npm run clean-cache && vite --config vite.config.web.ts", "preinstall-deps": "node scripts/preinstall-deps.js", "fix-venv-paths": "node scripts/fix-venv-paths.js", "fix-symlinks": "node scripts/fix-symlinks.js", @@ -35,6 +36,8 @@ "build:linux": "npm run prebuild && electron-builder --linux --publish never", "build:all": "npm run prebuild && electron-builder --mac --win --linux --publish never", "preview": "vite preview", + "build:web": "vite build --config vite.config.web.ts", + "preview:web": "vite preview --config vite.config.web.ts", "pretest": "vite build --mode=test", "test": "vitest run", "test:watch": "vitest", @@ -87,6 +90,7 @@ "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", diff --git a/scripts/check-electron-access.sh b/scripts/check-electron-access.sh new file mode 100644 index 000000000..9736265e0 --- /dev/null +++ b/scripts/check-electron-access.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Guardrail for web separation: +# only src/host/createHost.ts may read window.electronAPI/window.ipcRenderer. +violations="$( + rg -n \ + -e 'window\.electronAPI' \ + -e 'window\.ipcRenderer' \ + --glob '*.{ts,tsx,js,jsx}' \ + --glob '!src/host/createHost.ts' \ + src || true +)" + +if [[ -n "${violations}" ]]; then + echo "Found forbidden direct Electron window access outside Host bridge:" + echo "${violations}" + exit 1 +fi + +echo "Electron window access guard passed." diff --git a/scripts/smoke-web-local-brain.sh b/scripts/smoke-web-local-brain.sh new file mode 100644 index 000000000..9a4943050 --- /dev/null +++ b/scripts/smoke-web-local-brain.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP_DIR="$(mktemp -d)" +BACKEND_LOG="${TMP_DIR}/backend.log" +FRONTEND_LOG="${TMP_DIR}/frontend.log" +BACKEND_PID="" +FRONTEND_PID="" + +BRAIN_HOST="${BRAIN_HOST:-127.0.0.1}" +BRAIN_PORT="${BRAIN_PORT:-5001}" +WEB_HOST="${WEB_HOST:-127.0.0.1}" +WEB_PORT="${WEB_PORT:-5173}" + +cleanup() { + local exit_code=$? + if [[ -n "${FRONTEND_PID}" ]] && kill -0 "${FRONTEND_PID}" >/dev/null 2>&1; then + kill "${FRONTEND_PID}" >/dev/null 2>&1 || true + wait "${FRONTEND_PID}" 2>/dev/null || true + fi + if [[ -n "${BACKEND_PID}" ]] && kill -0 "${BACKEND_PID}" >/dev/null 2>&1; then + kill "${BACKEND_PID}" >/dev/null 2>&1 || true + wait "${BACKEND_PID}" 2>/dev/null || true + fi + if [[ ${exit_code} -ne 0 ]]; then + echo + echo "[smoke] backend log:" + cat "${BACKEND_LOG}" || true + echo + echo "[smoke] frontend log:" + cat "${FRONTEND_LOG}" || true + fi + rm -rf "${TMP_DIR}" +} +trap cleanup EXIT INT TERM + +wait_http() { + local url="$1" + local label="$2" + local timeout_seconds="${3:-120}" + local started_at + started_at="$(date +%s)" + + while true; do + if curl --silent --show-error --output /dev/null --fail "${url}"; then + return 0 + fi + if (( "$(date +%s)" - started_at > timeout_seconds )); then + echo "[smoke] timeout waiting for ${label}: ${url}" >&2 + return 1 + fi + sleep 1 + done +} + +assert_html_doc() { + local file="$1" + local label="$2" + if ! grep -Eiq "|&2 + return 1 + fi +} + +echo "[smoke] starting backend on ${BRAIN_HOST}:${BRAIN_PORT}" +( + cd "${ROOT_DIR}/backend" + EIGENT_BRAIN_HOST="${BRAIN_HOST}" \ + EIGENT_BRAIN_PORT="${BRAIN_PORT}" \ + EIGENT_DEBUG="false" \ + uv run python main.py >"${BACKEND_LOG}" 2>&1 +) & +BACKEND_PID=$! + +wait_http "http://${BRAIN_HOST}:${BRAIN_PORT}/health" "backend health" + +echo "[smoke] checking session header + health detail" +curl --silent --show-error \ + --header "X-Channel: web" \ + --dump-header "${TMP_DIR}/health_headers.txt" \ + --output "${TMP_DIR}/health.json" \ + "http://${BRAIN_HOST}:${BRAIN_PORT}/health" + +if ! grep -qi '^x-session-id:' "${TMP_DIR}/health_headers.txt"; then + echo "[smoke] missing X-Session-ID header in /health response" >&2 + exit 1 +fi + +curl --silent --show-error \ + --header "X-Channel: web" \ + --output "${TMP_DIR}/health_detail.json" \ + "http://${BRAIN_HOST}:${BRAIN_PORT}/health?detail=true" + +python3 - "${TMP_DIR}/health_detail.json" <<'PY' +import json +import sys +from pathlib import Path + +payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +assert isinstance(payload, dict), "health detail payload must be object" +assert "capabilities" in payload, "health detail missing capabilities" +assert isinstance(payload["capabilities"], dict), "capabilities must be object" +PY + +echo "[smoke] starting web frontend on ${WEB_HOST}:${WEB_PORT}" +( + cd "${ROOT_DIR}" + npm run dev:web -- --host "${WEB_HOST}" --port "${WEB_PORT}" >"${FRONTEND_LOG}" 2>&1 +) & +FRONTEND_PID=$! + +wait_http "http://${WEB_HOST}:${WEB_PORT}/" "frontend root" + +curl --silent --show-error \ + --output "${TMP_DIR}/web_root.html" \ + "http://${WEB_HOST}:${WEB_PORT}/" +assert_html_doc "${TMP_DIR}/web_root.html" "web root" + +status_code="$(curl --silent --show-error \ + --output "${TMP_DIR}/web_route.html" \ + --write-out "%{http_code}" \ + "http://${WEB_HOST}:${WEB_PORT}/project/smoke-route")" +if [[ "${status_code}" != "200" ]]; then + echo "[smoke] browser-router route fallback failed: status=${status_code}" >&2 + exit 1 +fi +assert_html_doc "${TMP_DIR}/web_route.html" "web route fallback" + +echo "[smoke] PASS: web + local brain smoke checks completed" diff --git a/src/App.tsx b/src/App.tsx index ebfca2717..fceb87b1e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { useHost } from '@/host'; import { queryClient } from '@/lib/queryClient'; import AppRoutes from '@/routers/index'; import { stackClientApp } from '@/stack/client'; @@ -29,6 +30,7 @@ import { useAuthStore } from './store/authStore'; const HAS_STACK_KEYS = hasStackKeys(); function App() { + const host = useHost(); const navigate = useNavigate(); const { setInitState } = useAuthStore(); const { token } = useAuthStore(); @@ -69,14 +71,14 @@ function App() { } }; - window.ipcRenderer?.on('auth-share-token-received', handleShareCode); - window.electronAPI?.onUpdateNotification(handleUpdateNotification); + host?.ipcRenderer?.on('auth-share-token-received', handleShareCode); + host?.electronAPI?.onUpdateNotification(handleUpdateNotification); return () => { - window.ipcRenderer?.off('auth-share-token-received', handleShareCode); - window.electronAPI?.removeAllListeners('update-notification'); + host?.ipcRenderer?.off('auth-share-token-received', handleShareCode); + host?.electronAPI?.removeAllListeners('update-notification'); }; - }, [navigate, setInitState]); + }, [host, navigate, setInitState]); // render wrapper const renderWrapper = (children: React.ReactNode) => { diff --git a/src/api/brain.ts b/src/api/brain.ts new file mode 100644 index 000000000..46b85725d --- /dev/null +++ b/src/api/brain.ts @@ -0,0 +1,179 @@ +// ========= 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. ========= + +/** + * Brain REST API - MCP and Skills endpoints. + * All calls go through Brain HTTP API (getBaseURL). No IPC fallback. + */ + +import { + fetchDelete, + fetchGet, + fetchPost, + fetchPostForm, + fetchPut, +} from './http'; + +export async function mcpList(): Promise<{ + mcpServers: Record; +}> { + const res = await fetchGet('/mcp/list'); + return res && typeof res.mcpServers === 'object' ? res : { mcpServers: {} }; +} + +export async function mcpInstall( + name: string, + mcp: Record +): Promise<{ success: boolean }> { + return fetchPost('/mcp/install', { name, mcp }); +} + +export async function mcpRemove(name: string): Promise<{ success: boolean }> { + return fetchDelete(`/mcp/${encodeURIComponent(name)}`); +} + +export async function mcpUpdate( + name: string, + mcp: Record +): Promise<{ success: boolean }> { + return fetchPut(`/mcp/${encodeURIComponent(name)}`, mcp); +} + +export async function skillsScan(): Promise<{ + success: boolean; + skills: Array<{ + name: string; + description: string; + path: string; + scope: string; + skillDirName: string; + isExample: boolean; + }>; +}> { + const res = await fetchGet('/skills'); + return res?.skills !== undefined ? res : { success: true, skills: [] }; +} + +export async function skillWrite( + skillDirName: string, + content: string +): Promise<{ success: boolean }> { + return fetchPost(`/skills/${encodeURIComponent(skillDirName)}`, { content }); +} + +export async function skillImportZip( + zipBuffer: ArrayBuffer, + replacements?: string[] +): Promise<{ + success: boolean; + error?: string; + conflicts?: Array<{ folderName: string; skillName: string }>; +}> { + const formData = new FormData(); + formData.append('file', new Blob([zipBuffer]), 'skill.zip'); + if (replacements?.length) { + formData.append('replacements', replacements.join(',')); + } + const res = await fetchPostForm('/skills/import', formData); + return (res ?? { success: false, error: 'Import failed' }) as { + success: boolean; + error?: string; + conflicts?: Array<{ folderName: string; skillName: string }>; + }; +} + +export async function skillRead( + skillDirName: string +): Promise<{ success: boolean; content: string }> { + return fetchGet(`/skills/${encodeURIComponent(skillDirName)}`); +} + +export async function skillDelete( + skillDirName: string +): Promise<{ success: boolean }> { + return fetchDelete(`/skills/${encodeURIComponent(skillDirName)}`); +} + +export async function skillListFiles( + skillDirName: string +): Promise<{ success: boolean; files: string[] }> { + const res = await fetchGet( + `/skills/${encodeURIComponent(skillDirName)}/files` + ); + return res?.files !== undefined ? res : { success: true, files: [] }; +} + +export async function skillGetPathByName( + skillName: string +): Promise<{ path: string } | null> { + const res = await fetchGet( + `/skills/path?name=${encodeURIComponent(skillName)}` + ); + return res?.path ? { path: res.path } : null; +} + +// --- Skill config (REST API, no Electron coupling) --- + +export async function skillConfigLoad( + userId: string +): Promise<{ success: boolean; config?: Record }> { + const res = await fetchGet( + `/skills/config?user_id=${encodeURIComponent(userId)}` + ); + return res?.config !== undefined ? res : { success: false }; +} + +export async function skillConfigInit( + userId: string +): Promise<{ success: boolean; config?: Record }> { + const res = await fetchPost('/skills/config/init', { user_id: userId }); + return res?.config !== undefined ? res : { success: false }; +} + +export async function skillConfigUpdate( + userId: string, + skillName: string, + config: { + enabled?: boolean; + scope?: { isGlobal?: boolean; selectedAgents?: string[] }; + addedAt?: number; + isExample?: boolean; + } +): Promise<{ success: boolean }> { + return fetchPut(`/skills/config/${encodeURIComponent(skillName)}`, { + user_id: userId, + ...config, + }); +} + +export async function skillConfigDelete( + userId: string, + skillName: string +): Promise<{ success: boolean }> { + return fetchDelete( + `/skills/config/${encodeURIComponent(skillName)}?user_id=${encodeURIComponent(userId)}` + ); +} + +export async function skillConfigToggle( + userId: string, + skillName: string, + enabled: boolean +): Promise<{ success: boolean; config?: Record }> { + const res = await fetchPost( + `/skills/config/${encodeURIComponent(skillName)}/toggle`, + { user_id: userId, enabled } + ); + return res ?? { success: false }; +} diff --git a/src/api/http.ts b/src/api/http.ts index 70fa418ce..da84ed9f8 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -16,19 +16,91 @@ import { showCreditsToast } from '@/components/Toast/creditsToast'; import { showStorageToast } from '@/components/Toast/storageToast'; import { showTrafficToast } from '@/components/Toast/trafficToast'; import { getAuthStore } from '@/store/authStore'; +import { + getConnectionConfig, + setConnectionConfig, +} from '@/store/connectionStore'; +import { + EventSourceMessage, + fetchEventSource, +} from '@microsoft/fetch-event-source'; const defaultHeaders = { 'Content-Type': 'application/json', }; -let baseUrl = ''; +export function getDefaultBrainEndpoint(): string { + const envEndpoint = import.meta.env.VITE_BRAIN_ENDPOINT; + if (envEndpoint && typeof envEndpoint === 'string') { + return envEndpoint.replace(/\/$/, ''); + } + if (import.meta.env.DEV) { + return 'http://localhost:5001'; + } + return ''; +} + +function persistSessionIdFromResponse(response: Response): void { + const sessionId = response.headers.get('x-session-id'); + if (!sessionId) { + return; + } + const current = getConnectionConfig().sessionId; + if (current !== sessionId) { + setConnectionConfig({ sessionId }); + } +} + +function shouldAttachAuthHeader(url: string): boolean { + return !url.includes('http://') && !url.includes('https://'); +} + +function buildBrainHeaders( + url: string, + customHeaders: Record = {}, + includeContentType = true +): Record { + const { token } = getAuthStore(); + const conn = getConnectionConfig(); + const headers: Record = { + ...(includeContentType ? defaultHeaders : {}), + 'X-Channel': conn.channel, + ...customHeaders, + }; + if (conn.sessionId) { + headers['X-Session-ID'] = conn.sessionId; + } + if (token && shouldAttachAuthHeader(url)) { + headers['Authorization'] = `Bearer ${token}`; + } + return headers; +} + +/** Reset cached baseUrl (e.g. when backend restarts). */ +export function resetBaseURL(): void { + setConnectionConfig({ brainEndpoint: '' }); +} + export async function getBaseURL() { - if (baseUrl) { - return baseUrl; + const cfg = getConnectionConfig(); + if (cfg.brainEndpoint) { + return cfg.brainEndpoint.replace(/\/$/, ''); + } + // Electron: get port from IPC + const port = await (window as any).ipcRenderer?.invoke('get-backend-port'); + if (port && port > 0) { + const resolved = `http://localhost:${port}`; + setConnectionConfig({ brainEndpoint: resolved }); + return resolved; } - const port = await window.ipcRenderer.invoke('get-backend-port'); - baseUrl = `http://localhost:${port}`; - return baseUrl; + // Pure Web: use VITE_BRAIN_ENDPOINT (dev default http://localhost:5001) + const envEndpoint = getDefaultBrainEndpoint(); + if (envEndpoint && typeof envEndpoint === 'string') { + const resolved = envEndpoint.replace(/\/$/, ''); // trim trailing slash + setConnectionConfig({ brainEndpoint: resolved }); + return resolved; + } + return ''; } async function fetchRequest( @@ -39,17 +111,7 @@ async function fetchRequest( ): Promise { const baseURL = await getBaseURL(); const fullUrl = `${baseURL}${url}`; - const { token } = getAuthStore(); - - const headers: Record = { - ...defaultHeaders, - ...customHeaders, - }; - - // Cases without token: url is a complete http:// path - if (!url.includes('http://') && token) { - headers['Authorization'] = `Bearer ${token}`; - } + const headers = buildBrainHeaders(url, customHeaders); const options: RequestInit = { method, @@ -82,23 +144,34 @@ async function handleResponse( ): Promise { try { const res = await responsePromise; + persistSessionIdFromResponse(res); if (res.status === 204) { return { code: 0, text: '' }; } const contentType = res.headers.get('content-type') || ''; - if (res.body && !contentType.includes('application/json')) { - return { - isStream: true, - body: res.body, - reader: res.body.getReader(), - }; + if (!contentType.includes('application/json')) { + if (!res.ok) { + const detail = await res.text().catch(() => ''); + const msg = detail?.trim() || `HTTP ${res.status}`; + const err = new Error(msg); + (err as any).status = res.status; + (err as any).response = res; + throw err; + } + if (res.body) { + return { + isStream: true, + body: res.body, + reader: res.body.getReader(), + }; + } + return null; } const resData = await res.json(); if (!resData) { return null; } - const { code, text } = resData; // showCreditsToast() if (code === 1 || code === 300) { @@ -123,9 +196,13 @@ async function handleResponse( } if (!res.ok) { - const err: any = new Error( - resData?.detail || resData?.message || `HTTP error ${res.status}` - ); + const detail = resData?.detail; + const msg = + (Array.isArray(detail) ? detail[0] : detail) || + resData?.message || + `HTTP ${res.status}`; + const err: any = new Error(typeof msg === 'string' ? msg : String(msg)); + err.status = res.status; err.response = { data: resData, status: res.status }; throw err; } @@ -163,6 +240,78 @@ export const fetchPut = (url: string, data?: any, headers?: any) => export const fetchDelete = (url: string, data?: any, headers?: any) => fetchRequest('DELETE', url, data, headers); +/** POST FormData to Brain base URL (for file uploads). */ +export async function fetchPostForm( + url: string, + formData: FormData, + customHeaders: Record = {} +): Promise { + const baseURL = await getBaseURL(); + const fullUrl = `${baseURL}${url}`; + const headers = buildBrainHeaders(url, customHeaders, false); + return handleResponse( + fetch(fullUrl, { method: 'POST', headers, body: formData }) + ); +} + +export async function uploadFileToBrain(file: globalThis.File): Promise<{ + file_id: string; + filename: string; + size: number; +}> { + const formData = new FormData(); + formData.append('file', file); + return fetchPostForm('/files', formData); +} + +export interface SSETransportOptions { + url: string; + method?: 'GET' | 'POST'; + body?: Record | string; + signal?: AbortSignal; + extraHeaders?: Record; + openWhenHidden?: boolean; + onmessage: (event: EventSourceMessage) => void | Promise; + onopen?: (response: Response) => void | Promise; + onerror?: (err: any) => number | null | undefined | void; + onclose?: () => void; +} + +export async function sseTransport( + options: SSETransportOptions +): Promise { + const baseURL = await getBaseURL(); + const fullUrl = + options.url.startsWith('http://') || options.url.startsWith('https://') + ? options.url + : `${baseURL}${options.url}`; + + const headers = buildBrainHeaders(options.url, options.extraHeaders); + const body = + typeof options.body === 'string' + ? options.body + : options.body + ? JSON.stringify(options.body) + : undefined; + + await fetchEventSource(fullUrl, { + method: options.method || 'POST', + openWhenHidden: options.openWhenHidden ?? true, + signal: options.signal, + headers, + body, + onmessage: options.onmessage, + async onopen(response) { + persistSessionIdFromResponse(response); + if (options.onopen) { + await options.onopen(response); + } + }, + onerror: options.onerror, + onclose: options.onclose, + }); +} + // =============== porxy =============== // get proxy base URL @@ -170,11 +319,9 @@ async function getProxyBaseURL() { const isDev = import.meta.env.DEV; if (isDev) { - const proxyUrl = import.meta.env.VITE_PROXY_URL; - if (!proxyUrl) { - return 'http://localhost:3001'; - } - return proxyUrl; + // Use empty base so request goes to same origin; Vite proxy forwards /api to VITE_PROXY_URL + // This avoids CORS when running dev:web (browser at 5173, server at 3001) + return ''; } else { const baseUrl = import.meta.env.VITE_BASE_URL; if (!baseUrl) { diff --git a/src/client/desktop/README.md b/src/client/desktop/README.md new file mode 100644 index 000000000..3f1662a7c --- /dev/null +++ b/src/client/desktop/README.md @@ -0,0 +1,6 @@ +# Desktop client (Electron) + +Desktop-only components and logic. Uses `useHost()` from `@/host`; when `host.electronAPI` is present. + +- WindowControls, HardwareBridge-related UI +- IPC handlers for window, file, CDP, etc. diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 000000000..85d04ef4e --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,23 @@ +// ========= 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. ========= +// Client adapters: desktop, web, cli, browser_extension, channel adapters. +// See docs/design/04-client.md. + +export { + getClientType, + isDesktop, + isElectron, + isWeb, + type ClientType, +} from './platform'; diff --git a/src/client/platform.ts b/src/client/platform.ts new file mode 100644 index 000000000..9c717066f --- /dev/null +++ b/src/client/platform.ts @@ -0,0 +1,49 @@ +// ========= 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. ========= +// Client platform detection. See docs/design/04-client.md. + +export type ClientType = + | 'desktop' + | 'web' + | 'cli' + | 'browser_extension' + | 'whatsapp' + | 'telegram' + | 'slack' + | 'discord' + | 'lark'; + +/** True when running inside Electron (desktop app). */ +export function isElectron(): boolean { + return ( + typeof window !== 'undefined' && + !!(window as any).electronAPI && + !!(window as any).ipcRenderer + ); +} + +/** Current client type. Web build = 'web'; Electron = 'desktop'. */ +export function getClientType(): ClientType { + if (typeof window === 'undefined') return 'web'; + if (isElectron()) return 'desktop'; + return 'web'; +} + +export function isDesktop(): boolean { + return getClientType() === 'desktop'; +} + +export function isWeb(): boolean { + return getClientType() === 'web'; +} diff --git a/src/client/web/README.md b/src/client/web/README.md new file mode 100644 index 000000000..ed547c596 --- /dev/null +++ b/src/client/web/README.md @@ -0,0 +1,7 @@ +# Web client + +Web-specific components. Used when `host.electronAPI` is null (from `useHost()`). + +- No Electron API +- Brain endpoint from VITE_BRAIN_ENDPOINT or user config +- File upload UI (no local file picker) diff --git a/src/components/AddWorker/ToolSelect.tsx b/src/components/AddWorker/ToolSelect.tsx index 5bc863484..35437aedb 100644 --- a/src/components/AddWorker/ToolSelect.tsx +++ b/src/components/AddWorker/ToolSelect.tsx @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { mcpInstall } from '@/api/brain'; import { fetchGet, fetchPost, @@ -22,6 +23,7 @@ import { import githubIcon from '@/assets/github.svg'; import IntegrationList from '@/components/IntegrationList'; import { Badge } from '@/components/ui/badge'; +import { useHost } from '@/host'; import { capitalizeFirstLetter, getProxyBaseURL } from '@/lib'; import { useAuthStore } from '@/store/authStore'; import { CircleAlert, Store, X } from 'lucide-react'; @@ -66,6 +68,8 @@ const ToolSelect = forwardRef< { installMcp: (id: number, env?: any, activeMcp?: any) => Promise }, ToolSelectProps >(({ onShowEnvConfig, onSelectedToolsChange, initialSelectedTools }, ref) => { + const host = useHost(); + const electronAPI = host?.electronAPI; const { t } = useTranslation(); // state management - remove internal selected state, use parent passed initialSelectedTools const [keyword, setKeyword] = useState(''); @@ -408,8 +412,8 @@ const ToolSelect = forwardRef< await proxyFetchPost('/api/v1/configs', configPayload); } - if (window.electronAPI?.envWrite) { - await window.electronAPI.envWrite(email, { key: envVarKey, value }); + if (electronAPI?.envWrite) { + await electronAPI.envWrite(email, { key: envVarKey, value }); } }; // MCP install related @@ -635,20 +639,16 @@ const ToolSelect = forwardRef< await proxyFetchPost('/api/v1/mcp/install?mcp_id=' + id); setInstalled((prev) => ({ ...prev, [id]: true })); const installedMcp = mcpList.find((mcp) => mcp.id === id); - if (window.ipcRenderer && installedMcp) { - const env: { [key: string]: string } = {}; + if (installedMcp?.install_command) { + const installCmd = { ...installedMcp.install_command }; if (envValue) { + const env: { [key: string]: string } = {}; Object.keys(envValue).map((key) => { env[key] = envValue[key]?.value; }); - installedMcp.install_command!.env = env; + installCmd.env = env; } - - await window.ipcRenderer.invoke( - 'mcp-install', - installedMcp.key, - installedMcp.install_command - ); + await mcpInstall(installedMcp.key, installCmd); } // after install successfully, automatically add to selected list if (installedMcp) { diff --git a/src/components/AddWorker/index.tsx b/src/components/AddWorker/index.tsx index 64fc05f49..823b5a453 100644 --- a/src/components/AddWorker/index.tsx +++ b/src/components/AddWorker/index.tsx @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { mcpList as fetchMcpConfig } from '@/api/brain'; import { fetchPost } from '@/api/http'; import githubIcon from '@/assets/github.svg'; import { Button } from '@/components/ui/button'; @@ -297,9 +298,11 @@ export function AddWorker({ setNameError(t('workforce.worker-name-already-exists')); return; } - let mcpLocal: any = {}; - if (window.ipcRenderer) { - mcpLocal = await window.ipcRenderer.invoke('mcp-list'); + let mcpLocal: any = { mcpServers: {} }; + try { + mcpLocal = await fetchMcpConfig(); + } catch { + // Backend may not be ready } const localTool: string[] = []; const mcpList: string[] = []; diff --git a/src/components/BrowserAgentWorkspace/index.tsx b/src/components/BrowserAgentWorkspace/index.tsx index 6b393f64b..3c704d584 100644 --- a/src/components/BrowserAgentWorkspace/index.tsx +++ b/src/components/BrowserAgentWorkspace/index.tsx @@ -14,6 +14,7 @@ import { fetchPut } from '@/api/http'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; +import { useHost } from '@/host'; import { TaskStatus } from '@/types/constants'; import { ArrowDown, @@ -34,6 +35,7 @@ import { Button } from '../ui/button'; export default function BrowserAgentWorkspace() { //Get Chatstore for the active project's task const { chatStore, projectStore } = useChatStoreAdapter(); + const host = useHost(); const [isSingleMode, setIsSingleMode] = useState(false); const scrollContainerRef = useRef(null); @@ -104,14 +106,14 @@ export default function BrowserAgentWorkspace() { const webviewContainer = document.getElementById('webview-container'); if (webviewContainer) { const rect = webviewContainer.getBoundingClientRect(); - window.electronAPI.setSize({ + host?.electronAPI?.setSize?.({ x: rect.left, y: rect.top, width: rect.width, height: rect.height, }); } - }, []); + }, [host]); const handleTakeControl = (id: string) => { console.log('handleTakeControl', id); @@ -123,7 +125,7 @@ export default function BrowserAgentWorkspace() { setTimeout(() => { getSize(); // show corresponding webview - window.electronAPI.showWebview(id); + host?.electronAPI?.showWebview?.(id); }, 400); }; @@ -152,15 +154,15 @@ export default function BrowserAgentWorkspace() { const [_url, setUrl] = useState(''); useEffect(() => { - window.ipcRenderer?.on('url-updated', (_event: any, newUrl: any) => { + host?.ipcRenderer?.on('url-updated', (_event: any, newUrl: any) => { setUrl(newUrl); }); // optional: clear listener when uninstall return () => { - window.ipcRenderer.removeAllListeners('url-updated'); + host?.ipcRenderer?.removeAllListeners?.('url-updated'); }; - }, []); + }, [host]); if (!chatStore) { return
Loading...
; @@ -176,7 +178,7 @@ export default function BrowserAgentWorkspace() { action: 'resume', }); setIsTakeControl(false); - window.electronAPI.hideAllWebview(); + host?.electronAPI?.hideAllWebview?.(); }} size="sm" variant="success" diff --git a/src/components/ChatBox/BottomBox/InputBox.tsx b/src/components/ChatBox/BottomBox/InputBox.tsx index be5892709..56291c0f4 100644 --- a/src/components/ChatBox/BottomBox/InputBox.tsx +++ b/src/components/ChatBox/BottomBox/InputBox.tsx @@ -20,6 +20,7 @@ import { PopoverTrigger, } from '@/components/ui/popover'; import { Textarea } from '@/components/ui/textarea'; +import { useHost } from '@/host'; import { processDroppedFiles } from '@/lib/fileUtils'; import { cn } from '@/lib/utils'; import type { TriggerInput } from '@/types'; @@ -48,6 +49,8 @@ let activeExpandedDialogId: string | null = null; export interface FileAttachment { fileName: string; filePath: string; + fileId?: string; + source?: 'local' | 'upload'; } /** @@ -142,6 +145,7 @@ export const Inputbox = ({ onTriggerCreated, hideExpandButton = false, }: InputboxProps) => { + const host = useHost(); const { t } = useTranslation(); const internalTextareaRef = useRef(null); const textareaRef = externalTextareaRef || internalTextareaRef; @@ -155,22 +159,25 @@ export const Inputbox = ({ const [isExpandedDialogOpen, setIsExpandedDialogOpen] = useState(false); const [triggerDialogOpen, setTriggerDialogOpen] = useState(false); const expandedTextareaRef = useRef(null); - const instanceIdRef = useRef( - `inputbox-${Math.random().toString(36).substr(2, 9)}` + const [instanceId] = useState( + () => `inputbox-${crypto.randomUUID().slice(0, 9)}` ); // Handle dialog open/close with singleton tracking - const handleExpandedDialogChange = useCallback((open: boolean) => { - if (open) { - activeExpandedDialogId = instanceIdRef.current; - setIsExpandedDialogOpen(true); - } else { - if (activeExpandedDialogId === instanceIdRef.current) { - activeExpandedDialogId = null; + const handleExpandedDialogChange = useCallback( + (open: boolean) => { + if (open) { + activeExpandedDialogId = instanceId; + setIsExpandedDialogOpen(true); + } else { + if (activeExpandedDialogId === instanceId) { + activeExpandedDialogId = null; + } + setIsExpandedDialogOpen(false); } - setIsExpandedDialogOpen(false); - } - }, []); + }, + [instanceId] + ); // Keyboard shortcut handler for Cmd+P / Ctrl+P // Opens dialog if none is open, or closes if this instance owns the open dialog @@ -180,10 +187,7 @@ export const Inputbox = ({ e.preventDefault(); // If this instance has the dialog open, close it - if ( - isExpandedDialogOpen && - activeExpandedDialogId === instanceIdRef.current - ) { + if (isExpandedDialogOpen && activeExpandedDialogId === instanceId) { handleExpandedDialogChange(false); } // If no dialog is open, open this one @@ -196,7 +200,7 @@ export const Inputbox = ({ window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [isExpandedDialogOpen, handleExpandedDialogChange]); + }, [isExpandedDialogOpen, handleExpandedDialogChange, instanceId]); const openRemainingPopover = () => { if (hoverCloseTimerRef.current) { @@ -308,7 +312,7 @@ export const Inputbox = ({ console.log('[Drag-Drop] Processing dropped files:', dropped.length); - const result = await processDroppedFiles(dropped, files); + const result = await processDroppedFiles(dropped, files, host); if (result.success) { console.log('[Drag-Drop] Setting files:', result.files); diff --git a/src/components/ChatBox/MessageItem/AgentMessageCard.tsx b/src/components/ChatBox/MessageItem/AgentMessageCard.tsx index a4773feef..af9381bd0 100644 --- a/src/components/ChatBox/MessageItem/AgentMessageCard.tsx +++ b/src/components/ChatBox/MessageItem/AgentMessageCard.tsx @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { useHost } from '@/host'; import { Check, Copy, FileText } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -41,6 +42,8 @@ export function AgentMessageCard({ className, attaches, }: AgentMessageCardProps) { + const host = useHost(); + const ipcRenderer = host?.ipcRenderer; // use content hash to track if typewriter effect is completed const contentHash = useMemo(() => { return `${id}-${content}`; @@ -102,7 +105,7 @@ export function AgentMessageCard({
{ e.stopPropagation(); - window.ipcRenderer.invoke('reveal-in-folder', file.filePath); + ipcRenderer?.invoke('reveal-in-folder', file.filePath); }} key={'attache-' + file.fileName} className="flex w-full cursor-pointer items-center gap-2 rounded-2xl border border-solid border-task-border-default bg-message-fill-default py-1 pl-2" diff --git a/src/components/ChatBox/MessageItem/MarkDown.tsx b/src/components/ChatBox/MessageItem/MarkDown.tsx index 43e2f6c37..38b6003b0 100644 --- a/src/components/ChatBox/MessageItem/MarkDown.tsx +++ b/src/components/ChatBox/MessageItem/MarkDown.tsx @@ -13,6 +13,7 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { useHost } from '@/host'; import { isHtmlDocument } from '@/lib/htmlFontStyles'; import '@/style/markdown-styles.css'; import DOMPurify from 'dompurify'; @@ -70,6 +71,8 @@ export const MarkDown = memo( /** Base directory for resolving relative image paths (e.g. markdown file's directory). */ contentBasePath?: string | null; }) => { + const host = useHost(); + const electronAPI = host?.electronAPI; const [displayedContent, setDisplayedContent] = useState(''); const [html, setHtml] = useState(''); const [previewImage, setPreviewImage] = useState(null); @@ -160,12 +163,9 @@ export const MarkDown = memo( try { const resolvedPath = resolveRelativePath(contentBasePath, src); - if ( - typeof window !== 'undefined' && - window.electronAPI?.readFileAsDataUrl - ) { + if (electronAPI?.readFileAsDataUrl) { const dataUrl = - await window.electronAPI.readFileAsDataUrl(resolvedPath); + await electronAPI.readFileAsDataUrl(resolvedPath); // Add cursor-pointer class and data attributes for click handling const newTag = ``; @@ -198,7 +198,7 @@ export const MarkDown = memo( }; processMarkdown(); - }, [displayedContent, contentBasePath]); + }, [displayedContent, contentBasePath, electronAPI]); // Add click handlers for images useEffect(() => { diff --git a/src/components/ChatBox/MessageItem/UserMessageCard.tsx b/src/components/ChatBox/MessageItem/UserMessageCard.tsx index c3eb5253f..c05002b1d 100644 --- a/src/components/ChatBox/MessageItem/UserMessageCard.tsx +++ b/src/components/ChatBox/MessageItem/UserMessageCard.tsx @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { useHost } from '@/host'; import { cn } from '@/lib/utils'; import { Check, Copy, FileText, Image, Sparkles } from 'lucide-react'; import { useCallback, useRef, useState } from 'react'; @@ -59,6 +60,8 @@ export function UserMessageCard({ className, attaches, }: UserMessageCardProps) { + const host = useHost(); + const ipcRenderer = host?.ipcRenderer; const [_hoveredFilePath, setHoveredFilePath] = useState(null); const [isRemainingOpen, setIsRemainingOpen] = useState(false); const [copied, setCopied] = useState(false); @@ -103,9 +106,16 @@ export function UserMessageCard({ return ; }; - const handleOpenSkillFolder = (skillName: string) => { - window.electronAPI?.openSkillFolder?.(skillName); - }; + const handleOpenSkillFolder = useCallback( + async (skillName: string) => { + const { skillGetPathByName } = await import('@/api/brain'); + const res = await skillGetPathByName(skillName); + if (res?.path && ipcRenderer?.invoke) { + ipcRenderer.invoke('reveal-in-folder', res.path); + } + }, + [ipcRenderer] + ); const contentNodes = parseContentWithSkillTags(content); const hasSkillTags = contentNodes.some((n) => n.type === 'skill'); @@ -175,10 +185,7 @@ export function UserMessageCard({ } onClick={(e) => { e.stopPropagation(); - window.ipcRenderer.invoke( - 'reveal-in-folder', - file.filePath - ); + ipcRenderer?.invoke('reveal-in-folder', file.filePath); }} > {/* File icon */} @@ -241,7 +248,7 @@ export function UserMessageCard({ } onClick={(e) => { e.stopPropagation(); - window.ipcRenderer.invoke( + ipcRenderer?.invoke( 'reveal-in-folder', file.filePath ); diff --git a/src/components/ChatBox/TaskBox/TaskCard.tsx b/src/components/ChatBox/TaskBox/TaskCard.tsx index 60531feae..ed1e7c7db 100644 --- a/src/components/ChatBox/TaskBox/TaskCard.tsx +++ b/src/components/ChatBox/TaskBox/TaskCard.tsx @@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; +import { useHost } from '@/host'; import { TaskItem } from './TaskItem'; import { TaskState, TaskStateType } from '@/components/TaskState'; @@ -58,6 +59,8 @@ export function TaskCard({ clickable = true, chatId, }: TaskCardProps) { + const host = useHost(); + const electronAPI = host?.electronAPI; const [isExpanded, setIsExpanded] = useState(true); const contentRef = useRef(null); const [contentHeight, setContentHeight] = useState('auto'); @@ -395,7 +398,7 @@ export function TaskCard({ chatStore.activeTaskId as string, task.agent?.agent_id ); - window.electronAPI.hideAllWebview(); + electronAPI?.hideAllWebview(); } }} key={`taskList-${task.id}`} diff --git a/src/components/ChatBox/UserQueryGroup.tsx b/src/components/ChatBox/UserQueryGroup.tsx index 08c106b8d..eb1fecc6f 100644 --- a/src/components/ChatBox/UserQueryGroup.tsx +++ b/src/components/ChatBox/UserQueryGroup.tsx @@ -334,6 +334,7 @@ export const UserQueryGroup: React.FC = ({ activeTaskId as string, file ); + chatState.setNuwFileNum(activeTaskId as string, 0); chatState.setActiveWorkspace( activeTaskId as string, 'documentWorkSpace' @@ -429,6 +430,7 @@ export const UserQueryGroup: React.FC = ({ transition={{ delay: 0.3 }} onClick={() => { chatState.setSelectedFile(activeTaskId as string, file); + chatState.setNuwFileNum(activeTaskId as string, 0); chatState.setActiveWorkspace( activeTaskId as string, 'documentWorkSpace' diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index dedbede29..03d567261 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -18,8 +18,11 @@ import { fetchPut, proxyFetchDelete, proxyFetchGet, + uploadFileToBrain, } from '@/api/http'; +import { isWeb } from '@/client/platform'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; +import { useHost } from '@/host'; import { generateUniqueId, replayActiveTask } from '@/lib'; import { proxyUpdateTriggerExecution } from '@/service/triggerApi'; import { useAuthStore } from '@/store/authStore'; @@ -45,6 +48,7 @@ const getChatStoreTotalTokens = (chatStore: VanillaChatStore): number => { }; export default function ChatBox(): JSX.Element { + const host = useHost(); const [message, setMessage] = useState(''); //Get Chatstore for the active project's task @@ -375,11 +379,6 @@ export default function ChatBox(): JSX.Element { } }, [skill_prompt, searchParams, setSearchParams]); - useEffect(() => { - if (!chatStore) return; - console.log('ChatStore Data: ', chatStore); - }, [chatStore]); - const scrollToBottom = useCallback(() => { if (scrollContainerRef.current) { setTimeout(() => { @@ -482,6 +481,7 @@ export default function ChatBox(): JSX.Element { reply: tempMessageContent, }); chatStore.setAttaches(_taskId, []); + chatStore.setIsPending(_taskId, false); if (chatStore.tasks[_taskId].askList.length === 0) { chatStore.setActiveAsk(_taskId, ''); } else { @@ -493,7 +493,6 @@ export default function ChatBox(): JSX.Element { let message = activeAskList.shift(); chatStore.setActiveAskList(_taskId, [...activeAskList]); chatStore.setActiveAsk(_taskId, message?.agent_name || ''); - chatStore.setIsPending(_taskId, false); chatStore.addMessages(_taskId, message!); } } else { @@ -743,20 +742,64 @@ export default function ChatBox(): JSX.Element { // File selection handler const handleFileSelect = async () => { try { - const result = await window.electronAPI.selectFile({ + const taskId = chatStore.activeTaskId as string; + const existingFiles = chatStore.tasks[taskId].attaches || []; + + if (isWeb()) { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.onchange = async () => { + if (!input.files?.length) { + return; + } + + const uploadedFiles: File[] = []; + for (const selectedFile of Array.from(input.files)) { + try { + const result = await uploadFileToBrain(selectedFile); + uploadedFiles.push({ + fileName: result.filename, + filePath: result.file_id, + fileId: result.file_id, + source: 'upload', + } as File); + } catch (error) { + console.error('Select File Upload Error:', error); + toast.error(`Failed to upload ${selectedFile.name}`); + } + } + + if (uploadedFiles.length === 0) { + return; + } + + const files = [ + ...existingFiles, + ...uploadedFiles.filter( + (uploaded) => + !existingFiles.some( + (existing) => existing.filePath === uploaded.filePath + ) + ), + ]; + chatStore.setAttaches(taskId, files); + }; + input.click(); + return; + } + + const result = await host?.electronAPI?.selectFile({ title: t('chat.select-file'), filters: [{ name: t('chat.all-files'), extensions: ['*'] }], }); - if (result.success && result.files && result.files.length > 0) { - const taskId = chatStore.activeTaskId as string; + if (result?.success && result.files && result.files.length > 0) { const files = [ - ...(chatStore.tasks[taskId].attaches || []), + ...existingFiles, ...result.files.filter( (r: File) => - !chatStore.tasks[taskId].attaches?.some( - (f: File) => f.filePath === r.filePath - ) + !existingFiles.some((f: File) => f.filePath === r.filePath) ), ]; chatStore.setAttaches(taskId, files); diff --git a/src/components/Dialog/CloseNotice.tsx b/src/components/Dialog/CloseNotice.tsx index 33411d979..7a62ca683 100644 --- a/src/components/Dialog/CloseNotice.tsx +++ b/src/components/Dialog/CloseNotice.tsx @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { useHost } from '@/host'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '../ui/button'; @@ -35,10 +36,12 @@ export default function CloseNoticeDialog({ onOpenChange, trigger, }: Props) { + const host = useHost(); + const electronAPI = host?.electronAPI; const { t } = useTranslation(); const onSubmit = useCallback(() => { - window.electronAPI.closeWindow(true); - }, []); + electronAPI?.closeWindow(true); + }, [electronAPI]); return ( diff --git a/src/components/Folder/index.tsx b/src/components/Folder/index.tsx index c2d32771a..9b7f05827 100644 --- a/src/components/Folder/index.tsx +++ b/src/components/Folder/index.tsx @@ -33,9 +33,10 @@ import { import { useEffect, useRef, useState } from 'react'; import FolderComponent from './FolderComponent'; -import { proxyFetchGet } from '@/api/http'; +import { fetchGet, getBaseURL } from '@/api/http'; import { MarkDown } from '@/components/ChatBox/MessageItem/MarkDown'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; +import { useHost } from '@/host'; import { deferInlineScriptsUntilLoad, injectFontStyles, @@ -50,6 +51,25 @@ const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg']; const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma']; const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi', 'mkv', 'flv', 'wmv']; +function FileLoadingSpinner({ fileName }: { fileName?: string } = {}) { + return ( +
+
+

+ {fileName ? ( + <> + Loading{' '} + {fileName} + ... + + ) : ( + 'Loading...' + )} +

+
+ ); +} + type FileTypeTarget = { name?: string; path?: string; @@ -117,6 +137,7 @@ interface FileTreeNode { icon?: React.ElementType; children?: FileTreeNode[]; isRemote?: boolean; + relativePath?: string; } interface FileInfo { @@ -163,6 +184,7 @@ export const FileTree: React.FC = ({ isFolder: child.isFolder, icon: child.icon, isRemote: child.isRemote, + relativePath: child.relativePath, }; return ( @@ -231,23 +253,30 @@ export const FileTree: React.FC = ({ ); }; -function downloadByBrowser(url: string) { - window.ipcRenderer - .invoke('download-file', url) - .then((result) => { - if (result.success) { - console.log('download-file success:', result.path); - } else { - console.error('download-file error:', result.error); - } - }) - .catch((error) => { - console.error('download-file error:', error); - }); +function downloadByBrowser(ipcRenderer: any, url: string) { + const ipc = ipcRenderer; + if (ipc?.invoke) { + ipc + .invoke('download-file', url) + .then((result: { success?: boolean; path?: string; error?: string }) => { + if (result?.success) { + console.log('download-file success:', result.path); + } else { + console.error('download-file error:', result?.error); + } + }) + .catch((error: unknown) => { + console.error('download-file error:', error); + }); + } else { + // Web mode: open in new tab for download + window.open(url, '_blank', 'noopener,noreferrer'); + } } export default function Folder({ data: _data }: { data?: Agent }) { //Get Chatstore for the active project's task + const host = useHost(); const { chatStore, projectStore } = useChatStoreAdapter(); const authStore = useAuthStore(); const { t } = useTranslation(); @@ -276,42 +305,97 @@ export default function Folder({ data: _data }: { data?: Agent }) { }, ]); const hasFetchedRemote = useRef(false); + const lastFetchKey = useRef(''); + const electronAPI = host?.electronAPI; + const ipcRenderer = host?.ipcRenderer; const selectedFileChange = (file: FileInfo, isShowSourceCode?: boolean) => { + const ipc = ipcRenderer; + const isWebMode = !ipc?.invoke; + if (file.type === 'zip') { - // if file is remote, don't call reveal-in-folder if (file.isRemote) { - downloadByBrowser(file.path); + downloadByBrowser(ipcRenderer, file.path); return; } - window.ipcRenderer.invoke('reveal-in-folder', file.path); - return; - } - // Don't open folders in preview - they are handled by expand/collapse - if (file.isFolder) { + ipc?.invoke('reveal-in-folder', file.path); return; } + if (file.isFolder) return; + setSelectedFile(file); setLoading(true); - console.log('file', JSON.parse(JSON.stringify(file))); - // For PDF files, use data URL instead of custom protocol - if (file.type === 'pdf') { - window.ipcRenderer - .invoke('read-file-dataurl', file.path) - .then((dataUrl: string) => { + // Remote files (path is URL): use URL directly for display where possible + if (file.isRemote && file.path?.startsWith('http')) { + // Images/audio/video: use path (URL) directly, loaders will use it + if (isImageFile(file) || isAudioFile(file) || isVideoFile(file)) { + setSelectedFile({ ...file }); + chatStore.setSelectedFile(chatStore.activeTaskId as string, file); + setLoading(false); + return; + } + // Other types (text, PDF, etc.): Electron uses IPC; Web fetches + if (!isWebMode) { + ipc + ?.invoke('open-file', file.type, file.path, isShowSourceCode) + ?.then((res: string) => { + setSelectedFile({ ...file, content: res }); + chatStore.setSelectedFile(chatStore.activeTaskId as string, file); + setLoading(false); + }) + ?.catch((error: unknown) => { + console.error('open-file error:', error); + setLoading(false); + }); + return; + } + const loadRemoteContent = async () => { + try { + const resp = await fetch(file.path!); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const contentType = resp.headers.get('content-type') || ''; + let content: string; + if (file.type === 'pdf' || contentType.includes('application/pdf')) { + const blob = await resp.blob(); + content = await new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = () => resolve(r.result as string); + r.onerror = reject; + r.readAsDataURL(blob); + }); + } else { + content = await resp.text(); + } + setSelectedFile({ ...file, content }); + chatStore.setSelectedFile(chatStore.activeTaskId as string, file); + } catch (e) { + console.error('Failed to load remote file:', e); + } finally { + setLoading(false); + } + }; + loadRemoteContent(); + return; + } + + // Electron: PDF and images (open-file reads binary as utf-8 and corrupts) + if (file.type === 'pdf' || isImageFile(file)) { + ipc + ?.invoke('read-file-dataurl', file.path) + ?.then((dataUrl: string) => { setSelectedFile({ ...file, content: dataUrl }); chatStore.setSelectedFile(chatStore.activeTaskId as string, file); setLoading(false); }) - .catch((error) => { + ?.catch((error: unknown) => { console.error('read-file-dataurl error:', error); setLoading(false); }); return; } - // For audio/video files, skip open-file — loaders handle reading themselves + // Audio/video: loaders use path or content if (isAudioFile(file) || isVideoFile(file)) { setSelectedFile({ ...file }); chatStore.setSelectedFile(chatStore.activeTaskId as string, file); @@ -319,15 +403,15 @@ export default function Folder({ data: _data }: { data?: Agent }) { return; } - // all other files call open-file interface, the backend handles download and parsing - window.ipcRenderer - .invoke('open-file', file.type, file.path, isShowSourceCode) - .then((res) => { + // Electron: open-file + ipc + ?.invoke('open-file', file.type, file.path, isShowSourceCode) + ?.then((res: string) => { setSelectedFile({ ...file, content: res }); chatStore.setSelectedFile(chatStore.activeTaskId as string, file); setLoading(false); }) - .catch((error) => { + ?.catch((error: unknown) => { console.error('open-file error:', error); setLoading(false); }); @@ -380,6 +464,7 @@ export default function Folder({ data: _data }: { data?: Agent }) { icon: file.icon, children: file.isFolder ? [] : undefined, isRemote: file.isRemote, + relativePath: file.relativePath, }; parentNode.children!.push(node); @@ -404,6 +489,9 @@ export default function Folder({ data: _data }: { data?: Agent }) { }); }; + const activeTaskId = chatStore?.activeTaskId as string; + const activeWorkspace = chatStore?.tasks[activeTaskId]?.activeWorkspace; + const taskAssigning = chatStore?.tasks[activeTaskId]?.taskAssigning; // Reset state when activeTaskId changes (e.g., new project created) useEffect(() => { hasFetchedRemote.current = false; @@ -411,70 +499,106 @@ export default function Folder({ data: _data }: { data?: Agent }) { setFileTree({ name: 'root', path: '', children: [], isFolder: true }); setFileGroups([{ folder: 'Reports', files: [] }]); setExpandedFolders(new Set()); - }, [chatStore?.activeTaskId]); - + }, [activeTaskId]); + + // project_id: must match Brain path ~/eigent/{email}/project_{project_id}/task_{task_id}/ + const projectId = (projectStore.activeProjectId as string) || activeTaskId; + + const fetchKey = `${projectId}|${activeTaskId || ''}`; + // Poll when task has agents that are running/pending (files may be generated) + const taskRunning = + !!taskAssigning?.length && + taskAssigning.some((a) => a.status === 'running' || a.status === 'pending'); + + /** + * Folder design: Frontend always fetches file list from Brain API. + * - GET /files?project_id=&email=&task_id= → returns [{filename, url, relativePath}] + * - Re-fetch when project/task changes (switch project) or periodically when task is running + */ useEffect(() => { - if (!chatStore) return; - const setFileList = async () => { - let res = null; - res = await window.ipcRenderer.invoke( - 'get-project-file-list', - authStore.email, - projectStore.activeProjectId as string - ); - let tree: any = null; - if ( - (res && res.length > 0) || - import.meta.env.VITE_USE_LOCAL_PROXY === 'true' - ) { - tree = buildFileTree(res || []); - } else { - if (!hasFetchedRemote.current) { - //TODO(file): rename endpoint to use project_id - res = await proxyFetchGet('/api/v1/chat/files', { - task_id: projectStore.activeProjectId as string, - }); - hasFetchedRemote.current = true; + if (!chatStore || !projectId || !authStore.email) return; + + const fetchFileList = async () => { + let res: any[] = []; + try { + const baseURL = await getBaseURL(); + if (!baseURL) { + console.warn('[Folder] Brain not connected, cannot fetch files'); + return; } - console.log('res', res); - if (res) { - res = res.map((item: any) => { + // Omit task_id to list all files in project (all tasks); backend uses os.walk recursively + const listRes = await fetchGet('/files', { + project_id: projectId, + email: authStore.email, + }); + if (Array.isArray(listRes)) { + res = listRes.map((item: any) => { + const url = item.url?.startsWith('http') + ? item.url + : `${baseURL}${item.url || ''}`; return { name: item.filename, - type: item.filename.split('.')[1], - path: item.url, + type: (item.filename || '').split('.').pop() || '', + path: url, + relativePath: item.relativePath || '', isRemote: true, }; }); - tree = buildFileTree(res || []); } + } catch (e) { + console.warn('[Folder] Failed to fetch files from Brain:', e); } + + const tree = buildFileTree(res); setFileTree(tree); - // Keep the old structure for compatibility setFileGroups((prev) => { const chatStoreSelectedFile = - chatStore.tasks[chatStore.activeTaskId as string]?.selectedFile; + chatStore.tasks[activeTaskId]?.selectedFile; if (chatStoreSelectedFile) { - console.log(res, chatStoreSelectedFile); const file = res.find( (item: any) => item.name === chatStoreSelectedFile.name ); - console.log('file', file); if (file && selectedFile?.path !== chatStoreSelectedFile?.path) { selectedFileChange(file as FileInfo, isShowSourceCode); } } - return [ - { - ...prev[0], - files: res || [], - }, - ]; + return [{ ...prev[0], files: res }]; }); }; - setFileList(); + + const shouldFetch = + lastFetchKey.current !== fetchKey || + (taskRunning && !hasFetchedRemote.current); + if (shouldFetch) { + lastFetchKey.current = fetchKey; + hasFetchedRemote.current = true; + fetchFileList(); + } + + let pollTimer: ReturnType | null = null; + if (taskRunning) { + pollTimer = setInterval(() => { + fetchFileList(); + }, 5000); + } + return () => { + if (pollTimer) clearInterval(pollTimer); + }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chatStore?.tasks[chatStore?.activeTaskId as string]?.taskAssigning]); + }, [ + fetchKey, + taskRunning, + taskAssigning, + activeWorkspace, + projectId, + activeTaskId, + authStore.email, + ]); + + // Reset hasFetchedRemote when project/task changes so next effect run will fetch + useEffect(() => { + hasFetchedRemote.current = false; + }, [projectId, activeTaskId]); const selectedFilePath = chatStore?.tasks[chatStore?.activeTaskId as string]?.selectedFile?.path; @@ -484,10 +608,16 @@ export default function Folder({ data: _data }: { data?: Agent }) { const chatStoreSelectedFile = chatStore.tasks[chatStore.activeTaskId as string]?.selectedFile; if (chatStoreSelectedFile && fileGroups[0]?.files) { + // Match: path (exact) > relativePath (chat path has task_xxx/filename) > name + const chatPath = (chatStoreSelectedFile.path || '').replace(/\\/g, '/'); + const chatRel = chatPath.match(/(task_[^/]+\/.+)$/)?.[1] ?? null; const file = fileGroups[0].files.find( - (item: any) => item.path === chatStoreSelectedFile.path + (item: any) => + item.path === chatStoreSelectedFile.path || + (chatRel !== null && item.relativePath === chatRel) || + item.name === chatStoreSelectedFile.name ); - if (file && selectedFile?.path !== chatStoreSelectedFile?.path) { + if (file && selectedFile?.path !== file.path) { selectedFileChange(file as FileInfo, isShowSourceCode); } } else if (!chatStoreSelectedFile && selectedFile) { @@ -507,11 +637,19 @@ export default function Folder({ data: _data }: { data?: Agent }) { const handleOpenInIDE = async (ide: 'vscode' | 'cursor' | 'system') => { try { if (!authStore.email || !projectStore.activeProjectId) return; - const folderPath = await window.electronAPI.getProjectFolderPath( + const folderPath = await electronAPI?.getProjectFolderPath( authStore.email, projectStore.activeProjectId ); - const result = await window.electronAPI.openInIDE(folderPath, ide); + if (!folderPath) { + toast.error(t('chat.failed-to-open-folder')); + return; + } + const result = await electronAPI?.openInIDE(folderPath, ide); + if (!result) { + toast.error(t('chat.failed-to-open-folder')); + return; + } if (!result.success) { toast.error(result.error || t('chat.failed-to-open-folder')); } else { @@ -547,8 +685,8 @@ export default function Folder({ data: _data }: { data?: Agent }) { )}
{!isCollapsed && - window.electronAPI?.getProjectFolderPath && - window.electronAPI?.openInIDE && ( + electronAPI?.getProjectFolderPath && + electronAPI?.openInIDE && (
) : isImageFile(selectedFile) ? (
- +
) : (
@@ -771,14 +909,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
                   
) ) : ( -
-
-
-

- {t('chat.loading')} -

-
-
+ ) ) : (
@@ -839,23 +970,40 @@ function toFileUrl(filePath: string): string { function ImageLoader({ selectedFile }: { selectedFile: FileInfo }) { const [src, setSrc] = useState(''); + const [loadError, setLoadError] = useState(false); useEffect(() => { setSrc(''); + setLoadError(false); + if (selectedFile.isRemote) { setSrc((selectedFile.content as string) || selectedFile.path); return; } - // Use file:// source so Chromium can stream/seek large media files. setSrc(toFileUrl(selectedFile.path)); }, [selectedFile]); + if (loadError) { + return ( +
+

{selectedFile.name}

+

+ Failed to load image. Try selecting again. +

+
+ ); + } + + if (!src) { + return ; + } + return ( {selectedFile.name} console.error('Image load error:', err)} + onError={() => setLoadError(true)} /> ); } @@ -866,13 +1014,17 @@ function AudioLoader({ selectedFile }: { selectedFile: FileInfo }) { useEffect(() => { setSrc(''); if (selectedFile.isRemote) { - setSrc(selectedFile.content || selectedFile.path); + setSrc(selectedFile.content || selectedFile.path || ''); return; } // Use file:// source so Chromium can stream/seek large media files. setSrc(toFileUrl(selectedFile.path)); }, [selectedFile]); + if (!src) { + return ; + } + return (

@@ -896,13 +1048,17 @@ function VideoLoader({ selectedFile }: { selectedFile: FileInfo }) { useEffect(() => { setSrc(''); if (selectedFile.isRemote) { - setSrc(selectedFile.content || selectedFile.path); + setSrc(selectedFile.content || selectedFile.path || ''); return; } // Use file:// source so Chromium can stream/seek large media files. setSrc(toFileUrl(selectedFile.path)); }, [selectedFile]); + if (!src) { + return ; + } + return (

{/* Floating notch-style zoom controls */} @@ -1241,7 +1592,7 @@ function HtmlRenderer({ ref={iframeRef} srcDoc={processedHtml} className="bg-white h-full w-full border-0" - sandbox="allow-scripts allow-forms" + sandbox="allow-scripts allow-forms allow-downloads" title={selectedFile.name} tabIndex={0} onLoad={() => iframeRef.current?.focus()} diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index a75f496c0..c8ee26336 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -18,6 +18,7 @@ import { InstallDependencies } from '@/components/InstallStep/InstallDependencie import TopBar from '@/components/TopBar'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { useInstallationSetup } from '@/hooks/useInstallationSetup'; +import { useHost } from '@/host'; import { useAuthStore } from '@/store/authStore'; import { useInstallationUI } from '@/store/installationStore'; import { useEffect, useState } from 'react'; @@ -27,6 +28,7 @@ import HistorySidebar from '../HistorySidebar'; import InstallationErrorDialog from '../InstallStep/InstallationErrorDialog/InstallationErrorDialog'; const Layout = () => { + const host = useHost(); const { initState, isFirstLaunch, @@ -53,22 +55,23 @@ const Layout = () => { useInstallationSetup(); useEffect(() => { + if (!host?.ipcRenderer || !host?.electronAPI) return; + const handleBeforeClose = () => { const currentStatus = chatStore.tasks[chatStore.activeTaskId as string]?.status; if (['running', 'pause'].includes(currentStatus)) { setNoticeOpen(true); } else { - window.electronAPI.closeWindow(true); + host.electronAPI.closeWindow(true); } }; - window.ipcRenderer.on('before-close', handleBeforeClose); - + host.ipcRenderer.on('before-close', handleBeforeClose); return () => { - window.ipcRenderer.removeAllListeners('before-close'); + host.ipcRenderer?.removeAllListeners('before-close'); }; - }, [chatStore.tasks, chatStore.activeTaskId]); + }, [chatStore.tasks, chatStore.activeTaskId, host]); // Determine what to show based on states const shouldShowOnboarding = diff --git a/src/components/TerminalAgentWorkspace/index.tsx b/src/components/TerminalAgentWorkspace/index.tsx index 54cf8e250..266dbf30e 100644 --- a/src/components/TerminalAgentWorkspace/index.tsx +++ b/src/components/TerminalAgentWorkspace/index.tsx @@ -15,6 +15,7 @@ import { fetchPut } from '@/api/http'; import Terminal from '@/components/Terminal'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; +import { useHost } from '@/host'; import { ArrowDown, ArrowUp, @@ -34,6 +35,8 @@ import { Button } from '../ui/button'; export default function TerminalAgentWorkspace() { //Get Chatstore for the active project's task + const host = useHost(); + const electronAPI = host?.electronAPI; const { chatStore, projectStore } = useChatStoreAdapter(); const { t } = useTranslation(); const [isSingleMode, setIsSingleMode] = useState(false); @@ -124,7 +127,7 @@ export default function TerminalAgentWorkspace() { action: 'resume', }); setIsTakeControl(false); - window.electronAPI.hideAllWebview(); + electronAPI?.hideAllWebview(); }} className="rounded-full" > diff --git a/src/components/TopBar/index.tsx b/src/components/TopBar/index.tsx index 1dc18956b..e16da9639 100644 --- a/src/components/TopBar/index.tsx +++ b/src/components/TopBar/index.tsx @@ -25,6 +25,7 @@ import EndNoticeDialog from '@/components/Dialog/EndNotice'; import { Button } from '@/components/ui/button'; import { TooltipSimple } from '@/components/ui/tooltip'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; +import { useHost } from '@/host'; import { share } from '@/lib/share'; import { useAuthStore } from '@/store/authStore'; import { useInstallationUI } from '@/store/installationStore'; @@ -50,6 +51,7 @@ import { toast } from 'sonner'; function HeaderWin() { const { t } = useTranslation(); + const host = useHost(); const titlebarRef = useRef(null); const controlsRef = useRef(null); const [platform, setPlatform] = useState(''); @@ -67,12 +69,12 @@ function HeaderWin() { isInstalling || installationState === 'waiting-backend'; useEffect(() => { - const p = window.electronAPI.getPlatform(); - setPlatform(p); - }, []); + setPlatform(host?.electronAPI?.getPlatform?.() ?? 'web'); + }, [host]); const exportLog = async () => { + if (!host?.electronAPI?.exportLog) return; try { - const response = await window.electronAPI.exportLog(); + const response = await host.electronAPI.exportLog(); if (!response.success) { alert(t('layout.export-cancelled') + response.error); @@ -84,7 +86,7 @@ function HeaderWin() { alert(t('layout.log-saved') + response.savedPath); } } catch (e: any) { - alert(t('layout.export-error') + e.message); + alert(t('layout.export-error') + (e as Error).message); } }; @@ -141,9 +143,9 @@ function HeaderWin() { try { const task = chatStore.tasks[taskId]; - // Stop the task if it's running + // Stop the task if it's running (use projectId - task_lock is keyed by project) if (task && task.status === ChatTaskStatus.RUNNING) { - await fetchPut(`/task/${taskId}/take-control`, { + await fetchPut(`/task/${projectId}/take-control`, { action: 'stop', }); } @@ -362,7 +364,8 @@ function HeaderWin() { )} - {chatStore.activeTaskId && + {host?.electronAPI && + chatStore.activeTaskId && chatStore.tasks[chatStore.activeTaskId as string] && (
)}
- {/* Custom window controls only for Linux (Windows and macOS use native controls) */} - {platform !== 'darwin' && platform !== 'win32' && ( + {/* Custom window controls only for Linux. Web: hidden. */} + {host?.electronAPI && platform !== 'darwin' && platform !== 'win32' && (
window.electronAPI.minimizeWindow()} + onClick={() => host?.electronAPI?.minimizeWindow()} >
window.electronAPI.toggleMaximizeWindow()} + onClick={() => host?.electronAPI?.toggleMaximizeWindow()} >
window.electronAPI.closeWindow()} + onClick={() => host?.electronAPI?.closeWindow()} >
diff --git a/src/components/WindowControls/index.tsx b/src/components/WindowControls/index.tsx index f5725998b..a28ad3468 100644 --- a/src/components/WindowControls/index.tsx +++ b/src/components/WindowControls/index.tsx @@ -12,20 +12,22 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { useHost } from '@/host'; import { Minus, Square, X } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import './index.css'; +/** Renders when host provides window controls. */ export default function WindowControls() { + const host = useHost(); const controlsRef = useRef(null); const [platform, setPlatform] = useState(''); useEffect(() => { - const p = window.electronAPI.getPlatform(); + if (!host?.electronAPI?.getPlatform) return; + const p = host.electronAPI.getPlatform(); setPlatform(p); - // Hide custom controls on macOS (uses native traffic lights) - // and on Windows (now uses native frame with native controls) if (p === 'darwin' || p === 'win32') { if (controlsRef.current) { controlsRef.current.style.display = 'none'; @@ -33,10 +35,8 @@ export default function WindowControls() { } }, []); - // Don't render custom controls on macOS or Windows (both use native controls) - if (platform === 'darwin' || platform === 'win32') { - return null; - } + if (!host?.electronAPI) return null; + if (platform === 'darwin' || platform === 'win32') return null; return (
window.electronAPI.minimizeWindow()} + onClick={() => host?.electronAPI?.minimizeWindow()} >
window.electronAPI.toggleMaximizeWindow()} + onClick={() => host?.electronAPI?.toggleMaximizeWindow()} >
@@ -64,7 +64,7 @@ export default function WindowControls() { e.preventDefault(); // Trigger window close - this will go through the before-close handler // which checks if tasks are running and shows confirmation if needed - window.electronAPI.closeWindow(false); + host?.electronAPI?.closeWindow(false); }} onMouseDown={(e) => { e.stopPropagation(); diff --git a/src/components/WorkFlow/node.tsx b/src/components/WorkFlow/node.tsx index c787944fb..5660d9157 100644 --- a/src/components/WorkFlow/node.tsx +++ b/src/components/WorkFlow/node.tsx @@ -15,6 +15,7 @@ import { AddWorker } from '@/components/AddWorker'; import { Button } from '@/components/ui/button'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; +import { useHost } from '@/host'; import { getToolkitIcon } from '@/lib/toolkitIcons'; import { useAuthStore, useWorkerList } from '@/store/authStore'; import { @@ -72,6 +73,8 @@ interface NodeProps { } export function Node({ id, data }: NodeProps) { + const host = useHost(); + const electronAPI = host?.electronAPI; const [isExpanded, setIsExpanded] = useState(data.isExpanded); const [selectedTask, setSelectedTask] = useState(null); const [selectedState, setSelectedState] = useState('all'); @@ -501,7 +504,7 @@ export function Node({ id, data }: NodeProps) { data.agent?.agent_id as string ); - window.electronAPI.hideAllWebview(); + electronAPI?.hideAllWebview(); }} > {browserImages.length > 0 && ( @@ -646,7 +649,7 @@ export function Node({ id, data }: NodeProps) { chatStore.activeTaskId as string, task.agent?.agent_id ); - window.electronAPI.hideAllWebview(); + electronAPI?.hideAllWebview(); } }} key={`taskList-${task.id}-${task.failure_count}`} diff --git a/src/components/WorkspaceMenu/index.tsx b/src/components/WorkspaceMenu/index.tsx index cb19f9ec7..7353fe3b9 100644 --- a/src/components/WorkspaceMenu/index.tsx +++ b/src/components/WorkspaceMenu/index.tsx @@ -18,6 +18,7 @@ import { } from '@/components/MenuButton/MenuButton'; import { Button } from '@/components/ui/button'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; +import { useHost } from '@/host'; import { useWorkerList } from '@/store/authStore'; import { useWorkflowViewportStore } from '@/store/workflowViewportStore'; import { AnimatePresence, motion } from 'framer-motion'; @@ -44,6 +45,7 @@ export function WorkSpaceMenu({ isChatBoxVisible = true, }: WorkSpaceMenuProps) { const { t } = useTranslation(); + const host = useHost(); const { chatStore } = useChatStoreAdapter(); const workerList = useWorkerList(); @@ -112,8 +114,8 @@ export function WorkSpaceMenu({ }, [chatStore, baseWorker, workerList, taskAssigning]); useEffect(() => { - if (!chatStore) return; - const cleanup = window.electronAPI.onWebviewNavigated( + if (!chatStore || !host?.electronAPI?.onWebviewNavigated) return; + const cleanup = host.electronAPI.onWebviewNavigated( (id: string, url: string) => { if (!chatStore.activeTaskId) return; const currentTask = getCurrentTask(); @@ -205,8 +207,9 @@ export function WorkSpaceMenu({ // capture webview const captureWebview = () => { + if (!host?.ipcRenderer) return; webviews.map((webview) => { - window.ipcRenderer + host.ipcRenderer .invoke('capture-webview', webview.id) .then((base64: string) => { const currentTask = getCurrentTask(); @@ -243,9 +246,8 @@ export function WorkSpaceMenu({ } ); - // Cleanup function to remove listener when component unmounts or dependencies change return cleanup; - }, [chatStore, activeTaskId, webViewUrls, taskAssigning]); + }, [chatStore, activeTaskId, webViewUrls, taskAssigning, host]); if (!chatStore) { return
Loading...
; @@ -337,7 +339,7 @@ export function WorkSpaceMenu({ } chatStore.setActiveWorkspace(chatStore.activeTaskId, val); - window.electronAPI.hideAllWebview(); + host?.electronAPI?.hideAllWebview?.(); }; return ( diff --git a/src/components/update/index.tsx b/src/components/update/index.tsx index 60d179877..ea0fbd984 100644 --- a/src/components/update/index.tsx +++ b/src/components/update/index.tsx @@ -13,19 +13,22 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { Progress } from '@/components/ui/progress'; +import { useHost } from '@/host'; import type { ProgressInfo } from 'electron-updater'; import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; const Update = () => { + const host = useHost(); + const ipcRenderer = host?.ipcRenderer; const [downloadProgress, setDownloadProgress] = useState(0); const [isDownloading, setIsDownloading] = useState(false); const { t } = useTranslation(); - const checkUpdate = () => { - window.ipcRenderer.invoke('check-update'); - }; + const checkUpdate = useCallback(() => { + ipcRenderer?.invoke('check-update'); + }, [ipcRenderer]); const onUpdateCanAvailable = useCallback( (_event: Electron.IpcRendererEvent, info: VersionInfo) => { @@ -37,14 +40,14 @@ const Update = () => { onClick: () => { setIsDownloading(true); setDownloadProgress(0); - window.ipcRenderer.invoke('start-download'); + host?.ipcRenderer?.invoke('start-download'); }, }, duration: Infinity, }); } }, - [t] + [host?.ipcRenderer, t] ); const onUpdateError = useCallback( @@ -95,33 +98,35 @@ const Update = () => { description: t('update.click-to-install-update'), action: { label: t('update.install'), - onClick: () => window.ipcRenderer.invoke('quit-and-install'), + onClick: () => ipcRenderer?.invoke('quit-and-install'), }, duration: Infinity, }); }, - [t] + [ipcRenderer, t] ); useEffect(() => { - if (sessionStorage.getItem('updateElectronShown')) { + if (!ipcRenderer || sessionStorage.getItem('updateElectronShown')) { return; } sessionStorage.setItem('updateElectronShown', '1'); - window.ipcRenderer?.on('update-can-available', onUpdateCanAvailable); - window.ipcRenderer?.on('update-error', onUpdateError); - window.ipcRenderer?.on('download-progress', onDownloadProgress); - window.ipcRenderer?.on('update-downloaded', onUpdateDownloaded); + ipcRenderer.on('update-can-available', onUpdateCanAvailable); + ipcRenderer.on('update-error', onUpdateError); + ipcRenderer.on('download-progress', onDownloadProgress); + ipcRenderer.on('update-downloaded', onUpdateDownloaded); checkUpdate(); return () => { - window.ipcRenderer?.off('update-can-available', onUpdateCanAvailable); - window.ipcRenderer?.off('update-error', onUpdateError); - window.ipcRenderer?.off('download-progress', onDownloadProgress); - window.ipcRenderer?.off('update-downloaded', onUpdateDownloaded); + ipcRenderer.off('update-can-available', onUpdateCanAvailable); + ipcRenderer.off('update-error', onUpdateError); + ipcRenderer.off('download-progress', onDownloadProgress); + ipcRenderer.off('update-downloaded', onUpdateDownloaded); }; }, [ + checkUpdate, + ipcRenderer, onUpdateCanAvailable, onUpdateError, onDownloadProgress, diff --git a/src/context/ConnectionContext.tsx b/src/context/ConnectionContext.tsx new file mode 100644 index 000000000..2d87b1400 --- /dev/null +++ b/src/context/ConnectionContext.tsx @@ -0,0 +1,111 @@ +// ========= 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. ========= + +/** + * ConnectionProvider: Phase 2 - config-driven Brain connection. + * Resolves brainEndpoint (Electron: get-backend-port) and sets X-Channel. + * getBaseURL in http.ts reads from connectionStore. + */ + +import { getDefaultBrainEndpoint } from '@/api/http'; +import { useHost } from '@/host'; +import type { ConnectionChannel } from '@/store/connectionStore'; +import { + getConnectionConfig, + setConnectionConfig, +} from '@/store/connectionStore'; +import React, { useEffect } from 'react'; + +export { + getConnectionConfig, + setConnectionConfig, +} from '@/store/connectionStore'; +export type { + ConnectionChannel, + ConnectionConfig, +} from '@/store/connectionStore'; + +interface ConnectionProviderProps { + channel?: ConnectionChannel; + children: React.ReactNode; +} + +export function ConnectionProvider({ + channel = 'desktop', + children, +}: ConnectionProviderProps) { + const host = useHost(); + + useEffect(() => { + const hasDesktop = !!(host?.electronAPI && host?.ipcRenderer); + const effectiveChannel = hasDesktop ? channel : 'web'; + setConnectionConfig({ channel: effectiveChannel }); + + const resolveEndpoint = async () => { + let resolvedEndpoint = ''; + if (hasDesktop && host?.electronAPI?.getBackendPort) { + try { + const port = await host.electronAPI.getBackendPort(); + if (port && port > 0) { + resolvedEndpoint = `http://localhost:${port}`; + setConnectionConfig({ brainEndpoint: resolvedEndpoint }); + return resolvedEndpoint; + } + } catch { + // IPC not ready + } + return resolvedEndpoint; + } + // Web: VITE_BRAIN_ENDPOINT (dev default http://localhost:5001) + const envEndpoint = getDefaultBrainEndpoint(); + if (envEndpoint && typeof envEndpoint === 'string') { + resolvedEndpoint = envEndpoint.replace(/\/$/, ''); + setConnectionConfig({ brainEndpoint: resolvedEndpoint }); + } else if (effectiveChannel === 'web') { + console.error( + '[ConnectionProvider] VITE_BRAIN_ENDPOINT not set for production web mode' + ); + } + return resolvedEndpoint; + }; + + const ensureSessionId = async (endpoint: string) => { + if (effectiveChannel !== 'web' || !endpoint) { + return; + } + if (getConnectionConfig().sessionId) { + return; + } + try { + const response = await fetch(`${endpoint}/health`, { + headers: { 'X-Channel': effectiveChannel }, + }); + const sessionId = response.headers.get('x-session-id'); + if (sessionId) { + setConnectionConfig({ sessionId }); + } + } catch { + // Brain may not be ready yet; session will be created on the first request. + } + }; + + resolveEndpoint().then((endpoint) => { + if (endpoint) { + void ensureSessionId(endpoint); + } + }); + }, [channel, host]); + + return <>{children}; +} diff --git a/src/hooks/useInstallationSetup.ts b/src/hooks/useInstallationSetup.ts index e8655462e..c61feba70 100644 --- a/src/hooks/useInstallationSetup.ts +++ b/src/hooks/useInstallationSetup.ts @@ -12,15 +12,19 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { checkBackendHealth, resetBaseURL } from '@/api/http'; +import { useHost } from '@/host'; import { useAuthStore } from '@/store/authStore'; +import { resetConnectionConfig } from '@/store/connectionStore'; import { useInstallationStore } from '@/store/installationStore'; import { useCallback, useEffect, useRef } from 'react'; /** - * Hook that sets up Electron IPC listeners and handles installation state synchronization - * This should be called once in your App component or Layout component + * Hook that sets up Electron IPC listeners and handles installation state synchronization. + * In Web mode (no Electron): polls Brain health via VITE_BRAIN_ENDPOINT, skips local install. */ export const useInstallationSetup = () => { + const host = useHost(); const { initState, setInitState, email } = useAuthStore(); const hasCheckedOnMount = useRef(false); @@ -48,97 +52,66 @@ export const useInstallationSetup = () => { (state) => state.setNeedsBackendRestart ); - // Shared function to poll backend status + // Shared function to poll backend/Brain status const startBackendPolling = useCallback(() => { console.log('[useInstallationSetup] Starting backend polling'); - // Immediately check backend status once - const checkBackendStatus = async () => { + const checkViaHealth = async (): Promise => { try { - const backendPort = await window.electronAPI.getBackendPort(); - if (backendPort && backendPort > 0) { - console.log( - '[useInstallationSetup] Backend immediately detected on port:', - backendPort - ); + const ok = await checkBackendHealth(); + if (ok) { + backendReady.current = true; + setSuccess(); + setInitState('done'); + setNeedsBackendRestart(false); + return true; + } + } catch (e) { + console.log('[useInstallationSetup] Health check failed:', e); + } + return false; + }; - // Verify backend is actually responding + // Electron: use getBackendPort + localhost health + const checkElectronBackend = async (): Promise => { + if (!host?.electronAPI?.getBackendPort) return false; + try { + const backendPort = await host.electronAPI.getBackendPort(); + if (backendPort && backendPort > 0) { const response = await fetch( `http://localhost:${backendPort}/health` ).catch(() => null); - if (response && response.ok) { - console.log( - '[useInstallationSetup] Backend health check passed immediately' - ); + if (response?.ok) { backendReady.current = true; setSuccess(); setInitState('done'); setNeedsBackendRestart(false); - return true; // Backend is ready, no need to poll + return true; } } - } catch (error) { - console.log( - '[useInstallationSetup] Initial backend check failed:', - error - ); + } catch (e) { + console.log('[useInstallationSetup] Electron backend check failed:', e); } - return false; // Backend not ready, need to poll + return false; }; - // Check immediately, then start polling if needed - checkBackendStatus().then((isReady) => { + const hasDesktop = !!(host?.electronAPI && host?.ipcRenderer); + const doCheck = hasDesktop ? checkElectronBackend : checkViaHealth; + + doCheck().then((isReady) => { if (isReady) { - console.log( - '[useInstallationSetup] Backend already ready, skipping polling' - ); + console.log('[useInstallationSetup] Backend ready, skipping polling'); return; } - console.log('[useInstallationSetup] Backend not ready, starting polling'); - - // Poll backend status every 2 seconds to ensure we catch when it's ready - // This is a fallback in case the backend-ready event is missed - const pollInterval = setInterval(async () => { - try { - const backendPort = await window.electronAPI.getBackendPort(); - if (backendPort && backendPort > 0) { - console.log( - '[useInstallationSetup] Backend poll detected ready on port:', - backendPort - ); - - // Verify backend is actually responding - const response = await fetch( - `http://localhost:${backendPort}/health` - ).catch(() => null); - if (response && response.ok) { - console.log('[useInstallationSetup] Backend health check passed'); - clearInterval(pollInterval); - - if (!backendReady.current) { - backendReady.current = true; - setSuccess(); - setInitState('done'); - // Clear the flag after backend is ready - setNeedsBackendRestart(false); - } - } - } - } catch (error) { - console.log( - '[useInstallationSetup] Backend poll check failed:', - error - ); - } + const pollInterval = setInterval(() => { + doCheck().then((ready) => { + if (ready) clearInterval(pollInterval); + }); }, 2000); - - // Clear polling after 30 seconds to prevent infinite polling - setTimeout(() => { - clearInterval(pollInterval); - }, 30000); + setTimeout(() => clearInterval(pollInterval), 30000); }); - }, [setSuccess, setInitState, setNeedsBackendRestart]); + }, [setSuccess, setInitState, setNeedsBackendRestart, host]); // Monitor for backend restart after logout useEffect(() => { @@ -168,9 +141,19 @@ export const useInstallationSetup = () => { hasCheckedOnMount.current = true; + // Web mode: skip Electron install, poll Brain health directly + if (!host?.electronAPI || !host?.ipcRenderer) { + console.log('[useInstallationSetup] Web mode: polling Brain health'); + installationCompleted.current = true; + setWaitingBackend(); + startBackendPolling(); + return; + } + const checkToolInstalled = async () => { + if (!host?.ipcRenderer) return { success: false }; try { - const result = await window.ipcRenderer.invoke('check-tool-installed'); + const result = await host.ipcRenderer.invoke('check-tool-installed'); if (result.success) { if (result.isInstalled) { @@ -179,8 +162,6 @@ export const useInstallationSetup = () => { ); installationCompleted.current = true; setWaitingBackend(); - - // Start polling for backend when tools are already installed startBackendPolling(); } @@ -202,9 +183,10 @@ export const useInstallationSetup = () => { }; const checkBackendStatus = async (_toolResult?: any) => { + if (!host?.electronAPI?.getInstallationStatus) return; try { const installationStatus = - await window.electronAPI.getInstallationStatus(); + await host.electronAPI.getInstallationStatus(); if (installationStatus.success && installationStatus.isInstalling) { startInstallation(); @@ -286,6 +268,9 @@ export const useInstallationSetup = () => { console.log('[useInstallationSetup] Backend ready event received:', data); if (data.success && data.port) { + // Reset cached baseURL so next getBaseURL fetches fresh port (handles restart) + resetBaseURL(); + resetConnectionConfig(); console.log( `[useInstallationSetup] Backend is ready on port ${data.port}` ); @@ -312,18 +297,21 @@ export const useInstallationSetup = () => { } }; - window.electronAPI.onInstallDependenciesStart(handleInstallStart); - window.electronAPI.onInstallDependenciesLog(handleInstallLog); - window.electronAPI.onInstallDependenciesComplete(handleInstallComplete); - window.electronAPI.onBackendReady(handleBackendReady); + if (!host?.electronAPI) return; + + host.electronAPI.onInstallDependenciesStart(handleInstallStart); + host.electronAPI.onInstallDependenciesLog(handleInstallLog); + host.electronAPI.onInstallDependenciesComplete(handleInstallComplete); + host.electronAPI.onBackendReady(handleBackendReady); return () => { - window.electronAPI.removeAllListeners('install-dependencies-start'); - window.electronAPI.removeAllListeners('install-dependencies-log'); - window.electronAPI.removeAllListeners('install-dependencies-complete'); - window.electronAPI.removeAllListeners('backend-ready'); + host.electronAPI.removeAllListeners('install-dependencies-start'); + host.electronAPI.removeAllListeners('install-dependencies-log'); + host.electronAPI.removeAllListeners('install-dependencies-complete'); + host.electronAPI.removeAllListeners('backend-ready'); }; }, [ + host, startInstallation, addLog, setSuccess, diff --git a/src/hooks/useIntegrationManagement.ts b/src/hooks/useIntegrationManagement.ts index d562cc0dc..120de1544 100644 --- a/src/hooks/useIntegrationManagement.ts +++ b/src/hooks/useIntegrationManagement.ts @@ -20,6 +20,7 @@ import { proxyFetchPost, proxyFetchPut, } from '@/api/http'; +import { useHost } from '@/host'; import { useAuthStore } from '@/store/authStore'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -36,6 +37,9 @@ export interface IntegrationItem { * Hook for managing integration configurations, OAuth, and installation state */ export function useIntegrationManagement(items: IntegrationItem[]) { + const host = useHost(); + const electronAPI = host?.electronAPI; + const ipcRenderer = host?.ipcRenderer; const { email, checkAgentTool } = useAuthStore(); // Local installed status @@ -155,11 +159,11 @@ export function useIntegrationManagement(items: IntegrationItem[]) { } } - if (window.electronAPI?.envWrite) { - await window.electronAPI.envWrite(email, { key: envVarKey, value }); + if (electronAPI?.envWrite) { + await electronAPI.envWrite(email, { key: envVarKey, value }); } }, - [configs, email] + [configs, electronAPI, email] ); // Process OAuth callback @@ -262,11 +266,11 @@ export function useIntegrationManagement(items: IntegrationItem[]) { if (!data.provider || !data.code) return; processOauth(data); }; - window.ipcRenderer?.on('oauth-authorized', handler); + ipcRenderer?.on('oauth-authorized', handler); return () => { - window.ipcRenderer?.off('oauth-authorized', handler); + ipcRenderer?.off('oauth-authorized', handler); }; - }, [processOauth]); + }, [ipcRenderer, processOauth]); // Listen to OAuth callback URL notification useEffect(() => { @@ -275,11 +279,11 @@ export function useIntegrationManagement(items: IntegrationItem[]) { setCallBackUrl(data.url); } }; - window.ipcRenderer?.on('oauth-callback-url', handler); + ipcRenderer?.on('oauth-callback-url', handler); return () => { - window.ipcRenderer?.off('oauth-callback-url', handler); + ipcRenderer?.off('oauth-callback-url', handler); }; - }, []); + }, [ipcRenderer]); // Process cached OAuth event when items are ready useEffect(() => { @@ -311,9 +315,9 @@ export function useIntegrationManagement(items: IntegrationItem[]) { if ( item.env_vars && item.env_vars.length > 0 && - window.electronAPI?.envRemove + electronAPI?.envRemove ) { - await window.electronAPI.envRemove(email, item.env_vars[0]); + await electronAPI.envRemove(email, item.env_vars[0]); } } catch (_e) { // Ignore error @@ -349,7 +353,7 @@ export function useIntegrationManagement(items: IntegrationItem[]) { prev.filter((c: any) => c.config_group?.toLowerCase() !== groupKey) ); }, - [configs, email, checkAgentTool] + [checkAgentTool, configs, electronAPI, email] ); // Helper to create MCP object from integration item diff --git a/src/host/README.md b/src/host/README.md new file mode 100644 index 000000000..555eddd9f --- /dev/null +++ b/src/host/README.md @@ -0,0 +1,26 @@ +# Host 抽象层 + +统一桌面(Electron)与 Web 的能力注入,避免在业务代码中显式判断运行环境。 + +## 使用方式 + +```tsx +import { useHost } from '@/host'; + +function MyComponent() { + const host = useHost(); + // host.electronAPI / host.ipcRenderer 在 Web 下为 null + if (host?.electronAPI?.someMethod) { + host.electronAPI.someMethod(); + } +} +``` + +## 初始化 + +`main.tsx` 中通过 `createHost()` 创建 host,由 `HostProvider` 注入。`createHost()` 是唯一读取 `window` 的地方。 + +## 后续扩展 + +- 桌面端若用其他技术栈(Tauri、原生等)重构,只需提供新的 host 实现 +- CLI、Browser Extension 等可复用同一套 React 组件,注入不同的 host diff --git a/src/host/context.tsx b/src/host/context.tsx new file mode 100644 index 000000000..88165f106 --- /dev/null +++ b/src/host/context.tsx @@ -0,0 +1,35 @@ +// ========= 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. ========= +// Host context: inject desktop or web capabilities. Components use useHost(), +// never direct global Electron APIs. + +import React, { createContext, useContext, useMemo } from 'react'; +import type { AppHost } from './types'; + +const HostContext = createContext(null); + +export function HostProvider({ + host, + children, +}: { + host: AppHost; + children: React.ReactNode; +}) { + const value = useMemo(() => host, [host]); + return {children}; +} + +export function useHost(): AppHost | null { + return useContext(HostContext); +} diff --git a/src/host/createHost.ts b/src/host/createHost.ts new file mode 100644 index 000000000..8dbe8fafe --- /dev/null +++ b/src/host/createHost.ts @@ -0,0 +1,27 @@ +// ========= 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. ========= +// Create host from environment. Single place that reads window. + +import type { AppHost } from './types'; + +export function createHost(): AppHost { + if (typeof window === 'undefined') { + return { electronAPI: null, ipcRenderer: null }; + } + const win = window as any; + return { + electronAPI: win.electronAPI ?? null, + ipcRenderer: win.ipcRenderer ?? null, + }; +} diff --git a/src/host/index.ts b/src/host/index.ts new file mode 100644 index 000000000..928b0ca1b --- /dev/null +++ b/src/host/index.ts @@ -0,0 +1,17 @@ +// ========= 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. ========= + +export { HostProvider, useHost } from './context'; +export { createHost } from './createHost'; +export type { AppHost } from './types'; diff --git a/src/host/types.ts b/src/host/types.ts new file mode 100644 index 000000000..e6f9f04e7 --- /dev/null +++ b/src/host/types.ts @@ -0,0 +1,20 @@ +// ========= 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. ========= +// Host abstraction: desktop (Electron) vs web. No explicit platform checks. +// See docs/design/04-client.md. + +export interface AppHost { + electronAPI: any; + ipcRenderer: any; +} diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 000000000..ef61a8bee --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,14 @@ +// ========= 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. ========= +// Deprecated: use useHost() from '@/host' instead. diff --git a/src/lib/fileUtils.ts b/src/lib/fileUtils.ts index 81282ce09..4cf367ca6 100644 --- a/src/lib/fileUtils.ts +++ b/src/lib/fileUtils.ts @@ -12,22 +12,81 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { uploadFileToBrain } from '@/api/http'; +import { isWeb } from '@/client/platform'; import type { FileAttachment } from '@/components/ChatBox/BottomBox/InputBox'; +import type { AppHost } from '@/host'; +import { createHost } from '@/host'; /** * Process dropped files: resolve paths via Electron, send through IPC, * and merge with existing attachments (deduplicated by filePath). */ export async function processDroppedFiles( - droppedFiles: File[], - existingFiles: FileAttachment[] + droppedFiles: globalThis.File[], + existingFiles: FileAttachment[], + host?: AppHost | null ): Promise< | { success: true; files: FileAttachment[]; added: number } | { success: false; error: string } > { + if (isWeb()) { + const uploadedFiles: FileAttachment[] = []; + + for (const droppedFile of droppedFiles) { + try { + const result = await uploadFileToBrain(droppedFile); + uploadedFiles.push({ + fileName: result.filename, + filePath: result.file_id, + fileId: result.file_id, + source: 'upload', + }); + } catch (error) { + console.error('[Drag-Drop] Upload failed:', droppedFile.name, error); + } + } + + if (uploadedFiles.length === 0) { + return { + success: false, + error: 'Failed to upload dropped files.', + }; + } + + const mergedFiles = [ + ...existingFiles.filter( + (existing) => + !uploadedFiles.find( + (uploaded) => uploaded.filePath === existing.filePath + ) + ), + ...uploadedFiles.filter( + (uploaded) => + !existingFiles.find( + (existing) => existing.filePath === uploaded.filePath + ) + ), + ]; + + return { + success: true, + files: mergedFiles, + added: uploadedFiles.length, + }; + } + + const electronAPI = host?.electronAPI ?? createHost().electronAPI; + if (!electronAPI) { + return { + success: false, + error: 'Desktop file access is unavailable.', + }; + } + const fileData = droppedFiles.map((f) => { try { - return { name: f.name, path: window.electronAPI.getPathForFile(f) }; + return { name: f.name, path: electronAPI.getPathForFile(f) }; } catch { console.error('[Drag-Drop] Failed to get path for:', f.name); return { name: f.name, path: undefined }; @@ -42,7 +101,7 @@ export async function processDroppedFiles( }; } - const result = await window.electronAPI.processDroppedFiles(validFiles); + const result = await electronAPI.processDroppedFiles(validFiles); if (!result.success || !result.files) { return { success: false, diff --git a/src/lib/htmlFontStyles.ts b/src/lib/htmlFontStyles.ts index 40ce1cd6d..57069469d 100644 --- a/src/lib/htmlFontStyles.ts +++ b/src/lib/htmlFontStyles.ts @@ -180,8 +180,13 @@ export function deferInlineScriptsUntilLoad(html: string): string { 'var __eigentCurrentScript=document.currentScript;', 'if(__eigentCurrentScript&&__eigentCurrentScript.nonce){__eigentScript.nonce=__eigentCurrentScript.nonce;}', `__eigentScript.text=${serializedContent};`, + 'try{', '(document.head||document.body||document.documentElement).appendChild(__eigentScript);', - '__eigentScript.remove();', + '}catch(__eigentErr){', + "console.error('[HtmlRenderer] Deferred inline script execution failed:',__eigentErr);", + '}finally{', + 'if(__eigentScript.parentNode){__eigentScript.remove();}', + '}', '};', "if(document.readyState==='complete'){__eigentRun();}else{window.addEventListener('load',__eigentRun,{once:true});}", '})();', diff --git a/src/lib/index.ts b/src/lib/index.ts index 55ea93e08..21f5e2d29 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { createHost } from '@/host'; import { getAuthStore } from '@/store/authStore'; export function getProxyBaseURL() { @@ -86,11 +87,12 @@ export async function uploadLog(taskId: string, type?: string | undefined) { if (import.meta.env.VITE_USE_LOCAL_PROXY !== 'true' && !type) { try { const { email, token } = getAuthStore(); + const electronAPI = createHost().electronAPI; const baseUrl = import.meta.env.DEV ? import.meta.env.VITE_PROXY_URL : import.meta.env.VITE_BASE_URL; - await window.electronAPI.uploadLog(email, taskId, baseUrl, token); + await electronAPI?.uploadLog(email, taskId, baseUrl, token); } catch (error) { console.error('Failed to upload log:', error); } diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index ebab89eb1..089317ea1 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -12,10 +12,14 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { createHost } from '@/host'; + const EnvOauthInfoMap = { notion: 'NOTION_TOKEN', }; +const getElectronAPI = () => createHost().electronAPI; + export class OAuth { public client_name: string = 'Eigent'; public client_uri: string = 'https://eigent.ai/'; @@ -165,8 +169,9 @@ export class OAuth { body: params.toString(), }).then((res) => res.json()); - if (window.electronAPI?.envWrite) { - await window.electronAPI.envWrite(email, { + const electronAPI = getElectronAPI(); + if (electronAPI?.envWrite) { + await electronAPI.envWrite(email, { key: EnvOauthInfoMap[provider as keyof typeof EnvOauthInfoMap], value: newToken.access_token, }); diff --git a/src/lib/skillToolkit.ts b/src/lib/skillToolkit.ts index 3a84bd945..3414ec406 100644 --- a/src/lib/skillToolkit.ts +++ b/src/lib/skillToolkit.ts @@ -12,6 +12,8 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { getConnectionConfig } from '@/store/connectionStore'; + /** * Skill toolkit utilities aligned with CAMEL's skill_toolkit: * https://github.com/camel-ai/camel/blob/master/camel/toolkits/skill_toolkit.py @@ -133,9 +135,8 @@ export function skillNameToDirName(name: string): string { return cleaned || 'skill'; } -/** Check if running in Electron with skills API available */ +/** Check if skills API is available (Brain REST only, no IPC). */ export function hasSkillsFsApi(): boolean { - return ( - typeof window !== 'undefined' && !!(window as any).electronAPI?.skillsScan - ); + if (typeof window === 'undefined') return false; + return !!getConnectionConfig?.()?.brainEndpoint; } diff --git a/src/main.tsx b/src/main.tsx index 75bc50bd0..3c281a282 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { isWeb } from '@/client/platform'; import '@fontsource/inter/400.css'; import '@fontsource/inter/500.css'; import '@fontsource/inter/600.css'; @@ -19,27 +20,37 @@ import '@fontsource/inter/700.css'; import '@fontsource/inter/800.css'; import { Suspense } from 'react'; import ReactDOM from 'react-dom/client'; -import { HashRouter } from 'react-router-dom'; +import { BrowserRouter, HashRouter } from 'react-router-dom'; import App from './App'; import { ThemeProvider } from './components/ThemeProvider'; import { TooltipProvider } from './components/ui/tooltip'; +import { ConnectionProvider } from './context/ConnectionContext'; +import { createHost, HostProvider } from './host'; import './i18n'; +import { injectHost } from './store/chatStore'; import './style/index.css'; // If you want use Node.js, the`nodeIntegration` needs to be enabled in the Main process. // import './demos/node' +const host = createHost(); +injectHost(host); +const Router = isWeb() ? BrowserRouter : HashRouter; +const initialChannel = isWeb() ? 'web' : 'desktop'; + ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - //
}> - - - - - - - + + + + + + + + + + + - // ); postMessage({ payload: 'removeLoading' }, '*'); diff --git a/src/pages/Agents/components/SkillUploadDialog.tsx b/src/pages/Agents/components/SkillUploadDialog.tsx index 65d228a59..ad2ccb79b 100644 --- a/src/pages/Agents/components/SkillUploadDialog.tsx +++ b/src/pages/Agents/components/SkillUploadDialog.tsx @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { skillImportZip } from '@/api/brain'; import ConfirmModal from '@/components/ui/alertDialog'; import { Button } from '@/components/ui/button'; import { @@ -22,6 +23,7 @@ import { } from '@/components/ui/dialog'; import { parseSkillMd } from '@/lib/skillToolkit'; import { useSkillsStore } from '@/store/skillsStore'; + import { AlertCircle, File, Upload, X } from 'lucide-react'; import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -109,7 +111,7 @@ export default function SkillUploadDialog({ } try { - const result = await (window as any).electronAPI.skillImportZip( + const result = await skillImportZip( pendingFileBuffer, Array.from(newConfirmed) ); @@ -162,7 +164,7 @@ export default function SkillUploadDialog({ } try { - const result = await (window as any).electronAPI.skillImportZip( + const result = await skillImportZip( pendingFileBuffer, Array.from(confirmedReplacements) ); @@ -203,12 +205,8 @@ export default function SkillUploadDialog({ setIsUploading(true); try { - // Zip import: read file in renderer and send buffer to main (no path in sandbox) + // Zip import: Brain REST (Web + Electron) or IPC fallback (Electron only) if (isZipToUse) { - if (!(window as any).electronAPI?.skillImportZip) { - toast.error(t('agents.skill-add-error')); - return; - } let buffer: ArrayBuffer; try { buffer = await fileToUse.arrayBuffer(); @@ -218,9 +216,7 @@ export default function SkillUploadDialog({ } // First, check for conflicts - const result = await (window as any).electronAPI.skillImportZip( - buffer - ); + const result = await skillImportZip(buffer); if (result?.conflicts && result.conflicts.length > 0) { // Store conflicts and show dialog for first conflict diff --git a/src/pages/Browser/CDP.tsx b/src/pages/Browser/CDP.tsx index 12d1e9293..93f545b4b 100644 --- a/src/pages/Browser/CDP.tsx +++ b/src/pages/Browser/CDP.tsx @@ -14,6 +14,7 @@ import AlertDialog from '@/components/ui/alertDialog'; import { Button } from '@/components/ui/button'; +import { useHost } from '@/host'; import { Globe, Link2, Loader2, Plus, Trash2 } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -28,6 +29,8 @@ interface CdpBrowser { } export default function CDP() { + const host = useHost(); + const electronAPI = host?.electronAPI; const { t } = useTranslation(); const [cdpBrowsers, setCdpBrowsers] = useState([]); const [deletingBrowser, setDeletingBrowser] = useState(null); @@ -40,9 +43,9 @@ export default function CDP() { const [connectError, setConnectError] = useState(''); const loadCdpBrowsers = async () => { - if (window.electronAPI?.getCdpBrowsers) { + if (electronAPI?.getCdpBrowsers) { try { - const browsers = await window.electronAPI.getCdpBrowsers(); + const browsers = await electronAPI.getCdpBrowsers(); setCdpBrowsers(browsers); } catch (error) { console.error('Failed to load CDP browsers:', error); @@ -52,23 +55,21 @@ export default function CDP() { useEffect(() => { loadCdpBrowsers(); - }, []); + }, [electronAPI]); useEffect(() => { - if (!window.electronAPI?.onCdpPoolChanged) return; - const cleanup = window.electronAPI.onCdpPoolChanged( - (browsers: CdpBrowser[]) => { - setCdpBrowsers(browsers); - } - ); + if (!electronAPI?.onCdpPoolChanged) return; + const cleanup = electronAPI.onCdpPoolChanged((browsers: CdpBrowser[]) => { + setCdpBrowsers(browsers); + }); return cleanup; - }, []); + }, [electronAPI]); const handleRemoveBrowser = async (browserId: string) => { setDeletingBrowser(browserId); try { - if (window.electronAPI?.removeCdpBrowser) { - const result = await window.electronAPI.removeCdpBrowser(browserId); + if (electronAPI?.removeCdpBrowser) { + const result = await electronAPI.removeCdpBrowser(browserId); if (result.success) { toast.success(t('layout.browser-removed')); } else { @@ -88,7 +89,7 @@ export default function CDP() { toast.loading(t('layout.launching-browser', { port: '...' }), { id: 'launch-browser', }); - const result = await window.electronAPI?.launchCdpBrowser(); + const result = await electronAPI?.launchCdpBrowser(); if (result?.success) { toast.success(t('layout.browser-launched', { port: result.port }), { id: 'launch-browser', @@ -141,8 +142,8 @@ export default function CDP() { return; } - if (window.electronAPI?.addCdpBrowser) { - const addResult = await window.electronAPI.addCdpBrowser( + if (electronAPI?.addCdpBrowser) { + const addResult = await electronAPI.addCdpBrowser( portNum, true, `External Browser (${portNum})` diff --git a/src/pages/Browser/Cookies.tsx b/src/pages/Browser/Cookies.tsx index 141e50d37..f14ed76b7 100644 --- a/src/pages/Browser/Cookies.tsx +++ b/src/pages/Browser/Cookies.tsx @@ -15,6 +15,7 @@ import { fetchDelete, fetchGet, fetchPost } from '@/api/http'; import AlertDialog from '@/components/ui/alertDialog'; import { Button } from '@/components/ui/button'; +import { useHost } from '@/host'; import { Cookie, Plus, RefreshCw, Trash2 } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -33,6 +34,8 @@ interface GroupedDomain { } export default function Cookies() { + const host = useHost(); + const electronAPI = host?.electronAPI; const { t } = useTranslation(); const [loginLoading, setLoginLoading] = useState(false); const [cookiesLoading, setCookiesLoading] = useState(false); @@ -195,8 +198,8 @@ export default function Cookies() { }; const handleRestartApp = () => { - if (window.electronAPI && window.electronAPI.restartApp) { - window.electronAPI.restartApp(); + if (electronAPI?.restartApp) { + electronAPI.restartApp(); } else { toast.error('Restart function not available'); } diff --git a/src/pages/Connectors/MCP.tsx b/src/pages/Connectors/MCP.tsx index 67cd499c4..c6d815c58 100644 --- a/src/pages/Connectors/MCP.tsx +++ b/src/pages/Connectors/MCP.tsx @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { mcpInstall, mcpRemove, mcpUpdate } from '@/api/brain'; import { fetchGet, fetchPost, @@ -413,18 +414,15 @@ export default function SettingMCP() { }; await proxyFetchPut(`/api/v1/mcp/users/${showConfig.id}`, mcpData); - if (window.ipcRenderer) { - //Partial payload to empty env {} - const payload: any = { - description: configForm.mcp_desc, - command: configForm.command, - args: arrayToArgsJson(configForm.argsArr), - }; - if (configForm.env && Object.keys(configForm.env).length > 0) { - payload.env = configForm.env; - } - window.ipcRenderer.invoke('mcp-update', mcpData.mcp_name, payload); + const payload: Record = { + description: configForm.mcp_desc, + command: configForm.command, + args: arrayToArgsJson(configForm.argsArr), + }; + if (configForm.env && Object.keys(configForm.env).length > 0) { + payload.env = configForm.env; } + await mcpUpdate(mcpData.mcp_name, payload); setShowConfig(null); fetchList(); @@ -496,10 +494,10 @@ export default function SettingMCP() { setInstalling(false); return; } - if (window.ipcRenderer) { - const mcpServers = data['mcpServers']; + const mcpServers = data['mcpServers']; + if (mcpServers && typeof mcpServers === 'object') { for (const [key, value] of Object.entries(mcpServers)) { - await window.ipcRenderer.invoke('mcp-install', key, value); + await mcpInstall(key, value as Record); } } } @@ -522,11 +520,7 @@ export default function SettingMCP() { try { checkAgentTool(deleteTarget.mcp_name); await proxyFetchDelete(`/api/v1/mcp/users/${deleteTarget.id}`); - // notify main process - if (window.ipcRenderer) { - console.log('deleteTarget', deleteTarget.mcp_key); - await window.ipcRenderer.invoke('mcp-remove', deleteTarget.mcp_key); - } + await mcpRemove(deleteTarget.mcp_key); setDeleteTarget(null); fetchList(); } finally { diff --git a/src/pages/Connectors/components/MCPMarket.tsx b/src/pages/Connectors/components/MCPMarket.tsx index e4ff96351..0c16c82b7 100644 --- a/src/pages/Connectors/components/MCPMarket.tsx +++ b/src/pages/Connectors/components/MCPMarket.tsx @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { mcpInstall, mcpRemove } from '@/api/brain'; import { proxyFetchDelete, proxyFetchGet, proxyFetchPost } from '@/api/http'; import githubIcon from '@/assets/github.svg'; import AnthropicIcon from '@/assets/mcp/Anthropic.svg?url'; @@ -218,13 +219,8 @@ export default function MCPMarket({ } setInstalled((prev) => ({ ...prev, [id]: true })); setInstalledIds((prev) => [...prev, id]); - // notify main process - if (window.ipcRenderer && mcpItem) { - await window.ipcRenderer.invoke( - 'mcp-install', - mcpItem.key, - mcpItem.install_command - ); + if (mcpItem?.install_command) { + await mcpInstall(mcpItem.key, mcpItem.install_command); } } catch (e) { console.error('Error installing MCP:', e); @@ -256,10 +252,7 @@ export default function MCPMarket({ } console.log('deleteTarget', deleteTarget); await proxyFetchDelete(`/api/v1/mcp/users/${id}`); - // notify main process - if (window.ipcRenderer) { - await window.ipcRenderer.invoke('mcp-remove', deleteTarget.key); - } + await mcpRemove(deleteTarget.key); setInstalledIds((prev) => prev.filter((item) => item !== deleteTarget.id) ); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index f3d7aaf25..5bd32b864 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -12,12 +12,13 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import { checkLocalServerStale } from '@/api/http'; +import { checkLocalServerStale, uploadFileToBrain } from '@/api/http'; import ChatBox from '@/components/ChatBox'; import Folder from '@/components/Folder'; import UpdateElectron from '@/components/update'; import Workflow from '@/components/WorkFlow'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; +import { useHost } from '@/host'; import { ChatTaskStatus } from '@/types/constants'; import { ReactFlowProvider } from '@xyflow/react'; import { AnimatePresence, motion } from 'framer-motion'; @@ -114,18 +115,15 @@ function ConnectionStatusIcon({ } export default function Home() { + const host = useHost(); const { t } = useTranslation(); - //Get Chatstore for the active project's task const { chatStore, projectStore } = useChatStoreAdapter(); const { - activeTab, activeWorkspaceTab, setActiveWorkspaceTab, chatPanelPosition, - hasTriggers, setHasTriggers, - hasAgentFiles, setHasAgentFiles, unviewedTabs, markTabAsUnviewed, @@ -134,7 +132,7 @@ export default function Home() { const { wsConnectionStatus, triggerReconnect } = useTriggerStore(); const authStore = useAuthStore.getState(); - const [activeWebviewId, setActiveWebviewId] = useState(null); + const [_activeWebviewId, setActiveWebviewId] = useState(null); const [isChatBoxVisible, setIsChatBoxVisible] = useState(true); const [addWorkerDialogOpen, setAddWorkerDialogOpen] = useState(false); const [triggerDialogOpen, setTriggerDialogOpen] = useState(false); @@ -151,27 +149,28 @@ export default function Home() { // Get the active project's folder path const activeProjectId = projectStore.activeProjectId; - if (!activeProjectId) return; + if (host?.ipcRenderer && !activeProjectId) return; - // Upload files using electron API + // Upload files using Electron API or Brain upload endpoint in pure Web mode. for (const file of Array.from(files)) { try { - const reader = new FileReader(); - reader.onload = async () => { - if (reader.result && window.ipcRenderer) { - await window.ipcRenderer.invoke('save-file-to-agent-folder', { + if (host?.ipcRenderer) { + const content = await file.arrayBuffer(); + if (activeProjectId) { + await host.ipcRenderer.invoke('save-file-to-agent-folder', { projectId: activeProjectId, fileName: file.name, - content: reader.result, + content, }); - // Mark the inbox tab as having new content - setHasAgentFiles(true); - if (activeWorkspaceTab !== 'inbox') { - markTabAsUnviewed('inbox'); - } } - }; - reader.readAsArrayBuffer(file); + } else { + await uploadFileToBrain(file); + } + // Mark the inbox tab as having new content + setHasAgentFiles(true); + if (activeWorkspaceTab !== 'inbox') { + markTabAsUnviewed('inbox'); + } } catch (error) { console.error('Error uploading file:', error); } @@ -181,17 +180,18 @@ export default function Home() { e.target.value = ''; }; - // One-time check: warn if local server is outdated after a git pull - useEffect(() => { - checkLocalServerStale(); - }, []); - // Detect files and triggers when project loads useEffect(() => { const detectAgentFiles = async () => { - if (!projectStore.activeProjectId || !authStore.email) return; + if ( + !projectStore.activeProjectId || + !authStore.email || + !host?.ipcRenderer + ) { + return; + } try { - const files = await window.ipcRenderer?.invoke( + const files = await host.ipcRenderer.invoke( 'get-project-file-list', authStore.email, projectStore.activeProjectId @@ -212,21 +212,24 @@ export default function Home() { authStore.email, setHasAgentFiles, setHasTriggers, + host, ]); - // Add webview-show listener in useEffect with cleanup + // One-time check: warn if local server is outdated after a git pull + useEffect(() => { + checkLocalServerStale(); + }, []); + useEffect(() => { + if (!host?.ipcRenderer) return; const handleWebviewShow = (_event: any, id: string) => { setActiveWebviewId(id); }; - - window.ipcRenderer?.on('webview-show', handleWebviewShow); - - // Cleanup: remove listener on unmount + host.ipcRenderer.on('webview-show', handleWebviewShow); return () => { - window.ipcRenderer?.off('webview-show', handleWebviewShow); + host.ipcRenderer?.off('webview-show', handleWebviewShow); }; - }, []); // Empty dependency array means this only runs once + }, [host]); // Extract complex dependency to a variable const taskAssigning = @@ -270,12 +273,15 @@ export default function Home() { // capture webview const captureWebview = async () => { + if (!host?.ipcRenderer) { + return; + } const activeTask = chatStore.tasks[chatStore.activeTaskId as string]; if (!activeTask || activeTask.status === ChatTaskStatus.FINISHED) { return; } webviews.map((webview) => { - window.ipcRenderer + host.ipcRenderer .invoke('capture-webview', webview.id) .then((base64: string) => { const currentTask = @@ -329,20 +335,21 @@ export default function Home() { clearInterval(intervalTimer); } }; - }, [chatStore, taskAssigning]); + }, [chatStore, taskAssigning, host]); const getSize = useCallback(() => { + if (!host?.electronAPI?.setSize) return; const webviewContainer = document.getElementById('webview-container'); if (webviewContainer) { const rect = webviewContainer.getBoundingClientRect(); - window.electronAPI.setSize({ + host.electronAPI.setSize({ x: rect.left, y: rect.top, width: rect.width, height: rect.height, }); } - }, []); + }, [host]); useEffect(() => { if (!chatStore) return; diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index fdd5a3c61..a44104dae 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -20,6 +20,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { proxyFetchGet, proxyFetchPost } from '@/api/http'; import WindowControls from '@/components/WindowControls'; +import { useHost } from '@/host'; import { hasStackKeys } from '@/lib'; import { useTranslation } from 'react-i18next'; @@ -31,6 +32,7 @@ const IS_LOCAL_MODE = import.meta.env.VITE_USE_LOCAL_PROXY === 'true'; let lock = false; export default function Login() { + const host = useHost(); // Always call hooks unconditionally - React Hooks must be called in the same order const stackApp = useStackApp(); const app = HAS_STACK_KEYS ? stackApp : null; @@ -215,6 +217,8 @@ export default function Login() { // Listen for direct token callback from Electron (eigent.ai login redirect) useEffect(() => { + if (!host?.ipcRenderer) return; + const handleTokenReceived = async (_event: any, token: string) => { if (!token) return; setIsLoading(true); @@ -243,43 +247,48 @@ export default function Login() { navigate('/'); }; - window.ipcRenderer?.on('auth-token-received', handleTokenReceived); + host.ipcRenderer.on('auth-token-received', handleTokenReceived); return () => { - window.ipcRenderer?.off('auth-token-received', handleTokenReceived); + host.ipcRenderer?.off('auth-token-received', handleTokenReceived); }; - }, [setAuth, setLocalProxyValue, navigate]); + }, [setAuth, setLocalProxyValue, navigate, host]); // Listen for auth code callback from Electron (Stack Auth OAuth flow) useEffect(() => { - window.ipcRenderer?.on('auth-code-received', handleAuthCode); - + if (!host?.ipcRenderer) return; + host.ipcRenderer.on('auth-code-received', handleAuthCode); return () => { - window.ipcRenderer?.off('auth-code-received', handleAuthCode); + host.ipcRenderer?.off('auth-code-received', handleAuthCode); }; - }, [handleAuthCode]); + }, [handleAuthCode, host]); useEffect(() => { - const p = window.electronAPI.getPlatform(); + if (!host?.electronAPI?.getPlatform) { + setPlatform('web'); + return; + } + const p = host.electronAPI.getPlatform(); setPlatform(p); - - if (platform === 'darwin') { + if (p === 'darwin') { titlebarRef.current?.classList.add('mac'); } - }, [platform]); + }, [host]); // Handle before-close event for login page useEffect(() => { + if (!host?.ipcRenderer || !host?.electronAPI) return; + const handleBeforeClose = () => { - window.electronAPI.closeWindow(true); + host.electronAPI.closeWindow(true); }; - window.ipcRenderer?.on('before-close', handleBeforeClose); + host.ipcRenderer.on('before-close', handleBeforeClose); return () => { - window.ipcRenderer?.off('before-close', handleBeforeClose); + host.ipcRenderer?.off('before-close', handleBeforeClose); }; - }, []); + }, [host]); // Hybrid/app mode: prepare auth callback URL on mount (don't auto-open browser) useEffect(() => { @@ -292,7 +301,7 @@ export default function Login() { } else { cbUrl = 'eigent://auth/callback'; try { - const url = await window.ipcRenderer?.invoke('get-auth-callback-url'); + const url = await host?.ipcRenderer?.invoke('get-auth-callback-url'); if (url) cbUrl = url; } catch (e) { // Fallback to eigent:// protocol @@ -302,7 +311,7 @@ export default function Login() { }; prepareCallbackUrl(); - }, []); + }, [host]); // Render local mode: "Start Eigent" button only const renderLocalMode = () => ( diff --git a/src/pages/Project/Workspace.tsx b/src/pages/Project/Workspace.tsx index b9b0cb8e8..6a638afcf 100644 --- a/src/pages/Project/Workspace.tsx +++ b/src/pages/Project/Workspace.tsx @@ -24,10 +24,12 @@ import { } from '@/components/ui/resizable'; import Workflow from '@/components/WorkFlow'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; +import { useHost } from '@/host'; import { ReactFlowProvider } from '@xyflow/react'; import { useCallback, useEffect, useState } from 'react'; export default function Tasks() { + const host = useHost(); //Get Chatstore for the active project's task const { chatStore, projectStore } = useChatStoreAdapter(); const [isChatBoxVisible, setIsChatBoxVisible] = useState(true); @@ -75,42 +77,43 @@ export default function Tasks() { return; } webviews.forEach((webview) => { - window.ipcRenderer - .invoke('capture-webview', webview.id) - .then((base64: string) => { - const currentTask = chatStore.tasks[activeTaskId as string]; - if (!currentTask || currentTask.type) return; - const currentTaskAssigning = [...currentTask.taskAssigning]; - const searchAgentIndex = currentTaskAssigning.findIndex( - (agent) => agent.agent_id === webview.agent_id - ); - - if ( - searchAgentIndex !== -1 && - base64 !== 'data:image/jpeg;base64,' - ) { - currentTaskAssigning[searchAgentIndex].activeWebviewIds![ - webview.index - ].img = base64; - chatStore.setTaskAssigning( - activeTaskId as string, - currentTaskAssigning + host?.ipcRenderer?.invoke && + host.ipcRenderer + .invoke('capture-webview', webview.id) + .then((base64: string) => { + const currentTask = chatStore.tasks[activeTaskId as string]; + if (!currentTask || currentTask.type) return; + const currentTaskAssigning = [...currentTask.taskAssigning]; + const searchAgentIndex = currentTaskAssigning.findIndex( + (agent) => agent.agent_id === webview.agent_id ); - const { processTaskId, url } = + + if ( + searchAgentIndex !== -1 && + base64 !== 'data:image/jpeg;base64,' + ) { currentTaskAssigning[searchAgentIndex].activeWebviewIds![ webview.index - ]; - chatStore.setSnapshotsTemp(activeTaskId as string, { - api_task_id: activeTaskId, - camel_task_id: processTaskId, - browser_url: url, - image_base64: base64, - }); - } - }) - .catch((error: unknown) => { - console.error('capture webview error:', error); - }); + ].img = base64; + chatStore.setTaskAssigning( + activeTaskId as string, + currentTaskAssigning + ); + const { processTaskId, url } = + currentTaskAssigning[searchAgentIndex].activeWebviewIds![ + webview.index + ]; + chatStore.setSnapshotsTemp(activeTaskId as string, { + api_task_id: activeTaskId, + camel_task_id: processTaskId, + browser_url: url, + image_base64: base64, + }); + } + }) + .catch((error: unknown) => { + console.error('capture webview error:', error); + }); }); }; @@ -128,13 +131,13 @@ export default function Tasks() { clearInterval(intervalTimer); } }; - }, [chatStore]); + }, [chatStore, host]); const getSize = useCallback(() => { const webviewContainer = document.getElementById('webview-container'); if (webviewContainer) { const rect = webviewContainer.getBoundingClientRect(); - window.electronAPI.setSize({ + host?.electronAPI?.setSize({ x: rect.left, y: rect.top, width: rect.width, @@ -142,7 +145,7 @@ export default function Tasks() { }); console.log('setSize', rect); } - }, []); + }, [host]); useEffect(() => { if (!chatStore.activeTaskId) { diff --git a/src/pages/Setting/General.tsx b/src/pages/Setting/General.tsx index 8ab1c96bf..ba4285950 100644 --- a/src/pages/Setting/General.tsx +++ b/src/pages/Setting/General.tsx @@ -17,6 +17,7 @@ import light from '@/assets/light.png'; import transparent from '@/assets/transparent.png'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { useHost } from '@/host'; import { LocaleEnum, switchLanguage } from '@/i18n'; import { useAuthStore } from '@/store/authStore'; import { useInstallationStore } from '@/store/installationStore'; @@ -37,6 +38,8 @@ import { import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; export default function SettingGeneral() { + const host = useHost(); + const electronAPI = host?.electronAPI; const { t } = useTranslation(); const authStore = useAuthStore(); @@ -81,7 +84,7 @@ export default function SettingGeneral() { const [proxyNeedsRestart, setProxyNeedsRestart] = useState(false); useEffect(() => { - const platform = window.electronAPI.getPlatform(); + const platform = electronAPI?.getPlatform?.() ?? 'web'; console.log(platform); const baseThemes = [ { @@ -108,7 +111,7 @@ export default function SettingGeneral() { } else { setThemeList(baseThemes); } - }, []); + }, [electronAPI]); const languageList = [ { @@ -160,9 +163,9 @@ export default function SettingGeneral() { useEffect(() => { // Load proxy configuration from global env const loadProxyConfig = async () => { - if (window.electronAPI?.readGlobalEnv) { + if (electronAPI?.readGlobalEnv) { try { - const result = await window.electronAPI.readGlobalEnv('HTTP_PROXY'); + const result = await electronAPI.readGlobalEnv('HTTP_PROXY'); if (result?.value) { setProxyUrl(result.value); } @@ -172,7 +175,7 @@ export default function SettingGeneral() { } }; loadProxyConfig(); - }, []); + }, [electronAPI]); // Save proxy configuration const handleSaveProxy = async () => { @@ -199,7 +202,7 @@ export default function SettingGeneral() { } } - if (!window.electronAPI?.envWrite || !window.electronAPI?.envRemove) { + if (!electronAPI?.envWrite || !electronAPI?.envRemove) { toast.error(t('setting.proxy-save-failed')); return; } @@ -207,13 +210,13 @@ export default function SettingGeneral() { setIsProxySaving(true); try { if (trimmed) { - const result = await window.electronAPI.envWrite(authStore.email, { + const result = await electronAPI.envWrite(authStore.email, { key: 'HTTP_PROXY', value: trimmed, }); if (!result?.success) throw new Error('envWrite returned no success'); } else { - const result = await window.electronAPI.envRemove( + const result = await electronAPI.envRemove( authStore.email, 'HTTP_PROXY' ); @@ -380,7 +383,7 @@ export default function SettingGeneral() { size="sm" onClick={ proxyNeedsRestart - ? () => window.electronAPI?.restartApp() + ? () => electronAPI?.restartApp() : handleSaveProxy } disabled={!proxyNeedsRestart && isProxySaving} diff --git a/src/pages/SignUp.tsx b/src/pages/SignUp.tsx index b1e84c396..d3e771cc5 100644 --- a/src/pages/SignUp.tsx +++ b/src/pages/SignUp.tsx @@ -28,6 +28,7 @@ import github2 from '@/assets/github2.svg'; import google from '@/assets/google.svg'; import eigentLogo from '@/assets/logo/eigent_icon.png'; import WindowControls from '@/components/WindowControls'; +import { useHost } from '@/host'; import { hasStackKeys } from '@/lib'; import { useTranslation } from 'react-i18next'; @@ -35,6 +36,7 @@ const HAS_STACK_KEYS = hasStackKeys(); const IS_LOCAL_MODE = import.meta.env.VITE_USE_LOCAL_PROXY === 'true'; let lock = false; export default function SignUp() { + const host = useHost(); // Always call hooks unconditionally - React Hooks must be called in the same order const stackApp = useStackApp(); const app = HAS_STACK_KEYS ? stackApp : null; @@ -279,21 +281,24 @@ export default function SignUp() { ); useEffect(() => { - window.ipcRenderer?.on('auth-code-received', handleAuthCode); - + if (!host?.ipcRenderer) return; + host.ipcRenderer.on('auth-code-received', handleAuthCode); return () => { - window.ipcRenderer?.off('auth-code-received', handleAuthCode); + host.ipcRenderer?.off('auth-code-received', handleAuthCode); }; - }, [handleAuthCode]); + }, [handleAuthCode, host]); useEffect(() => { - const p = window.electronAPI.getPlatform(); + if (!host?.electronAPI?.getPlatform) { + setPlatform('web'); + return; + } + const p = host.electronAPI.getPlatform(); setPlatform(p); - - if (platform === 'darwin') { + if (p === 'darwin') { titlebarRef.current?.classList.add('mac'); } - }, [platform]); + }, []); return (
diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index 3c404ea82..5454f2667 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -16,15 +16,16 @@ import { fetchDelete, fetchPost, fetchPut, - getBaseURL, proxyFetchGet, proxyFetchPost, proxyFetchPut, + sseTransport, uploadFile, waitForBackendReady, } from '@/api/http'; import { showCreditsToast } from '@/components/Toast/creditsToast'; import { showStorageToast } from '@/components/Toast/storageToast'; +import type { AppHost } from '@/host/types'; import { generateUniqueId, uploadLog } from '@/lib'; import { proxyUpdateTriggerExecution } from '@/service/triggerApi'; import { ExecutionStatus } from '@/types'; @@ -36,7 +37,6 @@ import { TaskStatus, type ChatTaskStatusType, } from '@/types/constants'; -import { fetchEventSource } from '@microsoft/fetch-event-source'; import { FileText } from 'lucide-react'; import { toast } from 'sonner'; import { createStore } from 'zustand'; @@ -44,6 +44,47 @@ import { getAuthStore, getWorkerList } from './authStore'; import { usePageTabStore } from './pageTabStore'; import { useProjectStore } from './projectStore'; +let _host: AppHost | null = null; + +export function injectHost(host: AppHost | null): void { + _host = host; +} + +function normalizeServerApiBaseUrl(url?: string): string | undefined { + if (!url) { + return undefined; + } + + const trimmed = url.replace(/\/$/, ''); + if (!trimmed) { + return undefined; + } + + if (trimmed.endsWith('/api/v1')) { + return trimmed; + } + + return `${trimmed}/api/v1`; +} + +function getDirectServerApiBaseUrl(): string | undefined { + if (import.meta.env.DEV) { + return normalizeServerApiBaseUrl( + import.meta.env.VITE_PROXY_URL || 'http://localhost:3001' + ); + } + + return normalizeServerApiBaseUrl(import.meta.env.VITE_BASE_URL); +} + +function getHostElectronAPI() { + return _host?.electronAPI ?? null; +} + +function getHostIpcRenderer() { + return _host?.ipcRenderer ?? null; +} + interface Task { messages: Message[]; type: string; @@ -535,10 +576,15 @@ const chatStore = (initial?: Partial) => setDelayTime, setType, } = get(); - const baseURL = await getBaseURL(); let systemLanguage = language; if (language === 'system') { - systemLanguage = await window.ipcRenderer.invoke('get-system-language'); + try { + systemLanguage = + (await getHostIpcRenderer()?.invoke?.('get-system-language')) ?? + 'en'; + } catch { + systemLanguage = 'en'; + } } if (type === 'replay') { setDelayTime(taskId, delayTime as number); @@ -582,15 +628,16 @@ const chatStore = (initial?: Partial) => } } - const base_Url = import.meta.env.DEV - ? import.meta.env.VITE_PROXY_URL + // Replay/share APIs live on the server side, not Brain. + const serverBaseUrl = import.meta.env.DEV + ? window.location.origin : import.meta.env.VITE_BASE_URL; const api = type == 'share' - ? `${base_Url}/api/v1/chat/share/playback/${shareToken}?delay_time=${delayTime}` + ? `${serverBaseUrl}/api/v1/chat/share/playback/${shareToken}?delay_time=${delayTime}` : type == 'replay' - ? `${base_Url}/api/v1/chat/steps/playback/${newTaskId}?delay_time=${delayTime}` - : `${baseURL}/chat`; + ? `${serverBaseUrl}/api/v1/chat/steps/playback/${newTaskId}?delay_time=${delayTime}` + : '/chat'; const { tasks: _tasks } = get(); let historyId: string | null = projectStore.getHistoryId(project_id); @@ -702,10 +749,11 @@ const chatStore = (initial?: Partial) => }; }); - // get env path + // get env path (Electron only) let envPath = ''; try { - envPath = await window.ipcRenderer.invoke('get-env-path', email); + envPath = + (await getHostIpcRenderer()?.invoke?.('get-env-path', email)) ?? ''; } catch (error) { console.log('get-env-path error', error); } @@ -744,8 +792,15 @@ const chatStore = (initial?: Partial) => projectStore.setHistoryId(project_id, historyId); }); } - const browser_port = await window.ipcRenderer.invoke('get-browser-port'); - const cdp_browsers = await window.ipcRenderer.invoke('get-cdp-browsers'); + let browser_port: number | undefined; + let cdp_browsers: any[] = []; + try { + browser_port = await getHostIpcRenderer()?.invoke?.('get-browser-port'); + cdp_browsers = + (await getHostIpcRenderer()?.invoke?.('get-cdp-browsers')) ?? []; + } catch { + // Web mode: no CDP + } // Lock the chatStore reference at the start of SSE session to prevent focus changes // during active message processing @@ -788,47 +843,47 @@ const chatStore = (initial?: Partial) => lockedTaskId = newTaskId; }; - const ssePromise = fetchEventSource(api, { + const requestBody = !type + ? { + project_id: project_id, + task_id: newTaskId, + question: + messageContent || + targetChatStore.getState().getLastUserMessage()?.content, + model_platform: apiModel.model_platform, + email, + model_type: apiModel.model_type, + api_key: apiModel.api_key, + api_url: apiModel.api_url, + extra_params: apiModel.extra_params, + installed_mcp: { mcpServers: {} }, + language: systemLanguage, + allow_local_system: true, + attaches: ( + messageAttaches || + targetChatStore.getState().tasks[newTaskId]?.attaches || + [] + ).map((f) => f.filePath), + summary_prompt: ``, + new_agents: [...addWorkers], + browser_port: browser_port, + cdp_browsers: cdp_browsers, + env_path: envPath, + search_config: searchConfig, + server_url: getDirectServerApiBaseUrl(), + } + : undefined; + + const ssePromise = sseTransport({ + url: api, method: !type ? 'POST' : 'GET', openWhenHidden: true, - signal: abortController.signal, // Add abort signal for proper cleanup - headers: { - 'Content-Type': 'application/json', - Authorization: - type == 'replay' - ? `Bearer ${token}` - : (undefined as unknown as string), - }, - body: !type - ? JSON.stringify({ - project_id: project_id, - task_id: newTaskId, - question: - messageContent || - targetChatStore.getState().getLastUserMessage()?.content, - model_platform: apiModel.model_platform, - email, - model_type: apiModel.model_type, - api_key: apiModel.api_key, - api_url: apiModel.api_url, - extra_params: apiModel.extra_params, - installed_mcp: { mcpServers: {} }, - language: systemLanguage, - allow_local_system: true, - attaches: ( - messageAttaches || - targetChatStore.getState().tasks[newTaskId]?.attaches || - [] - ).map((f) => f.filePath), - summary_prompt: ``, - new_agents: [...addWorkers], - browser_port: browser_port, - cdp_browsers: cdp_browsers, - env_path: envPath, - search_config: searchConfig, - }) - : undefined, - + signal: abortController.signal, + body: requestBody, + extraHeaders: + type == 'replay' && token + ? { Authorization: `Bearer ${token}` } + : undefined, async onmessage(event: any) { let agentMessages: AgentMessage; @@ -851,6 +906,29 @@ const chatStore = (initial?: Partial) => return; } + if ( + agentMessages && + typeof agentMessages === 'object' && + 'error' in agentMessages && + !('step' in agentMessages) + ) { + const currentStore = getCurrentChatStore(); + const currentTaskId = getCurrentTaskId(); + const errorText = + typeof (agentMessages as any).error === 'string' + ? (agentMessages as any).error + : 'Replay data is unavailable for this task.'; + + currentStore.addMessages(currentTaskId, { + id: generateUniqueId(), + role: 'agent', + content: errorText, + }); + currentStore.setIsPending(currentTaskId, false); + currentStore.setStatus(currentTaskId, ChatTaskStatus.FINISHED); + return; + } + // Check if this task has been stopped before processing any message // But allow messages that switch to new tasks (like confirmed events) const lockedTaskId = getCurrentTaskId(); @@ -1417,7 +1495,7 @@ const chatStore = (initial?: Partial) => let removeList: number[] = []; item.activeWebviewIds.map((webview, index) => { if (webview.processTaskId === task_id) { - window.electronAPI.webviewDestroy(webview.id); + getHostElectronAPI()?.webviewDestroy?.(webview.id); removeList.push(index); } }); @@ -2229,16 +2307,21 @@ const chatStore = (initial?: Partial) => ) ); - // Async file upload - let res = await window.ipcRenderer.invoke( - 'get-file-list', - email, - currentTaskId, - (project_id || projectStore.activeProjectId) as string - ); + // Async file upload (Electron only; skip in Web mode) + let res: any[] = []; + const hostIpcRenderer = getHostIpcRenderer(); + if (hostIpcRenderer?.invoke) { + res = await hostIpcRenderer.invoke( + 'get-file-list', + email, + currentTaskId, + (project_id || projectStore.activeProjectId) as string + ); + } if ( !type && import.meta.env.VITE_USE_LOCAL_PROXY !== 'true' && + Array.isArray(res) && res.length > 0 ) { // Upload files sequentially to avoid overwhelming the server @@ -2248,7 +2331,7 @@ const chatStore = (initial?: Partial) => .map(async (file: any) => { try { // Read file content using Electron API - const result = await window.ipcRenderer.invoke( + const result = await hostIpcRenderer.invoke( 'read-file', file.path ); @@ -2310,13 +2393,25 @@ const chatStore = (initial?: Partial) => } if (!type && historyId) { - const obj = { - project_name: tasks[currentTaskId].summaryTask.split('|')[0], - summary: tasks[currentTaskId].summaryTask.split('|')[1], - status: 2, - tokens: getTokens(currentTaskId), - }; - proxyFetchPut(`/api/v1/chat/history/${historyId}`, obj); + try { + const st = tasks[currentTaskId].summaryTask || ''; + const parts = st.split('|'); + const completionSummary = + (typeof agentMessages.data === 'string' + ? agentMessages.data + : '') || + parts[1] || + ''; + const obj = { + project_name: parts[0] || '', + summary: completionSummary, + status: 2, + tokens: getTokens(currentTaskId), + }; + proxyFetchPut(`/api/v1/chat/history/${historyId}`, obj); + } catch (e) { + console.warn('History update failed on END:', e); + } } uploadLog(currentTaskId, type); @@ -2350,7 +2445,7 @@ const chatStore = (initial?: Partial) => setTaskAssigning(currentTaskId, [...taskAssigning]); setTaskRunning(currentTaskId, [...taskRunning]); - if (!currentTaskId || !tasks[currentTaskId]) return 'N/A'; + if (!currentTaskId || !tasks[currentTaskId]) return; const task = tasks[currentTaskId]; let taskTime = task.taskTime; @@ -2586,6 +2681,17 @@ const chatStore = (initial?: Partial) => // Server closes connection onclose() { console.log('SSE connection closed'); + if (type) { + const currentStore = getCurrentChatStore(); + const currentTaskId = getCurrentTaskId(); + const currentTask = currentStore.tasks[currentTaskId]; + if (currentTask?.isPending) { + currentStore.setIsPending(currentTaskId, false); + } + if (currentTask && currentTask.status !== ChatTaskStatus.FINISHED) { + currentStore.setStatus(currentTaskId, ChatTaskStatus.FINISHED); + } + } // Abort to resolve fetchEventSource promise (for replay/load - allows awaiting completion) try { abortController.abort(); @@ -3316,14 +3422,16 @@ const chatStore = (initial?: Partial) => console.error('Error during SSE cleanup in clearTasks:', error); } - window.ipcRenderer - .invoke('restart-backend') - .then((res: unknown) => { - console.log('restart-backend', res); - }) - .catch((error: unknown) => { - console.error('Error in clearTasks cleanup:', error); - }); + const restartPromise = getHostIpcRenderer()?.invoke?.('restart-backend'); + if (restartPromise) { + restartPromise + .then((res: unknown) => { + console.log('restart-backend', res); + }) + .catch((error: unknown) => { + console.error('Error in clearTasks cleanup:', error); + }); + } // Immediately create new task to maintain UI responsiveness const newTaskId = create(); diff --git a/src/store/connectionStore.ts b/src/store/connectionStore.ts new file mode 100644 index 000000000..82249cc6a --- /dev/null +++ b/src/store/connectionStore.ts @@ -0,0 +1,104 @@ +// ========= 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. ========= + +/** + * Connection config for Phase 2: Brain endpoint, channel, session. + * Used by ConnectionProvider and http.ts getBaseURL. + */ + +export type ConnectionChannel = + | 'desktop' + | 'web' + | 'cli' + | 'whatsapp' + | 'telegram' + | 'slack' + | 'discord' + | 'lark' + | 'browser_extension'; + +export interface ConnectionConfig { + brainEndpoint: string; + channel: ConnectionChannel; + sessionId?: string; + authToken?: string; +} + +const SESSION_STORAGE_KEY = 'eigent_session_id'; + +function isWebRuntime(): boolean { + if (typeof window === 'undefined') { + return false; + } + const win = window as Window & { + electronAPI?: unknown; + ipcRenderer?: unknown; + }; + return !win.electronAPI && !win.ipcRenderer; +} + +function readStoredSessionId(): string | undefined { + if (!isWebRuntime()) { + return undefined; + } + + try { + return localStorage.getItem(SESSION_STORAGE_KEY) || undefined; + } catch { + return undefined; + } +} + +function persistStoredSessionId(sessionId?: string): void { + if (!isWebRuntime()) { + return; + } + + try { + if (sessionId) { + localStorage.setItem(SESSION_STORAGE_KEY, sessionId); + } else { + localStorage.removeItem(SESSION_STORAGE_KEY); + } + } catch { + // Ignore storage failures in private mode or restricted environments. + } +} + +let connectionConfig: ConnectionConfig = { + brainEndpoint: '', + channel: 'desktop', + sessionId: readStoredSessionId(), +}; + +export function setConnectionConfig(config: Partial): void { + connectionConfig = { ...connectionConfig, ...config }; + + if (connectionConfig.channel === 'web' && 'sessionId' in config) { + persistStoredSessionId(connectionConfig.sessionId); + } +} + +export function getConnectionConfig(): ConnectionConfig { + return { ...connectionConfig }; +} + +export function resetConnectionConfig(): void { + connectionConfig = { + brainEndpoint: '', + channel: 'desktop', + sessionId: undefined, + }; + persistStoredSessionId(undefined); +} diff --git a/src/store/installationStore.ts b/src/store/installationStore.ts index f750ee8dd..8e2a56ccc 100644 --- a/src/store/installationStore.ts +++ b/src/store/installationStore.ts @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { createHost } from '@/host'; import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; @@ -76,6 +77,8 @@ const initialState = { needsBackendRestart: false, }; +const getElectronAPI = () => createHost().electronAPI; + // Create the installation store export const useInstallationStore = create()( subscribeWithSelector((set, get) => ({ @@ -154,6 +157,7 @@ export const useInstallationStore = create()( retryBackend: async () => { try { + const electronAPI = getElectronAPI(); // Clear backend error and go back to waiting-backend state set({ backendError: undefined, @@ -163,9 +167,9 @@ export const useInstallationStore = create()( }); // Call restart-backend via electronAPI - const result = await window.electronAPI.restartBackend(); + const result = await electronAPI?.restartBackend(); - if (!result.success) { + if (!result?.success) { set({ backendError: result.error || 'Failed to restart backend', state: 'error', @@ -200,9 +204,10 @@ export const useInstallationStore = create()( try { startInstallation(); - const result = await window.electronAPI.checkAndInstallDepsOnUpdate(); + const electronAPI = getElectronAPI(); + const result = await electronAPI?.checkAndInstallDepsOnUpdate(); - if (result.success) { + if (result?.success) { // Keep waiting state until useInstallationSetup confirms backend readiness. setWaitingBackend(); } else { @@ -214,8 +219,10 @@ export const useInstallationStore = create()( }, exportLog: async () => { + const electronAPI = getElectronAPI(); + if (!electronAPI?.exportLog) return; try { - const response = await window.electronAPI.exportLog(); + const response = await electronAPI.exportLog(); if (!response.success) { alert('Export cancelled: ' + response.error); diff --git a/src/store/skillsStore.ts b/src/store/skillsStore.ts index 51a5916e4..77032c0d5 100644 --- a/src/store/skillsStore.ts +++ b/src/store/skillsStore.ts @@ -12,6 +12,16 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { + skillConfigDelete as brainSkillConfigDelete, + skillConfigInit as brainSkillConfigInit, + skillConfigLoad as brainSkillConfigLoad, + skillConfigToggle as brainSkillConfigToggle, + skillConfigUpdate as brainSkillConfigUpdate, + skillDelete as brainSkillDelete, + skillsScan as brainSkillsScan, + skillWrite as brainSkillWrite, +} from '@/api/brain'; import { buildSkillMd, hasSkillsFsApi, @@ -90,9 +100,12 @@ export const useSkillsStore = create()( const content = buildSkillMd(name, description, body); const dirName = skill.skillDirName || skillNameToDirName(name || 'skill'); - window.electronAPI.skillWrite(dirName, content).catch(() => { - // Ignore errors here; UI still holds the in-memory skill - }); + try { + await brainSkillWrite(dirName, content); + } catch (e) { + console.warn('[Skills] brainSkillWrite failed:', e); + // Ignore; UI still holds the in-memory skill + } skill = { ...skill, filePath: `${dirName}/SKILL.md`, @@ -108,24 +121,20 @@ export const useSkillsStore = create()( isExample: false, }; - // Update local configuration via Electron IPC + // Update local configuration via Brain REST API if (hasSkillsFsApi()) { try { const userId = emailToUserId(useAuthStore.getState().email); if (userId) { - await window.electronAPI.skillConfigUpdate( - userId, - newSkill.name, - { - enabled: newSkill.enabled, - scope: newSkill.scope, - addedAt: newSkill.addedAt, - isExample: false, - } - ); + await brainSkillConfigUpdate(userId, newSkill.name, { + enabled: newSkill.enabled, + scope: newSkill.scope, + addedAt: newSkill.addedAt, + isExample: false, + }); } } catch (error) { - console.warn('Failed to update skill config:', error); + console.warn('[Skills] Failed to update skill config:', error); // Continue anyway - skill is added to UI } } @@ -155,7 +164,7 @@ export const useSkillsStore = create()( if (!userId) return; const updatedSkill = { ...skill, ...updates }; - await window.electronAPI.skillConfigUpdate(userId, skill.name, { + await brainSkillConfigUpdate(userId, skill.name, { enabled: updatedSkill.enabled, scope: updatedSkill.scope, addedAt: updatedSkill.addedAt, @@ -182,22 +191,25 @@ export const useSkillsStore = create()( // Example skills cannot be deleted, only enabled/disabled if (current.isExample) return; - // Delete from filesystem + // Delete from filesystem via Brain REST API if (current.skillDirName && hasSkillsFsApi()) { - window.electronAPI.skillDelete(current.skillDirName).catch(() => { - // Ignore deletion errors; state will still be updated - }); + try { + await brainSkillDelete(current.skillDirName); + } catch (e) { + console.warn('[Skills] brainSkillDelete failed:', e); + // Ignore; state will still be updated + } } - // Delete from local configuration via Electron IPC + // Delete from local configuration via Brain REST API if (hasSkillsFsApi()) { try { const userId = emailToUserId(useAuthStore.getState().email); if (userId) { - await window.electronAPI.skillConfigDelete(userId, current.name); + await brainSkillConfigDelete(userId, current.name); } } catch (error) { - console.warn('Failed to delete skill config:', error); + console.warn('[Skills] Failed to delete skill config:', error); // Continue anyway - skill is removed from UI } } @@ -220,20 +232,18 @@ export const useSkillsStore = create()( ), })); - // Persist to local configuration via Electron IPC + // Persist to local configuration via Brain REST API if (hasSkillsFsApi()) { try { const userId = emailToUserId(useAuthStore.getState().email); if (userId) { - const result = await window.electronAPI.skillConfigToggle( + const result = await brainSkillConfigToggle( userId, skill.name, newEnabled ); if (!result.success) { - throw new Error( - result.error || 'Failed to toggle skill configuration' - ); + throw new Error('Failed to toggle skill configuration'); } console.log('Skill configuration updated:', result); } @@ -253,32 +263,32 @@ export const useSkillsStore = create()( return get().skills.filter((skill) => skill.isExample === isExample); }, - // Load skills from ~/.eigent/skills + // Load skills from ~/.eigent/skills via Brain REST API syncFromDisk: async () => { if (!hasSkillsFsApi()) return; try { const userId = emailToUserId(useAuthStore.getState().email); - const result = await window.electronAPI.skillsScan(); + const result = await brainSkillsScan(); if (!result.success || !result.skills) return; if (userId) { console.log(`[Skills] Initializing config for user: ${userId}`); - await window.electronAPI.skillConfigInit(userId); + await brainSkillConfigInit(userId); } let config: any = { global: null, project: null }; try { if (userId) { console.log(`[Skills] Loading config for user: ${userId}`); - const result = await window.electronAPI.skillConfigLoad(userId); - if (result.success && result.config) { - config.global = result.config; + const loadResult = await brainSkillConfigLoad(userId); + if (loadResult.success && loadResult.config) { + config.global = loadResult.config; console.log( - `[Skills] Loaded config with ${Object.keys(result.config.skills || {}).length} skills configured` + `[Skills] Loaded config with ${Object.keys(loadResult.config.skills || {}).length} skills configured` ); } else { - console.warn('[Skills] Failed to load config:', result.error); + console.warn('[Skills] Failed to load config'); } } else { console.warn( @@ -314,11 +324,7 @@ export const useSkillsStore = create()( addedAt, isExample, }; - await window.electronAPI.skillConfigUpdate( - userId, - s.name, - newSkillConfig - ); + await brainSkillConfigUpdate(userId, s.name, newSkillConfig); // Update in-memory config so subsequent skills in same sync see it if (!config.global) config.global = { skills: {} }; if (!config.global.skills) config.global.skills = {}; diff --git a/src/types/chatbox.d.ts b/src/types/chatbox.d.ts index 40c7d1a72..2103edf87 100644 --- a/src/types/chatbox.d.ts +++ b/src/types/chatbox.d.ts @@ -65,6 +65,8 @@ declare global { interface File { fileName: string; filePath: string; + fileId?: string; + source?: 'local' | 'upload'; } type AgentStatus = AgentStatusType; diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 778f4a51c..d4da6455d 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -145,68 +145,7 @@ interface ElectronAPI { folderPath: string, ide: string ) => Promise<{ success: boolean; error?: string }>; - // Skills (~/.eigent/skills) - getSkillsDir: () => Promise<{ - success: boolean; - path?: string; - error?: string; - }>; - skillsScan: () => Promise<{ - success: boolean; - skills?: Array<{ - name: string; - description: string; - path: string; - scope: string; - skillDirName: string; - isExample: boolean; - }>; - error?: string; - }>; - skillWrite: ( - skillDirName: string, - content: string - ) => Promise<{ success: boolean; error?: string }>; - skillDelete: ( - skillDirName: string - ) => Promise<{ success: boolean; error?: string }>; - skillRead: ( - filePath: string - ) => Promise<{ success: boolean; content?: string; error?: string }>; - skillListFiles: ( - skillDirName: string - ) => Promise<{ success: boolean; files?: string[]; error?: string }>; - skillImportZip: ( - zipPathOrBuffer: string | ArrayBuffer, - replacements?: string[] - ) => Promise<{ - success: boolean; - error?: string; - conflicts?: Array<{ folderName: string; skillName: string }>; - }>; - openSkillFolder: ( - skillName: string - ) => Promise<{ success: boolean; error?: string }>; - skillConfigInit: ( - userId: string - ) => Promise<{ success: boolean; config?: any; error?: string }>; - skillConfigLoad: ( - userId: string - ) => Promise<{ success: boolean; config?: any; error?: string }>; - skillConfigToggle: ( - userId: string, - skillName: string, - enabled: boolean - ) => Promise<{ success: boolean; config?: any; error?: string }>; - skillConfigUpdate: ( - userId: string, - skillName: string, - skillConfig: any - ) => Promise<{ success: boolean; error?: string }>; - skillConfigDelete: ( - userId: string, - skillName: string - ) => Promise<{ success: boolean; error?: string }>; + // Skills: all operations via Brain REST API setBrowserPort: (port: number, isExternal?: boolean) => Promise; getBrowserPort: () => Promise; getCdpBrowsers: () => Promise; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index f54f1d40e..335f13816 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -15,7 +15,7 @@ /// interface Window { - // expose in the `electron/preload/index.ts` - ipcRenderer: import('electron').IpcRenderer; - electronAPI: import('electron').ElectronAPI; + // Expose in Electron preload. Optional in Web mode (no Electron). + ipcRenderer?: import('electron').IpcRenderer; + electronAPI?: import('electron').ElectronAPI; } diff --git a/test/unit/api/http.test.ts b/test/unit/api/http.test.ts new file mode 100644 index 000000000..781e49d47 --- /dev/null +++ b/test/unit/api/http.test.ts @@ -0,0 +1,93 @@ +// ========= 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 { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/store/authStore', () => ({ + getAuthStore: () => ({ token: null }), +})); + +const showCreditsToast = vi.fn(); +const showStorageToast = vi.fn(); +const showTrafficToast = vi.fn(); + +vi.mock('@/components/Toast/creditsToast', () => ({ + showCreditsToast, +})); + +vi.mock('@/components/Toast/storageToast', () => ({ + showStorageToast, +})); + +vi.mock('@/components/Toast/trafficToast', () => ({ + showTrafficToast, +})); + +import { fetchPost, getBaseURL } from '@/api/http'; +import { + resetConnectionConfig, + setConnectionConfig, +} from '@/store/connectionStore'; + +describe('api/http handleResponse', () => { + beforeEach(() => { + resetConnectionConfig(); + setConnectionConfig({ + brainEndpoint: 'http://brain.local', + channel: 'web', + }); + showCreditsToast.mockClear(); + showStorageToast.mockClear(); + showTrafficToast.mockClear(); + vi.restoreAllMocks(); + }); + + it('throws for non-JSON error responses instead of returning stream object', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('bad gateway', { + status: 502, + headers: { 'content-type': 'text/html' }, + }) + ); + + await expect(fetchPost('/chat', { question: 'x' })).rejects.toThrow(); + }); + + it('keeps code-based handling reachable for non-OK JSON responses', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ code: 20, text: 'insufficient credits' }), { + status: 402, + headers: { 'content-type': 'application/json' }, + }) + ); + + const res = await fetchPost('/chat', { question: 'x' }); + expect(res.code).toBe(20); + expect(showCreditsToast).toHaveBeenCalledTimes(1); + }); +}); + +describe('api/http getBaseURL', () => { + beforeEach(() => { + resetConnectionConfig(); + }); + + it('uses latest connection config endpoint without stale module cache', async () => { + setConnectionConfig({ brainEndpoint: 'http://localhost:5001' }); + await expect(getBaseURL()).resolves.toBe('http://localhost:5001'); + + setConnectionConfig({ brainEndpoint: 'http://localhost:5002' }); + await expect(getBaseURL()).resolves.toBe('http://localhost:5002'); + }); +}); diff --git a/test/unit/lib/htmlFontStyles.test.ts b/test/unit/lib/htmlFontStyles.test.ts index 7cb632dc2..e92116c81 100644 --- a/test/unit/lib/htmlFontStyles.test.ts +++ b/test/unit/lib/htmlFontStyles.test.ts @@ -58,6 +58,7 @@ describe('deferInlineScriptsUntilLoad', () => { expect(output).toContain("document.createElement('script')"); expect(output).toContain('window.shared = 1;'); + expect(output).toContain('Deferred inline script execution failed'); }); it('does not rewrite non-javascript script types', () => { diff --git a/tsconfig.node.json b/tsconfig.node.json index 1e7e7d601..fe7b54f77 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -6,5 +6,5 @@ "resolveJsonModule": true, "allowSyntheticDefaultImports": true }, - "include": ["vite.config.ts", "package.json"] + "include": ["vite.config.ts", "vite.config.web.ts", "package.json"] } diff --git a/vite.config.ts b/vite.config.ts index a136bcae2..3f03b6adf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -109,15 +109,16 @@ export default defineConfig(({ command, mode }) => { return { host: url.hostname, port: +url.port, - proxy: { - '/api': { - target: env.VITE_PROXY_URL, - changeOrigin: true, - // rewrite: path => path.replace(/^\/api/, ''), - }, - }, }; })()), + proxy: env.VITE_PROXY_URL + ? { + '/api': { + target: env.VITE_PROXY_URL, + changeOrigin: true, + }, + } + : undefined, clearScreen: false, }, }; diff --git a/vite.config.web.ts b/vite.config.web.ts new file mode 100644 index 000000000..7a3b0c41e --- /dev/null +++ b/vite.config.web.ts @@ -0,0 +1,52 @@ +// ========= 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. ========= +// Web-only Vite config for frontend-backend separation (no Electron). +// Usage: npm run dev:web | npm run build:web + +import react from '@vitejs/plugin-react'; +import path from 'node:path'; +import { defineConfig, loadEnv } from 'vite'; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + return { + resolve: { + alias: { + '@': path.join(__dirname, 'src'), + }, + }, + plugins: [react()], + optimizeDeps: { + exclude: ['@stackframe/react'], + force: true, + }, + build: { + outDir: 'dist-web', + emptyOutDir: true, + sourcemap: true, + }, + server: { + port: 5173, + open: false, + proxy: env.VITE_PROXY_URL + ? { + '/api': { + target: env.VITE_PROXY_URL, + changeOrigin: true, + }, + } + : undefined, + }, + }; +}); From 68e171b89528ebb70603927d3d4c0988af20d365 Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Sun, 5 Apr 2026 10:21:55 +0800 Subject: [PATCH 02/10] fix: improve web browser/login flow and handle duplicate agent files --- backend/app/agent/factory/browser.py | 22 +- backend/app/controller/chat_controller.py | 122 ++++--- backend/app/controller/tool_controller.py | 307 +++++++++++++++++- backend/app/utils/browser_launcher.py | 38 +++ .../tests/app/agent/factory/test_browser.py | 53 +++ .../app/controller/test_chat_controller.py | 72 +++- .../app/controller/test_tool_controller.py | 101 ++++++ backend/tests/conftest.py | 2 + src/components/Folder/index.tsx | 289 ++++++++++++----- src/pages/Browser/CDP.tsx | 74 +++-- src/pages/Login.tsx | 81 ++++- src/pages/loginUtils.ts | 25 ++ test/unit/components/Folder/FileTree.test.tsx | 130 +++++++- test/unit/pages/Browser/CDP.test.tsx | 124 +++++++ test/unit/pages/loginUtils.test.ts | 41 +++ 15 files changed, 1311 insertions(+), 170 deletions(-) create mode 100644 src/pages/loginUtils.ts create mode 100644 test/unit/pages/Browser/CDP.test.tsx create mode 100644 test/unit/pages/loginUtils.test.ts diff --git a/backend/app/agent/factory/browser.py b/backend/app/agent/factory/browser.py index b0550d092..80c3caf94 100644 --- a/backend/app/agent/factory/browser.py +++ b/backend/app/agent/factory/browser.py @@ -15,6 +15,7 @@ import platform import threading import uuid +from urllib.parse import urlparse from camel.messages import BaseMessage from camel.toolkits import ToolkitMessageIntegration @@ -172,6 +173,7 @@ def browser_agent( selected_port = None selected_is_external = False cdp_url = None + cdp_owned_by_hands = False if use_browser and options.cdp_browsers: selected_browser = _cdp_pool_manager.acquire_browser( @@ -196,13 +198,24 @@ def browser_agent( ) cdp_url = f"http://localhost:{selected_port}" elif use_browser: + existing_cdp_url = env("EIGENT_CDP_URL", "").strip() selected_port = env("browser_port", "9222") cdp_url = f"http://localhost:{selected_port}" - if hands is not None: + + if existing_cdp_url: + cdp_url = existing_cdp_url + try: + parsed = urlparse(existing_cdp_url) + if parsed.port is not None: + selected_port = parsed.port + except Exception: + selected_port = env("browser_port", "9222") + elif hands is not None: try: cdp_url = hands.acquire_resource( "browser", toolkit_session_id, port=selected_port ) + cdp_owned_by_hands = True except (NotImplementedError, ValueError): cdp_url = f"http://localhost:{selected_port}" @@ -401,7 +414,11 @@ def release_cdp_from_agent(agent_instance): f"Released CDP for agent {agent_instance.agent_id}: " f"port={port}, session={session_id}" ) - elif session_id is not None and hands is not None: + elif ( + session_id is not None + and hands is not None + and getattr(agent_instance, "_cdp_owned_by_hands", False) + ): try: hands.release_resource("browser", session_id) except Exception as exc: @@ -423,5 +440,6 @@ def release_cdp_from_agent(agent_instance): agent._cdp_task_id = options.task_id agent._cdp_options = options agent._browser_toolkit = web_toolkit_for_agent_registration + agent._cdp_owned_by_hands = cdp_owned_by_hands return agent diff --git a/backend/app/controller/chat_controller.py b/backend/app/controller/chat_controller.py index 396ea589a..ba681233a 100644 --- a/backend/app/controller/chat_controller.py +++ b/backend/app/controller/chat_controller.py @@ -13,6 +13,7 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import asyncio +import inspect import logging import os import re @@ -53,7 +54,10 @@ set_current_task_id, task_locks, ) -from app.utils.browser_launcher import ensure_cdp_browser_available +from app.utils.browser_launcher import ( + ensure_cdp_browser_available, + is_cdp_url_available, +) router = APIRouter() @@ -64,6 +68,74 @@ SSE_TIMEOUT_SECONDS = 60 * 60 +def _is_remote_browser_hands(request: Request | None) -> bool: + hands = getattr(getattr(request, "state", None), "hands", None) + 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 = 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" + + +async def _prepare_browser_for_request( + request: Request | None, + port: int, +) -> bool: + existing_cdp_url = os.environ.get("EIGENT_CDP_URL", "").strip() + if existing_cdp_url: + is_available = await asyncio.to_thread( + is_cdp_url_available, existing_cdp_url + ) + if is_available: + if request is not None: + request.state.browser_available = True + return True + os.environ.pop("EIGENT_CDP_URL", None) + + if _is_remote_browser_hands(request): + if request is not None: + request.state.browser_available = True + return True + + try: + launched = await asyncio.to_thread(ensure_cdp_browser_available, port) + except Exception as e: + os.environ.pop("EIGENT_CDP_URL", None) + chat_logger.warning( + "Could not ensure CDP browser for web mode", + extra={"error": str(e), "port": port}, + ) + if request is not None: + request.state.browser_available = False + return False + + if launched: + os.environ["EIGENT_CDP_URL"] = f"http://127.0.0.1:{port}" + if request is not None: + request.state.browser_available = True + return True + + os.environ.pop("EIGENT_CDP_URL", None) + chat_logger.warning( + "CDP browser not available after ensure attempt", + extra={"port": port}, + ) + if request is not None: + request.state.browser_available = False + return False + + async def _cleanup_task_lock_safe(task_lock, reason: str) -> bool: """Safely cleanup task lock with existence check. @@ -194,31 +266,10 @@ async def start_chat_stream(data: Chat, request: Request): # through Chat / request context instead of mutating the process environment. os.environ["file_save_path"] = data.file_save_path() os.environ["browser_port"] = str(data.browser_port) - # Web mode: when no CDP browsers from Electron, Brain launches Chrome directly + # Web mode: reuse an existing CDP endpoint first, otherwise acquire browser + # through RemoteHands or launch a local browser when available. if not data.cdp_browsers: - try: - launched = await asyncio.to_thread( - ensure_cdp_browser_available, data.browser_port - ) - if launched: - os.environ["EIGENT_CDP_URL"] = ( - f"http://127.0.0.1:{data.browser_port}" - ) - request.state.browser_available = True - else: - request.state.browser_available = False - os.environ.pop("EIGENT_CDP_URL", None) - chat_logger.warning( - "CDP browser not available after ensure attempt", - extra={"port": data.browser_port}, - ) - except Exception as e: - request.state.browser_available = False - os.environ.pop("EIGENT_CDP_URL", None) - chat_logger.warning( - "Could not ensure CDP browser for web mode", - extra={"error": str(e), "port": data.browser_port}, - ) + await _prepare_browser_for_request(request, data.browser_port) os.environ["OPENAI_API_KEY"] = data.api_key os.environ["OPENAI_API_BASE_URL"] = ( data.api_url or "https://api.openai.com/v1" @@ -290,28 +341,17 @@ async def post(data: Chat, request: Request): @router.post("/chat/{id}", name="improve chat") -def improve(id: str, data: SupplementChat): +def improve(id: str, data: SupplementChat, request: Request): chat_logger.info( "Chat improvement requested", extra={"task_id": id, "question_length": len(data.question)}, ) task_lock = get_task_lock(id) - # Web mode: ensure CDP browser is available for follow-up tasks - # (browser may have been closed/crashed since last session) - try: - port = int(os.environ.get("browser_port", "9222")) - launched = ensure_cdp_browser_available(port) - if launched: - os.environ["EIGENT_CDP_URL"] = f"http://127.0.0.1:{port}" - else: - os.environ.pop("EIGENT_CDP_URL", None) - except Exception as e: - os.environ.pop("EIGENT_CDP_URL", None) - chat_logger.warning( - "Could not ensure CDP browser for supplement", - extra={"error": str(e)}, - ) + # Reuse an existing endpoint when possible to avoid tearing down + # a browser that was manually connected through the Browser page. + port = int(os.environ.get("browser_port", "9222")) + asyncio.run(_prepare_browser_for_request(request, port)) # Allow continuing conversation even after task is done # This supports multi-turn conversation after complex task completion diff --git a/backend/app/controller/tool_controller.py b/backend/app/controller/tool_controller.py index 89fbac87b..5bb4c981a 100644 --- a/backend/app/controller/tool_controller.py +++ b/backend/app/controller/tool_controller.py @@ -12,18 +12,28 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import asyncio import logging import os import shutil import threading import time +import uuid -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from app.agent.toolkit.google_calendar_toolkit import GoogleCalendarToolkit from app.agent.toolkit.linkedin_toolkit import LinkedInToolkit from app.agent.toolkit.notion_mcp_toolkit import NotionMCPToolkit +from app.utils.browser_launcher import ( + DEFAULT_CDP_PORT, + _is_cdp_available, + _is_port_in_use, + ensure_cdp_browser_available, + is_local_cdp_host, + normalize_cdp_url, +) from app.utils.cookie_manager import CookieManager from app.utils.oauth_state_manager import oauth_state_manager @@ -39,6 +49,301 @@ class LinkedInTokenRequest(BaseModel): logger = logging.getLogger("tool_controller") router = APIRouter() +_web_cdp_browser_meta: dict | None = None + + +class CdpBrowserConnectRequest(BaseModel): + port: int + name: str | None = None + + +def _build_web_cdp_browser( + endpoint: str, + *, + is_external: bool, + name: str | None = None, + added_at: int | None = None, + resource_session_id: str | None = None, + managed_by: str = "local", +) -> dict: + normalized_endpoint, host, port = normalize_cdp_url(endpoint) + default_location = ( + str(port) if is_local_cdp_host(host) else f"{host}:{port}" + ) + browser_name = name or ( + f"External Browser ({default_location})" + if is_external + else f"Managed Browser ({default_location})" + ) + browser_id = resource_session_id or ( + f"web-cdp-{port}" + if is_local_cdp_host(host) + else f"web-cdp-{host.replace('.', '-')}-{port}" + ) + return { + "id": browser_id, + "port": port, + "endpoint": normalized_endpoint, + "host": host, + "isExternal": is_external, + "name": browser_name, + "addedAt": added_at or int(time.time() * 1000), + "resourceSessionId": resource_session_id, + "managedBy": managed_by, + } + + +def _get_connected_cdp_endpoint() -> str | None: + cdp_url = os.environ.get("EIGENT_CDP_URL") + if cdp_url: + return cdp_url + if _web_cdp_browser_meta: + return _web_cdp_browser_meta.get("endpoint") + return None + + +def _get_connected_cdp_port() -> int | None: + cdp_url = _get_connected_cdp_endpoint() + if not cdp_url: + return None + try: + _, _, port = normalize_cdp_url(cdp_url) + return port + except Exception: + logger.warning("Invalid EIGENT_CDP_URL: %s", cdp_url) + return None + + +def _set_connected_cdp_browser( + endpoint: str, + *, + is_external: bool, + name: str | None = None, + resource_session_id: str | None = None, + managed_by: str = "local", +) -> dict: + global _web_cdp_browser_meta + normalized_endpoint, _, _ = normalize_cdp_url(endpoint) + os.environ["EIGENT_CDP_URL"] = normalized_endpoint + _web_cdp_browser_meta = _build_web_cdp_browser( + normalized_endpoint, + is_external=is_external, + name=name, + resource_session_id=resource_session_id, + managed_by=managed_by, + ) + return _web_cdp_browser_meta + + +def _clear_connected_cdp_browser() -> None: + global _web_cdp_browser_meta + os.environ.pop("EIGENT_CDP_URL", None) + _web_cdp_browser_meta = None + + +def _list_connected_cdp_browsers() -> list[dict]: + global _web_cdp_browser_meta + endpoint = _get_connected_cdp_endpoint() + if endpoint is None: + return [] + + if not _is_cdp_endpoint_available(endpoint): + _clear_connected_cdp_browser() + return [] + + if ( + _web_cdp_browser_meta + and _web_cdp_browser_meta.get("endpoint") == endpoint + ): + return [_web_cdp_browser_meta] + + inferred_browser = _build_web_cdp_browser(endpoint, is_external=True) + _web_cdp_browser_meta = inferred_browser + return [inferred_browser] + + +def _is_cdp_endpoint_available(endpoint: str) -> bool: + normalized_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 + + +def _is_remote_browser_hands(hands) -> bool: + if hands is None: + return False + try: + manifest = hands.get_capability_manifest() + except Exception: + return False + return manifest.get("deployment") == "remote_cluster" + + +async def _release_remote_browser_if_needed(request: Request | None) -> None: + meta = _web_cdp_browser_meta or {} + resource_session_id = meta.get("resourceSessionId") + if meta.get("managedBy") != "remote" or not resource_session_id: + return + + hands = getattr(getattr(request, "state", None), "hands", None) + if hands is None: + return + + try: + await asyncio.to_thread( + hands.release_resource, + "browser", + resource_session_id, + ) + except Exception as exc: + logger.warning( + "Failed to release remote browser session %s: %s", + resource_session_id, + exc, + ) + + +@router.get("/browser/cdp/list", name="list cdp browsers") +async def list_cdp_browsers(): + """List the currently connected CDP browser in web mode.""" + return _list_connected_cdp_browsers() + + +@router.post("/browser/cdp/launch", name="launch cdp browser") +async def launch_cdp_browser(request: Request): + """ + Launch or reuse a managed CDP browser for web mode. + + Returns: + Connection information for the managed browser. + """ + existing_browsers = _list_connected_cdp_browsers() + if existing_browsers: + browser = existing_browsers[0] + return { + "success": True, + "port": browser["port"], + "browser": browser, + "endpoint": browser.get("endpoint"), + "reused": True, + } + + hands = getattr(request.state, "hands", None) + if _is_remote_browser_hands(hands): + session_id = f"browser_ui_{uuid.uuid4().hex[:12]}" + try: + endpoint = await asyncio.to_thread( + hands.acquire_resource, + "browser", + session_id, + port=DEFAULT_CDP_PORT, + ) + except Exception as exc: + return { + "success": False, + "error": f"Failed to acquire remote browser: {exc}", + } + + browser = _set_connected_cdp_browser( + endpoint, + is_external=False, + resource_session_id=session_id, + managed_by="remote", + ) + return { + "success": True, + "port": browser["port"], + "browser": browser, + "endpoint": browser.get("endpoint"), + } + + port = DEFAULT_CDP_PORT + launched = ensure_cdp_browser_available(port) + if not launched: + if _is_port_in_use(port): + return { + "success": False, + "error": f"Port {port} is already in use and is not exposing CDP.", + } + return { + "success": False, + "error": "Failed to launch browser. Ensure Chrome/Chromium is installed or run playwright install chromium.", + } + + browser = _set_connected_cdp_browser( + f"http://127.0.0.1:{port}", + is_external=False, + ) + return { + "success": True, + "port": browser["port"], + "browser": browser, + "endpoint": browser.get("endpoint"), + } + + +@router.post("/browser/cdp/connect", name="connect cdp browser") +async def connect_cdp_browser(data: CdpBrowserConnectRequest): + """ + Connect an already-running browser that exposes CDP. + + Args: + data.port: CDP port exposed by the browser. + data.name: Optional custom display name. + """ + if data.port < 1 or data.port > 65535: + return {"success": False, "error": "Invalid port number."} + + if not _is_cdp_available(data.port): + return { + "success": False, + "error": f"No CDP browser found on port {data.port}.", + } + + browser = _set_connected_cdp_browser( + f"http://127.0.0.1:{data.port}", + is_external=True, + name=data.name, + ) + return { + "success": True, + "port": data.port, + "browser": browser, + } + + +@router.delete("/browser/cdp/{port}", name="disconnect cdp browser") +async def disconnect_cdp_browser(port: int, request: Request): + """ + Disconnect the current web-mode CDP browser reference. + + Note: + This does not terminate the browser process; it only clears + the backend's active CDP target. + """ + current_port = _get_connected_cdp_port() + if current_port is None: + return {"success": False, "error": "No connected browser to remove."} + + if current_port != port: + return { + "success": False, + "error": f"Browser on port {port} is not the active CDP connection.", + } + + await _release_remote_browser_if_needed(request) + _clear_connected_cdp_browser() + return {"success": True} @router.post("/install/tool/{tool}", name="install tool") diff --git a/backend/app/utils/browser_launcher.py b/backend/app/utils/browser_launcher.py index a061eaeeb..ec16a26b6 100644 --- a/backend/app/utils/browser_launcher.py +++ b/backend/app/utils/browser_launcher.py @@ -26,11 +26,49 @@ import socket import subprocess from pathlib import Path +from urllib.parse import urlparse logger = logging.getLogger("browser_launcher") # Default CDP port (must match browser_port in Chat model) DEFAULT_CDP_PORT = 9222 +LOCAL_CDP_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) + + +def is_local_cdp_host(host: str | None) -> bool: + """Return whether the CDP endpoint host points at the local machine.""" + if not host: + return True + return host.lower() in LOCAL_CDP_HOSTS + + +def normalize_cdp_url( + cdp_url: str, + *, + default_host: str = "127.0.0.1", + default_port: int = DEFAULT_CDP_PORT, +) -> tuple[str, str, int]: + """Normalize a CDP endpoint into ``scheme://host:port`` form.""" + parsed = urlparse(cdp_url) + scheme = parsed.scheme or "http" + host = parsed.hostname or default_host + port = parsed.port or default_port + return f"{scheme}://{host}:{port}", host, port + + +def is_cdp_url_available(cdp_url: str) -> bool: + """Check whether a CDP endpoint is reachable.""" + normalized, host, port = normalize_cdp_url(cdp_url) + if is_local_cdp_host(host): + return _is_cdp_available(port) + + try: + import httpx + + r = httpx.get(f"{normalized}/json/version", timeout=2.0) + return r.status_code == 200 + except Exception: + return False def _is_port_in_use(port: int) -> bool: diff --git a/backend/tests/app/agent/factory/test_browser.py b/backend/tests/app/agent/factory/test_browser.py index aab7aaadf..0c26a90c5 100644 --- a/backend/tests/app/agent/factory/test_browser.py +++ b/backend/tests/app/agent/factory/test_browser.py @@ -12,6 +12,7 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import os from unittest.mock import MagicMock, patch import pytest @@ -91,3 +92,55 @@ def test_browser_agent_creation(sample_chat_data): assert ( "search" in str(system_message).lower() ) # system_prompt contains search + + +def test_browser_agent_prefers_preconnected_cdp_url(sample_chat_data): + """A browser connected from the Browser page should be reused by the agent.""" + options = Chat(**sample_chat_data) + + from app.service.task import task_locks + + mock_task_lock = MagicMock() + task_locks[options.task_id] = mock_task_lock + + _mod = "app.agent.factory.browser" + 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, + patch.dict( + os.environ, + {"EIGENT_CDP_URL": "http://worker-17:9222"}, + clear=True, + ), + ): + 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_instance = MagicMock() + mock_search_instance.search_google = MagicMock() + mock_search_toolkit.return_value = mock_search_instance + 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" + ) diff --git a/backend/tests/app/controller/test_chat_controller.py b/backend/tests/app/controller/test_chat_controller.py index 692c0f49e..2356fa7d8 100644 --- a/backend/tests/app/controller/test_chat_controller.py +++ b/backend/tests/app/controller/test_chat_controller.py @@ -128,6 +128,10 @@ async def test_post_chat_sets_cdp_url_when_browser_ready( patch( "app.controller.chat_controller.step_solve" ) as mock_step_solve, + patch( + "app.controller.chat_controller.is_cdp_url_available", + return_value=False, + ), patch( "app.controller.chat_controller.ensure_cdp_browser_available", return_value=True, @@ -165,6 +169,10 @@ async def test_post_chat_clears_cdp_url_when_browser_unavailable( patch( "app.controller.chat_controller.step_solve" ) as mock_step_solve, + patch( + "app.controller.chat_controller.is_cdp_url_available", + return_value=False, + ), patch( "app.controller.chat_controller.ensure_cdp_browser_available", return_value=False, @@ -190,7 +198,51 @@ async def mock_generator(): assert "EIGENT_CDP_URL" not in os.environ assert mock_request.state.browser_available is False - def test_improve_chat_success(self, mock_task_lock): + @pytest.mark.asyncio + async def test_post_chat_preserves_existing_cdp_url( + self, sample_chat_data, mock_request, mock_task_lock + ): + chat_data = Chat(**sample_chat_data) + mock_request.state = SimpleNamespace() + + with ( + patch( + "app.controller.chat_controller.get_or_create_task_lock", + return_value=mock_task_lock, + ), + patch( + "app.controller.chat_controller.step_solve" + ) as mock_step_solve, + patch( + "app.controller.chat_controller.is_cdp_url_available", + return_value=True, + ), + patch( + "app.controller.chat_controller.ensure_cdp_browser_available", + ) as mock_ensure_browser, + patch("app.controller.chat_controller.load_dotenv"), + patch("app.controller.chat_controller.set_current_task_id"), + patch("pathlib.Path.mkdir"), + patch("pathlib.Path.home", return_value=MagicMock()), + patch.dict( + os.environ, + {"EIGENT_CDP_URL": "http://worker-17:9222"}, + clear=True, + ), + ): + + async def mock_generator(): + yield "data: test_response\n\n" + + mock_step_solve.return_value = mock_generator() + + await post(chat_data, mock_request) + + assert os.environ.get("EIGENT_CDP_URL") == "http://worker-17:9222" + assert mock_request.state.browser_available is True + mock_ensure_browser.assert_not_called() + + def test_improve_chat_success(self, mock_task_lock, mock_request): """Test successful chat improvement.""" task_id = "test_task_123" supplement_data = SupplementChat(question="Improve this code") @@ -203,15 +255,18 @@ def test_improve_chat_success(self, mock_task_lock): ), patch("asyncio.run") as mock_run, ): - response = improve(task_id, supplement_data) + mock_run.side_effect = lambda coro: coro.close() + response = improve(task_id, supplement_data, mock_request) assert isinstance(response, Response) assert response.status_code == 201 - mock_run.assert_called_once() + assert mock_run.call_count == 2 # put_queue is invoked when creating the coroutine passed to asyncio.run mock_task_lock.put_queue.assert_called_once() - def test_improve_chat_task_done_resets_to_confirming(self, mock_task_lock): + def test_improve_chat_task_done_resets_to_confirming( + self, mock_task_lock, mock_request + ): """Test improvement when task is done resets status to confirming.""" task_id = "test_task_123" supplement_data = SupplementChat(question="Improve this code") @@ -224,7 +279,8 @@ def test_improve_chat_task_done_resets_to_confirming(self, mock_task_lock): ), patch("asyncio.run") as mock_run, ): - response = improve(task_id, supplement_data) + mock_run.side_effect = lambda coro: coro.close() + response = improve(task_id, supplement_data, mock_request) assert mock_task_lock.status == Status.confirming assert isinstance(response, Response) @@ -268,11 +324,12 @@ def test_stop_chat_success(self, mock_task_lock): with ( patch( - "app.controller.chat_controller.get_task_lock", + "app.controller.chat_controller.get_task_lock_if_exists", return_value=mock_task_lock, ), patch("asyncio.run") as mock_run, ): + mock_run.side_effect = lambda coro: coro.close() response = stop(task_id) assert isinstance(response, Response) @@ -507,13 +564,14 @@ def test_improve_with_nonexistent_task(self): """Test improve endpoint with nonexistent task.""" task_id = "nonexistent_task" supplement_data = SupplementChat(question="Improve this code") + request = SimpleNamespace() with patch( "app.controller.chat_controller.get_task_lock", side_effect=KeyError("Task not found"), ): with pytest.raises(KeyError): - improve(task_id, supplement_data) + improve(task_id, supplement_data, request) def test_supplement_with_empty_question(self, mock_task_lock): """Test supplement endpoint with empty question.""" diff --git a/backend/tests/app/controller/test_tool_controller.py b/backend/tests/app/controller/test_tool_controller.py index 22b78e8e8..c6652c4aa 100644 --- a/backend/tests/app/controller/test_tool_controller.py +++ b/backend/tests/app/controller/test_tool_controller.py @@ -12,12 +12,15 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import os +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException from fastapi.testclient import TestClient +from app.controller import tool_controller from app.controller.tool_controller import install_tool @@ -139,6 +142,36 @@ async def test_install_notion_tool_with_complex_tools(self): mock_toolkit.connect.assert_called_once() mock_toolkit.disconnect.assert_called_once() + @pytest.mark.asyncio + async def test_launch_cdp_browser_uses_remote_hands_endpoint( + self, monkeypatch: pytest.MonkeyPatch + ): + class _FakeRemoteHands: + def get_capability_manifest(self): + return {"deployment": "remote_cluster"} + + def acquire_resource( + self, resource_type: str, session_id: str, **kwargs + ): + _ = (resource_type, session_id, kwargs) + return "http://worker-17:9222" + + monkeypatch.delenv("EIGENT_CDP_URL", raising=False) + tool_controller._web_cdp_browser_meta = None + request = SimpleNamespace( + state=SimpleNamespace(hands=_FakeRemoteHands()) + ) + + response = await tool_controller.launch_cdp_browser(request) + + assert response["success"] is True + assert response["endpoint"] == "http://worker-17:9222" + assert response["browser"]["managedBy"] == "remote" + assert response["browser"]["host"] == "worker-17" + assert os.environ["EIGENT_CDP_URL"] == "http://worker-17:9222" + + tool_controller._clear_connected_cdp_browser() + @pytest.mark.integration class TestToolControllerIntegration: @@ -198,6 +231,74 @@ def test_install_notion_tool_endpoint_with_connection_error( assert data["tools"] == [] assert "warning" in data + def test_launch_cdp_browser_endpoint_integration( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.delenv("EIGENT_CDP_URL", raising=False) + tool_controller._web_cdp_browser_meta = None + + with patch( + "app.controller.tool_controller.ensure_cdp_browser_available", + return_value=True, + ): + response = client.post("/browser/cdp/launch") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["port"] == 9222 + assert data["browser"]["id"] == "web-cdp-9222" + assert tool_controller._get_connected_cdp_port() == 9222 + + tool_controller._clear_connected_cdp_browser() + + def test_connect_list_and_disconnect_cdp_browser_endpoints( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.delenv("EIGENT_CDP_URL", raising=False) + tool_controller._web_cdp_browser_meta = None + + with patch( + "app.controller.tool_controller._is_cdp_available", + return_value=True, + ): + connect_response = client.post( + "/browser/cdp/connect", + json={"port": 9333, "name": "External Browser (9333)"}, + ) + + assert connect_response.status_code == 200 + connect_data = connect_response.json() + assert connect_data["success"] is True + assert connect_data["browser"]["port"] == 9333 + assert connect_data["browser"]["isExternal"] is True + + list_response = client.get("/browser/cdp/list") + assert list_response.status_code == 200 + assert list_response.json() == [connect_data["browser"]] + + disconnect_response = client.delete("/browser/cdp/9333") + assert disconnect_response.status_code == 200 + assert disconnect_response.json()["success"] is True + assert tool_controller._get_connected_cdp_port() is None + + def test_connect_cdp_browser_endpoint_returns_error_when_unreachable( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.delenv("EIGENT_CDP_URL", raising=False) + tool_controller._web_cdp_browser_meta = None + + with patch( + "app.controller.tool_controller._is_cdp_available", + return_value=False, + ): + response = client.post("/browser/cdp/connect", json={"port": 9555}) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + assert "9555" in data["error"] + @pytest.mark.model_backend class TestToolControllerWithRealMCP: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cc27f27ed..ef58488a0 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -17,6 +17,7 @@ import tempfile from collections.abc import AsyncGenerator, Generator from pathlib import Path +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -193,6 +194,7 @@ def mock_request(): """Mock FastAPI Request object.""" request = AsyncMock() request.is_disconnected = AsyncMock(return_value=False) + request.state = SimpleNamespace() return request diff --git a/src/components/Folder/index.tsx b/src/components/Folder/index.tsx index 9b7f05827..fec943add 100644 --- a/src/components/Folder/index.tsx +++ b/src/components/Folder/index.tsx @@ -130,6 +130,7 @@ function isVideoFile(file: FileTypeTarget) { // Type definitions interface FileTreeNode { + id: string; name: string; path: string; type?: string; @@ -151,6 +152,198 @@ interface FileInfo { isRemote?: boolean; } +type FileIdentity = Pick; + +function normalizeTreePath(path: string): string { + return path + .replace(/\\/g, '/') + .replace(/^\.?\//, '') + .replace(/\/+/g, '/') + .replace(/^\/+/, ''); +} + +function getNormalizedTreeRelativePath(file: FileInfo): string { + const normalizedRelativePath = normalizeTreePath(file.relativePath || ''); + if (normalizedRelativePath) { + return normalizedRelativePath; + } + + const normalizedName = normalizeTreePath(file.name || ''); + if (normalizedName) { + return normalizedName; + } + + return ''; +} + +function getComparableRelativePath(file?: FileIdentity | null): string | null { + if (!file) return null; + + const normalizedRelativePath = normalizeLookupPath(file.relativePath || ''); + if (normalizedRelativePath) { + return normalizedRelativePath; + } + + if (!file.path) return null; + + try { + const url = new URL(file.path, window.location.origin); + const pathParam = url.searchParams.get('path'); + return pathParam + ? normalizeLookupPath(decodeURIComponent(pathParam)) + : null; + } catch { + return null; + } +} + +export function isSameFileIdentity( + left?: FileIdentity | null, + right?: FileIdentity | null +): boolean { + if (!left || !right) return false; + + if (left.path && right.path && left.path === right.path) { + return true; + } + + const leftRelativePath = getComparableRelativePath(left); + const rightRelativePath = getComparableRelativePath(right); + + if (leftRelativePath && rightRelativePath) { + return leftRelativePath === rightRelativePath; + } + + return ( + !!left.name && + !!right.name && + left.name === right.name && + !leftRelativePath && + !rightRelativePath && + !left.path && + !right.path + ); +} + +export function findMatchingFile( + files: FileInfo[], + target?: FileInfo | null +): FileInfo | undefined { + if (!target) return undefined; + + if (target.path) { + const exactPathMatch = files.find((file) => file.path === target.path); + if (exactPathMatch) { + return exactPathMatch; + } + } + + const targetRelativePath = getComparableRelativePath(target); + if (targetRelativePath) { + const relativePathMatch = files.find( + (file) => getComparableRelativePath(file) === targetRelativePath + ); + if (relativePathMatch) { + return relativePathMatch; + } + } + + if (target.name) { + const sameNameMatches = files.filter((file) => file.name === target.name); + if (sameNameMatches.length === 1) { + return sameNameMatches[0]; + } + } + + return undefined; +} + +export function buildFileTree(files: FileInfo[]): FileTreeNode { + const root: FileTreeNode = { + id: 'root', + name: 'root', + path: '', + children: [], + isFolder: true, + }; + + const folderMap = new Map(); + folderMap.set('', root); + + const sortedFiles = [...files].sort((left, right) => { + const leftRelativePath = getNormalizedTreeRelativePath(left); + const rightRelativePath = getNormalizedTreeRelativePath(right); + const leftDepth = leftRelativePath.split('/').filter(Boolean).length; + const rightDepth = rightRelativePath.split('/').filter(Boolean).length; + + if (leftDepth !== rightDepth) { + return leftDepth - rightDepth; + } + + return leftRelativePath.localeCompare(rightRelativePath); + }); + + for (const file of sortedFiles) { + const normalizedRelativePath = getNormalizedTreeRelativePath(file); + const pathSegments = normalizedRelativePath.split('/').filter(Boolean); + const folderSegments = pathSegments.slice(0, -1); + const fileName = pathSegments[pathSegments.length - 1] || file.name; + + let parentNode = root; + let currentFolderPath = ''; + + for (const segment of folderSegments) { + currentFolderPath = currentFolderPath + ? `${currentFolderPath}/${segment}` + : segment; + + let folderNode = folderMap.get(currentFolderPath); + if (!folderNode) { + folderNode = { + id: `folder:${currentFolderPath}`, + name: segment, + path: currentFolderPath, + isFolder: true, + children: [], + relativePath: currentFolderPath, + }; + parentNode.children!.push(folderNode); + folderMap.set(currentFolderPath, folderNode); + } + + parentNode = folderNode; + } + + parentNode.children!.push({ + id: `file:${normalizedRelativePath || file.path || file.name}`, + name: fileName || file.name, + path: file.path, + type: file.type, + isFolder: file.isFolder, + icon: file.icon, + children: file.isFolder ? [] : undefined, + isRemote: file.isRemote, + relativePath: file.relativePath, + }); + } + + const sortTree = (node: FileTreeNode) => { + if (!node.children?.length) return; + + node.children.sort((left, right) => { + if (!!left.isFolder !== !!right.isFolder) { + return left.isFolder ? -1 : 1; + } + return left.name.localeCompare(right.name); + }); + + node.children.forEach(sortTree); + }; + + sortTree(root); + return root; +} + // FileTree component to render nested file structure interface FileTreeProps { node: FileTreeNode; @@ -176,7 +369,7 @@ export const FileTree: React.FC = ({ return (
0 ? 'ml-4' : ''}> {node.children.map((child) => { - const isExpanded = expandedFolders.has(child.path); + const isExpanded = expandedFolders.has(child.id); const fileInfo: FileInfo = { name: child.name, path: child.path, @@ -188,17 +381,17 @@ export const FileTree: React.FC = ({ }; return ( -
+
-
+
{activeTab === 'general' && } {activeTab === 'privacy' && }
diff --git a/src/pages/SignUp.tsx b/src/pages/SignUp.tsx index b1e84c396..5c1288a16 100644 --- a/src/pages/SignUp.tsx +++ b/src/pages/SignUp.tsx @@ -13,6 +13,7 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { Button } from '@/components/ui/button'; +import { SITE_URL } from '@/lib'; import { useAuthStore } from '@/store/authStore'; import { useStackApp } from '@stackframe/react'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -52,11 +53,7 @@ export default function SignUp() { // Hybrid/app mode without Stack keys: redirect to external signup useEffect(() => { if (!IS_LOCAL_MODE && !HAS_STACK_KEYS) { - window.open( - 'https://www.eigent.ai/signup', - '_blank', - 'noopener,noreferrer' - ); + window.open(`${SITE_URL}/signup`, '_blank', 'noopener,noreferrer'); navigate('/login', { replace: true }); } }, [navigate]); From 4227e06439ba131e4d2ea461bab61abd3f7eb049 Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Mon, 20 Apr 2026 16:34:37 +0800 Subject: [PATCH 07/10] fix(review): address PR #1536 feedback (file path normalization, Path typing, cleanup) --- backend/app/controller/file_controller.py | 9 ++++++++- backend/app/hands/capabilities.py | 16 ++++++++++++---- backend/app/hands/sandbox_hands.py | 5 ----- backend/app/router_layer/hands_resolver.py | 2 +- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/backend/app/controller/file_controller.py b/backend/app/controller/file_controller.py index 143d3212a..7e9619225 100644 --- a/backend/app/controller/file_controller.py +++ b/backend/app/controller/file_controller.py @@ -145,6 +145,11 @@ def _sanitize_email(email: str) -> str: return re.sub(r'[\\/*?:"<>|\s]', "_", email.split("@")[0]).strip(".") +def _normalize_relative_path(path: str) -> str: + """Normalize relative path to URL-safe POSIX style.""" + return path.replace("\\", "/") + + def _get_project_root(email: str, project_id: str) -> Path: """Get project root path: ~/eigent/{email}/project_{project_id}/.""" root = _get_eigent_root() @@ -221,7 +226,9 @@ async def list_project_files( result: list[dict] = [] for abs_path in paths: try: - rel = str(Path(abs_path).relative_to(base_path)) + rel = _normalize_relative_path( + Path(abs_path).relative_to(base_path).as_posix() + ) # URL-encode the relative path for stream endpoint path_param = quote(rel, safe="") result.append( diff --git a/backend/app/hands/capabilities.py b/backend/app/hands/capabilities.py index fc63ee4cc..eefd22677 100644 --- a/backend/app/hands/capabilities.py +++ b/backend/app/hands/capabilities.py @@ -114,7 +114,9 @@ class BrainCapabilities: mcp_allowlist: list[str] = field(default_factory=list) """used when mcp_mode=allowlist""" - workspace_root: str = "~/.eigent/workspace" + workspace_root: Path = field( + default_factory=lambda: Path("~/.eigent/workspace").expanduser() + ) """workspace root path""" deployment_type: str = "local" @@ -153,7 +155,9 @@ def detect_capabilities(config: dict | None = None) -> BrainCapabilities: has_browser=False, filesystem_scope="workspace_only", mcp_mode="all", # MCP available in all deployment modes - workspace_root=env("EIGENT_WORKSPACE", "~/.eigent/workspace"), + workspace_root=Path( + env("EIGENT_WORKSPACE", "~/.eigent/workspace") + ).expanduser(), deployment_type="docker", ) else: @@ -174,7 +178,9 @@ def detect_capabilities(config: dict | None = None) -> BrainCapabilities: has_browser=has_browser, filesystem_scope="full", mcp_mode="all", - workspace_root=env("EIGENT_WORKSPACE", "~/.eigent/workspace"), + workspace_root=Path( + env("EIGENT_WORKSPACE", "~/.eigent/workspace") + ).expanduser(), deployment_type="cloud_vm" if deployment == "cloud_vm" else "local", @@ -186,7 +192,9 @@ def detect_capabilities(config: dict | None = None) -> BrainCapabilities: has_browser=False, filesystem_scope="workspace_only", mcp_mode="all", # MCP available in all deployment modes - workspace_root=env("EIGENT_WORKSPACE", "~/.eigent/workspace"), + workspace_root=Path( + env("EIGENT_WORKSPACE", "~/.eigent/workspace") + ).expanduser(), deployment_type=deployment or "sandbox", ) diff --git a/backend/app/hands/sandbox_hands.py b/backend/app/hands/sandbox_hands.py index 1e5460815..002fea120 100644 --- a/backend/app/hands/sandbox_hands.py +++ b/backend/app/hands/sandbox_hands.py @@ -16,11 +16,6 @@ from app.hands.interface import IHands -# Terminal command allowlist for sandbox -SANDBOX_TERMINAL_ALLOWLIST = frozenset( - {"ls", "cat", "python", "node", "pwd", "echo"} -) - class SandboxHands(IHands): """Limited capabilities: workspace only, MCP allowed, no browser""" diff --git a/backend/app/router_layer/hands_resolver.py b/backend/app/router_layer/hands_resolver.py index d72f3d739..49b3606ae 100644 --- a/backend/app/router_layer/hands_resolver.py +++ b/backend/app/router_layer/hands_resolver.py @@ -39,7 +39,7 @@ from app.hands.capabilities import detect_capabilities from app.hands.environment_hands import EnvironmentHands -logger = logging.getLogger("hands.resolver") +logger = logging.getLogger(__name__) # Global EnvironmentHands singleton, initialized at startup _environment_hands: IHands | None = None From 376d6c146ae013eff0940804133b943c0091c98a Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Fri, 24 Apr 2026 10:58:53 +0800 Subject: [PATCH 08/10] fix login url for test env --- src/pages/loginUtils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/loginUtils.ts b/src/pages/loginUtils.ts index 2c7c3ae7a..1c6665304 100644 --- a/src/pages/loginUtils.ts +++ b/src/pages/loginUtils.ts @@ -13,13 +13,15 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= export const DESKTOP_LOGIN_CALLBACK_URL = 'eigent://auth/callback'; +const DEFAULT_SITE_URL = 'https://www.eigent.ai'; +const SITE_URL = import.meta.env.VITE_SITE_URL || DEFAULT_SITE_URL; export function getWebLoginCallbackUrl(origin: string): string { return new URL('/login', origin).toString(); } export function getExternalLoginUrl(callbackUrl: string): string { - const loginUrl = new URL('https://www.eigent.ai/signin'); + const loginUrl = new URL('/signin', SITE_URL); loginUrl.searchParams.set('callbackUrl', callbackUrl); return loginUrl.toString(); } From 3780ea2b7da20053bcadde8d5fb36c2c470b6a18 Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Fri, 1 May 2026 10:11:24 +0800 Subject: [PATCH 09/10] :art: add test env --- .env.development | 4 + .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/workflows/build.yml | 2 +- backend/app/agent/agent_model.py | 37 +++ backend/app/agent/factory/mcp.py | 51 +++- backend/app/agent/toolkit/terminal_toolkit.py | 2 +- backend/app/component/model_validation.py | 14 + backend/app/model/chat.py | 2 +- backend/app/model/model_platform.py | 18 ++ backend/app/utils/single_agent_worker.py | 23 +- backend/main.py | 2 + backend/pyproject.toml | 2 +- .../app/component/test_model_validation.py | 19 ++ backend/uv.lock | 10 +- electron/main/fileReader.ts | 133 ++++---- electron/main/githubReleaseCdnProvider.ts | 166 ++++++++++ electron/main/index.ts | 30 +- electron/main/init.ts | 10 + electron/main/update.ts | 61 +++- package.json | 2 +- src/assets/wechat_qr.jpg | Bin 267102 -> 268244 bytes src/components/Toast/creditsToast.tsx | 3 +- src/components/Toast/storageToast.tsx | 3 +- src/components/TopBar/index.tsx | 3 +- src/components/WorkFlow/node.tsx | 90 +++--- src/lib/index.ts | 3 + src/lib/llm.ts | 38 ++- src/lib/share.ts | 3 +- src/pages/Agents/Models.tsx | 50 ++- src/pages/Browser/Cookies.tsx | 3 +- src/pages/Login.tsx | 4 +- src/pages/Setting/General.tsx | 3 +- src/pages/Setting/Privacy.tsx | 3 +- src/pages/Setting/index.tsx | 19 +- src/pages/SignUp.tsx | 7 +- src/store/chatStore.ts | 285 +++++++++++++----- src/types/index.ts | 2 + .../electron/githubReleaseCdnProvider.test.ts | 67 ++++ test/unit/store/chatStore.test.ts | 116 ++++++- 39 files changed, 1024 insertions(+), 268 deletions(-) create mode 100644 electron/main/githubReleaseCdnProvider.ts create mode 100644 test/unit/electron/githubReleaseCdnProvider.test.ts diff --git a/.env.development b/.env.development index 81b3be797..ded8a2814 100644 --- a/.env.development +++ b/.env.development @@ -1,8 +1,12 @@ VITE_BASE_URL=/api +# =======Production environment variables======= +SERVER_URL=https://dev.eigent.ai VITE_PROXY_URL=https://dev.eigent.ai +VITE_SITE_URL=https://www.eigent.ai VITE_USE_LOCAL_PROXY=false +# =======Local environment variables======= # VITE_PROXY_URL=http://localhost:3001 # VITE_USE_LOCAL_PROXY=true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f0bffe1bd..8c446e2ce 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -12,7 +12,7 @@ body: id: version attributes: label: What version of eigent are you using? - placeholder: E.g., 0.0.89 + placeholder: E.g., 0.0.90 validations: required: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6a5c88896..d39e22b2c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -347,7 +347,7 @@ jobs: files: | gh-release-assets/* - # Extract version from tag (e.g., v0.0.85 -> 0.0.89) + # Extract version from tag (e.g., v0.0.85 -> 0.0.90) - name: Extract version if: startsWith(github.ref, 'refs/tags/') id: version diff --git a/backend/app/agent/agent_model.py b/backend/app/agent/agent_model.py index 1b1fe860b..b7078a146 100644 --- a/backend/app/agent/agent_model.py +++ b/backend/app/agent/agent_model.py @@ -24,6 +24,7 @@ from app.agent.listen_chat_agent import ListenChatAgent, logger from app.model.chat import AgentModelConfig, Chat +from app.model.model_platform import patch_bedrock_cloud_config from app.service.task import ActionCreateAgentData, Agents, get_task_lock from app.utils.event_loop_utils import _schedule_async_task @@ -80,6 +81,14 @@ def agent_model( for attr in config_attrs: effective_config[attr] = getattr(options, attr) extra_params = options.extra_params or {} + # Cloud mode: inject default Bedrock region and adjust URL for proxy. + if ( + effective_config.get("model_platform") == "aws-bedrock-converse" + and options.is_cloud() + ): + effective_config["api_url"], extra_params = patch_bedrock_cloud_config( + effective_config["api_url"], extra_params + ) init_param_keys = { "api_version", "azure_ad_token", @@ -89,6 +98,10 @@ def agent_model( "client", "async_client", "azure_deployment_name", + "region_name", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", } init_params = {} @@ -112,6 +125,26 @@ def agent_model( else: model_config[k] = v + # Auto-inject prompt caching based on model platform + try: + model_platform_enum = ModelPlatformType( + effective_config["model_platform"].lower() + ) + if model_platform_enum in { + ModelPlatformType.ANTHROPIC, + ModelPlatformType.AWS_BEDROCK_CONVERSE, + }: + model_config.setdefault("cache_control", "5m") + elif model_platform_enum == ModelPlatformType.OPENAI: + model_config.setdefault( + "prompt_cache_key", str(options.project_id) + ) + except (ValueError, AttributeError): + logging.error( + f"Invalid model platform: {effective_config['model_platform']}", + exc_info=True, + ) + if agent_name == Agents.task_agent: model_config["stream"] = True if agent_name == Agents.browser_agent: @@ -135,6 +168,10 @@ def agent_model( ) model_platform_enum = None + if effective_config["model_platform"].lower() == "anthropic": + if model_config.get("max_tokens") is None: + model_config["max_tokens"] = 128000 + model = ModelFactory.create( model_platform=effective_config["model_platform"], model_type=effective_config["model_type"], diff --git a/backend/app/agent/factory/mcp.py b/backend/app/agent/factory/mcp.py index 4f1dc86ab..f31cefd8e 100644 --- a/backend/app/agent/factory/mcp.py +++ b/backend/app/agent/factory/mcp.py @@ -12,15 +12,18 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import asyncio +import logging import uuid from camel.models import ModelFactory +from camel.types import ModelPlatformType from app.agent.listen_chat_agent import ListenChatAgent, logger from app.agent.prompt import MCP_SYS_PROMPT from app.agent.toolkit.mcp_search_toolkit import McpSearchToolkit from app.agent.tools import get_mcp_tools from app.model.chat import Chat +from app.model.model_platform import patch_bedrock_cloud_config from app.service.task import ActionCreateAgentData, Agents, get_task_lock @@ -73,6 +76,38 @@ async def mcp_agent(options: Chat): ) ) ) + extra_params = { + k: v + for k, v in (options.extra_params or {}).items() + if k not in ["model_platform", "model_type", "api_key", "url"] + } + api_url = options.api_url + if options.model_platform == "aws-bedrock-converse" and options.is_cloud(): + api_url, extra_params = patch_bedrock_cloud_config( + api_url, extra_params + ) + + # Build model_config_dict with prompt caching + model_config_dict = {} + if options.is_cloud(): + model_config_dict["user"] = str(options.project_id) + try: + platform_enum = ModelPlatformType(options.model_platform.lower()) + if platform_enum in { + ModelPlatformType.ANTHROPIC, + ModelPlatformType.AWS_BEDROCK_CONVERSE, + }: + model_config_dict.setdefault("cache_control", "5m") + elif platform_enum == ModelPlatformType.OPENAI: + model_config_dict.setdefault( + "prompt_cache_key", str(options.project_id) + ) + except (ValueError, AttributeError): + logging.error( + f"Invalid model platform: {options.model_platform}", + exc_info=True, + ) + return ListenChatAgent( options.project_id, Agents.mcp_agent, @@ -81,20 +116,10 @@ async def mcp_agent(options: Chat): model_platform=options.model_platform, model_type=options.model_type, api_key=options.api_key, - url=options.api_url, - model_config_dict=( - { - "user": str(options.project_id), - } - if options.is_cloud() - else None - ), + url=api_url, + model_config_dict=model_config_dict or None, timeout=600, # 10 minutes - **{ - k: v - for k, v in (options.extra_params or {}).items() - if k not in ["model_platform", "model_type", "api_key", "url"] - }, + **extra_params, ), # output_language=options.language, tools=tools, diff --git a/backend/app/agent/toolkit/terminal_toolkit.py b/backend/app/agent/toolkit/terminal_toolkit.py index 7f5299d4b..3306e7c37 100644 --- a/backend/app/agent/toolkit/terminal_toolkit.py +++ b/backend/app/agent/toolkit/terminal_toolkit.py @@ -41,7 +41,7 @@ # App version - should match electron app version # TODO: Consider getting this from a shared config -APP_VERSION = "0.0.89" +APP_VERSION = "0.0.90" def get_terminal_base_venv_path() -> str: diff --git a/backend/app/component/model_validation.py b/backend/app/component/model_validation.py index c8da48be8..1ab4c3373 100644 --- a/backend/app/component/model_validation.py +++ b/backend/app/component/model_validation.py @@ -19,6 +19,8 @@ from camel.agents import ChatAgent from camel.models import ModelFactory, ModelProcessingError +from app.model.model_platform import BEDROCK_CONVERSE_REGION + logger = logging.getLogger("model_validation") # Expected result from tool execution for validation @@ -227,6 +229,12 @@ def create_agent( raise ValueError(f"Invalid model_type: {model_type}") if platform is None: raise ValueError(f"Invalid model_platform: {model_platform}") + if str(platform).lower() == "anthropic": + model_config_dict = dict(model_config_dict or {}) + if model_config_dict.get("max_tokens") is None: + model_config_dict["max_tokens"] = 4096 + if str(platform).lower() == "aws-bedrock-converse": + kwargs.setdefault("region_name", BEDROCK_CONVERSE_REGION) model = ModelFactory.create( model_platform=platform, model_type=mtype, @@ -326,6 +334,12 @@ def validate_model_with_details( "Creating model", extra={"platform": model_platform, "model_type": model_type}, ) + if str(model_platform).lower() == "anthropic": + model_config_dict = dict(model_config_dict or {}) + if model_config_dict.get("max_tokens") is None: + model_config_dict["max_tokens"] = 4096 + if str(model_platform).lower() == "aws-bedrock-converse": + kwargs.setdefault("region_name", BEDROCK_CONVERSE_REGION) model = ModelFactory.create( model_platform=model_platform, model_type=model_type, diff --git a/backend/app/model/chat.py b/backend/app/model/chat.py index 8f13a5aa4..1b9b25a8c 100644 --- a/backend/app/model/chat.py +++ b/backend/app/model/chat.py @@ -116,7 +116,7 @@ def get_uvx_env(self) -> dict[str, str]: ) def is_cloud(self): - return self.api_url is not None and "44.247.171.124" in self.api_url + return self.api_url is not None and "eigent-proxy" in self.api_url def file_save_path(self, path: str | None = None): email = re.sub(r'[\\/*?:"<>|\s]', "_", self.email.split("@")[0]).strip( diff --git a/backend/app/model/model_platform.py b/backend/app/model/model_platform.py index 3ee760c71..94ca0d918 100644 --- a/backend/app/model/model_platform.py +++ b/backend/app/model/model_platform.py @@ -24,6 +24,24 @@ "llama.cpp": "openai-compatible-model", } +# Bedrock Converse requires a region during model initialization. +BEDROCK_CONVERSE_REGION: Final[str] = "us-west-2" + + +def patch_bedrock_cloud_config( + api_url: str, extra_params: dict +) -> tuple[str, dict]: + """Patch API URL and extra_params for Bedrock Converse in cloud mode. + + Appends '/bedrock' to the proxy URL and defaults the region. + Returns the updated (api_url, extra_params). + """ + extra_params = dict(extra_params) + extra_params.setdefault("region_name", BEDROCK_CONVERSE_REGION) + if not api_url.rstrip("/").endswith("/bedrock"): + api_url = api_url + "/bedrock" + return api_url, extra_params + def normalize_model_platform(platform: str) -> str: """Normalize provider aliases to supported model platform names.""" diff --git a/backend/app/utils/single_agent_worker.py b/backend/app/utils/single_agent_worker.py index acd4b6ba4..d276326fd 100644 --- a/backend/app/utils/single_agent_worker.py +++ b/backend/app/utils/single_agent_worker.py @@ -12,10 +12,15 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import asyncio import datetime import logging +from collections.abc import Awaitable, Callable -from camel.agents.chat_agent import AsyncStreamingChatAgentResponse +from camel.agents.chat_agent import ( + AsyncStreamingChatAgentResponse, + ChatAgentResponse, +) from camel.societies.workforce.prompts import PROCESS_TASK_PROMPT from camel.societies.workforce.single_agent_worker import ( SingleAgentWorker as BaseSingleAgentWorker, @@ -67,7 +72,13 @@ def __init__( self.worker = worker # change type hint async def _process_task( - self, task: Task, dependencies: list[Task], stream_callback=None + self, + task: Task, + dependencies: list[Task], + stream_callback: Callable[ + ["ChatAgentResponse"], Awaitable[None] | None + ] + | None = None, ) -> TaskState: r"""Processes a task with its dependencies using an efficient agent management system. @@ -146,6 +157,10 @@ async def _process_task( async for chunk in response: chunk_count += 1 last_chunk = chunk + if stream_callback: + maybe = stream_callback(chunk) + if asyncio.iscoroutine(maybe): + await maybe if chunk.msg and chunk.msg.content: accumulated_content += chunk.msg.content logger.info( @@ -186,6 +201,10 @@ async def _process_task( last_chunk = None async for chunk in response: last_chunk = chunk + if stream_callback: + maybe = stream_callback(chunk) + if asyncio.iscoroutine(maybe): + await maybe if chunk.msg: if chunk.msg.content: accumulated_content += chunk.msg.content diff --git a/backend/main.py b/backend/main.py index 92843bf1e..00321d984 100644 --- a/backend/main.py +++ b/backend/main.py @@ -37,6 +37,8 @@ logging.getLogger("camel.base_model").setLevel(logging.WARNING) logging.getLogger("camel.agents").setLevel(logging.WARNING) logging.getLogger("camel.societies").setLevel(logging.WARNING) +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) from app import api from app.component.environment import env diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5a0724ace..948a46977 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.11,<3.12" dependencies = [ "pip>=23.0", - "camel-ai[eigent]==0.2.90a6", + "camel-ai[eigent]==0.2.90", "fastapi>=0.115.12", "fastapi-babel>=1.0.0", "uvicorn[standard]>=0.34.2", diff --git a/backend/tests/app/component/test_model_validation.py b/backend/tests/app/component/test_model_validation.py index a8c65d5f8..4dfa1de57 100644 --- a/backend/tests/app/component/test_model_validation.py +++ b/backend/tests/app/component/test_model_validation.py @@ -215,6 +215,25 @@ def test_create_agent_invalid_model_platform(): create_agent(model_platform=None, model_type="GPT_4O_MINI") +@pytest.mark.unit +@patch("app.component.model_validation.ModelFactory.create") +@patch("app.component.model_validation.ChatAgent") +def test_create_agent_hardcodes_bedrock_converse_region( + mock_chat_agent, mock_model_factory +): + """Test Bedrock Converse validation always uses the hardcoded region.""" + mock_model_factory.return_value = MagicMock() + mock_chat_agent.return_value = MagicMock() + + create_agent( + model_platform="aws-bedrock-converse", + model_type="anthropic.claude-3-5-sonnet", + api_key="test_key", + ) + + assert mock_model_factory.call_args.kwargs["region_name"] == "us-west-2" + + @pytest.mark.unit def test_validation_missing_model_type(): """Test validation with missing model type.""" diff --git a/backend/uv.lock b/backend/uv.lock index 8fbada652..7feab5347 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = "==3.11.*" resolution-markers = [ "sys_platform == 'win32'", @@ -242,7 +242,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiofiles", specifier = ">=24.1.0" }, - { name = "camel-ai", extras = ["eigent"], specifier = "==0.2.90a6" }, + { name = "camel-ai", extras = ["eigent"], specifier = "==0.2.90" }, { name = "debugpy", specifier = ">=1.8.17" }, { name = "fastapi", specifier = ">=0.115.12" }, { name = "fastapi-babel", specifier = ">=1.0.0" }, @@ -313,7 +313,7 @@ wheels = [ [[package]] name = "camel-ai" -version = "0.2.90a6" +version = "0.2.90" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "astor" }, @@ -331,9 +331,9 @@ dependencies = [ { name = "tiktoken" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/52/5113d146328de8cb538f0616d0e7c65395dc52c6147a7b74d901978108da/camel_ai-0.2.90a6.tar.gz", hash = "sha256:9f1f537e49de690b03b4c110ace2ac17afa41663b108f65a0c0e609ef3b4eb27", size = 1206365, upload-time = "2026-03-13T14:49:21.5Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/b3/da958646c69b42cfafd3fc081b77bb19bb625773766da6ffa2d77c9c66e0/camel_ai-0.2.90.tar.gz", hash = "sha256:43f11673390cc8d4451d6b1bb2913ddef6131f411f47da02d16a3989c8096d02", size = 1212904, upload-time = "2026-03-22T08:37:04.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/a2/fa855f44589640b8ec1a3c21767d4fdf23ab5918a9aa2a73f21d1f7c2e3b/camel_ai-0.2.90a6-py3-none-any.whl", hash = "sha256:5ccc9af05559af51e421eb9ac1f48b30950117c061dd62dd67cb3e5b13f42d89", size = 1690382, upload-time = "2026-03-13T14:49:18.718Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e2/7005080797edcc760dcf7695ece29f97e18189ea2c56cd7514592801f0c9/camel_ai-0.2.90-py3-none-any.whl", hash = "sha256:9998c434779a1a847d9ccddce1c069f22fb9667b19ba06a2452c479882169082", size = 1700705, upload-time = "2026-03-22T08:37:02.259Z" }, ] [package.optional-dependencies] diff --git a/electron/main/fileReader.ts b/electron/main/fileReader.ts index adccf1782..40bc2b94c 100644 --- a/electron/main/fileReader.ts +++ b/electron/main/fileReader.ts @@ -31,6 +31,7 @@ interface FileInfo { relativePath: string; task_id?: string; project_id?: string; + source?: 'project_output' | 'camel_log'; } export class FileReader { @@ -658,11 +659,14 @@ export class FileReader { } } - public getFileList( + private resolveTaskPaths( email: string, taskId: string, projectId?: string - ): FileInfo[] { + ): { + dirPath: string; + logPath: string; + } { const safeEmail = email .split('@')[0] .replace(/[\\/*?:"<>|\s]/g, '_') @@ -670,8 +674,8 @@ export class FileReader { const userHome = app.getPath('home'); let dirPath: string; + let logPath: string; - // Check if projectId is provided for new project-based structure if (projectId) { dirPath = path.join( userHome, @@ -680,25 +684,72 @@ export class FileReader { `project_${projectId}`, `task_${taskId}` ); - } else { - // First try project-based structure (scan for existing projects) - const userDir = path.join(userHome, 'eigent', safeEmail); - const projectBasedPath = this.findTaskInProjects(userDir, taskId); + logPath = path.join( + userHome, + '.eigent', + safeEmail, + `project_${projectId}`, + `task_${taskId}` + ); + return { dirPath, logPath }; + } - if (projectBasedPath) { - dirPath = projectBasedPath; + const userDir = path.join(userHome, 'eigent', safeEmail); + const projectBasedPath = this.findTaskInProjects(userDir, taskId); + + if (projectBasedPath) { + dirPath = projectBasedPath; + const projectMatch = projectBasedPath.match(/project_([^\\\/]+)/); + if (projectMatch) { + logPath = path.join( + userHome, + '.eigent', + safeEmail, + projectMatch[0], + `task_${taskId}` + ); } else { - // Fallback to legacy direct task structure - dirPath = path.join(userHome, 'eigent', safeEmail, `task_${taskId}`); + logPath = path.join(userHome, '.eigent', safeEmail, `task_${taskId}`); } + return { dirPath, logPath }; } + dirPath = path.join(userHome, 'eigent', safeEmail, `task_${taskId}`); + logPath = path.join(userHome, '.eigent', safeEmail, `task_${taskId}`); + return { dirPath, logPath }; + } + + public getFileList( + email: string, + taskId: string, + projectId?: string + ): FileInfo[] { + const { dirPath, logPath } = this.resolveTaskPaths( + email, + taskId, + projectId + ); + const camelLogPath = path.join(logPath, 'camel_logs'); + try { - if (!fs.existsSync(dirPath)) { + const projectFiles = fs.existsSync(dirPath) + ? this.getFilesRecursive(dirPath, dirPath).map((file) => ({ + ...file, + source: 'project_output' as const, + })) + : []; + const camelLogFiles = fs.existsSync(camelLogPath) + ? this.getFilesRecursive(camelLogPath, camelLogPath).map((file) => ({ + ...file, + source: 'camel_log' as const, + })) + : []; + + if (projectFiles.length === 0 && camelLogFiles.length === 0) { return []; } - return this.getFilesRecursive(dirPath, dirPath); + return [...projectFiles, ...camelLogFiles]; } catch (err) { console.error('Load file failed:', err); return []; @@ -713,57 +764,11 @@ export class FileReader { success: boolean; path: { dirPath: string; logPath: string }; } { - const safeEmail = email - .split('@')[0] - .replace(/[\\/*?:"<>|\s]/g, '_') - .replace(/^\.+|\.+$/g, ''); - const userHome = app.getPath('home'); - - let dirPath: string; - let logPath: string; - - // Check if projectId is provided for new project-based structure - if (projectId) { - dirPath = path.join( - userHome, - 'eigent', - safeEmail, - `project_${projectId}`, - `task_${taskId}` - ); - logPath = path.join( - userHome, - '.eigent', - safeEmail, - `project_${projectId}`, - `task_${taskId}` - ); - } else { - // First try project-based structure - const userDir = path.join(userHome, 'eigent', safeEmail); - const projectBasedPath = this.findTaskInProjects(userDir, taskId); - - if (projectBasedPath) { - dirPath = projectBasedPath; - // Extract project from path to construct log path - const projectMatch = projectBasedPath.match(/project_([^\\\/]+)/); - if (projectMatch) { - logPath = path.join( - userHome, - '.eigent', - safeEmail, - projectMatch[0], - `task_${taskId}` - ); - } else { - logPath = path.join(userHome, '.eigent', safeEmail, `task_${taskId}`); - } - } else { - // Fallback to legacy direct task structure - dirPath = path.join(userHome, 'eigent', safeEmail, `task_${taskId}`); - logPath = path.join(userHome, '.eigent', safeEmail, `task_${taskId}`); - } - } + const { dirPath, logPath } = this.resolveTaskPaths( + email, + taskId, + projectId + ); try { let success = false; diff --git a/electron/main/githubReleaseCdnProvider.ts b/electron/main/githubReleaseCdnProvider.ts new file mode 100644 index 000000000..0b2f25bba --- /dev/null +++ b/electron/main/githubReleaseCdnProvider.ts @@ -0,0 +1,166 @@ +// ========= 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 type { + AppUpdater, + ResolvedUpdateFileInfo, + UpdateInfo, +} from 'electron-updater'; +import type { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +const { GitHubProvider } = + require('electron-updater/out/providers/GitHubProvider') as { + GitHubProvider: new ( + options: GitHubProviderOptions, + updater: AppUpdater, + runtimeOptions: ProviderRuntimeOptions + ) => object; + }; + +const { resolveFiles } = require('electron-updater/out/providers/Provider') as { + resolveFiles: ( + updateInfo: UpdateInfo, + baseUrl: URL, + pathTransformer?: (path: string) => string + ) => Array; +}; + +export const DEFAULT_CDN_RELEASE_BASE_URL = 'https://cdn.eigent.ai/releases'; + +export type UpdatePlatformDirectory = + | 'mac-arm64' + | 'mac-intel' + | 'win-x64' + | 'linux-x64'; + +type GitHubProviderOptions = { + provider: 'github'; + owner: string; + repo: string; + releaseType?: 'draft' | 'prerelease' | 'release' | null; + channel?: string | null; + host?: string | null; + protocol?: 'https' | 'http' | null; + token?: string | null; + private?: boolean | null; + tagNamePrefix?: string; + vPrefixedTagName?: boolean; +}; + +export type GitHubReleaseCdnOptions = { + owner: string; + repo: string; + releaseType?: 'draft' | 'prerelease' | 'release' | null; + channel?: string | null; + host?: string | null; + protocol?: 'https' | 'http' | null; + token?: string | null; + private?: boolean | null; + tagNamePrefix?: string; + vPrefixedTagName?: boolean; + cdnBaseUrl: string; + platformDir: UpdatePlatformDirectory; +}; + +export function normalizeCdnReleaseBaseUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +export function getUpdatePlatformDirectory( + platform: NodeJS.Platform, + arch: NodeJS.Architecture +): UpdatePlatformDirectory | null { + switch (platform) { + case 'darwin': + if (arch === 'arm64') { + return 'mac-arm64'; + } + if (arch === 'x64') { + return 'mac-intel'; + } + return null; + case 'win32': + return arch === 'x64' ? 'win-x64' : null; + case 'linux': + return arch === 'x64' ? 'linux-x64' : null; + default: + return null; + } +} + +export function getGitHubReleaseChannel( + platform: NodeJS.Platform, + arch: NodeJS.Architecture +): string { + if (platform === 'darwin') { + return arch === 'arm64' ? 'latest-arm64' : 'latest-x64'; + } + + return 'latest'; +} + +export function buildVersionedReleaseBaseUrl( + cdnBaseUrl: string, + version: string, + platformDir: UpdatePlatformDirectory +): string { + return `${normalizeCdnReleaseBaseUrl(cdnBaseUrl)}/v${version}/${platformDir}/`; +} + +export class GitHubReleaseCdnProvider extends GitHubProvider { + private readonly cdnBaseUrl: string; + private readonly platformDir: UpdatePlatformDirectory; + + constructor( + options: GitHubReleaseCdnOptions, + updater: AppUpdater, + runtimeOptions: ProviderRuntimeOptions + ) { + const githubOptions: GitHubProviderOptions = { + provider: 'github', + owner: options.owner, + repo: options.repo, + releaseType: options.releaseType, + channel: options.channel, + host: options.host, + protocol: options.protocol, + token: options.token, + private: options.private, + tagNamePrefix: options.tagNamePrefix, + vPrefixedTagName: options.vPrefixedTagName, + }; + + super(githubOptions, updater, runtimeOptions); + + this.cdnBaseUrl = normalizeCdnReleaseBaseUrl(options.cdnBaseUrl); + this.platformDir = options.platformDir; + } + + resolveFiles(updateInfo: UpdateInfo): Array { + const versionedBaseUrl = new URL( + buildVersionedReleaseBaseUrl( + this.cdnBaseUrl, + updateInfo.version, + this.platformDir + ) + ); + + return resolveFiles(updateInfo, versionedBaseUrl, (filePath: string) => + filePath.replace(/ /g, '-') + ); + } +} diff --git a/electron/main/index.ts b/electron/main/index.ts index 1b9d5da40..3c699b9cc 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -99,6 +99,7 @@ interface CdpBrowser { addedAt: number; } let cdp_browser_pool: CdpBrowser[] = []; +let cdpLastAssignedPort = 9223; // tracks the highest port ever assigned, never decreases let cdpHealthCheckTimer: ReturnType | null = null; const CDP_POOL_FILE = path.join(os.homedir(), '.eigent', 'cdp-browsers.json'); @@ -121,8 +122,12 @@ function loadCdpPool(): void { ...b, isExternal: true, })); + cdpLastAssignedPort = cdp_browser_pool.reduce( + (max, b) => Math.max(max, b.port), + cdpLastAssignedPort + ); log.info( - `[CDP POOL] Loaded ${cdp_browser_pool.length} browser(s) from disk` + `[CDP POOL] Loaded ${cdp_browser_pool.length} browser(s) from disk, lastAssignedPort=${cdpLastAssignedPort}` ); } } catch (e) { @@ -759,18 +764,27 @@ function registerIpcHandlers() { // Launch CDP browser with automatic port assignment ipcMain.handle('launch-cdp-browser', async () => { try { - // 1. Find available port (9224–9300) by checking no CDP browser is listening + // 1. Always increment port from the last assigned port // Port 9223 is reserved for the login browser let port: number | null = null; - for (let p = 9224; p < 9300; p++) { - if ( - !cdp_browser_pool.some((b) => b.port === p) && - !(await isCdpPortAlive(p)) - ) { + for (let p = cdpLastAssignedPort + 1; p < 9300; p++) { + if (!(await isCdpPortAlive(p))) { port = p; break; } } + // Wrap around if we hit the ceiling + if (port === null) { + for (let p = 9224; p <= cdpLastAssignedPort && p < 9300; p++) { + if ( + !cdp_browser_pool.some((b) => b.port === p) && + !(await isCdpPortAlive(p)) + ) { + port = p; + break; + } + } + } if (port === null) { return { success: false, error: 'No available port in 9224-9299' }; } @@ -905,6 +919,7 @@ function registerIpcHandlers() { addedAt: Date.now(), }; cdp_browser_pool.push(newBrowser); + cdpLastAssignedPort = port; saveCdpPool(); notifyCdpPoolChanged(); @@ -1815,7 +1830,6 @@ function registerIpcHandlers() { // Read file content const fileContent = await fsp.readFile(filePath); - log.info('File read successfully:', filePath); return { success: true, diff --git a/electron/main/init.ts b/electron/main/init.ts index 57fb26c87..b7b3c62e7 100644 --- a/electron/main/init.ts +++ b/electron/main/init.ts @@ -289,6 +289,16 @@ 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'); + const devFileServerUrl = readEnvValue(devEnvPath, 'SERVER_URL'); + if (devFileServerUrl) { + resolvedServerUrl = devFileServerUrl; + resolvedSource = `dev env file SERVER_URL (${devEnvPath})`; + } + } + if (!resolvedServerUrl && process.env.SERVER_URL) { resolvedServerUrl = process.env.SERVER_URL; resolvedSource = 'process.env SERVER_URL'; diff --git a/electron/main/update.ts b/electron/main/update.ts index 5239725e8..61ccc81a5 100644 --- a/electron/main/update.ts +++ b/electron/main/update.ts @@ -19,6 +19,12 @@ import type { UpdateInfo, } from 'electron-updater'; import { createRequire } from 'node:module'; +import { + DEFAULT_CDN_RELEASE_BASE_URL, + getGitHubReleaseChannel, + getUpdatePlatformDirectory, + GitHubReleaseCdnProvider, +} from './githubReleaseCdnProvider'; const { autoUpdater } = createRequire(import.meta.url)('electron-updater'); @@ -56,25 +62,42 @@ export function update(win: Electron.BrowserWindow) { console.log('Current version:', autoUpdater.currentVersion.version); console.log('Update config path:', autoUpdater.getUpdateConfigPath?.()); console.log('User data path (where config lives):', app.getPath('userData')); - if (app.isPackaged) { - autoUpdater.checkForUpdatesAndNotify(); + const platformDir = getUpdatePlatformDirectory( + process.platform, + process.arch + ); + + if (!platformDir) { + console.warn( + `[AutoUpdater] Updates are not configured for ${process.platform}/${process.arch}` + ); + return; } + + const cdnBaseUrl = + process.env.EIGENT_UPDATER_CDN_BASE_URL || DEFAULT_CDN_RELEASE_BASE_URL; + const channel = getGitHubReleaseChannel(process.platform, process.arch); const feed = { - provider: 'github', + provider: 'custom' as const, + updateProvider: GitHubReleaseCdnProvider, owner: 'eigent-ai', repo: 'eigent', - releaseType: 'release', - channel: - process.platform === 'darwin' - ? process.arch === 'arm64' - ? 'latest-arm64' - : 'latest-x64' - : 'latest', + releaseType: 'release' as const, + channel, + cdnBaseUrl, + platformDir, }; autoUpdater.setFeedURL(feed); + console.log('[AutoUpdater] setFeedURL:', feed); + + if (app.isPackaged) { + autoUpdater.checkForUpdatesAndNotify().catch((err: Error) => { + console.log('[AutoUpdater] Initial update check failed:', err.message); + }); + } + if (!app.isPackaged) { - console.log('[DEV] setFeedURL:', feed); // In development, check for updates but don't fail if it errors autoUpdater.checkForUpdates().catch((err: Error) => { console.log( @@ -147,10 +170,24 @@ function startDownload( callback: (error: Error | null, info: ProgressInfo | null) => void, complete: (event: UpdateDownloadedEvent) => void ) { + const nativeMacUpdater = + process.platform === 'darwin' + ? ((autoUpdater as { nativeUpdater?: NodeJS.EventEmitter }) + .nativeUpdater ?? null) + : null; + autoUpdater.on('download-progress', (info: ProgressInfo) => callback(null, info) ); autoUpdater.on('error', (error: Error) => callback(error, null)); - autoUpdater.on('update-downloaded', complete); + + if (nativeMacUpdater) { + nativeMacUpdater.once('update-downloaded', () => + complete({} as UpdateDownloadedEvent) + ); + } else { + autoUpdater.on('update-downloaded', complete); + } + autoUpdater.downloadUpdate(); } diff --git a/package.json b/package.json index 264deabbc..c9b1f4480 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eigent", - "version": "0.0.89", + "version": "0.0.90", "main": "dist-electron/main/index.js", "description": "Eigent", "author": "Eigent.AI", diff --git a/src/assets/wechat_qr.jpg b/src/assets/wechat_qr.jpg index 2c1bc979042874fb200df6be514b63e51f25668c..ec8ca75246bee09640bedd7137e0803541bad444 100644 GIT binary patch delta 217536 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

Yd&>eYSmS9U98z8GT^*%r^0(Y&Xz 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

^X)s)X~9KZ%esZJRbw>ya_Rr#Rj_a%&8oWf1x| zH8+=Z7h6`8XQ^lJ@AB1D{ZZ=x! z;Zwt}sQD&-v~v)sZq$whdbUhTM4HDn6V0(L4$}p?wXVZ+Gj!cA-LvWyNMFHXENY~* z4)FbxFI;!?v*tVW##!dYeC$F6D1;@S-)EDnusJKMdtKiP_zJn)B+uaM0_X!i*wi-v zP+-!4F8MrJ+sBCNZ!z*ZtvY<#ydf~UVn7fo=;lRp{NY3~l$2gfZH>d|KwoX#$z`xdsDHwBc++iSw&>>9GSC38`wa0iB1+ z={L&$gNZ!-@WCYM1{XKN9qc?50NS+O1lUbzga!309ePt9$h|C~&-w(LF_#l}YDbix zJpL@Z5va3U7JJTxS4Xf`N6_Ig36RyPPczYr3EAu#+<`7wht7CD!jx8;F1D<|1!y=p z)+a4Hy;xs8FN~yyljun26S(q38p^Ah z5(_Yo$k*v)5Z)i{=)7$mrmS+wz1v{#4#bbdJ`B1^2K#)HkXkn-J1dp{vjpfQebi=| zZoet2i^-`FWRx{1g`s|e$5BQe&I`^uzM?0x}(E zsWXRw9HGm>Ht2dYWt&Nwm4!#87)CJl8e5Yl<=~BN_QtZ6H+|*3P0^9N8%bMRTIo*j zo0^ga2YW8P&(al3(uYCMvTN&FX%WxMr`L(Ce@!B#s+CN4j-1s_A_5>$=a;`&RaYZ2 z`AB~-M1v#YI-duG(j8=)n_kRXQyrfKXera$+ZA#B_*{%=4u zM7a54x4GexgF&@{db_DllGZWpr;K81Zm}~h9^e6;o^#?6wNOvTLEt&`2>FU25Mo^ z;bav+`*rB}BlQdc+H~wrb?J}OYG0%Y7+`F!RY^0B_4BIfmanAY+rVXMfG;LAxffXs z_=@`#KMA#h9GtlBP|qfI23LrP3wamm1`+a@6 z>LfK@ufAtCx*DrfWAYTQi~Y*m|5Ek2l>Om&|1%kFFoXteM}^_4Z{hU|6nd75unb|bLUUq#G04H z4PT7j=Ga^7rc*NssB+=rwt1BdA;C1Djn$hTwB#KPc%2Mq`)eN&taMj{)o8Pxl$RPS z*$t2NRw^3{3(8SUtWw@G23zM+pqu`~9eIR+pO1^mGxrzA2L zfnn+Rre^!Vmsv)Sp{@S_qRVbF+O-Qp7ZfG*qEjttKF(&d+&xk#_9;Xqd0K_8dXBg zokGBYBUTU)R|9QGzC42=G6cD`@8( z_^!RA6jF2krf)-I2l_@Es6Jf>=DX93~ zjG@Cn>#C>&^wI>fi-{nX;k@vzAOzu%!NfmpwxvPaM4r+&#`*&e$g#)$abeYvK3d|V zh>vIPPe8pVaT?TEF%pXykodngx^sk%ZedO~P`Xyr?&SN>V!ryW@6`~oNcNAfyVqN{ zJY)FWVo^dk>K+mcV$sWU%lF}>g-AE4XXoV-rM_2V(vn$2P>##{<2D~dG4+&=J!|mL zBYuFw6ixZMsq}E#3S)UoQtn?4pc8klQ8l3-YTc&===9UQe?megQK->un(>vrHaKUX zpv&#e^ULN4A+>KInrs!lmED8kylJmstrvVYsQ7sBjlhR56Xfj+7<%BQ(+>78iuliq z?JqMy3_Hg321y4n(q;1}HOeHZ0`p(=1)ALN+jBU7(aj|Hax3%M9hDmbs>lEM9)R<4 zvA({px-NBh*C>S6C`*?=Il=C$1yoh7C@ zoU(2lKN6x7v+i6sX`S6zzdjV0Po#Km7WaW@Lz4JcL}=AdLP8;rSgY-q-pMw*RLA5p zavM;21MShR`n`xO##%xoK;?BVFzrcO4KH+JEO*mHaKR%iR;^gMLRb3BU>om&NYk{9Z?3M^*twb@2V)LYKQ z-}qXhtI>`Y1yWClZvquWtw6(3)D;5M({cJ&)1~UP1R6x^4ICDbj!h1-_&GndAas~TQ`Q=l3&LjP-!)L)uT>L!HwAWTs5?3 z=hmmqV@}xtXO|^s&L~lzYu)>^7AG-Z?^6U7*S{V<`wHkQh~Zhl&t3l@sZknE*<7S2 zx;Rcv91}?hYsu?;{hULCLn?mW{HB8wv!YO5i57)qs|PTy>n~r`CevAdeeMIVQM2DLS+ZgW@p#8b?tR8o1xIkh=s_U!PK%~eG-WSiGXjKGjij)s` zW>-6}p=X@$qklqn&r7w68Z9PgG7}qoN1jBo6n}eY^ zo>R}$(q@HaH8?>`d%NTMZi=@XfAaIQOL?z<+`G~K%<#N!oa3C z0;VSD2rIc!5_7@AbBxP;-OJPj1IR|G)--=vA?Z$HfB1{b%B>=HuamBe;Y0qb%c-J> zrhxPSWdpsKmt7^+Y7k+vz!3N}sL1NiE3;2cma0E2?ocJ2Zx{3N60m;A9o)+sJnTV} zPaevy^=)0ie6_S0Sh7DI!H0#h=tZj$G=|()(P!))4DwuGxFWcAU-s~%5nxoZW; zO#nO!@B9x|H>2l*&ma^;4`~_5iq%CsmqCk4SK2xl&j)=6(NTNcn}g7RV|rq4s{i&C z-&KWHt$kyMMy)3UD~>)!Hk>MgtiOKH97g|Bkw6NO$B?yqt!~ri#$$m9+|9o`H7?K2 zHb#vb)DiFXxt2W86O?>y_))f8#ikVCxXX%9Qo&Z6+QhsC1@0SfC#igM-gtWG7K7)z zr>q0)M>9H61dJ)iZApq=hP)#-iod5=;A$d8R3MKEAl@uxzXn1D`mgX;>}#SQ)^Pms zcxt)Fcdu-*{>Di4ig=0k-L)1m@D-#Xdms*_K8X@?_Tl-GS>!xk^K7TTT;jTt2Y07hgQrV2ITZH^GVVLTBG@` zrns}~ltt{P!9Qq*!0aG=y8s)2H~vv$NxyXcEx3?ehLh*#EQdB`hix8K2y3vth>>%7 z0Hc0?B-)YyayP6Sh7<2XFd^P2NW&>fM&DA`hsTzrOABh>b$Gf#vRE^ft2buz0Om^Y zu$L+@a}iCr1Z*4^vw?xJbnW<&<-;JMC28AQbPKClpNPYi_3nxYM^$NSb!4v50(bix}K;HHSFUKw!8Pz?46)C zRRa?13YjAds{0SdFt6}w#cg{pnam<{gtlD(ajWvhiCkuuE9!-<8}FY3bMm>hFI$Qb zm<|T{I!gjeRjc>-{YKfm-UQm|T$`(HmUolnLTI-na==2=oQ{tXHBAdMGkQ{`S+5KI z_rJ<%bG{!HceB3Q6i+Gk&lAU@*#tx>$J2yn=*qICUWWlxycPfDK7Pm3*5*=8%8J^P zE@7R}DZ$`hZCkQe4W__rKi*--HsL>DRdC1cO()VN7wZG`B3y;8&d%I_uXZV3nk7H(eIDM;vz}6HHees%6y(YF<9;l-DU+_+ds%Zo5ugvFgKi$BwMPk^cHX{p9}RDa|BNK``=hmas5)Eea&DycK0eF;GD(lfi<%b= z*+0bJd7#@$HH9C!0T&=qokk-Z+@{T@I@8NvAD%=z`;-E^&#$xeX&jf8)K$d4Ci1zu z$7fKwUo?S-kwyM#ff96T462y;{E^SI9e(+oCc7TQSDm1n_bZN-NHgwDAEyrMaAz^g z2d!3@s;eUlvVvrB!cZ(*Ra3Pz`(s9sywI4D8KtE*sa9Fc%S4nuXy8*J0+3d`62gJ4vFajTmqRwg7-fC5@r)mW(5 zxfYbG+>^2%m_gp%l}jfj)z;QG*GG+wiDvmvKU5yoQ)}WA{~Gtd1;fZWh{aDEcnYHS zYiQ|ncaC@bH~4loK?{d3-!93_av)U^HAp9>8$LT9-OAZc!^npqhnBtKU?I zyzv3?;P*PXRsSZ0|EhnWKiQb%w&J=S_^Uh(u~^QdSYZorqi=oFYUO5T07{D8!FgSm zRVe5)-MVa7eXx2bV}9D#FmAr6lMymqe!;>5c?N#osvBb-T3z3qZp$MV@tNaMk*^^C zSMK0hzdp)(pfibho(ryfP}YV5EdGiN!5|z|2utEbPoO{ywO_B>hGXFS2jKG9pBfy= ztc#cP4V;<>7`$;JFId6F$D;A zYe;;gyW`&c@MU=9;diS^bBP(t`}-=^nLw1^UC!Q;>rCD&<{R)|Bf%(9@(8-FyiLky zM4PFM@aVCuQ)6cAtc#l`L)5K8Z>o2$EKf4I78gT+BD^J%AUv{-D&Z=R;G+Dor3by) z=~O9~1TRU=ic77NO}pQ6iME4eg$ezYzBT8fz>x4ApeGwq?eW{ zSJ0I+&~p%$;w4Zcb3p}=00bphsX=d^`Ip;*CAiO$+Z3||ahVI-!+-NML_*pGPDwX! zG+HWHa9(BJr@fuQP`}}u))ZSq0NrkCm2q!+rWwy=`KigunV!JEf%qLES-Q_e>PG*> zq_b=DF->7wkK=Nt(En}(;Ky{lT-PN5eBul0kW>ri+_teFs1BgnnfIS1&nopY_m>-= z`^5*Gdv5&%;C*DpZrWEXr;0ge-%Tg^MEYnKWqDRAn-)9!HRH>_`jc`JC(ix7kNJat z1=jtVJW~1gd^thM!Y8(a(#_xA-xXqI!?DnARSbDrlw$UwQp#NYXWtWMJayr8}RU@|qGjCs~3n<1xm5KRh1q=F(y{ z^nQfbm-Op!Zdmoe9EsH*VFHty?Ng#Dznp$U6A^kdKRnYHx_VHipZ_0h*A~NgqTzjV z)_k4MLt>u~3ZVlWJs7#8`5N&$xerlYw;jI-Y|&eoz9~Tr#`2A0m+BHfHP_b1&gx%G zd~IJZ%l$B@b?rkN`-T7)c>i}%y#m}g#>{2-_Nn<}Rj*A|%mR|m#olrFW`8sf=e;9~ z#X}Wt_BoL6fiS3d8lk~Av*lBX(G%M86u%eC3&}s)Qj)#Bc$KO+SK`X=ejw>#vhety zLbOFH=?gchqky$k?$OUFyd$1rTCg`q?0=sUFB6Tu*L}j|6bv@qv<*hufLi)BHV}bkCt|D9Dn|M?^dpyB8seuyLD~ zHQ$llnwCEm-Q`V9;?$(7YI7VFdHcE|(=aNm%{nhGEEr*`!2=H2BZNIulz{4J3w)Ck zQAe;2%54r9O3k_WuFU^(V z7Cigl_dsG|gU%Kh`e=b5SmnFZ1!UI?CTo!A1Fxc+^lf!syXI9-NRfvmQ`bV|D`3Oi zh98VhdL^4ZB>rrXZJ@;{jL#>T9jWC~V+m8M{+af=s{_hf*R&<4YGZliRvaCe8y?)( zG48pA9It@|ju4R`az?_dHlol=xm7?#ACZK1+&>N~7@5_8M({4pCuG+JnOf9wUn82r z3j|`S0P<7?Q3tn>ywQc;c|z2f>(FpZAr8GK&S<=Fa8oXyLJ-7vv!OzASC%+WP<5T? z{PDdk@A}fKZ!%|+nf5zi+*B2{wScQ~# zvGOupdWD7z3D>NziQBo}I$&IGd2|{Kc@613KCM8pf5~u`nlX1~wzw;#mC(bW`C-*1 zXNk>Y(2o*u_Z<{Il>RX@2|ENcBWC9*!(W@}md$l0CM=m19aOYdx_o$}2f7*|)qlD{ z@3@J6#{?fp$jsmL*|)N?@PV033u!vbZ%tA?oGoW|$W zr_!!X?Q$sDvxufED$`J;d42tLUF(KM4$t3!P88c|HsrP!Y-aH{Y$u=%GouYTxYP3n zH0c%63$rVWs^nf;PPm{g+59G*EIDn;l^ar00J2u$it;}_WICrW|7PX7i^??;rY-_b z3M!-aS~1m+koR%DpMv{Up$q*TA+*9#u$5*6`0#PSm7U+C!`%72?IRA>9>lbpOlRsi z*RQL(hPDrT-4CR-v}vM_4KQ+euqgC%_ywmH2|`N2JP>M6T_1I={*zs$D^>5($JbzD z%$(H4NWmK~VhgtgxKpM;3UKINp@z^^;c;op+QHQ`f66at@-rmNSUfjDIuS2_xU2J{ z@bi+-n;jJeTlWIt+D}QhIsU;|H7~tHVO8Z57n`^{ zv*$j_777a{_-`T;pgI~7+rcsufUtD$EgpC5(K)>eapFiFo15DT^OYU&Wf_D%vxPjV z{VWXAW6^Vf!RS$=CI?{NLM?b223%pGC;$(gEW)WRg@-al9!kCH?-WU`yCB?lsf3QQ zNBll6L07-u#3M!cV+f4qC-NJpx}tlKHQHS+dcabL>D*?cMe&!uLX4%;Gdm-q+UMS+ zy#2f8zVGwzCdPo)n(&HS8ecD<8NNc2SSj(+r=Q96q*oJUVbT+90|5WIeV%>NgO84| zOZ6|yiz$p1^jFs8mVQzud+j6c$4kfpdc~ScdXJov7K8O-q zh37rZsU9JSZgiqUFfEDH@v+V3C0!-cn@JX*lKZQYhMJ2+EJr0PqfPd^_tCPu_I*9; zA|RlnXP%orlpz=$Ly_M@cAc9p{d2o%XvT6C2~DZuaMw;b6kEPEjTsuOI2+g_c<0eC`Gb>D%c(KT{Rd%3B4#M zTuF=6-~h}MVc@|w64t^yxzj9eQyDPiK_^{ipJtsB#kCqEa%-@?TuiV6p-5AQYDZun zM32O#yLAS@+5K4WVGmpA$3$_o^{xCn?35*ocapFK=?_{)X5w zTtQ>PyXT?nSeiHR&UB_ou_!!dYixb)#v>Lj+r(!{iYCSY26ZijZYbcG#9EHO2zX&U zW+aR6gzQf}OGv_9l7eT>6K!92z9e0D54rV3S9^Mvmt4v9$@@jXzcm60wYg}hU*GB2 zY@xzVD8>rEe`pvW63s34ys5PPto2oT>IVj3z@0*L2iTGyQ&0^}t0mL&a6#Ue=t9fB z>!owe(pGSdpM$PAjsdDb*K?gI!*nlP5rZyiz5NtI(C-a09hkfIWK%%)drW@x%laig zJh=Mi@9959Oz+WqU-635vM9sW&$1;jam!QS=j^sHXG6<-_{?@QTnh7US4s_Dx%$vg zgocKSXk5ca@g@)Gzw$7QxPRINbHPgK$pB|RFc;jQ`W&Ja0F2~%vI&whT@VaNABP7w zp9iiI?1%9TTfSx%9?y~BrZTBa%t_U{I>ZCpjWN2pDor7>j?PIZKLSS?K@dak_hXp>AX>0B>HU-O2}6MT`^6r1wRwzL=`h6b63s(j?? z{FH~!x^L>``FR4(mIDa-=GA1WG%~Xului|nn%$ZE#;_vBy_sw-|A0m&Xfd98%hv-~ zE}zZz^}IGPpiuGAmP zRaax6D2#Yle!=+})vD4US{m4n(Gaiw%PgA>R}k?6^jMjQ7EV9py-M8uT(`W8QXY(+ zbFaWj=e2r&5%0@ot&NK;4Q<16TiG?oBz9Y98QB^ij}ju>S!AMd;`FiN-nUNQc%;Ol zr@nu?N=`83Ny-I@ufe{2A)dXI%Olf(O1lAAR{@~e;k{hWNf0n#`rRThx$UhtFwj(N z%X7s%S_z07@KdU&D3C^7*Q@sQv1@T&Hx;` zF*nQN3dH-{fRd9YzeLIBVA&cM`rA{V^nyGM>LzSr>%=WOlOgfiYYBW87}b7SO0>6d z{%{B=Wzw!1|B~nvcXZ;uZrvyUstF=n6NnVT$p_HxI=AT=S6}I6@Ec<_t-fV6|!fX_~&^I)RZMQpNgiHF=D|bzh zjgLd}JZM#rasQhMnKuKJEd6Ds&TEcHy5>vn1vB#lR4mVHD`ywfU)H6Tsz-`nvEfvv zJf~st(e5v2+S(X}H8h2OosE4!EN3QnEnGlpY+0SH8V9^|)@Xz6xoa^ToU)dET(1+& zJ#T+15O5q|4YOaXjSn#))7uj@!>CD2OCAlYR4p`cWDU<>_#)@_b)u#t8f&l@gP;w( zlc$+S=;$#?n0LZ>9bZP;N6)lOTgi^*>aTu_U+)pOC{%$%X~m!tm_M!k0D@3^>38m< zrivc&y9YE!MOfcuNjN8pji9$2Ta)Zm;a7aGW+HWZR_Dj*7vMn>OzAQ#_c~kCD%t89 zQPq#$f?$s21?gEwHY-<#F?K%E_NnjcHNWQH7azZjxzS6-$LDD^a)V2?E;R63`MosN z^>~NE0Ul`Eq@fM~x`toU7YW*Ju$z`NUtf&nG@S5?m7edX_p@tARnxHzAKXu(dj~=7`JlYdNATc5JWzQTZz48@jqo;e{LOi9jz2TV9?m$iRC)HV}pe zxx+^;(uXe8QJrZ6EpxhWU^$pTU%fxR6C}E&hLXwKa(P1gDrSOON7~NeMm7x;QQv5i z3i2Us0cIRKI+}FlGof-!tnQ6jW$pJkm8+I4>k98AYyp;-QF#cum2kwXYGB?+!&|5e*sMBF{G7v`N_0JjbFjWP$^@f&emHOOmC2nuF#qg$ z^;=*cgk;mJo30G-3tW4;6eLSDNgW;%t~+Syp}f~$PjO3GzbWEy2vUN4;aC%<2ssNq z*+4<8^whJbE$zO6tl1x$my)+$%55S?_j+Wgx6mva^nS-nyKiyYMSdBY6NdxOGNU@H ztju*8?0cy~89GELd!Cc^*TA23Ma>xM;J{`sYYuuFQ#uz(!{0{Kz6(38CAlN-YCXObvpT^BA!S6ZXcxN$}(NOvB($Ould{kW4s-}nlhq(m=yqV@1^jz zShx;2R^9VVKi7WldG(e((6RZRifb-h4XTi@pwB-zi`76&xIAke*?@z!EvCb3!-|$m zMca>;4jb<@PhF#vk>1^*{@&6hbwiJSq@49!I-Y<%G(5dKY zF?#V+GD#WO!2v(fAL{hBz?9@kol=oQ3B1~Ly2pjjK)msjR?5J=&$8!&DQt(?$d{oG zFq3sD|9hzpP2dD=@jmvy;Y1r$$3it{4BFlxSmP}Ae&Sj5GE-Qp?DX?VFg4zB)hzEgeVfTsGHLEc z8nS`8@kQ)IBMZKyws=aQQnbAX4&T;kdG$zK3&kW!Z&5{F3KG+v+*Y7(0_h4;{IX<$ z?t;)2JCDH&UxN!IwO-0`4a{pI+`?DNfn^P)f0b=hNn3^{wAk=J)~ zVlVCyAI2pRKTBRcm-2Mj14c6i#~QeSNtn^;r$FarfcF%^3dAoJ9qtI}Kj%KYoIZa= zxF$(CFU}EK^}W>h;qFyI7#DM|0&chpY_l4|jA*GJSpW+U&l$rwC>D9+8yuea@`sq{ zt{A_ppEY7*{c#R2Ll9NkHYMH)^y$6u1(BHM{u$oF*_@R^ z8n90iAsZydYy;myxUg_RT`#9CZ{b#uXC4yMeXllI-LU7P-{j3fVXqIG+9Dxbb}_7n zvjQ;erRvey89z7uD987zs;2EFB?W>Ztb%WPh5m)P5XV3&LLyNeHO#am6Xj#LnGVet zm&|Qt+nE}h)P*_bgt8dleB{5cccG;9jI1Zn#ww)TPX@42;>#_l>TJ;xg3?ideysUX zKxv0)u1NZr{A9%)UU~6~&Y0Ns)IMo*3P0HHC6xJT0@xn~K>jusM{EbULXs|aq~q1~ z@~Cz4u=#h!xgFO}sTOR{WObIH<$ls;+wayjpX_^JeWj>gG=GbRUsIca5p-`a^0P`|hcf4p z(z)K={@$+r51GlOApiAA?4!`SHjxBK4V&x#aAMbL3BI7Y*$LX37i6Te_7^%-Eq9AV zm=nsyO>Z1jUU8+odhd*1Us?A*j@#QB88}G@u-w)}UWt}t{TK{u6FXW)Qz8}%(G~GK%($wR+IXDsl2RB`Wc3Fx9r+XL z@155MsPNVI)?D6S20-@VD{J{>BAWF+JjVo``LKDd$~?jimD;*L?m(;PGW7sUpW0j<|0qDa1#muaVxxohbDA+ zLL^a`_^~O$sE=x39^>%_spOVQo{pI z>yHa%XO<8RfrQJX!)vr`tS^?|e^KN8G$x0^sVD3i^0qp8*bAwD_aND#|Ujrl365K(3Y(NpT*-xBqENucr=E{n=5_c&QG|NH|}4y{?QU!sp2 z-`+g}6OV%cxKEt^4~9X|g#LpGL(6-Ipg5oTK2ESXbU=O9f!+pos|)+x(0o6E0gN=U zhS%S9RLRyOdG7tUXul1eb=^gEgchTSmIfAdHvgAo$}kU|%>_Um1+Ra?vmns$W2M^D zp3vb`#xF50k`8fsq#UMjZ^QYx@)`S$ z3EY`aD5r{KvurI1(9L8)t%9q>;NM^y#MUL}zr=6ENR;gi^X=xI7IZ?oDV@-3 zIM%S>+9-S)mZEq>%&>K^56cGuiwIcbk*wAC(bjgj#$J@fv6%t|^4)5v`Q42oisJx$Zt41aN-27QlKB}p#%jDEWG2ZE@x ztmSIDo=Lz&%O{b110Lu`3K>g#q~@Ky$dj=snD6W){bD+R-PIs) z=_Nk@fp1AhiBeiF5$nquBrC)3<2-l$j3!*PPKx+dDa&`u2IlHa1X;>*qC!yv5C%<9 z4?Da>)%l{GlNLqBJohj8TJ|K;=jC^SB^T>}aHW2!)3!e=p|>G#U|vRvKMorYgXtfl zkc20Jc>k>!XN9CNB4EPu<|FHWHfz2rtr ze)cQw`w91eB{9!K(?E5~c%$xLos-7p7qv=h^JnrSH2}&dmNJ-PeNH zZ)uq5B9?RD;&fH2D{;5)y}$~T9@u$>C*_S++nt)J=h3epXMJBtD;m#j%1jwL`^xHQ zXr|}P4lU*+NRRuXe0F>)BFetR*eRq#m_Q-BJAZWH-p6uf9d4N(wc!41dFRvmY}=R# z%%i-03s%*J6s2tTlyf(Uc3xQ}+#l&}Khq&?75T!|B7m_e3nMsu;@XHnoaSgGIvMK+ zW-bN?q6p@L=eYg zml4dAKTgE4B%o-l%FZ~%m5Pl z&gCJORejniL!`=iNTaj3BxLhwrHK-&>}Dnm_1tl=yk)5`P&7u7mPZE;Qr*WhwvX>W zJQnQoZGa)4FFcy1Df0nsW^X;9PT!%I+XQ`)Buub4)Qzxkg*u5d>Bfo?{#IX`wmBVj ziVW)OuJlTM&zG?jbqZ(pbO5Vn28G2t$fq9a#tEmNcu(BSxXIW1+E3Fg{J4#u!HAgh zNkzPT+PUYZQbHdKav7*ugNJUYucs~F)%F9;ke_GS##*9!Wu`zB`Ju)6$c(7iRb)D z(xP1&<{m=}!lqJrOUz5+@DE*!v|0d&(}gI9#1?#cZ#`3g%Z=kj_^{kbt;5$DVO;=H zcu{pz>-J}XlY25JzQ(~%;X+O^09zYr6u79MrcX;vOTA}gu2G87KMd|;(A&6x_2)G& z7LYDB0ntIZ7wQFnZg+T;)@Oa3m0EM$|B6h9GXNvpV-UNsI>m7XN~Oy;zYoN_Y-t0v z?Hnu3Mr5KN{R0#O?o#|n9z76>g%Z4a z#_mJ=`E~{J!Nk@73ZLLz_A@lqq@9wb-I1M<{7Pw9HHXXVTpw#C0tWweo0vRNd1)6WD2pP zoT&esmx&aGq}dCabP}vUZ!|_9-P)x9_2|VYtd7qa_8=N1i&y5~YuGW4fd}mD?%da+ zX@m~(YT+!l&>U`XFuAfmqQkw+vzdY4BmfTLR+r&tZM`mKAO%;^757*jWB6pm?`kV& zevej`c)F=nGW??WaT*Mq?K;#1B-zPvXx+OEAsHiffuvK~^DT10U|`J+>s!ZNss=0` zRe1Wb<6w)Hmy9oIgzgx#gO|z9ZI!7_XqO989RutxG_++$XSle@>@!+U`1=E7h#1=QE(B$qT!kURv#8NP*8A-Az4@84d56xzzt;%2GzLhbbDu7XC0xqt z#y7_2Y~RA31OR#P?_owm|=j;ktJQDVXB*(FP(E&k$iIFr z=+h4-g~#jbcRIWplEz?6TjLa2bx=QGL55@}LJ?Te&C<3L^8HbvQzIT#KUMV3ER^MI zr_vWIP0gx@#l4D#PRn_@%3I^O4j>0}^U$2?`;`t*THd8zNZ`jd!~3RkTj7z-9{z4g z1?Od@WUY%YIm11F_Q0Zwb^r~9nE(d!#)bXqhMlCRWh!6yGx(nO@qcpa;cC24VAaOi zsM%p;mDqMR&YU|0hPd$h*CzdFAjuJ&BR0s(#<36L+D~hexc=u-<0Nixme88bkwEu( zq~NrW1T%o}MWxk}7F*ge^@lz&0z|t(G*HDs5|KI7i(On3gbTN293MU7bsq80&$pHJ zbjZjT4?Js%|0mI|a39eOCpgz4UA`X!xpwE(iFwQXiXx|u5{;pAFw(FL5VlnMcr_a^ zKIo%eqCcWi(z?VWuJo03)D_|06nKOOeXJCMYRZ`NhAwxrJYK$!-oToGZF8Ug>3CsB zt>5UO8^xaD=Gx2Gg311RSAG#6)B5;3H*?n0lS|Cy=<%oyP0x1ODcOow6op@-4PVmQ&3XD$Pe=tl5>_7rfHrk`}8+7}^9 zIQ(A|v_3RD413pYADi8Hnudt_)L(sA6ZDL^{MB>XTZET&nfYuRG#8=$3QHE#!iDJN5WihuFjHNgjbNOHfkA#<9UCN-n4k=ZyI>d( zk-3zy)Qlk5y^66Xc`Z>@?f7QZ$)!_%fj3_+%;EaQ?M1#wC$L(6Ir~DKJ1XBwmYzl* z0i~7?fhqiu36PYNU#=Q{yMiQM!O0iASklFzRg(^1Ss!kP8$MdN=rwZZ44CS*UiG00 z^pZg>*s$`joLmJF|2*(2JaAsJ`f66!(j17`y?zo&c-^jtlv$B3;gXQxjrHIC#Cn#ZT zq8JFjjZSn+6hotSKwI~JHah9{x3!-<63+E z{z_X*dvn_Hlk(p8Smc8~Rv%f;dn82TbOb|!0x9J4l+#3yfi49~4gjK7<)4s{SN(-1 z7W^bHnEJ5vrD;YEOZ4{&-;ck~{}IO3GiI@5fF-ycp58iMznD@_bs+XacWL z`uWqVy$J9SGMGN?>;S@UR>+{jZQ07JQ9uSI?iSXeOA+-Ig8yraxKMQ{8Gfr`BS@i$ z#6Atkx2U~|XB}zf<2y6YTJ|_BmsWCWFF}GhpSCbZ47fsPUq3uG_dWc6#vx$#ESPGg zN|zX3QHgfSdShNy@pj9Ssq6#xB3+seA{Mhc*1epAD&9${z|rb z$x-eenh54k;=u<;E&yfLjV;kA4IcgJ!C@lAsA4SF(HX7IS-5tw-JIEEXTZ^940w~96?Qt z6fDfQ3F6;apEGK6z6y)eXKvVnf_w`qHDwFlj+})4Mu4C7i{&QyC6Vftc8&NbjiZb~ zx1~)gdf5>W276T3JZWvl5=w2CR>9o*0G?KKFDN!rl&VYym4GfOlzf7il5KPvUyeiI zN0)M9E~y;<&Q#lo8}av2$xM|iRcO&r9u|Kh5V~_BTR41lqx>y$08HUczb9?X^dgxd zW5Fc~e4xFw^rO>_?;68V;%1_SzEwVLZF{bL+2L_*mqt2r>lhBx%!CGF%t(Kd>Bl03 z-6&Zrf6KUpdz?P=63nRVY@z#?D6#17oC98}H82B(s3@#c zmr%?_B6`3sC5q?YZX1!sDWujHLL0LqqUk2=N<&*w8hT2zLqp4;pMQoCn-8ZbQ2|@8 zc_vfn>D%ma%S-HIU&tc1thEY=Z2yBrbl)y;VHiNp^7LYluMlSjbCPBj4N1?-J$P_$ znzm)%?cI9FE|u=bzcD`+@ZglJp4o|WK1wiSZt%GE4b=&O_bOAl@b!>|%$Z=-_bBJ@ zAzJN^7T)yFt>*tp*s0U`Zk z+qYU=wjNk+Zg8wWhZqhkNG9UQ59}LAimcBdudKW9!11W~f)7pY4kl!MeWAbl$eE>` zSj5hw!*i%l!gK+O^|I##mFdjavGP~GjwDS>wNz)P(bJK~9TiFK7v#L{9v-oeQ+t3Q zUM!!z&z|o|@nH9zJ;n9%_TB2lvTJWrWv2KN7vs*XlAZEuXi%{+|Aig5@(M z_l)D2!tH?!K5I`4$wGycPMh2De6JG`=QXQNks|lUC?A?aJy(s{P~)Ho*$;ex%SqX< zhwMLhHg%Q^pRklEozhPgT{FM+%2DOW!c)fJ8)%@04769-5O<2HX83ZBZQX+#mDMKi z7M#B4nU9=#T07$7kOnxf9+~$!{+9(l-iQ~0?i7>4W3y5X2{RguS zX#G8|x4(h)+l7{k|ARHK(ANG;QcuTww&hjdJXZOlSHwp)H*YZHMMlAg7e`rP3^#s- z0-Z>x@j5#VMbKj_*#_Cqti+$}_H24G?@{l- z@rICI;glKTH_;J>WgG3cw&vHr?vikDH)^Ag|^LLc&Pwbwjv@FEV(y-}&LdM2=R zTbNHcD^ZQ|ye(qQHT&AsOK}ijcMzhW@V1*fBtIy!Zl%0mo)3tM6+NzO)j3D-%9dXn z+_*k0pusd`F842G4Pl^m0t3S8do-xEOXR_=%8jW+_f;9-K)6ho#=dR;>yL-;Tsu4! z!$EQ)4Sv{Vbn!d>y^kn2zu@LJ%y0Fl6N|A?%z7N;3=61!6J&{yrh@pM&_QLPIFsOg z2yO^3HWN12sT--9)c@_3=|6Yye)+2c7qZVdeJYcZK%V^%_i-s&)Tef0Cq(}BGrUe62nToJB;_2Qc?t=Y1}Ye|Re+eZ1aztF?Z;JNE&VW-cY z;y}gKHO)lOMf-eHzO{jsw5&B^Upw zSYVpH(RMe__57&H%%p3-h$D032VA1cD}#qh=eXJQ`%wd^2|$8_#{)rt{+vWY7#A47 zatjo4lCQiOQ1cgClYgJM_E==%=Yrug zF7$~UHB_9sRjo6Ydxyfk%*hv6fbDDjq7!0!Bd7;ZZPAxAm!i3 zutpYy;zJE88!d+_M0(Z}rTw)$K1`$JZpJgGdFEDOwh5KbI%Kxv^66J(vrcedee0vb z5n+*xSZ)M8%CU|WRYS8Ws`3ig@#%wm$<}lEIH&sFr;*ut7bk>V*9i8rTkI=wh?~Cu z0ws5sJY+dRwIsQMyldZo#p!AO#oggEzmV1C?>_BWfN;4=d&%eJ#TXzH*XI%QqLzLj z4!_(VwKTBwFEslh`q4sNXd>bo=~#vOhIPI9hZTQ-w>&WP$~O#qA1CF*TCQp>-XQ9M z3`30}k7IATpaIu+1)dtiw;WE~>>XtqgOL1NEro>eP(xSF^OENI9zot8>pVve8+&G7 zfvG#?{ev%up>{H;e#AR|t;}=2Eo%vb%yl{@{AU{Dw@ch9fWyH2j2TYx3FHO+?g?ltE< zL*((yy~iPNaD223Fnu>rms+JGE73K8>Y$+sq8tCU$8XQoC^B#Q(nm0QlBJ=Pnctzy zU;yFIPCO9=i@@v{3E~EB=K6%3z;zq#Zv?XQ3?+!9tU?3rZZ+?ai`x^!w_a|T%O#(5RtxCdTq%(+aS? zUwBjAq>#<$`ofrPY96-C-{KIVM;^a1haR53)z>IqFs$$%x>QcI1!R&2o=mF83YvpH zhU8YGvaguGb$L=wI2PTeR_BrD5iGuYW~|Rj)`s-cWc?w&8KPpXe!z(~<+D#*zXobQ z0iMD-BcZX%qlyWKms&DnQbO+}i#^EJ9tJYH*>Mae@H9F{**Wmgqev@}SxKZd#Urb6 zr`_j+w+o&|{X*vN)B0xVqXxx2VKi~9IeYzQUNJz?@gIxS4%g46Jo+~c}!o?)wx+)Y773X45S8jTHfkg%fGXtFS|b)H5!8e+=K-r8^XhmVqv7e5-Wdr1^X56)BJW~CChWJEHMJcN%_-_q$mw`k7q9l-V21197>L0@Pu>7P?pmEouFZ+h1HV+ zk^F`yV|Yd0d$j)9L6WV3 zmZn#S-BF;_rg0m|Q~xudiJ*C|A?ra_cuF4U71pe0xzd)qryn_YxjPpRx$~?Y!JZ^1~7+t4SG86my!m zD4eU;+1XxlfLllrDRjBt_3%O}%G*wLkM-Lb<)1)gvb=wH)(3$8RDJw7I6U>6IWhJn z;W&>XJDC+0723AeW&JBfE^0rE5*C)*SDx*1CS3KW#MG&zTb% zx~88bk#Uphg_4V`GHS6P_&W>ksvo&U9h`<)8#x_8Uf843$+GGXymx!r*9?U4lTCWsh8aJSlM0n1YTlMH z`*_2+CWca3?d>l5z^B8&Q2Z#eSvPrqR~YDn2~f51(d-x-$4j5zthQj{!=VvP!r0X1 zk<){)kSNZQ1BcFVaw4TTmguvblLgv2g_pnUmVvuS{boyXIuKYxe-uX>hGwoZXKNUI zW+kzJBB~`Vh1IeX$O1;4 zl(Z<8lrXL5HT#|Iqh*a#m^l9_Q^eO&6d!#FBH9Bhdl=;FXmbt^|M0_2Y24( z8v^dTfAL+s^GL`L-zB|E75tVeF1dnC8|oApbbO~5oS^$UqRNf`r>cxCk+vW#Y=3r` zGh|_eCqL;iLmRb4$so7-_YtgGQ6mfENhpV+;7AozSPdk|k;*}bgXA!Cu?-We6mWKN zV~ojPR{i!?T}`-NSg=A2?^X9bvzRk0ffN-4!39`y>)#X6I-_ah4<^cQPn+LQmKeOH zRWDs6<)PZ-`78c24D}Ff1+h-}N;wB=^!WZ#npQ`ahH7A6xbS`R6;08yZvg>Ng@)7} z{w`13wm~^t<^5)?C`t^x=zNzl=!d<91B#oj%a^8YU?ueJvX*Z;YxYEVd-Sz*JbqZ_ zqwqOJF`mX(KUaUfQm3dW5EAlbI@_7ktNCkfq)ths@h6UBV6lPVH)|pyGs2?Ble!cq z?nS@;CjU{@b|U)mEkBv`705e&HS1#{rqm;(?MKsUCHfO$HX8pY8&i&(#NV7WLc|7X z(NF^i>kqgAPT*Wa9NPw$~ew5muCk{cnRL0n}Q*R;{$t;f>h0kCIaFTrM&8S z$Gw@`GyZq#nfYVOv_H9LP3~LXo_D)5z{l}5q2+&1gLpOtAc@!HdxtyfA!jpmFI{tIUj6Rg!=Qb)}!PoBp z;Nkh2hPLLD(5)jur=NO-)*t>WIP8G01ds|tW}t*)^jjuWw2++E5+$+EzrJ~1O2y=@ z`>0iTb}<6usI{jBgr&C-Rq))(?w}zujSNIzwMGr-fh=2^l!R#)S$|EbW)XiM!CMKtw$=@a=zM2i_$@eZJv;KKP z%&)Gh?_dGh1<7qJh}-^LL(Xll|N3=Detk{T)rRqWS{ozt9>~mHg2V&Xn8??$P&yJf zf(MSIJUIWAtTzq#!ynFfW2{~+oyP zya`I~;*Yu5JQ3KGBcQr)f)J2Z2dp6GtwP>0iuNW*D_)KN)0meDKS`^=F4rNO#vh<` zBUXF5*jv6deG}*{>Hk?2AUK^2ZP+(!YPaRBtF>tes>fBR-T#%D%LDX{(r$2(yp!#c%sMpdqTkvT%I5?R7`tmeUHx+b3#!L|OlXU~aonH@ zX%yQpW-T-oqi`lI{ri9>8 z6bpsD#IaRofBT~|ckS_79PAnp;9)S4&1KN0nL#}u>Ak%Rr+-8qZQ=^YV``qZ52j)e z4ICL$8iRr2!NpEcoyiOD*p3ienC$XXk3Bm)mMXMMu4xBD3n_KfD*;uv+s(fI=5=$i z@D3=mtO?5Gzjj}-Tl^)in^%Jk=m2h$(-kWi)9G$TAYK4Tfme1Jm^}PQERXfX#81y= zOtgHfZytBIy`x3|XLxHkO{@!kJM*AqffQ*6s3Fn6kRDzs*|NQPY35q#ao#0oqig2r|dcQEfTj& zw{cwMV5OyB-=ybRBm2CH8tRx%Ktcx%ARjEIRbtS|$R@6q5B?)nF{eq)n2rxSjAk#) zlj=9z`e36?eJ1NIiaW}CW$)+7E`SqSOaWMf$K9m=U^T?gtbe#%K?6orO`WP-H#;}r zRkreRMelQ%PsFUE=xV-yMcw;Qdjp-c7q?dsx;r|1-j`aRGir_oAj55`&g_~$L(0b$&0U*c~Z%{3J|n13E4M|rwLv@E?a6>$r=&hbZd z90uRq6B{$QE*p%Q&Ds5H@^TY?FYvHjJMw4I1x`kJ=yNo1sBP)@!!DQWmN&)V)JkIf z1M17UQyBV-^AcJ+_B5Y9h>46Q7m77D?Nsn=O853iYJOF(|2?W}if!-c$UBj&NT~72 zw_>|$cBxIJFAnzIfBzZ%=xgHS3Rgi4@6=BtL?qc7eq?T2S3uU>X$9LdVs)1wxh!Ei z#F{J$;cU6}fW#!Yz`_Ef1J7)dxu*DrvSbx(6=h9KJyqVVhXV^PiPyhb$pD>6NR7Ec z5yW!tbO~*jggxle_$)TxdE%=oVS=4^ScaGJjsE7fVWUD=h{+oP@&YA#^}QJR9pxqMB?vpg zFCr2zbv1jpgfw3NX1mv`b%%j7JCjZ707P?m4ZO&di_sejMFQ4u5|$71ug0!C6i!hu zr}Nu!Sk?FyClwHiR;GWQI)@c|8riVM{UkYRW=*h*pXAElDX`Vj@Q=wkJ#oZAoY&Mf zfeW%&Y5?KeaAgLdFAuO{Ms6ha3zHZwYyAC8-1@WY%GXstrm6vJ^NeDcSUsJx$!QL3 zNuc`xsRF0$?G5vLMJ8sqEH6LLhn-3GQpkyd?FVLI*-`Q?P>+H!my;d-$?x_r+CQ>v z58!f{&C`yP$S09&roWUd)uel5vR3ubAK@%9dX)H;a^bB-3F2;wHrF}(DK&gLn{;z- zXZEL4N6cAY%EleBl4A5fkf4-;&*h^)_4uV(IA9nFSNh`aNuDOXsXjPADix{f9&M%O z;5@-^YV3>AJ3DJbbMYPTrvC%+dr@D5DCO1=@BCKO^J4Pjq|sM%+8s80?xGIY^3qRB zi23!bCFn`PrydDW^1z1(g4ZNb@6w)hX*#-Xm*up@*Vb6m*w~p}NP)&U8`j{7Az1OXeCch!o>K)I`}w<;3413qp_F;U7SkCF8C zj+@~X=%oN+JW2-?pIxpWi@dZVlt_e6|1tjUsOHj&IWs8K-(aUul#%n<%XO!4*1XTZ zkYk{`pU{JvjRZuUit@u`h>W9&(OyH#plT565}}U*NKP?=!+f$XY&X3y|HcBQJNj_| zOVg#sK|#+qyQiPyi?hGF-b`D7A-sP4RR(WX9lc-Gfdqpo)H74f}e|ujN#~_w>Y_ z_r>mu`O3|r94VsA;_mySkhK;AbrR|kiQ!olL*fMb%T(LH++F?>5TkwOVN1w|;iC%+ zZn$AVmnWj$meuY@BCb3QF5mWkdD{`1I|zfmQovZNciQ^=Q=eOf1JOQ0RLt!qq7xc7 z@#|_w`^4(!dxs}$!V{KuxYP?oZ(Zihl2W_sepkxp4eJFM{lyFPKsguGbKY3_Hc%@M zt_m*|u%YJJfz-(mLRZqAWS=4vq)UlRiDhp)Rh=g?g4FMq%KfnAwQL*Hyq_J;~8 zT+PH@8(G*Y$zBS%`sg8F-hhP+$a00i|I`yLGPU8UaBiww?beLrb&c)NSj*-1f7&cgBUO)vk_UrdOu^Fk{bmnlK?p zDbF%17|3#e6cGS*4jjKWh-hI!aX=mOiwa8`%x)4fG7VGxj@f+J!lpBCj_nY!x6+Vw z$z<6yW<8XFdh8Lt=yZ6KdScgc>UqqBwuErMu7_Q&i;I|ZWu(QhhF&=9P4Sl^?v_J( zQcMRYs<5SS9GZT#{Llh$Y%74b#Sc8KIj7dQ?AVn2j=Al7JM2dV zSwzHhooG+MZTXqjOjkyI4Zop7-0gR9VG6_dHMO^NG^Eb|%wB63DLeCtds1?_J8_s9 zD81|_V--NfUdBtZNXL2+Bq1)p@Z%-EU9wH@-ya%zpJQpjv?q=RbrOEbnJI`L6J0H# zd7u*!%T?g~{mH_5A>#{g=uo14{6p)181#2N5-dvhO#kMY?4MFF!W^jgpn@e%tR*y) zmuIb`7vk=WU4eN0K!gJcP2leSnF}j=s`V=^xt;HZdajw9h!;%dvf7)VL4T5>;us&} zqd@q_j1cIrx?x?tNVz*PE%pNMleF1f5~_9cA;j0loND-GA)_~)so785{hJ0{BTHYl=Z3wjw*{wYm%KsPRUzk+9NiO!BB{u*?I znsXA3td1l`(;nfs!mRc=p2ts!`EE|$4X9Gvfw8PV60)$jHR+axoFD$5PY!U=EILYI z6fOAd#azTO$)pIus<0(MaHOTrPGTG6>K4gH@LK;r%~7hg$%NfoO$k2fCqhHRLdD8HQ{gbW`c<< zcTkk$yMLx_IaC8XGHBFPMuXN*PkjprM#zbj9SpI^ks@@6h)}>AVy5E9$H(#D45)z7l>p{kdkp?LGG~`JnOBnh@mC#=Hy6Eeo~zEY zxmQ?leZ=pm$+ZWy+;4ad-j%Ieim+fMjbYR)lt&dYfkesL>WEtVmNuKaA5MMvZvN56 zU{Lyzx6;S0Pjzfk38o_GM#S>#C4Q7t4m8i?N}NK;U{kEVsA#Rd!=&R^k{Qm_g<4}S zy%-tlFV41F_F*uA9fAQG>>-RAPAjEreJ>Y8sakl{%neqL*3QrOgcOA=Sl!#|ZxmI^ z5XmSVlzrR%-Qq*pN$@qy=rhe_a$i-Ol)2QMCZDxEbh)8c$;r5)I_Oz;H6`j8BqC=U z6fyigmI-v6Ny39gP}A6Jyx|fr(#`iEJ3cHp*iUO$#oi`B<*cyM8a~%@nJ+O}rV_o~ z6^A%x3>v&WXHciMAfg>Q7121|2Ho^uMT*`|t3L;WG}x|xAzU{MFGbl;a@chpnOf8= zeU8aG)4rIfZvWGHh$3#|B3);^z{*+}A^>_-g7y}|6%Nx>e);*5# zq{U;OF(adf7rJunJ6cs`B;w!UtV;>w-e5dF7lxuJrtAV%$Pe<@;p+U`g=f1JR zlt+&JOx+!|+=ZcS?fR(^@h_Iy7v8lOOa%q?ax%c+6^P~{pj}ITM0t5=8PTy4*y|Et z==_;#rxKpmR0QSWhB^NFi+$KW$^7VK?nthw1h>f_^;uy$`!7=Ma(!*T7&=2>c55NZX^~UX)d*!8N=CO@ULlZHqI<#E1Bw&1E5jBhD)A~!bdJ$V>(VqTs zOt39qZ@IBkO3@H^s|`qqn9{)@W86 zB(=_qZ^-?N&$8t-t;-XzRXsr%I0 z5m@FP1A2>tEI?rgA}k%`z|Y2_l#K}a8JUs|^15m^WjVTyv&@#BeJSXZo`T*`4`T4^ zpS~O@HL8>_Q1c?9{7P$kN8Wm&V`p)hm8@LxX{tk|Nxj<*Pm*0c)R*=K3ll-9acArkP`k6jP(YwyIZXq#Itm020qSWmka^ts z_G#y>A2n{*U0qe{^0agXZ1aL;ji-s(<+ zQlITYWX*`Kh^u?SU;*k2f_PyG_XJ=k;?%H}T_&7g`*g~R>a9Oln_G3ixEPQ8Em*OPRa9KuF%=xd(_Qj0=TgX{M4bm+GT2x{x8q6m`<+ERvW~np4}ZOCZ<9%r zHfMbyg9cUQ^(NrF0)Epzh?YZFLVaA6Bz;@zPxK3XL3y{*bF&fi&{d@BEl6P^_G*VN zCWkO)$A{RB#vc?R&!DDJ@-tMiL(Ldll4BCOW>Py=z4y;A+_$s$M>t<{2;IB;iAQRm zH4oYU3=9;hSnH-@`0Zq$v|%ajFnT?-T!VOr?F$4J=Q{DvV$`8%XYF$C+vRr-Ri_dd zf(08MJIU7Xy_P4;-eF|u8DpS6p!k4wKBCpjP?GTTIfBl9QEGXCvCK1$$#ms%dBvxO ze1aCy4ID|AGxIgBdggpX%(C{E$&s)-vBb%~F7>JBU-w-yL{|&H<6B0h=f90|MlbC3 znWuMTTMbw#$U|Md|DXqF#Bx0x#A5|Qz>A1ma6Y7I^;WxATq7nuJY59!<><)CYiBDO z+eS_g>oMuUxOOSBJbg$>SNc@=jbj}omi@83SH9u-uDDUd5y5vZ8X~T*^O;U~_lQWH zmS7iT_!I#wVk8b-dsij=VhlZMei%4+6xXAV(Ul>{SpL?~#!1V2dPXnDxJ^_}+3jGd z0-l9^YpvOI~>S4dR)^pNyAV_GtO%8I*bHtWG!T&=# zpt}%elUjlv;5ifw8>dMZVpBiD{*g`o_lJfknun^?NXmb(kOrzMG4|(cUr)aGBs=+! znz`IR(sXJ^TKO4NSRp<11R4f*uOMCH_G_Oo63;-1dAxX1*B6$mk!Yja+(#yx^=9+N z7XhFl^a8z=K^G5S{ttGbtP7H@lNo@?r~D}fv^bf~fiF>(LT^qg+2Db)m_$iwUEw>e zxS)y;-mU@zb{Ak&<*KfB%KRY*eG-DzuTN2fm;cs&S(b%$)jTh6#>tzb!ZYuh81R38 zX5`&v2KK~1Nb3Ln6$Se;3KX$LG9H6oE4xkFHdcN&I6J&9_9L=_+nmsKTWCygO!KKX z#-FLbDBJ)70`!Nvr(Io~U9H73`QPL2Gu!5Ex`nOr6@~WDxDyz@D!|ESDifs=y1v7g6H^w~t3rHdbAXA(>-6!!4YZ zSDk6ke(ow2|2qBl^!y)G6g?^|Fdfbx_Rap6%e2vzS8hwlQMtZfXV&`H3LR8-2E*+^ zEPa8c$kt|3omcJVlumZKymr04w|)KFeo|eLtyjLX(`wR$6roV5UM65zkJa}>9E^3f zG=<8qN&eUl$VDAre!o_fhw}KXmafE4%X%}EKH@liRR4sjf;gl9Z(TACEH#FDn=)I@ z8Aulx_oy2NKNC+6{%}Z5&P4&?{kL$P^TA6;*@WVS&e&z!rTN={K0QxM2igyarrHl^ z+_?`(fi5{0Kb%e)K25Ike)gW>ONCH@^KYxp+p??YkH#^U)xw}XEJl~g|ASmk8Yi%< zq9rMeGd9yr>VmghuGe>tMoZMCs>}LaR2Q$7_)@6c$}a-z6;(o1e^+E8T_MZ=Z;76i z%fu9rff_5g&HCcX`?-XE8|vW;2nh>f(W&%FW%IL-RD(Y-b*$?Tn4y9I9><(VYm#}i zYQt)scNNN&2y=-!B{8tV&C|+?!#;=O&h;70Jiz*()4EO)zpZW`H0R*!Z~UCtFbYgw z;n2UKFD#Y$8fI>B8QC|5a%$gC;T~!ZY{g$BLGWF58~gGXK3NJ;-Ay1q!nNKsWUskI?t~*RztK z1&tmJw~rSL-tK(5n)n%*ZwyH#n88p0>ivfkyYuA0n-Bxv5GzF=oPgrm$=@&T0AqqS zYpZ}YO!$ekhe=_%Rs)l{R}%~b#-N@v0PilvHVJo^bCq;c1-llM0+opg-rX;N_7} z*{;x}vl`gH=ggmtUbm$Bg>Ki>+TjdRPaRDLx16>)Q5oH^pz zKw*@3R+X>8`$DKDEJVGk?WV-T5##=JY3Gu&PgqUZhoz3vpKMjdoxJUka%u8S9n&4% z(?-z^PXq1S2Alo=UYdrM^We=A$>?aWn(Y#Ymp;s6{|h_T|1a#!1ohy0K~n#H51693 z5C^FOORLY?yq8<)}YR&+nDd~b9yM?=iOua&ldF=K0ms{mutCY11U5^d|fB}{sbXsDdksM+M2_l!jt@a zUia=9K6;okB$WxqIzFQbpl)UULm4L(nNcpG1Q(osI7%M*EJfU3(;QMM?r-OGeP-Ez zzrp^fPsp14TZO=JYy*%P6(bh`$#Js?-4)%w=vYIu&s6pM+U%azLqFGwtKcdp#Vw@T z5Rs|ZU}d&~^gl6T(_sic>IsO5NDb>q41e*xVb1f!oEkqE;aT@q=)Iz!;kso!7lFVK@DcFAU}N_}^KVPS-m7>Q%*k@uv+V{C3+wE8lu>QD+7N zhV+GIv`0oQteRRTcTY687S~w*l#BGpKfh>Y|1n*Rwe;B;&{v{4c9#<`qvy&8u^4!F zG~x(Vr*KxvzeKGe@YP`8J-WT?t8>~N19AwZQv$mzt%mnWxfV-dMOCtln$X7_ZV(Wp z5&nm?XuHPgCYrkC!JW`w|8#`h*SZHfsynJEFFtsYbwaP4WR>KdJ_N#!Zw=1bLF2 z380yzJO3Y=zB`=C|NsBkdt~n;BV}gGI#gD&Dr6;OWs{I`qwGy6LPe>Nz2(?M_TCO6 z`yA>xImdbbUVXmT?+=%M&UMbY@B4M0^Dzk8;U2~F!TxtAc6sl+2ER8r{P@P`(v+~| zx&nFOAO#FEdTDta3QkKM?j43hW>Ky4AN%S>CecW^$8nlbo3;{iw?^Fc#5L)75gsiAv`>4L)M&%i~FybQ^(E%aO zS+pKj3?0-{0reIEEM6~NQM~4r#>w7Rou?^oGc#FDa;n$Ix}28=qVl(BI#i_P{~W~w z;V-DVGDh*>Mz`u}&)(&F2I-)NCjE*^=?l*B2Z1czWPNkO+!n$sNLmP5OMO_hu)evW zf}SbHMPOHul7HA_cRBSf-K}h$xcay-AU3aqy|D0FZlWgpa+uc`!NDnDEr~79Fy(fu z9-k0doYjTtN0wCY*%;^T-X7ZG_`nLq6ot6Fj%{TtTo-l!ubgE|r zJ)AZ#fGoN83$eX{aQ()@sa&%}o3@pKHI9(rRony2a( z^&kA@aLm7%d}qbyN&&k;a)TZ{%!2)B6>TyaX%_*CUh&TH$R> zOoAh-E{D?cM~Seruct)nT(qXg_pMsYQM^>eW=5V$qBSz-MP^x3ADtJUE8m~Ww-FM= z0c6clx5-=?ku9!glw~kxM@fE;bR_C_U2{8{Gp5ZWmp;KLmn6(^QyA$$jO63!fj8t{+kl*Ue;F z^(t+X4TZ+lnJ3u4^B-j6+jLSqBLU+CCLBMW16ON@et^WBtltD=+CNF**PuE)yoRzh zw2Ydsh4O{YHS?5BuNsFSZ^4|7MZ!1BJuw3Nu^f_DlehYg{F>2KSFWAFpng;y3@?La z!;#_1-0b0=>UYrr>wi|$)SZg0mxdi2f>`Iia5HyH4)9QiZQU<%sA7%m9V9fvivox$^tU;6@ufP8TB8XV*@0n1 z+(j}LlWPZl^usQT(E@tFEzm&{fQnx9^Dx@;*s!~p#G&3I{s{49u05K%o)`ae-^i|Me**(cYY$6`M1%J8d>=J6fqBGy5DA6cA%>mT4dcBLQ4;p7R87yR%xP zbDc`VOwK<^wXc2}Nz$l{O%USsnsZhz;ro6`wMWx@gx7D)7bsZio6K``Te_Zq4hwSM zmU=m1jq|6u$0k=gq`0jywxu%avh*ybgU~A*tckWQbf@Xp%W08YP5*r5=Q*Nfi zlPvyTkoF6e7Nx}X_DAQR048KLwx|Y*LSdk^1&`L0>vt()3m%g+w`S{ZEw<8rDb9H+ z$Vep;Wjni{i+2x`IlX)mB5Y|X4p+eOr##3&;!b>vc$A@2#qt<14gC*OmqroUJ8`dQSdanUnVDzy2A z2?eegwVGHD)OBW!^7Utahvcp7&IB%C29Q543}$qbQj1)$?S)K`X!F&1j{fzcZY!YN zrwQ<4UG{4O^3SS1TH|lu&0S&hmhq+uL@X^aarK{2Xo0oN|NVtF?l=Dj6U+6tAQghl zHFRet@FvYQQ!o#k_`+-2qS`4_v6~N5X|@&pn<3ul6}s6e`mEArdT8+y=o*l{h+5Db zuXpz>)F%?hRkI@-TMK1<7~bUib=MCZPoSi@oy~vo$_^>6K}Dv`vtPF)U7IsrK>i%bmmg(`SC`Xqg+(gmiWF^pL|Q2P$|v$ue9`Pq z!`;6%-|r(XI{JGQr3G*1fTPWh=8EpLH%?ZxU$LV3TB4sm9xFpWvej8yadv~G>dWM`zn&kW$t|AX;_S#d@IHN3OY`?IAfKNH8g z13Zt*!>W>(ba+}xMy5rpX!=NmcX^14Ku-T{<3TC<6G;*7^({!ksthmwmeHf{0n500 zIEvq=W93rJSA{g@d0WW+G^4D}6MJe_cjr^!#h2NRBviun5$XmYcj)6TPsrPn;G+Ccw)2E89c6C-Yf~4}3g72VAw};$?wRS6# zSxS^tnM2?;U_Y{5dEfxP#TM=^{(kUo|GG?k;PTz>h2i?VxgRsS*MyrAm83ZCI6o}U zf933#uB)r-A%g`|-)J|2(0OjVC{tpd67mZ!$*Qp-XJzcctK!7f(L0&#bLK(S*YA0Y zYKd&92>*Tt{a;9u7}S&r^T!#XIlIxp#p_22UTw-Bmv2jpS?kiMi*Pc*`10Rd5 z!TQEv>58`btNJ>b>s%X4b6uZ3t2#CwCi zw0P2p!T{FHGi`u;{(3?#j-GM^<(@sb&0 zBoWMbU|0^i3>}2yGL=Q!xS~uVhtOe{YqS}A{XQl^*s+}B{@dWCN4^8SCZ_t8|7z-W z@HN7`I|I_lcAZrIwAOB5D!)%qtAG#ACIn6+Tx1sFuqLzhNcP|fa-3^8b3uKpRkF)W zs1=DW^-$Ab4NN^Yxt6ujTfY+OsSv|@fTw0-`?$g4m^gk`@?CPgOnC0QcQSXR_@u_G z`<=h+;_0T)5{OQ}3~(MoF?(LZni?^+X&xj+JN8-NLz{7@8;$!Iyfp4cH;;WA_0+)jtF_$So*zRa^R~sL=BJM{Y2_# zOoU6T?D>2QYKCBo*)R$GdCly(B32NLnj}D0ox#sR3x5;$ngB;F%bl$puii@-$f4_O z5ustWJV`1^{iu=bYY3w~guBje%n~>%3?qI-unD^QBvlxw>=2`#T_!_~HZCZVhLb>4 zQJ|Rz7zaYgI`1e)@DS=_RQaCjbE@XnmPu5m`|Ep`UHzM#A}LK2f31+>yg(<5a)}L7 z{I_5d;GW2{c(dq^-SJ_)g`@vz3cC+prvd{P1QJG=1O9QuyeiYa-&zH4PhMPHDMs_d zI&Wh`ZEdQb2ggOz^sXH@f{nLBz9jv`!bR!!j0@$+KZy8>~b@b`;Kv2VF{hCqz zxvyjrLRsFJsWnS4tar>%uGaC?ZdK)?zpg$l0Bm<^Av}HNCsF-SUk@%|<0#5Hs8=KO zdUf0=a%zPRs;!tv*ofu`^@z)O+T>L5?tVJSHO0RqXff=a^AD9og05ZKVzTKXOf)ZiL^<0WkLu>`M|zBc&{shbS6yaoyUw4 z<;>o#n!IzGhK5v##=@kw?A4rVQ8rq?lbbS9a|`^Y0*N!oe;sLBLf%pLDmdg6lA3IL zLDtZs`nlq}0_nASUx8}{QU}Yl+-z^VDZh_QP(s>)tMhQ|LyTr^=(-FuK@xrV?(^=Q zwo_$R_bP=f!;3?$4J%e@B83KZgs5^T2Q`+nN4Y9D#Ll)tR6e`64)@W z&FN7%z0>2QKZ7zW5z+10CzqyNmy^!_$z@pdWKoFX_=o-SXC2J5V!LIj~KOM!5LQAE6rKFJfFHBlz8{ zDQmsIW<0Q(yTOJjf9~-j(Do~;v)>d9G^BmGh>2n0ElG;nYd?% zjU{`tvJd8$ck;q}f3tLry@$#h^~@g*bQCjpa|@HRVwHg@yWbAF{j-&2}j|?hc$999@t8c>2r^=k>HPPfh3bK3gX8_oODGfOn3zj z^O~9Fas*{aqHn#8CCTWdA}3V=zFdmqAj>Q7K-EY4mG5^g^7^vT#obRE8{|sRxAGp5 zY~(1>r>*CU%}?t||mw zwX^-PC*mv7LYj5a;vsu8Y?BA}+h^`BcI-Tl5QsSip>nM#QWf&)<`3-wMkd(2!dGZp zwO3BcO!ObTD&~bBC8Vy|NPeMR+YE6%ki`cP2XheoVa~-K@LJ=HjS4+IWUg`ROdz+{ z!J+q8$+Y>OqMF~YEtJQYC1nnh2tE;D0MB&kJTC(AtEhfEn&7-{A9uDA^*;ECz3qzK zZVv0*%>=18Gte*guk2*andT&{QppWhX$0W-JH$a`T?+Dm7jtQM5mg!=sAgo+!;|yQ zE1+~ISGi%*=E2f;b?Gdd;&+2810(l--X{g?vsK`p3P0dQhi)`RtKa=V0N##X=x>0~ zPoCT2mHQG~bKY#LqdLTAdKj=N*W`Bm&N7n;UR;lZs=?Rv!%1OmP(E>*m-dh>>M{^2 z31YT&H?z|FtcA^G$ro*rSI)R>Mapyy=J-~2gl545TNtUSehwmp$1*Ec9T)NuH)@6^ z5HGdQRO)0Gya`CUKSUv?KQVI0D9V)2fvfhRuUz-ZWQRT4`kbr>paH|w))6t5Ji7Ux zq2*pL#pU*|jhP*nyrOz2XPDPyxp_PL{IVj$(|~d!I%(a$*Ce`~H5Vwx%cvV(dS3qe zaBXtkBk5(Lo3&_Q^wmdxZIO#?2P4Lm`UD}I8D2DEGHfqc z^jP_O*_RFI3jN+Z%37Kv@)2lp^61!IEKuQ&t^)~S_j2`;VX0NGkI1JN*%psXZ$$^B zzOJ-<|387ReuZ2Z;+_r*V*Wm1?{-o)Jy z@dbWw_a>@t8u=KxIQqgkWit(avyq(FhjHM-KZaew0TOwfwGnc@;x~ib%&bG-k*u(Q zldLI;OZbm|zD%QMfkIUexsA0qFRbK5A{75k+E%N9fR_hEj|;eihB)5&I0K5Ofm|>D zUW<*b^-_%7rF%-V^oiz)8OhI+x0-;$4Cg)r*n2;~_rRGoFF)p(#N}Ey8cKxPaP#PU zRXnE=k-J7Kpyyi*V}2Jb=GVXb2awwOphnC6&My%FJV~~R3rC$>Xoy)&9yv~xTl*Td z)m~36y!S!d*}@89`tdg1ualb{>--Q`+^flpwulM^xah}E&onbib)B19S~46HMfm6Q z$>tbK(14844_I4Z)&G3me*Y)Nabq1MqZEr?Z_oYh+Nzq#P5C2bgh=M?{X`hGrnzmV zO{VPgv+WIdd?6_=#NpEC4dexPBD^k9Ucw+efWPS&nvGsnqmMeU&uv83tsWrokM{Z zHbHjDS1N)|A0_b4=-ho2nyDkl#{nf1*CJ3r9v2{f|N6Y< uBGF&(C*DY)dOYaA_ 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≫91ExG1W_oB! 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/} z-iF4+8R9KtAOueaHm=KWa1vkh=}K%~`J96^^*4foWLnJRhhO7;EYo)zwB?cx@FTiT z_Ii`@Pl{1N`=*unvpa<#-AdG+UeW@G~)gw6-6 zl_E-S+UFSb^k1UEd@#ARjpZ@>t||RT9Jp(%@y=~g$GiR)uS^__DJaPpX6Gu!%VKF5 z$6Meg<13(DNQ^kfQ5ciyHvVW%K%YvMR;3SgNwV zjEWk8rD@!Uaorqy#2d?P+$fD^q+DkH?AsWL8LA@f9}jcc7YFG*V15U5ATj8e^s!)v zLvpWBg4HeJNkvZOMsWx(TOHDuOXL{}ETVxo?p5aODX z^mL-5w8m<8#H+XHqRsk?E(JdW1k6OCu7f%h4SF$+2hpuFoWE{t3qu^}dAXj0HS%+@ zBIxMmf5a@-|3(?vfC^zyLQ#h79nSk}G=H-;pHV)QKIj}r{)v0!S)BW@UosQf>Uqib zBKbtA{DWmWvWj%KKcD0Frh3K46JqsD6Z0y!-m5+$*!ma0QSZ*aE`l^@QQF@9D20b> zTSDWMrA{BlN&Y+1hlR~;`ki*gv6vKw=cMZ6_Nqkw3csIs2J#gQ8Ar)}Yj!|sJ;=(3 zdd#ok>063#?j>WKVtANi^t-OJJa11lxiOOzFXvP7=xeaNHHo<*MrqT(1?(DwgJpdV zAf(p`t9Q<`5A4qY_}LS`+fdN+#h5Tb`q;E|ak518X@P4@cn89}dIwFWr$twco(|ui zweis6eOm>zbJ4d5<^SK;GPFgR-^O+~{xTN+a=GJAcG0OobBs8hfWP@o_`DJ}7Q}6| zX^seY3ae|5`NY+~`t!u@HOxo;h}ZXZ|SRX$`ikkG3E~%_103_Ae%nRZHd^*M>^IK6Vb}L&MWje~azL`JI9aQclxW z4yh#f;vSpRGMC)uv|y1gUhX`ucv+%QkGu*t`#P%dX#?)`qx>Cz+Vl`Mb4Tp$qKYmb zV6Go~GhAr%>1`;yOHO4pK%vm#pQ>a&H7CPpJwgoeBE1lcfG-g8RI*iTTS+6V0@MNr z#7=9jWT*#L-e%*Qnrt~J67CxznsdqBI~8$DeYT`)jXeW0oR5BYjJ%? z?h4E3rrhJkg54c9>>R0>jduN!>?!c?KB(NZ3I?aKi+0q_Fd+ zD2Kd$pM~&!r;B_lXKb(5pk4(27wL;G7psT(ZNwG$Na({p5R8u77>Qa*J#`!kzf-L# z5`qH@uClzvqu*y0eshm?S||eJNC^`_my62l3Q~BisFU$^cIzXqdn5GhL0mb^?*yj` z8T`+QDfj!9kKAIWdjas~8Fg9t%e-PN|DO}y1Gu%`L@Dq1Ki-M5Xg23~wJ(3Foy{Qv z2W~PsG_{@ZE7-oiK7{iQEa?Ogz09x=+MjTK=cP|(u2kUY_p=4B1|ALRWhHY)n+caG zzhrt(EIoGqgs{g3PI^VXtnz5AWoKw~^t2#j}k z`KV9w=m`^<>eIT!3GM3IFCW9%##+DUaC|haY?_jYZjtNV`S-%K*P8L|qQ3N%u)#w# zHX(HQ&6qM*VW|8a$K_#4Zk8z`rXWkxXIK28_iJ(YUdTpHc{v!NU+|wibXMp*ZhvZO zi5dJ)Zr0rn0btLadWf7k0j50cl^o`^&Q?y9XKp%>!)q?`Qm+LS&S%Cx>BG=qErsX= zrei5XB%&y&3@#mkY-Qd0K}anh!q{IJ3>m9&tnuGr`gn?>IVkEkr-$h~Ejr}TiZTf) zg?oD`8>RnV24#+Ev(2DtTP|afgJAG&o6&H@YZ}<@$PIfSI{c6}OlB)1+r?=HA|TN{f4)=DP6-T-ocNA z69Mk4X9*@^Elu3Y=xY$g&sWunRDLt{(=6F5!$R6quE!+~e-^i&XpcW+5fFW1dO5`t zI_jrSxeMynexZ zmG=iBYVH}wTgEkD7(1lL^hJ~I7!#s~w1KAT${&>uW_zy>NFKjX9sGoP_7TdKRKu2r ziJFpdF9KoL!S{Z@ukOZeO`0VbR1`cX(|H0-z*4uL( zF2Mv9{K$;Y*JB))zYMGQ8B=Du`v;;*7>$+PIW=}kRi8XO+%upfGOG#OUiz>|zySf5 zgflGaVd5gA>ZDV}(!SOpws)~_&%LaY&^&N~lr5z&OFZ!YxLzLN4c%^J?r{BKCc0P$ z75=H)4p#+hJtGS|!%rY?!1bCEI_pkppT-mr??TQT|4U7Mp#OP;+24N;GO$b|Vbil= z6~RYo-U^*ooYxTH4AoU=1OXsJsPyUiE3O%(I>_ADmcUf5{6#uQD zVhhP3mA9N}I5!_$&TPm<4u{znm9R$QQ?9&x~*gv|E$uoR>${Es|aElV7Z?ob) zlVo1?1cGXduF2*i%vhv$-BtP#AWJ_X+4<9W&Cnh?GvTT-6}gNo-fR@fT)7md6MPz> z>I^_SV^>cFP6ZBhz(b4z)fXASUxD&8{?fY$AuIUuWr$o+v+wz!Qp*xFV>LD(VgEJw z?q4m1;ZKYU9Vd?YPwXS&=h)E6{maYv^n*CG)C{W>$F;i1wL8U~!&O?A6fU|Ck(Fc0 zOviMJ69!e;I&EkjSC24mJ#VFr(AidC8VFFg&qpEk3GcpcHr5H~PRR4;8=B|FqMY-Ps#7NHycec>!3b2JV<~LR`Rp9+-R0yCj}F_c32pnwFd<#1eZd$ z>AZa^159yYH9joye+l88e+~Gd*R-p0+SvvxpqJQv^BHhuH;BRtuP0)xJ7$v`xV7Kd zT6Fv@Dv$0?JwIt#mUU?swdFH^BSvAkF2q&UCrIm_;W9UB-|B}(=8P#L9-tD3g3C#c=20#2 nq8ToFcg;gnRhFS#No}gH|I<$Xf5eojZp+ZMx*et~Tjc)-5g_D{ diff --git a/src/components/Toast/creditsToast.tsx b/src/components/Toast/creditsToast.tsx index 9c5f2319a..c0846ebb2 100644 --- a/src/components/Toast/creditsToast.tsx +++ b/src/components/Toast/creditsToast.tsx @@ -13,6 +13,7 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import i18n from '@/i18n'; +import { SITE_URL } from '@/lib'; import { toast } from 'sonner'; export function showCreditsToast() { @@ -22,7 +23,7 @@ export function showCreditsToast() { {i18n.t('chat.you-ve-reached-the-limit-of-your-current-plan')} (window.location.href = 'https://www.eigent.ai/pricing')} + onClick={() => (window.location.href = `${SITE_URL}/pricing`)} > {i18n.t('chat.upgrade')} {' '} diff --git a/src/components/Toast/storageToast.tsx b/src/components/Toast/storageToast.tsx index e10639a0c..5c3fbd98e 100644 --- a/src/components/Toast/storageToast.tsx +++ b/src/components/Toast/storageToast.tsx @@ -13,6 +13,7 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import i18n from '@/i18n'; +import { SITE_URL } from '@/lib'; import { toast } from 'sonner'; export function showStorageToast() { @@ -25,7 +26,7 @@ export function showStorageToast() { Please{' '} (window.location.href = 'https://www.eigent.ai/pricing')} + onClick={() => (window.location.href = `${SITE_URL}/pricing`)} > {i18n.t('chat.upgrade')} {' '} diff --git a/src/components/TopBar/index.tsx b/src/components/TopBar/index.tsx index 1dc18956b..18ea1fa92 100644 --- a/src/components/TopBar/index.tsx +++ b/src/components/TopBar/index.tsx @@ -25,6 +25,7 @@ import EndNoticeDialog from '@/components/Dialog/EndNotice'; import { Button } from '@/components/ui/button'; import { TooltipSimple } from '@/components/ui/tooltip'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; +import { SITE_URL } from '@/lib'; import { share } from '@/lib/share'; import { useAuthStore } from '@/store/authStore'; import { useInstallationUI } from '@/store/installationStore'; @@ -113,7 +114,7 @@ function HeaderWin() { try { const res: any = await proxyFetchGet('/api/v1/user/invite_code'); if (res?.invite_code) { - const inviteLink = `https://www.eigent.ai/signup?invite_code=${res.invite_code}`; + const inviteLink = `${SITE_URL}/signup?invite_code=${res.invite_code}`; await navigator.clipboard.writeText(inviteLink); toast.success(t('layout.invitation-link-copied')); } else { diff --git a/src/components/WorkFlow/node.tsx b/src/components/WorkFlow/node.tsx index cd3208952..337914506 100644 --- a/src/components/WorkFlow/node.tsx +++ b/src/components/WorkFlow/node.tsx @@ -425,17 +425,17 @@ export function Node({ id, data }: NodeProps) { : 'w-[342px]' } ${ data.isEditMode ? 'h-full' : 'max-h-[calc(100vh-200px)]' - } rounded-xl border-worker-border-default bg-worker-surface-primary flex overflow-hidden border border-solid ${ + } flex overflow-hidden rounded-xl border border-solid border-worker-border-default bg-worker-surface-primary ${ getCurrentTask()?.activeAgent === id ? `${agentMap[data.type]?.borderColor} z-50` - : 'border-worker-border-default z-10' - } ease-in-out transition-all duration-300 ${ + : 'z-10 border-worker-border-default' + } transition-all duration-300 ease-in-out ${ (data.agent?.tasks?.length ?? 0) === 0 && 'opacity-30' }`} > -

-
-
+
+
+
-
+
@@ -460,7 +460,7 @@ export function Node({ id, data }: NodeProps) { - +
{ e.stopPropagation(); const newWorkerList = workerList.filter( @@ -496,7 +496,7 @@ export function Node({ id, data }: NodeProps) {
{/* {JSON.stringify(data.agent)} */} {toolkitLabels.map((toolkit, index) => ( @@ -506,7 +506,7 @@ export function Node({ id, data }: NodeProps) { ))}
{ chatStore.setActiveWorkspace( chatStore.activeTaskId as string, @@ -518,15 +518,15 @@ export function Node({ id, data }: NodeProps) { > {browserImages.length > 0 && (
{browserImages.map((img, index) => (
{data.type} @@ -536,7 +536,7 @@ export function Node({ id, data }: NodeProps) { (_, index) => (
) )} @@ -545,8 +545,8 @@ export function Node({ id, data }: NodeProps) { {data.type === 'document_agent' && data?.agent?.tasks && data.agent.tasks.length > 0 && ( -
-
+
+
@@ -554,14 +554,14 @@ export function Node({ id, data }: NodeProps) { {data.type === 'developer_agent' && terminalTasks.length > 0 && (
{terminalTasks.map((task) => (
-
+
@@ -570,7 +570,7 @@ export function Node({ id, data }: NodeProps) { (_, index) => (
) )} @@ -578,7 +578,7 @@ export function Node({ id, data }: NodeProps) { )}
{data.agent?.tasks && data.agent?.tasks.length > 0 && ( -
+
{/*
Subtasks
*/}
{ e.stopPropagation(); }} - className="scrollbar scrollbar-always-visible gap-2 px-3 pb-2 ease-out animate-in fade-in-0 slide-in-from-bottom-4 flex flex-col overflow-y-auto duration-500" + className="scrollbar scrollbar-always-visible flex flex-col gap-2 overflow-y-auto px-3 pb-2 duration-500 ease-out animate-in fade-in-0 slide-in-from-bottom-4" style={{ maxHeight: data.img && data.img.length > 0 @@ -662,7 +662,7 @@ export function Node({ id, data }: NodeProps) { } }} key={`taskList-${task.id}-${task.failure_count}`} - className={`gap-2 rounded-xl px-sm py-sm ease-in-out animate-in fade-in-0 slide-in-from-left-2 flex transition-all duration-300 ${ + className={`flex gap-2 rounded-xl px-sm py-sm transition-all duration-300 ease-in-out animate-in fade-in-0 slide-in-from-left-2 ${ task.reAssignTo ? 'bg-task-fill-warning' : task.status === TaskStatus.COMPLETED @@ -755,14 +755,14 @@ export function Node({ id, data }: NodeProps) { : task.status === TaskStatus.BLOCKED ? 'text-text-body' : 'text-text-primary' - } text-xs font-medium leading-13 pointer-events-auto text-wrap break-all whitespace-pre-line select-text`} + } pointer-events-auto select-text whitespace-pre-line text-wrap break-all text-xs font-medium leading-13`} > -
+
No. {getTaskId(task.id)}
{task.reAssignTo ? ( -
+
Reassigned to {task.reAssignTo}
) : ( @@ -784,11 +784,11 @@ export function Node({ id, data }: NodeProps) {
{task.content}
{task?.status === TaskStatus.RUNNING && ( -
+
{/* active toolkit */} {lastActiveToolkit?.toolkitStatus === AgentStatusValue.RUNNING && ( -
+
{getToolkitIcon( lastActiveToolkit.toolkitName ?? '' )} @@ -799,11 +799,11 @@ export function Node({ id, data }: NodeProps) { ].activeWorkspace ? '!w-[100px]' : '!w-[500px]' - } min-w-0 pt-1 text-xs leading-17 text-text-primary flex-shrink-0 flex-grow-0 overflow-hidden text-ellipsis whitespace-nowrap`} + } min-w-0 flex-shrink-0 flex-grow-0 overflow-hidden text-ellipsis whitespace-nowrap pt-1 text-xs leading-17 text-text-primary`} >
@@ -824,14 +824,14 @@ export function Node({ id, data }: NodeProps) { animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 24 }} transition={{ duration: 0.3, ease: 'easeIn' }} - className="gap-sm rounded-r-xl bg-worker-surface-secondary py-2 pl-sm flex w-[342px] shrink-0 flex-col overflow-hidden" + className="flex w-[342px] shrink-0 flex-col gap-sm overflow-hidden rounded-r-xl bg-worker-surface-secondary py-2 pl-sm" >
{ e.stopPropagation(); }} - className="scrollbar scrollbar-always-visible pr-sm max-h-[calc(100vh-200px)] overflow-y-scroll" + className="scrollbar scrollbar-always-visible max-h-[calc(100vh-200px)] overflow-y-scroll pr-sm" > {selectedTask && ( @@ -841,7 +841,7 @@ export function Node({ id, data }: NodeProps) { animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -16 }} transition={{ duration: 0.25, ease: 'easeIn' }} - className="gap-sm flex w-full flex-col" + className="flex w-full flex-col gap-sm" > {selectedTask.toolkits && selectedTask.toolkits.length > 0 && @@ -851,7 +851,7 @@ export function Node({ id, data }: NodeProps) { {toolkit.toolkitName === 'notice' ? (
{/* first row: icon + toolkit name */} -
+
{toolkit.toolkitStatus === AgentStatusValue.RUNNING ? ( + {toolkit.toolkitName}
{/* second row: method + message */} -
-
+
+
{toolkit.toolkitMethods ? toolkit.toolkitMethods .charAt(0) @@ -923,10 +923,10 @@ export function Node({ id, data }: NodeProps) { : ''}
{toolkit.message} @@ -937,7 +937,7 @@ export function Node({ id, data }: NodeProps) { {toolkit.message && ( @@ -960,9 +960,9 @@ export function Node({ id, data }: NodeProps) { onWheel={(e) => { e.stopPropagation(); }} - className="group my-2 rounded-lg bg-surface-primary relative flex w-full flex-col" + className="group relative my-2 flex w-full flex-col rounded-lg bg-surface-primary" > -
+
Completion Report
diff --git a/src/lib/index.ts b/src/lib/index.ts index 55ea93e08..63fbcf8a1 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -14,6 +14,9 @@ import { getAuthStore } from '@/store/authStore'; +export const SITE_URL = + import.meta.env.VITE_SITE_URL || 'https://www.eigent.ai'; + export function getProxyBaseURL() { const isDev = import.meta.env.DEV; diff --git a/src/lib/llm.ts b/src/lib/llm.ts index 65f4340e6..e06105bb0 100644 --- a/src/lib/llm.ts +++ b/src/lib/llm.ts @@ -136,9 +136,43 @@ export const INIT_PROVODERS: Provider[] = [ id: 'aws-bedrock', name: 'AWS Bedrock', apiKey: '', - apiHost: '', + apiHost: 'https://bedrock-mantle.us-east-1.api.aws/v1', description: 'AWS Bedrock model configuration.', - hostPlaceHolder: 'e.g. https://bedrock-runtime.{{region}}.amazonaws.com', + is_valid: false, + model_type: '', + }, + { + id: 'aws-bedrock-converse', + name: 'AWS Bedrock Converse', + apiKey: '', + apiHost: 'https://bedrock-runtime.us-east-1.amazonaws.com', + description: + 'AWS Bedrock Converse model configuration. Auth: API Key (Bearer Token), or Access Key ID + Secret Access Key (+Session Token).', + externalConfig: [ + { + key: 'region_name', + name: 'Region', + value: 'us-east-1', + }, + { + key: 'aws_access_key_id', + name: 'Access Key ID', + value: '', + secret: true, + }, + { + key: 'aws_secret_access_key', + name: 'Secret Access Key', + value: '', + secret: true, + }, + { + key: 'aws_session_token', + name: 'Session Token (Optional)', + value: '', + secret: true, + }, + ], is_valid: false, model_type: '', }, diff --git a/src/lib/share.ts b/src/lib/share.ts index 2754e04ba..e0000af50 100644 --- a/src/lib/share.ts +++ b/src/lib/share.ts @@ -13,6 +13,7 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { proxyFetchPost } from '@/api/http'; +import { SITE_URL } from '@/lib'; import { toast } from 'sonner'; export const share = async (taskId: string) => { @@ -20,7 +21,7 @@ export const share = async (taskId: string) => { const res = await proxyFetchPost(`/api/v1/chat/share`, { task_id: taskId, }); - const shareLink = `${import.meta.env.VITE_USE_LOCAL_PROXY === 'true' ? 'eigent://callback' : 'https://www.eigent.ai/download'}?share_token=${res.share_token}__${taskId}`; + const shareLink = `${import.meta.env.VITE_USE_LOCAL_PROXY === 'true' ? 'eigent://callback' : `${SITE_URL}/download`}?share_token=${res.share_token}__${taskId}`; navigator.clipboard .writeText(shareLink) .then(() => { diff --git a/src/pages/Agents/Models.tsx b/src/pages/Agents/Models.tsx index 31a2dbacc..ab1372ca0 100644 --- a/src/pages/Agents/Models.tsx +++ b/src/pages/Agents/Models.tsx @@ -37,6 +37,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { SITE_URL } from '@/lib'; import { INIT_PROVODERS } from '@/lib/llm'; import { useAuthStore } from '@/store/authStore'; import { Provider } from '@/types'; @@ -119,12 +120,15 @@ export default function SettingModels() { } = useAuthStore(); const _navigate = useNavigate(); const { t } = useTranslation(); - const getValidateMessage = (res: any) => - res?.message ?? - res?.detail?.message ?? - res?.detail?.error?.message ?? - res?.error?.message ?? - t('setting.validate-failed'); + const getValidateMessage = (res: any): string => { + const msg = + res?.message ?? + res?.detail?.message ?? + res?.detail?.error?.message ?? + res?.error?.message ?? + t('setting.validate-failed'); + return typeof msg === 'string' ? msg : JSON.stringify(msg); + }; const [items, _setItems] = useState( INIT_PROVODERS.filter((p) => p.id !== 'local') ); @@ -144,6 +148,7 @@ export default function SettingModels() { const [showApiKey, setShowApiKey] = useState(() => INIT_PROVODERS.filter((p) => p.id !== 'local').map(() => false) ); + const [showSecret, setShowSecret] = useState>({}); const [loading, setLoading] = useState(null); const [errors, setErrors] = useState< { @@ -507,7 +512,7 @@ export default function SettingModels() { form[idx]; let hasError = false; const newErrors = [...errors]; - if (items[idx].id !== 'local') { + if (items[idx].id !== 'local' && items[idx].id !== 'aws-bedrock-converse') { if (!apiKey || apiKey.trim() === '') { newErrors[idx].apiKey = t('setting.api-key-can-not-be-empty'); hasError = true; @@ -544,7 +549,7 @@ export default function SettingModels() { const res = await fetchPost('/model/validate', { model_platform: item.id, model_type: form[idx].model_type, - api_key: form[idx].apiKey, + api_key: form[idx].apiKey || null, url: form[idx].apiHost, extra_params: external, }); @@ -1068,6 +1073,7 @@ export default function SettingModels() { grok: PROVIDER_AVATAR_URLS.grok, mistral: PROVIDER_AVATAR_URLS.mistral, 'aws-bedrock': bedrockImage, + 'aws-bedrock-converse': bedrockImage, azure: azureImage, 'openai-compatible-model': openaiImage, // Use OpenAI icon as fallback // Local models @@ -1197,7 +1203,7 @@ export default function SettingModels() { { - window.location.href = `https://www.eigent.ai/pricing`; + window.location.href = `${SITE_URL}/pricing`; }} className="cursor-pointer text-body-sm text-text-label underline" > @@ -1220,7 +1226,7 @@ export default function SettingModels() {
-
+
{activeTab === 'general' && } {activeTab === 'privacy' && }
diff --git a/src/pages/SignUp.tsx b/src/pages/SignUp.tsx index b1e84c396..5c1288a16 100644 --- a/src/pages/SignUp.tsx +++ b/src/pages/SignUp.tsx @@ -13,6 +13,7 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { Button } from '@/components/ui/button'; +import { SITE_URL } from '@/lib'; import { useAuthStore } from '@/store/authStore'; import { useStackApp } from '@stackframe/react'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -52,11 +53,7 @@ export default function SignUp() { // Hybrid/app mode without Stack keys: redirect to external signup useEffect(() => { if (!IS_LOCAL_MODE && !HAS_STACK_KEYS) { - window.open( - 'https://www.eigent.ai/signup', - '_blank', - 'noopener,noreferrer' - ); + window.open(`${SITE_URL}/signup`, '_blank', 'noopener,noreferrer'); navigate('/login', { replace: true }); } }, [navigate]); diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index 3c404ea82..b00dc3d4b 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -83,6 +83,165 @@ interface Task { nextExecutionId?: string; } +type UploadFileSource = 'project_output' | 'camel_log' | 'user_attachment'; + +interface UploadCandidate { + path: string; + name: string; + uploadName: string; + source: UploadFileSource; +} + +interface GeneratedUploadFile { + path?: string; + name?: string; + isFolder?: boolean; + relativePath?: string; + source?: Exclude; +} + +interface UploadOutcome { + success: boolean; + fileName: string; + source: UploadFileSource; + error?: unknown; +} + +function getFileNameFromPath(filePath: string): string { + const segments = filePath.split(/[\\/]/).filter(Boolean); + return segments.at(-1) || 'file'; +} + +function isReadableLocalPath(filePath?: string): filePath is string { + if (!filePath) return false; + return !/^(https?:|file:|blob:|data:)/i.test(filePath); +} + +function buildUploadName( + fileName: string, + source: UploadFileSource, + taskId: string, + attachmentIndex: number, + relativePath?: string +): string { + if (source === 'camel_log') { + if (relativePath) { + return `camel_log/${relativePath}/${fileName}`; + } + return `camel_log/${fileName}`; + } + + if (source === 'user_attachment') { + return `user_attachment/${fileName}`; + } + + return `project_output/${fileName}`; +} + +export function collectTaskUploadFiles( + generatedFiles: GeneratedUploadFile[], + messages: Message[], + pendingAttaches: File[] = [], + taskId = 'unknown_task' +): UploadCandidate[] { + const uploadCandidates: Array< + Omit & { relativePath?: string } + > = []; + + for (const file of generatedFiles) { + if (!file?.path || !file?.name || file.isFolder) continue; + uploadCandidates.push({ + path: file.path, + name: file.name, + relativePath: file.relativePath, + source: file.source === 'camel_log' ? 'camel_log' : 'project_output', + }); + } + + const attachmentFiles = [ + ...messages.flatMap((message) => message.attaches || []), + ...pendingAttaches, + ]; + + for (const attachment of attachmentFiles) { + if (!isReadableLocalPath(attachment?.filePath)) continue; + uploadCandidates.push({ + path: attachment.filePath, + name: + attachment.fileName?.trim() || getFileNameFromPath(attachment.filePath), + source: 'user_attachment', + }); + } + + const uniqueCandidates = new Map(); + let attachmentIndex = 1; + for (const file of uploadCandidates) { + if (!uniqueCandidates.has(file.path)) { + const { relativePath, ...rest } = file; + uniqueCandidates.set(file.path, { + ...rest, + uploadName: buildUploadName( + file.name, + file.source, + taskId, + file.source === 'user_attachment' ? attachmentIndex++ : 0, + relativePath + ), + }); + } + } + + return Array.from(uniqueCandidates.values()); +} + +async function uploadTaskFiles( + files: UploadCandidate[], + uploadTargetId: string +): Promise { + const results: UploadOutcome[] = []; + + for (const file of files) { + try { + const result = await window.ipcRenderer.invoke('read-file', file.path); + if (!result.success || !result.data) { + results.push({ + success: false, + fileName: file.name, + source: file.source, + error: result.error || 'Failed to read file', + }); + continue; + } + + const formData = new FormData(); + const blob = new Blob([result.data], { + type: 'application/octet-stream', + }); + formData.append('file', blob, file.uploadName); + // TODO(file): rename endpoint to use project_id + formData.append('task_id', uploadTargetId); + + await uploadFile('/api/v1/chat/files/upload', formData); + console.log('File uploaded successfully:', file.uploadName, file.source); + results.push({ + success: true, + fileName: file.uploadName, + source: file.source, + }); + } catch (error) { + console.error('File upload failed:', file.uploadName, file.source, error); + results.push({ + success: false, + fileName: file.uploadName, + source: file.source, + error, + }); + } + } + + return results; +} + export interface ChatStore { updateCount: number; activeTaskId: string | null; @@ -652,7 +811,7 @@ const chatStore = (initial?: Partial) => model_platform: cloud_model_type.includes('gpt') ? 'openai' : cloud_model_type.includes('claude') - ? 'aws-bedrock' + ? 'aws-bedrock-converse' : cloud_model_type.includes('gemini') ? 'gemini' : 'openai-compatible-model', @@ -2229,83 +2388,59 @@ const chatStore = (initial?: Partial) => ) ); - // Async file upload - let res = await window.ipcRenderer.invoke( - 'get-file-list', - email, - currentTaskId, - (project_id || projectStore.activeProjectId) as string - ); - if ( - !type && - import.meta.env.VITE_USE_LOCAL_PROXY !== 'true' && - res.length > 0 - ) { - // Upload files sequentially to avoid overwhelming the server - const uploadResults = await Promise.allSettled( - res - .filter((file: any) => !file.isFolder) - .map(async (file: any) => { - try { - // Read file content using Electron API - const result = await window.ipcRenderer.invoke( - 'read-file', - file.path - ); - if (result.success && result.data) { - // Create FormData for file upload - const formData = new FormData(); - const blob = new Blob([result.data], { - type: 'application/octet-stream', - }); - formData.append('file', blob, file.name); - //TODO(file): rename endpoint to use project_id - formData.append( - 'task_id', - (project_id || projectStore.activeProjectId) as string - ); - - // Upload file - await uploadFile('/api/v1/chat/files/upload', formData); - console.log('File uploaded successfully:', file.name); - return { success: true, fileName: file.name }; - } else { - console.error('Failed to read file:', result.error); - return { - success: false, - fileName: file.name, - error: result.error, - }; - } - } catch (error) { - console.error('File upload failed:', error); - return { success: false, fileName: file.name, error }; + const uploadTargetId = (project_id || + projectStore.activeProjectId) as string | undefined; + if (!type && import.meta.env.VITE_USE_LOCAL_PROXY !== 'true') { + if (!uploadTargetId) { + console.warn( + 'Skip file upload because no active project ID was found' + ); + } else { + try { + const generatedFiles = + ((await window.ipcRenderer.invoke( + 'get-file-list', + email, + currentTaskId, + uploadTargetId + )) as GeneratedUploadFile[]) || []; + const filesToUpload = collectTaskUploadFiles( + generatedFiles, + tasks[currentTaskId].messages, + tasks[currentTaskId].attaches, + currentTaskId + ); + + if (filesToUpload.length > 0) { + const uploadResults = await uploadTaskFiles( + filesToUpload, + uploadTargetId + ); + const failedUploads = uploadResults.filter( + (result) => !result.success + ); + if (failedUploads.length > 0) { + console.error('Failed to upload files:', failedUploads); } - }) - ); - // Count successful uploads - const successCount = uploadResults.filter( - (result) => - result.status === 'fulfilled' && result.value.success - ).length; - - // Log failures - const failures = uploadResults.filter( - (result) => - result.status === 'rejected' || - (result.status === 'fulfilled' && !result.value.success) - ); - if (failures.length > 0) { - console.error('Failed to upload files:', failures); - } + const generatedSuccessCount = uploadResults.filter( + (result) => + result.success && result.source === 'project_output' + ).length; - // add remote file count for successful uploads only - if (successCount > 0) { - proxyFetchPost(`/api/v1/user/stat`, { - action: 'file_generate_count', - value: successCount, - }); + if (generatedSuccessCount > 0) { + proxyFetchPost(`/api/v1/user/stat`, { + action: 'file_generate_count', + value: generatedSuccessCount, + }); + } + } + } catch (error) { + console.error( + 'Failed to prepare task files for upload:', + error + ); + } } } diff --git a/src/types/index.ts b/src/types/index.ts index feda06ebd..82eeea590 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,6 +16,8 @@ type externalConfig = { key: string; name: string; value: string; + placeholder?: string; + secret?: boolean; options?: { label: string; value: string; diff --git a/test/unit/electron/githubReleaseCdnProvider.test.ts b/test/unit/electron/githubReleaseCdnProvider.test.ts new file mode 100644 index 000000000..bfb88f7e0 --- /dev/null +++ b/test/unit/electron/githubReleaseCdnProvider.test.ts @@ -0,0 +1,67 @@ +// ========= 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 { + buildVersionedReleaseBaseUrl, + getGitHubReleaseChannel, + getUpdatePlatformDirectory, + normalizeCdnReleaseBaseUrl, +} from '../../../electron/main/githubReleaseCdnProvider'; + +describe('githubReleaseCdnProvider', () => { + it('maps supported platforms to the expected release directories', () => { + expect(getUpdatePlatformDirectory('darwin', 'arm64')).toBe('mac-arm64'); + expect(getUpdatePlatformDirectory('darwin', 'x64')).toBe('mac-intel'); + expect(getUpdatePlatformDirectory('win32', 'x64')).toBe('win-x64'); + expect(getUpdatePlatformDirectory('linux', 'x64')).toBe('linux-x64'); + }); + + it('returns null for unsupported platform and architecture combinations', () => { + expect(getUpdatePlatformDirectory('darwin', 'ia32')).toBeNull(); + expect(getUpdatePlatformDirectory('win32', 'arm64')).toBeNull(); + expect(getUpdatePlatformDirectory('linux', 'arm64')).toBeNull(); + expect(getUpdatePlatformDirectory('freebsd', 'x64')).toBeNull(); + }); + + it('normalizes the CDN base URL before building versioned release paths', () => { + expect( + normalizeCdnReleaseBaseUrl('https://cdn.eigent.ai/releases///') + ).toBe('https://cdn.eigent.ai/releases'); + }); + + it('builds versioned CDN URLs for updater downloads', () => { + expect( + buildVersionedReleaseBaseUrl( + 'https://cdn.eigent.ai/releases/', + '0.0.90', + 'mac-arm64' + ) + ).toBe('https://cdn.eigent.ai/releases/v0.0.90/mac-arm64/'); + + expect( + buildVersionedReleaseBaseUrl( + 'https://cdn.eigent.ai/releases', + '0.0.90', + 'win-x64' + ) + ).toBe('https://cdn.eigent.ai/releases/v0.0.90/win-x64/'); + }); + + it('maps mac builds to the GitHub release channels used in CI', () => { + expect(getGitHubReleaseChannel('darwin', 'arm64')).toBe('latest-arm64'); + expect(getGitHubReleaseChannel('darwin', 'x64')).toBe('latest-x64'); + expect(getGitHubReleaseChannel('win32', 'x64')).toBe('latest'); + expect(getGitHubReleaseChannel('linux', 'x64')).toBe('latest'); + }); +}); diff --git a/test/unit/store/chatStore.test.ts b/test/unit/store/chatStore.test.ts index 9f5d07cd1..fcf240dfa 100644 --- a/test/unit/store/chatStore.test.ts +++ b/test/unit/store/chatStore.test.ts @@ -94,7 +94,10 @@ vi.mock('../../../src/store/projectStore', () => ({ import { proxyFetchGet } from '@/api/http'; import { fetchEventSource } from '@microsoft/fetch-event-source'; import { generateUniqueId } from '../../../src/lib'; -import { useChatStore } from '../../../src/store/chatStore'; +import { + collectTaskUploadFiles, + useChatStore, +} from '../../../src/store/chatStore'; import { useProjectStore } from '../../../src/store/projectStore'; import { ChatTaskStatus } from '../../../src/types/constants'; @@ -114,6 +117,117 @@ describe('ChatStore - Core Functionality', () => { vi.clearAllMocks(); }); + describe('Task Upload Files', () => { + it('collects project outputs, camel logs, and unique user attachments', () => { + const uploadFiles = collectTaskUploadFiles( + [ + { + path: '/tmp/project/report.md', + name: 'report.md', + source: 'project_output', + }, + { + path: '/tmp/logs/ba4462e1/agent.log', + name: 'agent.log', + relativePath: 'ba4462e1', + source: 'camel_log', + }, + { + path: '/tmp/project', + name: 'project', + isFolder: true, + source: 'project_output', + }, + ], + [ + { + id: 'msg-1', + role: 'user', + content: 'question', + attaches: [ + { + fileName: 'brief.pdf', + filePath: '/Users/test/Documents/brief.pdf', + }, + { + fileName: 'report.md', + filePath: '/tmp/project/report.md', + }, + ], + }, + ] as any, + [ + { + fileName: 'followup.csv', + filePath: '/Users/test/Documents/followup.csv', + }, + ], + 'task-123' + ); + + expect(uploadFiles).toEqual([ + { + path: '/tmp/project/report.md', + name: 'report.md', + uploadName: 'project_output/report.md', + source: 'project_output', + }, + { + path: '/tmp/logs/ba4462e1/agent.log', + name: 'agent.log', + uploadName: 'camel_log/ba4462e1/agent.log', + source: 'camel_log', + }, + { + path: '/Users/test/Documents/brief.pdf', + name: 'brief.pdf', + uploadName: 'user_attachment/brief.pdf', + source: 'user_attachment', + }, + { + path: '/Users/test/Documents/followup.csv', + name: 'followup.csv', + uploadName: 'user_attachment/followup.csv', + source: 'user_attachment', + }, + ]); + }); + + it('skips remote attachment URLs and falls back to filename from path', () => { + const uploadFiles = collectTaskUploadFiles( + [], + [ + { + id: 'msg-2', + role: 'user', + content: 'question', + attaches: [ + { + fileName: '', + filePath: 'C:\\Users\\test\\Desktop\\notes.txt', + }, + { + fileName: 'remote.pdf', + filePath: 'https://example.com/remote.pdf', + }, + ], + }, + ] as any, + [], + 'task-456' + ); + + expect(uploadFiles).toEqual([ + { + path: 'C:\\Users\\test\\Desktop\\notes.txt', + name: 'notes.txt', + uploadName: 'user_attachment/notes.txt', + source: 'user_attachment', + }, + ]); + }); + }); + describe('Task Creation', () => { it('should create a task with unique ID', () => { const { result } = renderHook(() => useChatStore()); From f3e1110ac02db923ca10939575d3b26e93060194 Mon Sep 17 00:00:00 2001 From: Tong Chen Date: Fri, 1 May 2026 11:04:33 +0800 Subject: [PATCH 10/10] Feat/eigent new design (#1577) Co-authored-by: Douglas Co-authored-by: Douglas Lai <115660088+Douglasymlai@users.noreply.github.com> --- .github/workflows/test.yml | 3 + .gitignore | 1 + .lintstagedrc.json | 1 + backend/app/__init__.py | 6 +- backend/app/agent/factory/browser.py | 32 +- backend/app/agent/listen_chat_agent.py | 16 +- backend/app/controller/chat_controller.py | 16 +- backend/app/controller/health_controller.py | 17 +- backend/app/controller/tool_controller.py | 95 +- backend/app/utils/browser_launcher.py | 136 +- .../tests/app/agent/factory/test_browser.py | 62 + .../app/controller/test_chat_controller.py | 16 +- .../app/controller/test_health_controller.py | 51 + .../app/controller/test_tool_controller.py | 56 +- .../tests/app/utils/test_browser_launcher.py | 108 + docs/troubleshooting/bug.md | 28 +- electron/main/index.ts | 348 +-- electron/main/init.ts | 3 +- electron/main/utils/log.ts | 43 + electron/main/webview.ts | 53 +- electron/preload/index.ts | 4 + package.json | 12 +- scripts/check-design-token-usage.mjs | 196 ++ scripts/check-electron-access.sh | 5 +- scripts/check-i18n-locale-parity.js | 122 + scripts/design-token-usage.allowlist | 15 + scripts/verify-theme-tokens.ts | 195 ++ .../app/domains/chat/api/share_controller.py | 1 + .../app/domains/chat/api/step_controller.py | 1 + src/api/http.ts | 3 +- src/assets/dark.png | Bin 20451 -> 0 bytes src/assets/icon/cursor.svg | 1 + src/assets/icon/github.svg | 3 + src/assets/icon/google_calendar.svg | 16 + src/assets/icon/google_gmail.svg | 7 + src/assets/icon/linkedin.svg | 6 + src/assets/icon/notion.svg | 4 + src/assets/icon/rag.svg | 16 + src/assets/icon/reddit.svg | 3 + src/assets/icon/slack.svg | 8 +- src/assets/icon/vs-code.svg | 23 + src/assets/icon/whatsapp.svg | 11 + src/assets/icon/x.svg | 3 + src/assets/light.png | Bin 25147 -> 0 bytes src/assets/logo/icon_black.svg | 33 +- src/assets/logo/icon_white.svg | 37 +- src/assets/transparent.png | Bin 24346 -> 0 bytes src/client/platform.ts | 9 +- src/components/AddWorker/ToolSelect.tsx | 542 ++-- src/components/AddWorker/index.tsx | 435 +++- .../Background/DashedLinesBackground.tsx | 53 + .../Background/DotPatternBackground.tsx | 47 + .../Background/DottedLinesBackground.tsx | 53 + .../Background/GridPatternBackground.tsx | 47 + .../Background/RuledLinesBackground.tsx | 49 + src/components/Background/index.ts | 19 + src/components/BottomBar/index.tsx | 63 - .../BrowserAgentWorkspace/index.tsx | 86 +- .../ChatBox/BottomBox/BoxHeader.tsx | 44 +- .../BottomBox/ChatInputModelDropdown.tsx | 645 +++++ .../ChatBox/BottomBox/ExpandedInputBox.tsx | 239 -- src/components/ChatBox/BottomBox/InputBox.tsx | 476 ++-- .../ChatBox/BottomBox/QueuedBox.tsx | 43 +- .../ChatBox/BottomBox/RichChatInput.tsx | 463 ++++ src/components/ChatBox/BottomBox/index.tsx | 49 +- src/components/ChatBox/HeaderBox/index.tsx | 85 - .../ChatBox/MessageItem/AgentMessageCard.tsx | 101 +- .../ChatBox/MessageItem/FeedbackCard.tsx | 19 +- .../{ => MessageItem}/FloatingAction.tsx | 13 +- .../ChatBox/MessageItem/MarkDown.tsx | 36 +- .../ChatBox/MessageItem/NoticeCard.tsx | 20 +- .../MessageItem/PreparingToExecuteTasks.tsx | 35 + .../MessageItem/SplittingProgressRow.tsx | 161 ++ .../ChatBox/MessageItem/SummaryMarkDown.tsx | 34 +- .../MessageItem/TaskCompletionCard.tsx | 36 +- .../MessageItem/TaskWorkLogAccordion.tsx | 834 +++++++ .../ChatBox/{ => MessageItem}/TokenUtils.tsx | 13 + .../ChatBox/MessageItem/UserMessageCard.tsx | 404 +-- .../MessageItem/UserMessageRichContent.tsx | 165 ++ .../ChatBox/ProjectChatContainer.tsx | 166 +- src/components/ChatBox/ProjectSection.tsx | 5 +- src/components/ChatBox/README.md | 341 +-- .../ChatBox/TaskBox/StreamingTaskList.tsx | 41 +- src/components/ChatBox/TaskBox/TaskCard.tsx | 151 +- src/components/ChatBox/TaskBox/TaskItem.tsx | 41 +- src/components/ChatBox/TaskBox/TaskType.tsx | 18 +- .../ChatBox/TaskBox/TypeCardSkeleton.tsx | 40 +- src/components/ChatBox/UserQueryGroup.tsx | 488 ++-- src/components/ChatBox/index.tsx | 583 ++--- .../GroupedHistoryView/ProjectDialog.tsx | 60 +- .../GroupedHistoryView/ProjectGroup.tsx | 276 ++- .../GroupedHistoryView/TaskItem.tsx | 34 +- .../GroupedHistoryView/index.tsx | 126 +- src/components/Dashboard/HistoryTabsNav.tsx | 233 ++ .../{ => Dashboard}/IntegrationList/index.tsx | 101 +- .../{ => Dashboard}/SearchInput/index.tsx | 20 +- .../index.tsx => Dashboard/VerticalNav.tsx} | 12 +- src/components/Dialog/CloseNotice.tsx | 8 +- src/components/Dialog/EndNotice.tsx | 10 +- src/components/Dialog/ReportBugDialog.tsx | 293 +++ .../{ => Dialog}/SearchHistoryDialog.tsx | 15 +- src/components/ErrorBoundary/index.tsx | 20 +- src/components/Folder/FolderComponent.tsx | 2 +- src/components/Folder/ZoomControls.tsx | 23 +- src/components/Folder/index.tsx | 2174 ++++++++++++----- src/components/GlobalSearch/index.tsx | 84 +- src/components/Halo/index.tsx | 144 -- src/components/HistorySidebar/SearchInput.tsx | 4 +- src/components/HistorySidebar/index.tsx | 245 +- src/components/InstallStep/Carousel.tsx | 23 +- .../InstallStep/InstallDependencies.tsx | 16 +- .../InstallationErrorDialog.tsx | 12 +- src/components/{ => Layout}/AnimationJson.tsx | 2 +- src/components/Layout/ThemeProvider.tsx | 152 ++ src/components/Layout/index.tsx | 6 +- src/components/Layout/tokenAliases.ts | 60 + src/components/MenuButton/MenuButton.tsx | 220 -- src/components/Notification/index.tsx | 73 + .../ProjectPageSidebar/BottomAction.tsx | 103 + .../ProjectPageSidebar/HeaderAction.tsx | 243 ++ src/components/ProjectPageSidebar/NavList.tsx | 147 ++ .../ProjectPageSidebar/NavListSessionRows.tsx | 217 ++ src/components/ProjectPageSidebar/NavTab.tsx | 333 +++ .../ProjectPageSidebar/constants.ts | 33 + src/components/ProjectPageSidebar/index.tsx | 438 ++++ .../Session/HeaderBox/ChatTimeline.tsx | 253 ++ src/components/Session/HeaderBox/index.tsx | 74 + src/components/Session/SessionGroup.tsx | 123 + src/components/Session/SessionSidePanel.tsx | 129 + .../Session/SessionSidePanelFoldButton.tsx | 75 + .../Session/SessionSidePanelHeader.tsx | 56 + .../Session/SidePanelAccordionBox.tsx | 119 + .../SidePanelSections/AgentFolderSection.tsx | 143 ++ .../SidePanelSections/AgentPoolSection.tsx | 380 +++ .../ExecutionContextSection.tsx | 101 + .../SidePanelSections/ProgressSection.tsx | 101 + .../SidePanelSections/buildContextItems.ts | 163 ++ .../collectSidePanelOutputFiles.ts | 59 + .../Session/SidePanelSections/primitives.tsx | 172 ++ .../SingleAgent/SingleAgentSidePanel.tsx | 111 + src/components/Session/SingleAgent/index.tsx | 15 + .../Session/Workforce/ExpandedOverlay.tsx | 251 ++ .../Workforce/FoldedPanel/AgentDetailPane.tsx | 606 +++++ .../Session/Workforce/FoldedPanel/index.tsx | 521 ++++ .../Session/Workforce/WorkforceSidePanel.tsx | 151 ++ .../Workforce/WorkforceSidePanelHeaderEnd.tsx | 66 + src/components/Session/index.tsx | 149 ++ .../Session/sessionSidePanelLayout.ts | 24 + src/components/SideBar/index.tsx | 120 - src/components/TaskState/index.tsx | 85 +- src/components/Terminal/index.tsx | 12 +- .../TerminalAgentWorkspace/index.tsx | 109 +- src/components/ThemeProvider.tsx | 61 - src/components/TopBar/index.tsx | 798 ++++-- src/components/Trigger/ActivityLogItem.tsx | 56 +- .../Trigger/DynamicTriggerConfig.tsx | 82 +- src/components/Trigger/ExecutionLogs.tsx | 88 +- src/components/Trigger/SchedulePicker.tsx | 26 +- src/components/Trigger/TriggerDialog.tsx | 98 +- src/components/Trigger/TriggerListItem.tsx | 303 ++- .../Trigger}/Triggers.tsx | 222 +- src/components/Trigger/index.tsx | 182 ++ src/components/WorkFlow/MarkDown.tsx | 52 +- .../WorkFlow/TaskLogPanelContent.tsx | 182 ++ src/components/WorkFlow/agentToolkitLabels.ts | 53 + src/components/WorkFlow/agents.tsx | 55 +- src/components/WorkFlow/baseWorkers.ts | 82 + src/components/WorkFlow/index.tsx | 85 +- src/components/WorkFlow/node.tsx | 382 +-- .../index.tsx | 219 +- src/components/Workspace/FoldedAgentCard.tsx | 354 +++ src/components/Workspace/SingleAgentList.tsx | 32 + .../Workspace/WorkforceAgentList.tsx | 105 + .../Workspace/WorkspaceExamplePrompts.tsx | 90 + .../Workspace/WorkspaceProjectPicker.tsx | 246 ++ .../Workspace/WorkspaceRecentSessions.tsx | 144 ++ .../Workspace/WorkspaceSessionModeToggle.tsx | 191 ++ src/components/Workspace/index.tsx | 540 ++++ src/components/ui/HoverScrollText.tsx | 106 + src/components/ui/accordion.tsx | 6 +- src/components/ui/alert.tsx | 102 +- src/components/ui/alertDialog.tsx | 35 +- .../{ => ui}/animate-ui/icons/alarm-clock.tsx | 2 +- src/components/ui/animate-ui/icons/blocks.tsx | 174 ++ .../{ => ui}/animate-ui/icons/bot.tsx | 2 +- .../ui/animate-ui/icons/clipboard-list.tsx | 174 ++ .../{ => ui}/animate-ui/icons/compass.tsx | 2 +- .../{ => ui}/animate-ui/icons/hammer.tsx | 2 +- .../{ => ui}/animate-ui/icons/icon.tsx | 2 +- .../{ => ui}/animate-ui/icons/orbit.tsx | 2 +- .../{ => ui}/animate-ui/icons/pin.tsx | 2 +- .../{ => ui}/animate-ui/icons/radio.tsx | 2 +- .../{ => ui}/animate-ui/icons/settings.tsx | 2 +- .../{ => ui}/animate-ui/icons/sparkle.tsx | 2 +- .../animate-ui/primitives/animate/slot.tsx | 0 src/components/ui/badge.tsx | 196 +- src/components/ui/button.tsx | 588 ++++- src/components/ui/card.tsx | 8 +- src/components/ui/carousel.tsx | 116 +- src/components/ui/checkbox.tsx | 25 +- src/components/ui/command.tsx | 47 +- src/components/ui/dialog.tsx | 77 +- src/components/ui/dropdown-menu.tsx | 30 +- src/components/ui/formFieldSurface.ts | 268 ++ src/components/ui/input-select.tsx | 393 ++- src/components/ui/input.tsx | 124 +- src/components/ui/menu-button.tsx | 302 +++ src/components/ui/popover.tsx | 53 +- src/components/ui/progress-install.tsx | 4 +- src/components/ui/resizable.tsx | 33 +- src/components/ui/select.tsx | 120 +- src/components/ui/semanticProps.ts | 61 + src/components/ui/separator.tsx | 2 +- src/components/ui/sheet.tsx | 17 +- src/components/ui/sidebar.tsx | 73 +- src/components/ui/skeleton.tsx | 5 +- src/components/ui/sonner.tsx | 8 +- src/components/ui/switch.tsx | 8 +- src/components/ui/table.tsx | 74 +- src/components/ui/tabs.tsx | 338 ++- src/components/ui/tag.tsx | 348 ++- src/components/ui/textarea.tsx | 130 +- src/components/ui/toggle-group.tsx | 4 +- src/components/ui/toggle.tsx | 6 +- src/components/ui/tokenAliases.ts | 141 ++ src/components/ui/tooltip.tsx | 7 +- src/components/update/index.tsx | 65 +- src/hooks/use-app-version.tsx | 7 +- src/hooks/use-is-in-view.tsx | 2 +- src/hooks/useInitialChatPanelLayout.ts | 46 + src/hooks/useInitialWorkforceFoldedLayout.ts | 56 + src/i18n/locales/ar/agents.json | 10 +- src/i18n/locales/ar/chat.json | 34 +- src/i18n/locales/ar/layout.json | 68 +- src/i18n/locales/ar/setting.json | 68 + src/i18n/locales/ar/triggers.json | 3 + src/i18n/locales/ar/workforce.json | 6 +- src/i18n/locales/de/agents.json | 10 +- src/i18n/locales/de/chat.json | 34 +- src/i18n/locales/de/layout.json | 68 +- src/i18n/locales/de/setting.json | 76 +- src/i18n/locales/de/triggers.json | 3 + src/i18n/locales/de/workforce.json | 6 +- src/i18n/locales/en-us/agents.json | 10 +- src/i18n/locales/en-us/chat.json | 34 +- src/i18n/locales/en-us/layout.json | 92 +- src/i18n/locales/en-us/setting.json | 42 + src/i18n/locales/en-us/triggers.json | 2 + src/i18n/locales/en-us/workforce.json | 6 +- src/i18n/locales/es/agents.json | 10 +- src/i18n/locales/es/chat.json | 34 +- src/i18n/locales/es/layout.json | 68 +- src/i18n/locales/es/setting.json | 76 +- src/i18n/locales/es/triggers.json | 3 + src/i18n/locales/es/workforce.json | 6 +- src/i18n/locales/fr/agents.json | 10 +- src/i18n/locales/fr/chat.json | 34 +- src/i18n/locales/fr/layout.json | 68 +- src/i18n/locales/fr/setting.json | 97 +- src/i18n/locales/fr/triggers.json | 3 + src/i18n/locales/fr/workforce.json | 6 +- src/i18n/locales/it/agents.json | 10 +- src/i18n/locales/it/chat.json | 34 +- src/i18n/locales/it/layout.json | 68 +- src/i18n/locales/it/setting.json | 76 +- src/i18n/locales/it/triggers.json | 3 + src/i18n/locales/it/workforce.json | 6 +- src/i18n/locales/ja/agents.json | 10 +- src/i18n/locales/ja/chat.json | 34 +- src/i18n/locales/ja/layout.json | 68 +- src/i18n/locales/ja/setting.json | 76 +- src/i18n/locales/ja/triggers.json | 3 + src/i18n/locales/ja/workforce.json | 6 +- src/i18n/locales/ko/agents.json | 10 +- src/i18n/locales/ko/chat.json | 36 +- src/i18n/locales/ko/layout.json | 68 +- src/i18n/locales/ko/setting.json | 76 +- src/i18n/locales/ko/triggers.json | 3 + src/i18n/locales/ko/workforce.json | 6 +- src/i18n/locales/ru/agents.json | 10 +- src/i18n/locales/ru/chat.json | 34 +- src/i18n/locales/ru/layout.json | 68 +- src/i18n/locales/ru/setting.json | 76 +- src/i18n/locales/ru/triggers.json | 3 + src/i18n/locales/ru/workforce.json | 6 +- src/i18n/locales/zh-Hans/agents.json | 10 +- src/i18n/locales/zh-Hans/chat.json | 34 +- src/i18n/locales/zh-Hans/layout.json | 87 +- src/i18n/locales/zh-Hans/setting.json | 36 + src/i18n/locales/zh-Hans/triggers.json | 3 + src/i18n/locales/zh-Hans/workforce.json | 6 +- src/i18n/locales/zh-Hant/agents.json | 10 +- src/i18n/locales/zh-Hant/chat.json | 34 +- src/i18n/locales/zh-Hant/layout.json | 70 +- src/i18n/locales/zh-Hant/setting.json | 56 + src/i18n/locales/zh-Hant/triggers.json | 3 + src/i18n/locales/zh-Hant/workforce.json | 6 +- src/lib/applyDefaultModelSelection.ts | 182 ++ src/lib/chatTaskIdTime.ts | 19 + src/lib/richText.ts | 146 ++ src/lib/sessionNavLead.ts | 173 ++ src/lib/taskLifecycleUi.ts | 130 + src/lib/themeTokens/README.md | 68 + src/lib/themeTokens/catalog.ts | 158 ++ src/lib/themeTokens/colorMath.ts | 266 ++ src/lib/themeTokens/dtcg.ts | 150 ++ src/lib/themeTokens/engine.ts | 1173 +++++++++ src/lib/themeTokens/index.ts | 19 + src/lib/themeTokens/naming.ts | 23 + src/lib/themeTokens/types.ts | 142 ++ src/lib/themeTokens/verifier.ts | 407 +++ src/main.tsx | 2 +- src/pages/Agents/Memory.tsx | 16 +- src/pages/Agents/Models.tsx | 365 ++- src/pages/Agents/Skills.tsx | 60 +- .../Agents/components/SkillDeleteDialog.tsx | 2 +- src/pages/Agents/components/SkillListItem.tsx | 45 +- .../Agents/components/SkillUploadDialog.tsx | 324 ++- src/pages/Agents/index.tsx | 78 +- src/pages/Browser/CDP.tsx | 176 +- src/pages/Browser/Cookies.tsx | 49 +- src/pages/Browser/Extension.tsx | 22 +- src/pages/Browser/index.tsx | 82 +- src/pages/Channels/index.tsx | 80 +- src/pages/Connectors/MCP.tsx | 878 ++++++- src/pages/Connectors/Search.tsx | 18 +- .../Connectors/components/CookieManager.tsx | 18 +- .../Connectors/components/MCPAddDialog.tsx | 8 +- .../Connectors/components/MCPConfigDialog.tsx | 2 +- .../Connectors/components/MCPDeleteDialog.tsx | 4 +- .../Connectors/components/MCPEnvDialog.tsx | 9 +- .../Connectors/components/MCPListItem.tsx | 22 +- src/pages/Connectors/components/MCPMarket.tsx | 40 +- src/pages/Connectors/index.tsx | 53 +- src/pages/History.tsx | 197 +- src/pages/Home.tsx | 1120 ++++----- src/pages/Login.tsx | 22 +- src/pages/Project/Workspace.tsx | 303 --- src/pages/Projects/Project.tsx | 160 +- src/pages/Setting/API.tsx | 14 +- src/pages/Setting/Appearance.tsx | 505 ++++ src/pages/Setting/General.tsx | 153 +- src/pages/Setting/Privacy.tsx | 35 +- src/pages/Setting/index.tsx | 199 +- src/pages/SignUp.tsx | 26 +- src/shared/modelProviderImages.ts | 87 + src/store/authStore.ts | 169 +- src/store/chatStore.ts | 295 ++- src/store/connectionStore.ts | 12 +- src/store/pageTabStore.ts | 266 +- .../ui/Button.stories.tsx} | 101 +- .../ui/Dialog.stories.tsx} | 48 +- .../ui/Input.stories.tsx} | 8 +- src/style/index.css | 158 +- src/style/token.css | 711 +----- src/style/tokens/base.color.json | 299 +++ src/style/tokens/component.color.json | 412 ++++ src/style/tokens/contracts/default.base.json | 15 + src/style/tokens/contracts/default.dark.json | 4 + src/style/tokens/contracts/default.light.json | 4 + src/style/tokens/manifest.json | 29 + src/style/tokens/semantic.color.json | 215 ++ src/types/chatbox.d.ts | 3 + src/types/constants.ts | 11 + src/types/electron.d.ts | 17 + src/types/workspace.d.ts | 2 +- tailwind.config.js | 253 +- test/mocks/electronMocks.ts | 25 + .../unit/components/AgentPoolSection.test.tsx | 306 +++ test/unit/components/ChatBox.test.tsx | 102 +- .../Dialog/ReportBugDialog.test.tsx | 82 + .../components/Layout/ThemeProvider.test.tsx | 101 + test/unit/components/SearchInput.test.tsx | 2 +- .../components/TaskWorkLogAccordion.test.tsx | 417 ++++ test/unit/electron/main/index.test.ts | 16 +- test/unit/lib/themeTokens/engine.v2.test.ts | 437 ++++ test/unit/lib/themeTokens/verifier.test.ts | 65 + tsconfig.node.json | 7 +- 378 files changed, 33269 insertions(+), 9950 deletions(-) create mode 100644 backend/tests/app/controller/test_health_controller.py create mode 100644 backend/tests/app/utils/test_browser_launcher.py create mode 100644 scripts/check-design-token-usage.mjs create mode 100644 scripts/check-i18n-locale-parity.js create mode 100644 scripts/design-token-usage.allowlist create mode 100644 scripts/verify-theme-tokens.ts delete mode 100644 src/assets/dark.png create mode 100644 src/assets/icon/cursor.svg create mode 100644 src/assets/icon/github.svg create mode 100644 src/assets/icon/google_calendar.svg create mode 100644 src/assets/icon/google_gmail.svg create mode 100644 src/assets/icon/linkedin.svg create mode 100644 src/assets/icon/notion.svg create mode 100644 src/assets/icon/rag.svg create mode 100644 src/assets/icon/reddit.svg create mode 100644 src/assets/icon/vs-code.svg create mode 100644 src/assets/icon/whatsapp.svg create mode 100644 src/assets/icon/x.svg delete mode 100644 src/assets/light.png delete mode 100644 src/assets/transparent.png create mode 100644 src/components/Background/DashedLinesBackground.tsx create mode 100644 src/components/Background/DotPatternBackground.tsx create mode 100644 src/components/Background/DottedLinesBackground.tsx create mode 100644 src/components/Background/GridPatternBackground.tsx create mode 100644 src/components/Background/RuledLinesBackground.tsx create mode 100644 src/components/Background/index.ts delete mode 100644 src/components/BottomBar/index.tsx create mode 100644 src/components/ChatBox/BottomBox/ChatInputModelDropdown.tsx delete mode 100644 src/components/ChatBox/BottomBox/ExpandedInputBox.tsx create mode 100644 src/components/ChatBox/BottomBox/RichChatInput.tsx delete mode 100644 src/components/ChatBox/HeaderBox/index.tsx rename src/components/ChatBox/{ => MessageItem}/FloatingAction.tsx (82%) create mode 100644 src/components/ChatBox/MessageItem/PreparingToExecuteTasks.tsx create mode 100644 src/components/ChatBox/MessageItem/SplittingProgressRow.tsx create mode 100644 src/components/ChatBox/MessageItem/TaskWorkLogAccordion.tsx rename src/components/ChatBox/{ => MessageItem}/TokenUtils.tsx (85%) create mode 100644 src/components/ChatBox/MessageItem/UserMessageRichContent.tsx rename src/components/{ => Dashboard}/GroupedHistoryView/ProjectDialog.tsx (77%) rename src/components/{ => Dashboard}/GroupedHistoryView/ProjectGroup.tsx (57%) rename src/components/{ => Dashboard}/GroupedHistoryView/TaskItem.tsx (78%) rename src/components/{ => Dashboard}/GroupedHistoryView/index.tsx (86%) create mode 100644 src/components/Dashboard/HistoryTabsNav.tsx rename src/components/{ => Dashboard}/IntegrationList/index.tsx (80%) rename src/components/{ => Dashboard}/SearchInput/index.tsx (86%) rename src/components/{Navigation/index.tsx => Dashboard/VerticalNav.tsx} (82%) create mode 100644 src/components/Dialog/ReportBugDialog.tsx rename src/components/{ => Dialog}/SearchHistoryDialog.tsx (92%) delete mode 100644 src/components/Halo/index.tsx rename src/components/{ => Layout}/AnimationJson.tsx (94%) create mode 100644 src/components/Layout/ThemeProvider.tsx create mode 100644 src/components/Layout/tokenAliases.ts delete mode 100644 src/components/MenuButton/MenuButton.tsx create mode 100644 src/components/Notification/index.tsx create mode 100644 src/components/ProjectPageSidebar/BottomAction.tsx create mode 100644 src/components/ProjectPageSidebar/HeaderAction.tsx create mode 100644 src/components/ProjectPageSidebar/NavList.tsx create mode 100644 src/components/ProjectPageSidebar/NavListSessionRows.tsx create mode 100644 src/components/ProjectPageSidebar/NavTab.tsx create mode 100644 src/components/ProjectPageSidebar/constants.ts create mode 100644 src/components/ProjectPageSidebar/index.tsx create mode 100644 src/components/Session/HeaderBox/ChatTimeline.tsx create mode 100644 src/components/Session/HeaderBox/index.tsx create mode 100644 src/components/Session/SessionGroup.tsx create mode 100644 src/components/Session/SessionSidePanel.tsx create mode 100644 src/components/Session/SessionSidePanelFoldButton.tsx create mode 100644 src/components/Session/SessionSidePanelHeader.tsx create mode 100644 src/components/Session/SidePanelAccordionBox.tsx create mode 100644 src/components/Session/SidePanelSections/AgentFolderSection.tsx create mode 100644 src/components/Session/SidePanelSections/AgentPoolSection.tsx create mode 100644 src/components/Session/SidePanelSections/ExecutionContextSection.tsx create mode 100644 src/components/Session/SidePanelSections/ProgressSection.tsx create mode 100644 src/components/Session/SidePanelSections/buildContextItems.ts create mode 100644 src/components/Session/SidePanelSections/collectSidePanelOutputFiles.ts create mode 100644 src/components/Session/SidePanelSections/primitives.tsx create mode 100644 src/components/Session/SingleAgent/SingleAgentSidePanel.tsx create mode 100644 src/components/Session/SingleAgent/index.tsx create mode 100644 src/components/Session/Workforce/ExpandedOverlay.tsx create mode 100644 src/components/Session/Workforce/FoldedPanel/AgentDetailPane.tsx create mode 100644 src/components/Session/Workforce/FoldedPanel/index.tsx create mode 100644 src/components/Session/Workforce/WorkforceSidePanel.tsx create mode 100644 src/components/Session/Workforce/WorkforceSidePanelHeaderEnd.tsx create mode 100644 src/components/Session/index.tsx create mode 100644 src/components/Session/sessionSidePanelLayout.ts delete mode 100644 src/components/SideBar/index.tsx delete mode 100644 src/components/ThemeProvider.tsx rename src/{pages/Project => components/Trigger}/Triggers.tsx (66%) create mode 100644 src/components/Trigger/index.tsx create mode 100644 src/components/WorkFlow/TaskLogPanelContent.tsx create mode 100644 src/components/WorkFlow/agentToolkitLabels.ts create mode 100644 src/components/WorkFlow/baseWorkers.ts rename src/components/{WorkspaceMenu => WorkforceMenu}/index.tsx (67%) create mode 100644 src/components/Workspace/FoldedAgentCard.tsx create mode 100644 src/components/Workspace/SingleAgentList.tsx create mode 100644 src/components/Workspace/WorkforceAgentList.tsx create mode 100644 src/components/Workspace/WorkspaceExamplePrompts.tsx create mode 100644 src/components/Workspace/WorkspaceProjectPicker.tsx create mode 100644 src/components/Workspace/WorkspaceRecentSessions.tsx create mode 100644 src/components/Workspace/WorkspaceSessionModeToggle.tsx create mode 100644 src/components/Workspace/index.tsx create mode 100644 src/components/ui/HoverScrollText.tsx rename src/components/{ => ui}/animate-ui/icons/alarm-clock.tsx (99%) create mode 100644 src/components/ui/animate-ui/icons/blocks.tsx rename src/components/{ => ui}/animate-ui/icons/bot.tsx (98%) create mode 100644 src/components/ui/animate-ui/icons/clipboard-list.tsx rename src/components/{ => ui}/animate-ui/icons/compass.tsx (98%) rename src/components/{ => ui}/animate-ui/icons/hammer.tsx (98%) rename src/components/{ => ui}/animate-ui/icons/icon.tsx (99%) rename src/components/{ => ui}/animate-ui/icons/orbit.tsx (98%) rename src/components/{ => ui}/animate-ui/icons/pin.tsx (98%) rename src/components/{ => ui}/animate-ui/icons/radio.tsx (98%) rename src/components/{ => ui}/animate-ui/icons/settings.tsx (98%) rename src/components/{ => ui}/animate-ui/icons/sparkle.tsx (98%) rename src/components/{ => ui}/animate-ui/primitives/animate/slot.tsx (100%) create mode 100644 src/components/ui/formFieldSurface.ts create mode 100644 src/components/ui/menu-button.tsx create mode 100644 src/components/ui/semanticProps.ts create mode 100644 src/components/ui/tokenAliases.ts create mode 100644 src/hooks/useInitialChatPanelLayout.ts create mode 100644 src/hooks/useInitialWorkforceFoldedLayout.ts create mode 100644 src/lib/applyDefaultModelSelection.ts create mode 100644 src/lib/chatTaskIdTime.ts create mode 100644 src/lib/richText.ts create mode 100644 src/lib/sessionNavLead.ts create mode 100644 src/lib/taskLifecycleUi.ts create mode 100644 src/lib/themeTokens/README.md create mode 100644 src/lib/themeTokens/catalog.ts create mode 100644 src/lib/themeTokens/colorMath.ts create mode 100644 src/lib/themeTokens/dtcg.ts create mode 100644 src/lib/themeTokens/engine.ts create mode 100644 src/lib/themeTokens/index.ts create mode 100644 src/lib/themeTokens/naming.ts create mode 100644 src/lib/themeTokens/types.ts create mode 100644 src/lib/themeTokens/verifier.ts delete mode 100644 src/pages/Project/Workspace.tsx create mode 100644 src/pages/Setting/Appearance.tsx create mode 100644 src/shared/modelProviderImages.ts rename src/{components/ui/button.stories.tsx => stories/ui/Button.stories.tsx} (65%) rename src/{components/ui/dialog.stories.tsx => stories/ui/Dialog.stories.tsx} (93%) rename src/{components/ui/input.stories.tsx => stories/ui/Input.stories.tsx} (97%) create mode 100644 src/style/tokens/base.color.json create mode 100644 src/style/tokens/component.color.json create mode 100644 src/style/tokens/contracts/default.base.json create mode 100644 src/style/tokens/contracts/default.dark.json create mode 100644 src/style/tokens/contracts/default.light.json create mode 100644 src/style/tokens/manifest.json create mode 100644 src/style/tokens/semantic.color.json create mode 100644 test/unit/components/AgentPoolSection.test.tsx create mode 100644 test/unit/components/Dialog/ReportBugDialog.test.tsx create mode 100644 test/unit/components/Layout/ThemeProvider.test.tsx create mode 100644 test/unit/components/TaskWorkLogAccordion.test.tsx create mode 100644 test/unit/lib/themeTokens/engine.v2.test.ts create mode 100644 test/unit/lib/themeTokens/verifier.test.ts 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 d46a6a756cc60c33701e4cfe3cb0d8f9c2bee753..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20451 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 \ 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 f0de3f5e5409521fb829332d13ed3b76c0b3e884..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25147 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 - - - - - + + + + + + - + - - - - - - - - - + + + + + + + + - + 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 79852345596b38d3db02af520f6e6f497040d94d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24346 zcmV)1K+V62P)a5jGLxSma zR+EG@gr=J@!H~8chO})4+t|jEEgM-_vdY#_8f?u|Ni~mexc}KXXYX(CbMC8>Sjm&B z>b`sb`~PRy``h0h&N)mM___6lkG*S>(tn#oZepSxoP_FsEKI5V$>nuoE`JZ@YWuAI zPvsLT?^90tFH8N`?x|NveY?Jo2QmJtzl+3s<%;sFy)XDZZZs{wN+5i5TWNr$?AXDojP<^$!E1Ls73=?(w%9gkD@!~HL}UjOl{ ziZFjT<#f}C;i?T!$sgih5}yw3jQ9Z5R%@d%8Po}Z3ajWfb)ea0(Rzgrsuvj5_ z*2MZPZPn@WYHw-Jzus4Lsk~k#6WVdqXQw(v7)xzM9h5AXSR$AD`n38z$$p{OYl>eX zh^!MeNP;?NRd#K6mL{OmjSlg!-UVxsaxSwY|xel0zZXim7ml z$+3i`wXq`VXc~u9n8`LFukEUHnVZ>C6cmxTk0WHW8nA{yU#Uo=7RPL>bpO>x8M6`{ zyV?)yvMXR*Y1VFIN&(H@T4{f3J8k|P3IIfPEX(^t9?=4$qdgWiq=E85Q?k<#zjTBg zR4`=j%oy70fa9R)fUt?q)b==xOcZ4JrX^nf#dy}Oum1Qi7O{Ju-Xsr(&5}bfRA{`R zMv&0%I!VQJCu8HK8C9{zjcs#2r6VPv{@1H9dEsQk$ z@;;)K^7>ScK}Z-%b_y|d0k2ep8Z8<->?yA+B9AbinKEb?csmq9zzJR8XZRz8h|z_S zlhOsH55Us;#FQG6ITGuAQ%GC`3;dPOt;71*@BTdXkupS#Yag*cN+MF2->oKVdVIRmu@D_cke2O&F9ccbj1(G3vZV}}PZT13m`y`$yz|8bOfX`)8N zF3o||jz3x3GZOk6PE}$PITZ;uGpr7@qgy68ozf{!Lni4=F1u}o<63_oiX)hmt{HFF z_b?g_p^3t&Q}h%Fjr%U7biyxo8dXtN=}d-MF_8sgqdfp9r(62{a<4JxuogYHw()EJ z)=>PKDlC(FzuJ!~+#EK&qYg%C1Gu{TDF{2zWC!*!sG{{L>)5NbH~>M!vRjU-td)({ zc|`ki2PgzZ8=$%6^}B{jPfE+*XJ|cu7C`O9?0km2K~)sjly5@F&F@ae!*liZOh5(< zmh(V!tnoW|Q!m!`uN#|0)P1g>rNJ~B{6Gb5sqoDQ){4_ve!p>%__TFi70LNc44A|d zRCenGXQ3vqi9({dIjhNGh&v|NmS-P{?}I2oJp zImDzcARs0QptLDox1fOp^$;)uomcViA*djTQ`MJBsz-9mtxtvJ(R+j=+GHl8GsywV z`c+zVaax6lj@6-b#|B!-a3+jADmE09te$NOJ~t*9UJSl+SE?MHX?0qJZ%ii0oZ~y< zGbkqT)l;@;35+3-E`~WwTLvm|SOgv9ZH142=oIm>^6rw+DMjq$gu;ZOg(qG8B_!4; zkRgBeR#xrdaCdiF$fh zrA4wEaC4Oc_5-mD&P)ulA!zp77u2uRU^-MvSg1=LjLRl>JgI58 z_#YR12(@rGoEWhB2lp@sq`X=xuU`(F=6#ytkr8a|_CiQjALH1lCI( zUbk*V__?4vgVUx_Oi{!`o_1|T%R#NisGkpn5HAUN{3JOLkohU9bewx}HkU=)n#sY( z@yAfNSy!k_IId=?a`<8v$4`ywaYs?`oU8z2_Cj1?LJ$xs)A8QD&C(>>Sh9H36-FA zWKj#Z1ldO?w=t>xji`Z0*G{8u0y8dfZYy6lPH$l|b%sZ$SLj=AK>GaW{<_ADYO-8fQ&`LX-qPB>dWEUKh69I_W^o=1i()kUJtSr{hkeA zB@{H!T-sO@b;?5$fYnnbfu(7_MYd2XL`Ua*Y!0#IE5;Z%y^A9>LcIQ-)V{CMq7J&6 ztkluddTrs_snaeGeX04WD5ApW0^rElI&0s}fffXGsY#D^;v6iU|JW%SYHS=_F2j6I+;Q&cg0f7_t_~4jwP*lE+%`ls z5GE;SWin^Tp)10LxOX3ai%GTY5bh-%bkhuvRRJztA2+5zWt&`A0s&2$G1-+kaBj&q zA2+mL5W;gOFz4349&H)0-lMN0uFem5b@T^3rG1ZLevBU4IC)o7_V6o3NYDVWMU`|c zFdk;LB)EC?Efjb<#I{G9awu#$XZOx~y@m47J{HRy7iKs0o*Yr=Nvh5nF=3MCK|Aqh zj}Ty}6DEGO^!vkIae)KfSGbOUJ%uU!p zpLEFKkxQ5w7v=Qs6b8) ztttn%@Fv<_$I(z|MhKPtrY6>rU9CUsS)Eu{5EZ1cp=Ich2Moy&T zIdQ)Xi|$~e@}**7fq)8a1LS6O0L7fUvP2?~On}*=BsI{Ha+5VdyE3EwHYVz1>Nud0 z#-*i~M^gZmau`C5qBmzzVCpQJ?)Rk zQ_MHM2?cKof=6YF_^rvTH2JB*KegDs@m(axpwxpPIE&POsuV<(CR@@+m2ZkUg^+D* zG*ncD%B@u*P1z9QRAr5LxeK{9+M+a;q-(viXo~pzkSWbAjubK#)P@ByZqQEx^`|C( zPWO7+7_!Pvn=1%-G{!9@#zn(~?kv!(S=LwCnJ!txLK%W-kw;9pxZF9xk;-aKR4@~} zr=E#t?;2HpP}q_vVmiB)6wOV9hlsP!x?wh%F&W}n%S6s%hg~I-a(l_tJyJ@6-H`>lE9b12 zj~gb~<+XY&8I^vhMs$JDm_HkBa9U5Y*XPoB@SjA3jMkU(H#DVqhGi-lP`Hr9ffj4T-6*M2z9_ zx(aILpIKz~x=y%#4DF|n9=KRpqD0uERw?cfe0f8(eL9iuyt+V%O*%6LYI+!{_yRFZ9=!Qw#}m|%&5rJS8kfrkTHYkSi6lKi== zWbE!-Rp-+z3%vC#EyqKx5uQqRL2=F=^r3t{fqfAa`1l2ic$x`jH!LmjmBAa?gQH~} zM%Om(z{DJ0jcARd4SCp@vkTe%pol;<kR)!9T@$#4TVv#X{<1|&7~|?zOlcQkzPzE*JIAJQ1hLk;R|QtT z#i(>LCGlv2iK2ZK1BMB5`en~Cco28`?CEL_TQ-x3CQZPBq56MN8);}RDDJQ7H555n zEBB~qo;5kd!M`fProq}{mJ)-7(gAl)RP!gr75vC4EvTsu$tD)zq85mzX>3@TC>Qmi zOdNw9kptYs44FlNv`NxU0Eo6%=sEg22e+`7A)<_^{3{3{YxqtGqg|6n=f?QFhHM8y zxKcvF?CGZ1G)^gRj8FixR9R%kf+}iyfFKiQ42o4nqB^IbkM+A_&P|CUUfnEPpsy=Y z8!mem__IGhcSwxTxW&4~5L-iuqeLG@^-bK8!0Jvxb!yyPKc6BF?oLEUCj=F6t=Ncy zV7Lj>+7L=%UMMpX1GqjBN=mFgf;xH3(75D@BMeCWj)i(e2ljNC=t^0X{vZ?90ZQ|_ zmCHGx8fB4{lKgqFbo90}n*0f?fhOnR)tH3bNPdA|B>-hta7*WFT!|Eetw3u31EnBPJCV zrV4&Ix9F1l5~T@?&i_$^V1Hz4f7;{_g`?n4^vQ2H7X#)(VXLaZr^OnUy@AObRkI+b zJYI9b-R;j7Az!Qa8B(OY8TYce;&`kDU0f zbP)!UiSi0)OV zsn5ynXop!dSt{HTiHVdh}ws8JKtwODOUgNxibHj^$p z90j1_i(VR*h;%Yl(K~g45CLsX$;udTZ^c=!V&y2TwHmh^JdfQv;btvxrjHm{6C*LP zUN(t=3mZ&6kPHVk2UoS=fe|8mTsGYz&ukJVSf#FT@?7Sh{nSph{M)3QDX%-Mp$L z!9)ud7JI_NLv7iiNBi0ws=EA73!JLSbn8)BgI539gt}!K&uvf_ot{D29CI&WP04Fq zx-6%U2PDA8q4S72#)~3~;`h<0&I~uP&pqOyV;~M_2^7UZqTLI^T!m7A&O%+LQhbOy zz(5W`jJC4Vp^Wd5gQLN*t+KN3B?L))zKlAVOect+%VbQ9Mob2=iaS-x!GvCGvR!z_ zZortjtSz(#PEzM+ctgo!ozPqXVUHvPnRU>>gs0=FW2MCA)zH9be?Due@4uUnf)SNk?aEx>a zlErbwC%;e=MMXy6#m5FnB8@B%OuY0;0EBns)aTWO*ikJC9;TnChTyOU4x%!ywTq+~M zA0lgZFz`0(+mB zA|z!4I5rE$6cga4o4qh-v_;S<9CV`xp}5|^PIzkqg9|-Lh;o=?#v#}yI4w@BD`Z>& zyoMU+X%Pw0ke#fQLYL_Gt%2RXkwNu2%EbGquT{!3_T?-7IMv=-X~ZunFTvn zSofWRme_!}&XO1xI;m(xi;PhE55bHFbIaOLP zH`07+CeTbSlT{yrUiGGMy~Njl|0pf|!tUZX8rMv&Y&8UvQ*c4OAN-Hn=o(}j^sXb2 z-)cNOp`i{_wgu6iG(pe<$mp3;X@UjB8jJJ{CGbDh43~M0HYE|(F~{7!ZU(*R#*9HP zs)1FZtX#OJ=FhF#Yr^~@)Wp;*OU^BM6?0IXMQw+*$8EI+*U1)B_zJBYvG106NmvW2 z{+dAfQp9~=U2Xdcnwz6>jtu#|uAMn@6|kq4Bx0voq@lp(A0T=KHc_3@G|dojHlfqi z-)mn_^ahWE2=4hDM^#+bYo=}QF40Zg` zCzjotgo%f6*n}CH45-iGP*fNlBY2c5LPOwq($mLja-^8$EpyFs zIG@ISjMl>%af3j&h8)ip8|w`NqKSsVC-ss617VI9Q){%cwJ!8vYv~BoperaUoLvqV zmDnS+>%AAz%5~dlQn;@i7v;s#2<53YL1EKr|8@JQ5HuKCHB(WBS1!aHBUI=okSyGAaon?8?1>#N55FcyqK&*IuH1zj84xo!&y@JtK{a z(`J@paKU-&;$)PaS}`AgZjlJ^B(W+zHuZ+}R@&o|RKUA_)O+($|Er ziGFjozz67pKjuKe(tzWOFM@9EWO$b$i`1;Bp}4i4fY#YRaQG3E6PQJt$B-3wE?MPn zQlg^s<@MUN7t@(7^R#s5GvzB2r85~+*5dzeCmYyBq(!iapt7JMQ5$%E1)J4X^X5l% zh~XJ%!v%|6g~E}_VHKs>AF)1EZQK3xyJ+deGL6rcXcn^9Ocxk?A_l#_SCU3)81NN*iQ@{y%blVmqd}RXkjbb|GE~YG zHSUJv5C&<2Wy)=zL?R@_*En)`9d1E4OEvoFOX}Jc(0b2=ZXWWPd)RnM_K_nmWeF>! z8(T;UL~i`t$_gy1*6Q?pyqaa}td71M8pl#+QKH|qFJt-g!l5-BgeAQ>oGVIyqz|MQ|gW7PgB0-&$jx`#M0_N{UD=sL)T36wduU zi?nsmjw0N}e9Q}Vd#_O|H1Z(R2>i9XgwX3VT0GsTCWMKcTiSZ>8t)z)BoN^-lSvVQ zkg(?zbhWRPbTBcL5!+5o$73M!x*WGG$u^YurMqXouoCdtp_xj z=xYyVtXS#*(K^B5tT0WB(?5S?l_uBhrHyU#>L7A-+Qz8ujK$GbhO!!67TsP$WhYs` zY{5fsXE1uEED!`QQElxu6zIXCjKj(|tPmY4?=O02klW;N3kNb#WZ)sDsMDzsRIHmr zM!fg*84if6hQ-8_r_eD09B7Y!A??25jLU>sh>1;}G{J4i>hofSUfl(4K!A1TV5XC(2SzqzR;;Re+%6q+_)WMaHzEAd9F+S0YH^}~ei*D4=^Mo3I1CR}$LDxd5dXnyxX2@Phnwhc)QLoG}e z3n(U9n471q3)}RO^}WTp!Z8t;<_WbZ(xuEN{{6Jxu6bfo$%bvZ>R|BYC0LW~b6=j5Jw` zu?E`sG$=3 z46{2yZe~qmW-7-Jz}M7jjWU#sVHB5ei*#&V+D5lO@;N%SdYTSC`ylD=rGHynQPTrzYe}XFLqIys(wN^3>fW@32nKo_LNv`_O0UHJ84Y9(eYCI=yz9 zK3{%+_SiG@>isv;Uwrk?>8?k=L|5#-l78dUzfMQa9Hlp1^`;`6+vsx--9~?L&tKAg zPu)jvzv1olu>&8Yk01CrUAgB<+Ozc{dhGCHbnv-@bnX6Y>CQ*Ks6z7}KJ}Z5oO6>o z`dU$#YC#@+<{^5;CD+igv&ZS9U-~op)K@-9`?l{>Yw^L`K0q%N!MW)bH_@jKe40Le z_owLU{a4fR)5qx#{`wE;_WS>auDkSFdi^!8qrX1z*Ys;2{&(~Pw|pP{Rk`j@Kli6} z`QFQlkQSww4CV9N=o4T1q$=zDWS;*0pZy*^{o=EV>L2<1U(mtFA1c>hPDf82D}ukD z{_KuV(A^K;qYCx#Q;*QAu6!kZ_MR^k#hcLkKk*?tyLOhYyW(2fRs{W5KJe>w&m;HJ zTVMMY`kjydA$|4h56~^Idkfum_ZR3dKK}{2>*0IpEw6ba{rTT~lJ5Q5{WL9-^7yk) zmhZoU{_+c-E<(S9zUhWH(FZ>9C-lAF{9UxVwmJ)}otV1c>l2|0%r@FdatS^=M@j+X>bmxplc*hSQ85;3CyD{20T$!qf6`VBw6<2^eh4ew>I zB_L^{?U^!`riHC7BpyPm;l4uVx4wOahLaPef>BCZLvR%h3o0Ib=04iLeLwBrwV%HH zhHMqhpA-eT^z(E4AFC zAG!J6^z`8;>F}8&1wmKo+i&s(yY!*_IdS$xLGB#|Jf`&U(+|`46+!<` zpZ|0Eme<^@WBAzfPtY?(pbs5?fesyefnIyfjda;X`{|jZ&lW4Pm9D+?6-AIArfET+ zuRL}iJ@mxa=*-zObgU@TwDjTFsT1_bQ(vdAK6)QrylXFg^3L1n)z`d=F4?t@9((Qy zdc$?EqdV^Z621D$SE(iaOhN6cEdSsgKTP*NbRXUH${Xp>(Zh7tgI`tabokg&I&tO{ z-T&x8`pI|y6#d%&^&jX5zV#inyI7X>=?3PMVnZQMpoQPi3B3z*TNT8exN>)WU!&K2 z2)+;>SI8Y`qeMZ5r%$MWbN6}wuWsri_D6Fpxw}6;S1Pl*SifSm3<(~on;wR6d+08Y zjD@VGJ-XXe9*p#NJlLI2=YJdHI=y1gb@ae<57N`eo~D~$aWj49fzQzEFTbe>_zAjv z*HyH4>z*R?2k7y`PZSz*nNkwp2BF?#S|(tUCQ2zPXRH)uYvI4!w{54@jn#7fhAPgr z7hgvo_}qu+9dGy^I$f^0Xy>jnX0=RqDEG+pToKSS>*vb7Qfb$y$bIGBt2Ar5Iz3nJ zxk7Kf{^nvawy0uWeesnAtzWOt+p}wrQiQd&b2>H?o+I1;uQ*4A;_TSDy*y*RaN9{y zgcs=UM-He!ua)*yYqERW4%$})b!}~inUjJtGKQ!f^4O107wr?wRZ5y2~$}wN)+OCUsXffKC z9y~zL9)4b_(~k1_>#x0m{?EVoApP*S|2_KJ!3XHZS6olu@`hXJ)ajFfdWzGjgWx95 zB>k_wKwp4%gcLJJeRo4vm#_YNAlpPO@`SggYs8qb_sJ-r%Yv0Y@CBqL4>gccDN1?L zdwrh~k8psa+l4cQ??ZSDtgo{QIV}{9{B4E*o&bVZ4NHl9*VTnG@F&lmRDrr;*Of)c z&d@E_-a>~?9->QjTta6z&eFx(FV^>KBd^$I0H_*O<=10#SrcZq3 z6SQk(Cw>2$zMt-W>HyvT;O+E1Z+ItdD~PyJcy&eZ`=7a=e&)M=T7~itKlO)n?cQtX z-EaF5y6xa+>G2mIqi?+CO?1i5OX<|wDMhQjg(t5T%kkD%y_F6=eNczyoo{}pD%InM z9;eq|{W`j8@0IkS+x|$$=Nqp3hT>N&7oNOQsKDLy#%tfG$ocWV{VV!=Z~5*bPjxSEsM*=I&9Z5s!r!EuYEI}IDJa*^U5o( zDQ&t)Daku;`EFV$3VQz&57W*fq_4U1dRpIDD^BJ@LFRktop1RL`snQ+r=us2(|_~B zKS|FOf&TN)e2n%L?)|3gUQ5p&c|m=c>kBohT>Z-h&41!uKSqbDWjT6;F57#FE&q~^ zANLT8JVO0RosD<8azWv4BHM{GuJ7t+F8G~7$JSRZeJ}I2(X`}F*s&>-Cc~5-6R|W~ zVT&&~@*Di&t3G~}Ooqp5s|R-ia^o?61V`Hw+I}+LQvu&7|8C24*)Ls6!;_1|bGE?P zW8cP?rnIZ@IZEiab|@a$7;^T=JT!e6I?66-wJ&$_~z2D`-DIx2WS@ z6|>&Ea>$MKYLUhwkh|!~!_O2ac$djr5!91sPG~z9^y&qR@C zc^iA7ObTbgHy3KxSTaXb>|MN8PhzMiGw5R4jl%bL(wQRAzE^bpcN*#5)_K!iTTW-& zw~_7F4F*?3qrS&SeWtFcPtGG@4hLg=wPPAqwMu*POdq4Z-osnOUJTW^EzE^;mk(GWTum5_cs}Yi z%6AqQ=IBg8?pmf`p1^aj$OO{<$lb!lA*lIYkB5S%v%vx6j#4j4@iW9Qeq^59vWRhf!!d(q5>DyO(L;7+%=i4r!% z_GA;GE=`3zn`!n1AHFzN2+O7lST^JnimF##G!=Uv<|i z=A7neW-n??US8`l7MdXqOF`Z|C?a`Ey!XbXT^j`(Hi^RpCWPCQ>|d4MAD-{K|^;DK7Yny3>1nR!eomcxo4ie;it(M#G<>yXr9%8Q~WQ;+? zHoN;b#B?8JGZmRZ25DxGI<}QfvS_oB?a`I|`+cuwh69M}J72Y{j5THo!E`W9+y&H_ zX5!7QTCc;vXiTQ_Ys&3!&q)rQpv??O{IokG7yiU8`uu-SvfTtXK z>_`uHkr6~o>VH^?Zhnf8?H3FXO<25=y{A^t7L>HjxT8Q5EC){AR!njT|1_C-lZZW@ z-?^Z1+$wqV?Cj4Dw0MJ>`HGJ7=If;U02O+8W{OFgZ540&MS5Yp5K8mx8{P-i-GQ3f z&3(g|&O<$!xgl-3Ve1P1KJWV%_~A{Bz0fN(HGc6s4;`YP_niwNYA8?t{FfGRyIYIC z#Ef>8qAoKRvg%fC>6=4|MyKrbYl)3ReIrw?cpPnYy~I&_MoXg>rGHpf6M!;FeLW$^ z7>`UirKwHOjT9--rryUOq>Q0Wy+Co-DSs`S>~n?@=s0&&H#pLlYevg>MFa>Gs+qEG z(3vQFI)^CN9Pwd~QJxVFw@90HOShItt_u<)N}Hdp{TsUpXxB+EiAbDr-i)f7?^=&;s4X$;`JqS~M7iMeJH??*x2DZPxLG85R@bOCS}yb$eApreeBmMK zYUDQ~*JdVm!SOlo9p3c$g~ZM8Ka0}p$O*m7PpBmsR`P{z`Z7=SHf?TSuatbjc2)pY zE2BalEqrC9YnCgpjSX;M6 zF1Rzo<1Lg6Owy*h^4a&f8#9gpYORpj_heHe<9%thH634QPll;w>;24a#7$;Yn_W36uynz>m_D&INIb!PJfhB!sEp5Y#Q$SYFCRbsh1L7kA>b zY4Y3NezIvK1tzQeJ)Y4CGWUYr4V|1nQ8N#a5Z&|pA+t}jcG?F`=cHpfvu*45OleL?5=*oG%v=F_Fx%fMNF03r>FU^kE|KDdgb$O|uk>Zp(5&FA-j7ezdwZ^7LkC4=L zyMvX4)T?-ids?>Lspu7)`d42Quxx~bbiPsA;WG+VM+SsG)o@FbdZp0LIT7#$A6+954(zR)_F90gR< zo5eb>fXH7(ZAYfgBYi2bv`Z78Hxh9ajQ3fbqIFljtS~{7!XlsdRJ3;%r0QeRrsu@? z)A>R!n_kIXUQ#=Eg8(N1Z5WO@O2zkx1I&@tfM5}4& zuXYnmbUw4*^uyjZPpK1~7d@gLUAfV5w+_D6SJEc5lJlR*o2C_;70QKsO*0Ll_FQfa z2Vra%dnM6J|Fr()3q0G6lWY>xOy@IxxlkYL@0^J@g+%Ek)TyT`ZES19Ce-%eCi1PP z>*hr{w-(w4K@y-D``a*%5!BRlLgXZ#W{7IE+0jz>pbaJPb%rDNn|)c6v*gy=EQp|V zFv{DY(lxE??TlhXq$X|dVc*X4qb@VgA{U_`#m-0 zUF2(JX;I5|>&l{({E#x~p4_aG(h-qTF2Ir&gzX3qVg&C>(H*QrL(QSK&fb`1J7Ct?wik`W;6Y_ z37@%1Hv(E;!Y>UOo*=RI&r&H*XC<*+Wk;ti7r0wP`#xC($5{n6)4p97(OBZx4?Oub zU2w5|c`IFi<+U`KV|w=Jizvjf#x`y}>WLvlp4&))$||;E-w|=iYrxWj=A=@TDKx3^ znx+MQbe>Ow$$_P@J;XH5Y>*@nCZ8N2UAcaFZh;1#(7EY3>}}^U?c73?-oWyEEje9C zOLT5L8~f)8x3JV^A@2n$l$P2KxNf=HbaV*TQI*zu-yYcoCL?2pYCYa0GNIAZ3Bc_A zBGagEr}(ZaP7k{56HcL=O?7**;tmjI1^OjpECPg^5o{k*iHEvxCVW2AHpOqnZta;A zK6Kqj^3r{K>0@`^O274y572{8JW40eo}#TwTj-TnTundwuJ_RQy!Bh@*%uF)#dlyN zQ|fv*-P9?!Y8%eOrj`sb7Ahgwnay`6C=_MYHuK3I_Oo4JTXQ$JKCEkuj2#{YN!#Sg z)|EeXf$pA_J-S}<&PV@-7AEtwtNcF2c3V~}=(u6=_N@!s^`3V>e}V zc)*;T{gKwrW{>T3Jy>Hq1;gfh_}(4w*-^OndxK+ziFWwtYSmmd z`(#G_x6aPI(9ZvCE2ZOu9r%PuN8MIob>j@Z@1s9Y&z*RV&aR)P58e3z+E(6w?PWLW zDxcJqtXARPy>bz~c4*chtgo#w)%bQVSPV>%KrtOWr!Fipp=`Vj3W6syb)Wm|CI^NEoz$^Eb~9b*ts%l< zGz%#G-i>i9ZfazxYC`K9`8KJP+3&ps>bGl6q1_hwH{uVdNnIv)$Gvya&-}{2pey!X zs!IWf$%L+X#ntrW<4@>r1ItUx^p|&hhHk#`jdb*HD*?xez~ z4_4EGM(Jn>&#W{`;&5r=t5Fo?)ZA6ZdHfdEs-1iRwDLW#b#W$8w4!+KGI?UP0gHu} z{NhJ{fxhjA@1TEp%TLqmE`KBa#4SHbzxTP{p$DHYTCFR2D5y_ewR8OJar&P>_OEHr z);;vyH@=geI`$O3aOx2D>S%~+9ITrZlf}^W0jYf5q|v*L!ZH0?wsww=0{Gi0lOd^R zQ(*?V)#L>oOGqu}X-v7b%Qq`W`)tGTXuYuKc|mu8@*z&*xnv0*<&XpXB_f)siI@1( z9Jngnt&|C;Er=yh5)yS6O4e=u5m(s$nfS!S#w<*&;j7zhy#Et_s>|`_=jZ9n*>m*x z^%})mU!;k+bT9}`h>||%uhQd}hD>TUzyV&YMvWQ%z zsIj-#3D#LZOW|aSDSYw;4^{x5e2`Uxx^vv21Z{XJ;KQ6Dn~1u9$88VZO4sgvCB2~t z_W$}<|EAEDW$pYwdfU&^hrj%%^mE_y-_yB`RYl9?`7MQ`ouq&Bv42Ic+5g&t_Gjs? z$M2-;FTTE@`VKn1eue@Gc{Acb8+pcH&P;|<28SOkLV%i}&sf~S7$p6QEq>UXkml}1 zThzgAAZD+c5fiI;^srU~gjsesgWb(i_!x8JMFGgt1TG1ZA~<+pfCt=4cKZ%Adi2si zqSnh*ZsziE{>+!C3s0UqdYB$~;t|@tZI|v_@|qj3*Y^MVzx#Fiz2E&^T_paSzxlhg zbL&pJ|FN&rp<_q&dsEOjtu8_!&g#AGJBaHSnC!F?o>~kZq~@Gg7N6O zruJOj*;Q;Q;Sc=K_t8K8CqGNy^_|~E$BrGN_x$LOk{`ppx_*wPddxVJhi%Ol6v!fo z0=Ec>U=F1TpQU-m9AQmOPu4bUlsBd|MW5@jB@=2C03FAJ1T;lp_5(W_ZS5?#(3#aU z^o>`)nQkk>{a<|3y9?hwLI3s>|CYY#>bKG#-Sq+bzBhcocCOxUQlcwQ9(htxzZT3q zc<3O_74vrZ)KR*8*A<1@yopYi#S=QzjghHp6N;!`cr}E6hlO2?T-qYpHz{^@xLDhwq5%TB=KRbE?p^+l6^b( z(nhY^XbkiVKmR^@*Z2Q@`oib$(6Ri%AN-+0U*?PKO?CIJy4zR=h7|BKDi!^T?fDJeKJ--ACz-W%LAuz06I)WKP5U4&D2_4 zn{_q%4f}4On=X4j{gV&;41M2C@1mdh=ASA^y+}V<`1o6|`4&2M_LzQuT2iT(@48&q zQyx8ij4s=GMTxpRK-XP-9sTUL{$o0F>PSqUZ3vn*TjslnIuV)8=`|tn1Cc0@z~s#Y zjbnw3b*Ujk2oIT4OOerSKdM;lLdJ%={#_A2k$cu(@b(D8V7Vc^(1oHT_B|C$w)&tE zitT5!lgy;j`ax*(6_%|1>24pwWa}OsvkmH;-WbWkPqqk#ot|VCek^EQ6FP5y!<*^( zBS#dlcJ10h`}glJ$o+2mmp}LO^u!ZS(2nif>CoX9>E_qIk#=p{rh7a(C2>JcVnRH0 zg08S3OtCExfM&>cjan;&hWPidy7j7*>#BMXb>TR)GvSyx`YNI`fgbch%Z- zOc@Tdw{P=LZ@DC%vDvC{w9{MZ=VzQm1Dsok5P~ON z#x{Nu0D-wV^&Xt&zQ5QG6?Hjg!4+;Pp$b$N`LS%WV-22rf$(N*E)VFwfwA-7J<{lEMi9r*eKy1z@^U#9N)a^lPxdfm0x({KFKU!uz| zzJy*la*XPhnQMFJX!SFX(tHgQ>X|*6T2#;E&lNhONj@GGWc83?EELf>tq3k7KW1S* zifms3*=Gm`+Xm^T-JIg2vIV?1SULpo4}3Qn?MJIcuBTM$D`5Of0O+ z?)G!**2hj$@MSJ8U?eSz9sU~nW05!etY)isZ{Mj@^-n(YF}m~Mm+9!qlSR;X&|7YN z6aCQJ|8trvvGEsoyvbX-O+>0AB_vTXq>ATSe72)WW3yM*R`r1zQN=kcdb6ln% zeSX7UipcebsXvVB{O8T0xD^d`(Fk_MZDGK8zkAIPF!5*VUML)~djH#>#GEfcMkoZ) zwTNeNCf*Y$l17zs_dSG%tv&})H{2k#VFjp}LtOW5+_i0oIu>IMX8<^koH|bH>r>4b zPYU>*F6ce|=z}!>)M?5~6T1gX3j);5oyOdvEn38f=$3fGZZ<Q<*61y^x?a zzKfAIUVAk)rE~6>?6q!P0fh0LP{T?9nzAco2wj^!k7oKtIUXN&tQtLHA`*pG7Act( z@TLU4weZK8aQG6cz-gDS@C#epW@F(Vh@I6}ofXtd`@{}BM&j8|2yFr#P1JV>C?FyK`Pf<}lnqS4q1?8BgFM2)fJ6k)uqS$QG0$0Naim1Wi#N4W5}ME9>nDmZU;Qlk+3B_;l^ShMoj?ex63paT#DXKm?TetB zBS@CbTxHrgQC<%cCRC@BT?tLv#3TlN@ysB1jxA9f`CEYp+?Tz59N2E5J|KMkYYHWE zYbD*(>-7xuB`D~HV4h~*ZUCR#_$cW$pQMiU)U6ElCc)%rq)tNP3{?C6dXcO3Bd3Ty z`83Tvd8#;>3w97vEY5b&BjxoeS#-tZvB7{JHYVIPJo_<%>L-pRt~ls~Onjg{5u&jm zBk|E?)mrQcC|<-=zCBmW&Atg8{os^Ne)2f2+%(X_Wkv88EDRZrpv;b5OF~nG(^5i` zX$BdgwI0fF1k?fGSw4x6B^)+uccVm&3OS#S2n^Za8OmqcGD(^q*45lN8^?vH(wnge z_u^GkT6se;!Y9IvsGM32Gr@vM%(G29k~A)Pf}=VTMJipqL(;~Ev8Q6o6WQ}P#0=x< z^w1X|Kojg-Nf$#2i)F@;jh>K|IOs;I(i9A)huAa0j6@;&mm==pW4_(rYk`z8tQJ6h z;WQ1;oYvzUdHXVXhS?V}*g2&+tfg;-CyXs5$mbPvsu8>a)@h{4SB@{zLXq?R%Nw+F zC~rvJt+gUSQWSMzV&AzcWBBUCmG>rM2tRTk`4XDjm(;eO`dsmtSEmpv0>5`V%@I2d z?jzJSdR7aXd&D7(^@bL%O5OVz_o)bna>~wgYi8W)x@{fmNT_Te4{r_-mil{%(w6Z_ zT3T#*01oA`eISL-E;4QT=j$}7XM?U<(kX#E)HBx82JCXS$h?At7%~lhUVN=o)P=~S zzGwU7q>&>)cdZThTGW;?zFOLD@ClpEi|d zKp{Bhjr6(!ade}KEtwU3opBWULK*+%#cm$q?1MD=2BKbMV{45~QL|S>lAte4vwLlE z<=~5SwsiJb`FF>Bri+)Sw0nMo=IZ(EqaD|$@$t+E#aZEwZthq;RtD}$8&Z9GZAhgK zI|@MorSP3vQ)W;AAQzd5H>1&nKO8pP)wU9_1*cibL5@9m*xIUOva^>=n zlM2`bR3PiMn#F|))q+?iP@59ID$dPx$%1jZH6W53zGFZ|`RUPtPTspnyWe?^)(^+* zn|@UrK6*$A$EK4D`w%iJZ0D~J)l6^XDBzSHVY5|bXo4qWwK;|dOGsoPyCbmdC zsGF{&ZvhFn28!TFyeUP@pl@!4ihQI+S}h5e>Szj`TqAnu_&gmrwoK2gEz-35kjc!G zX3xlKl1-roJK;eRcpw8J5CRi)JOZ{Zyzu;EiH!ZpNM8r_bkiDVN}ARy?qHEAb;kkP zb^?UBI|`;$fp8r+y>_0Mlbu2qVKAK>n{fMw)ihOVRipAJr$7l*O-i!9lhcT;M9023 zDZ*XGVNPO&4}(V8SY!oY6>sraZgU(>D(#&0o=-;XJm+XXqiW8>f_72|tQ@yUjmF<~ zO6>4m-^OY1ec%~KGNPR|SCEqJK-=#-VoyzWAg*wPm2o7KwlSfcLoK%ZP>xx_(7H^3 z1DZHfm+m^4o}4{V4WS+(7X+EhL1o$HWwd)^+X(yOFsXu`X>~*Bp_7v$=nHgcV@XfF znz+U(H^IIXsx?&ryhfr4ZU*>j>Q)PTpqE)<@j<54rIs@gNMb;oRu~oVqIdRTZj%Nw z;?2|Xagxl|nB*TsOx>d_V$+R2sApc3w{A;Uozd)rT)uU#jLYhXU`{gYk&t4q?Mr3+ zs&Jpadw~|V@@%~hVW3@Ls^gkHE>GQ#wLZphanQNXR=1*PoDGdlZ(U#| zs4h93#CI9bwWeEZ@NK!vqVdQC0j*6domwa!)wwdu2agYQ zpz!n;rVD!LmFkP113xBHpk(HtzBdk{5g9$xUiqAvcn(v^O>kB43D0M|P7wZv@O}WO z!J*@AJ6lp<6>O{HfnpoE2u|aOXErJ^CZuWH?8bqrW_~mFL>|p-kneq7G3Mm7#{UPP z3!+B@V#VdZ$rh$#cg)lDSV>&WODGxpB&Go+pVl;Mv&NUS$Vi1|%+!`_MGq^HLI}@M z6_zA>WFj?6qW^LT%^;?XzR$XjBF+lU$i{gQ7&ONe2WR2T=)lg6OB0=nU0$lGcbZWeYE6Y+IWRwQ33Gw3>gEJenX_z#I&~ zCLp&WAwSOA)>3d2a%yRG*K9L?5>b^Dj^BX%qM;w7^BKY&XR-s~w7(dmcFsGKA7nJ> z_Fzp{s(UkQQLl+ zgVQEo3_w6Col96M2I_lhg+^e+Qur==Ez-a^+a}ZO@!S$2M3V$p7QuJ9fd;$a8EEJ( zR5f}bPE{p{nM_zL2y1Lzjkepo8^y_CfHcA00Y02yuOW^Jz^KMv8kkFbmH;^QdxKRM)|QT`JzGt! z`G~cTr#42~Ac?rOC#N)gSQTT7(9tiIRP!opOgzGa$rLgc2)pLuPZEy^kOGIma`~Q+ zhWxF1Td+?E0pE3Mt8!%XJrplTk3Cfd9z(Zk%+u*$as}wb(hVpig$Va(D}`Xa1-!qm zoy5+=1n^P$G3Nt{i8)Z2&hR}D3bn8t{1OR967y6nKs}FVfQ52-@#n-2S~f7$!km5= z+^NNpIn0pp{37Jq=}eEDOmye*1v>NxavV0IL&ZA6}_?D7P+~%LA0fV znHn5Gn;lhvjM?RYC7e#}Og7GE5`wnlC<}pe6=EYMpp2+X+pWuDuc}(8wt?bMR{YS73gPeevk=4hO9N9f4t^%rNaYAPK zIig}XFvEO_k)me1wo}VIv^|4mR#o5y3OZm*Kofk;AB;vwb-1}HZ@s6M(Pp<=177E# z$SIW6|3=}uCpbDAjwpweq(?Esg^PZ<(3S_!EEcL#%rRgf_bcTq3Qk+Gz4@hn_)&3r z*#6W(%8nY`8>#x%0Aynq6KV^7S?kput)T(RvBUhCu``rGoZVz~V!nSV&O7(5sZRbW>F?WFWxanvZl1#Ei_OU#jI>DwijR-_qMSi7a+kx zE#q{sc+NjFkl*U2ZJC;YkNSaB_MM3^qB#)m+>%}G%$qjyf_oEDXFmxM8`70C8Ix+= zu=XSuO!2s#)aq|cbUfC}MJMX{=vZ0giz+mfT9IYB_%4qx(20W+n%gpJkq|*Z7y>QW@r#6J z(Xn$ZHPnSjNi%t26Ks#j?245G)ouR#s7H!v_5_s+lu(KgU(|3A#R+Jhfbo4`%yL@X zT9G!JeN99$?lj}%4{9u&zp2@8$3b?w({GPSaRy21TeLzvjQUlO8r6#MIBZ0W%{ zAx9DdE|~H*5@SFe7#_p)lXD&G?Lx6dtHtU1(veBw<_o$?y2jKq*6{gh5c+3io%|7k zZrJ401wnB{ww{uQyP{;4U@4Ty5zyS6R5zU*lfc0Pbx|=}v|<#5C*2Te63d?3Ww#-a z0>Q5BE2yvg;u!*+EjL@MYYrKC8fNfu2v%`GU-;q{?YyEmQt?Szy^pW>GT}fQFy;m( zQ;(z>nu`}TWYB;dtC9>cM|yrI$v`unT~#Ko=!eoLabZ5dU_R!F``2Pq#4Hc znVWboB8#{-^kneg-6Gca>h=n#4>5Vp~5UHk@K|6qK;IN(*tKpN0lCA!Vf%>axxQg z3;V2LLK|q!t&>09H#pWxL1|yR&|Ba+O)1cHK`K_E4lF1G=341=f1cUMNGQ4wPACkfM|anuqsCUa3%N| z1P9aSCYyzHFQxiLL&AE@m@Ct=NX3H^dGbk@`*jPnW92@XrLO*yI&+m8AY-jP7^RiU z!kSvg?Knw76ep52>I=-7l4eH4g_TBR4NF8OpBg$cMD)vN6g? z`axk3CZrf-Uo>1ZdIO$M9Zw9!@r40IAOm*WrK&Q;0sGryiJsb+(EJ>Vmav-KmWPQO zXiOfE4Ke6QeWQ^oq(~ANyp5Y25BceiiO75m-OeRxH;wZDt+ys_XJn+WoM1Y5Zh?lmT9ltHTGDTa;K73Or76H5 za|(w`pQ^Plh#EjR^h|Q+QQ9$X^%OJ!wUgD&xDa8W+a7kJ*Mb(KUd7C#Yq1Vb_C8M# zw>CO8rL57R>dTxf6Mb%KAuhq#xOJ97h^Ix-pS^QyNt!8)QLCct6f?oW$}Z^~eYJZ^ z#JL@9G-5#>pQYGK(Ht;91R_pe(N~M)cx=`|9YhFU{Zm7hI^MFb$(u}VoEf{AS)^hF zHE^?w_&jfrU`e7aBh(#sOh6MgpPVlieNLk;s)ZvqrOYtV zgQ2lERx1JPzoSG})xaFI#mz~UNGXT?A<$X;8r zWiV=yoWo1(FQN}C%5l0xRHhgs?F-d?J1i2kuysn$-&;`p$$6SzwnGIpBesL3Y*5J1Y6^BBV>CykgHLJ^Ne)av3kLl;!zzxnw{G+R*GLOsmoh@UPL= zUXs#}T61h@4z;dAKEiye;QIh`=L>+Nv&1~O_8U@k)F4^E!orJZ=x`ktH3W!|Cp{R; z*ASwsLt2}pTaK~j26TalYNYRa%}O1{BK=R@v7Lr_pdJA*u%d|K)M!slU^4NXgmxJ< z!X(#usQV$-_&j4PpJYN*=|aswv|X=8PTom#A0KLaZGwhW$iBW~0o7(5SFcY=Ef z1Vwp%G83DN!+E;+9_tzePiir$Kvi9=izF6TrgZ4OWja<+d~O-28IcRJ1tpmxI%SJ# zM+if$eV7`(sKP9I-ZXjiJhcWls|zP>f~=(?9wQhmV<4iAw~Vl!aOQmk7%OQ&>l`Mm zWSG=i48=;R^C-5%LY>E?@B7vx#ta6rux66EV%_J|BGun(CQ`Q`so#TaO(2tu@9U-^ z*|(oFY@V>~Q&_L5>6Zb>IuymM@p2u5QI02ov*}d`69em#Au4Fd$SEQ@wj(Wr_n{+9 zQL7M! zYY2zdnnJra=WtLwrmRAgJX+*xZId72zrbcgc2l^7lMc*+)QQnH@PN`(b80GJ)eNUE zr^>>?VQUmIGr26_n2l}SGskUMatvdTroU(Mi(33Q$B*x6N;*!GQ?u$5@&ay2$Y9>mk>mKZYm z0c@Zhbv>|C5~2uLQ2kr}7QH%M^t2|4VlkFj>@uVFS3Kr9 zTH`Chwd6rn0LJgh?29zBPCc<;PYt)&tSS^14S4OkVDCX-T6p+oDf~o%&}@|3CW@L4 zq%W&_)Ozu?4V_h8_LmW-u3ueP8R_}^x6+BH7inQB_i&4;t#=#APnn3b*0VcM7XBoF zfyKL%a_a;Ew7OeIXKR-VuZKzN#~E`?P;?<|3yp2&fvjps;dIw$447^kVgn`3FC@?h z2Mc{Q=n4r;Ywm92lSaHRX4WCax{G@Q*Ss0HPcMwQ-g z2rm2wky``mITDg{SVskR^g)&Xrn zeX}T;+B!|ZNYDyswsgYEjeEEAJ`?JY5y3%0z0m?FLSRL|MNK`)0M!=SpK7A2+8GPJJwz#Rf3aLU}e-5`K@z zqy1JbGfUKVvu#2uqlZ5&WzSfOXxp=;*eT7!rHVUYz#X8~fWG&&ANsF8+vItE?kCFE z5BU7r-q8g9Z9ALH2pRz0h2JU#C6vR`&4>XoM+R3}UAbs4 z4fX8&ou0N4S%@yjp5n@nb28x;LUz}BWcB+A);g)LAy6hOo(B^9b@grR%Pn5#9pOdN znjFDro`diOA{0ttE@M)E$5xFxwFMt;b2%b%Cr}dG*1)V_E&q`5mu~pLd;UO`*X8@b z>Tj3dU5yRgSisfh=-?0xYVNjH&JbkU) z{K#6OUBzV2B{-gr`N?Z!IL3mKp`m8&z?i?bqhHlcB3c) zM6QT67vBqez#g43s$g@AtqZ_C`(sJ!1k4S#`>o4Iy|(V-fAY`*rG*W;=&I9NQp?#B zC(UFldV7X7V$=|9pqdMC6Bs66^&Wzuh-U2@G+IINK0)B5i({)_!*2fDGd#-V*23y{ zB)&x=k-*|US0dees-#x$?Q+AID?$>-h2FCEKrilpg=tY@td z8medRl>g6`RQDyzL^rNbEOw>yo)KRn-b#3v!WZWkwLZ2Xn2y4a$U_`pV%(fCTSxoKw`hNA9&n8;jwN5wx*w=J> z2wKnWIn%5>coI#{k!SFRrg&-DIz1i;zJ|8XpH{nyGWMLhdvw>e@A>^OmN~~gtlS$9 zu7CV_7^8&EQrm-YU2RzMb^JLV-Cx?~)8{USahRE()SBLRWp0v=6?pi{Yyabq-bR~# Z{$JtZa6;mA7_9&R002ovPDHLkV1j9Zp8Eg* 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()}