diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..a948491df --- /dev/null +++ b/.dockerignore @@ -0,0 +1,70 @@ +# Keep build contexts small. This applies to every Dockerfile in the repo since +# Docker reads it from the context root. + +# Source control +.git +.gitignore +.gitattributes + +# Editor / OS +.vscode +.idea +.DS_Store +*.swp +*.swo + +# Build outputs +dist +build +out +release +.next +.cache +.vite +.parcel-cache +storybook-static + +# Dependencies installed inside the container; never copy host's tree. +node_modules +**/node_modules +.pnp.* +.yarn + +# Test / coverage artifacts +coverage +.nyc_output +playwright-report +test-results + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Local env (the image must not bake host secrets). +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Electron / desktop build artifacts not needed in runtime images. +out +release-builds +dist_electron + +# Backend / server runtime data not needed in frontend/runtime images. +server/runtime +server/.venv +server/lang +server/celerybeat-schedule* +server/.image_env +backend/.venv + +# Docs / reviews — not relevant to runtime image. +docs + +# Other working dirs in this repo that bloat the build context. +projects +benchmark diff --git a/.env.development b/.env.development index ded8a2814..76a7db8e4 100644 --- a/.env.development +++ b/.env.development @@ -1,14 +1,23 @@ VITE_BASE_URL=/api -# =======Production environment variables======= +# =======Production environment variables start======= 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 +VITE_REMOTE_CONTROL_WEB_ORIGIN=https://remote.eigent.ai +REMOTE_CONTROL_WEB_ORIGIN=https://remote.eigent.ai +VITE_REMOTE_CONTROL_LOCAL_API_URL=https://dev.eigent.ai +# =======Production environment variables end ======= -# =======Local environment variables======= +# =======Local environment variables start ======= +# SERVER_URL=http://localhost:3001 # VITE_PROXY_URL=http://localhost:3001 # VITE_USE_LOCAL_PROXY=true +# VITE_REMOTE_CONTROL_WEB_ORIGIN=http://localhost:5174 +# REMOTE_CONTROL_WEB_ORIGIN=http://localhost:5174 +# VITE_REMOTE_CONTROL_LOCAL_API_URL=http://localhost:3001 +# =======Local environment variables end======= # Dummy Stack Auth keys for local dev (enables StackProvider gating). # Replace with real values from Stack dashboard when needed. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index dae0ca7af..50786fb81 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.91 + placeholder: E.g., 1.0.0 validations: required: true diff --git a/.github/workflows/build-view.yml b/.github/workflows/build-view.yml index 001a4f529..0e1984303 100644 --- a/.github/workflows/build-view.yml +++ b/.github/workflows/build-view.yml @@ -121,6 +121,10 @@ jobs: VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }} + VITE_USE_LOCAL_PROXY: ${{ secrets.VITE_USE_LOCAL_PROXY }} + VITE_REMOTE_CONTROL_WEB_ORIGIN: ${{ secrets.VITE_REMOTE_CONTROL_WEB_ORIGIN }} + VITE_REMOTE_CONTROL_LOCAL_API_URL: ${{ secrets.VITE_REMOTE_CONTROL_LOCAL_API_URL }} USE_NPM_INSTALL_BUN: 'true' # Step for macOS builds with signing @@ -160,6 +164,10 @@ jobs: VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }} + VITE_USE_LOCAL_PROXY: ${{ secrets.VITE_USE_LOCAL_PROXY }} + VITE_REMOTE_CONTROL_WEB_ORIGIN: ${{ secrets.VITE_REMOTE_CONTROL_WEB_ORIGIN }} + VITE_REMOTE_CONTROL_LOCAL_API_URL: ${{ secrets.VITE_REMOTE_CONTROL_LOCAL_API_URL }} USE_NPM_INSTALL_BUN: 'true' # Step for Windows builds without signing @@ -175,6 +183,10 @@ jobs: VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }} + VITE_USE_LOCAL_PROXY: ${{ secrets.VITE_USE_LOCAL_PROXY }} + VITE_REMOTE_CONTROL_WEB_ORIGIN: ${{ secrets.VITE_REMOTE_CONTROL_WEB_ORIGIN }} + VITE_REMOTE_CONTROL_LOCAL_API_URL: ${{ secrets.VITE_REMOTE_CONTROL_LOCAL_API_URL }} USE_NPM_INSTALL_BUN: 'true' # Step for Linux builds @@ -190,6 +202,10 @@ jobs: VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }} + VITE_USE_LOCAL_PROXY: ${{ secrets.VITE_USE_LOCAL_PROXY }} + VITE_REMOTE_CONTROL_WEB_ORIGIN: ${{ secrets.VITE_REMOTE_CONTROL_WEB_ORIGIN }} + VITE_REMOTE_CONTROL_LOCAL_API_URL: ${{ secrets.VITE_REMOTE_CONTROL_LOCAL_API_URL }} USE_NPM_INSTALL_BUN: 'true' # Verify built app contains Electron Framework diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1aad2305f..7204fe7d5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -129,6 +129,10 @@ jobs: VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }} + VITE_USE_LOCAL_PROXY: ${{ secrets.VITE_USE_LOCAL_PROXY }} + VITE_REMOTE_CONTROL_WEB_ORIGIN: ${{ secrets.VITE_REMOTE_CONTROL_WEB_ORIGIN }} + VITE_REMOTE_CONTROL_LOCAL_API_URL: ${{ secrets.VITE_REMOTE_CONTROL_LOCAL_API_URL }} USE_NPM_INSTALL_BUN: 'true' # Step for Windows builds without signing @@ -143,6 +147,10 @@ jobs: VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }} + VITE_USE_LOCAL_PROXY: ${{ secrets.VITE_USE_LOCAL_PROXY }} + VITE_REMOTE_CONTROL_WEB_ORIGIN: ${{ secrets.VITE_REMOTE_CONTROL_WEB_ORIGIN }} + VITE_REMOTE_CONTROL_LOCAL_API_URL: ${{ secrets.VITE_REMOTE_CONTROL_LOCAL_API_URL }} USE_NPM_INSTALL_BUN: 'true' # Step for Linux builds @@ -157,6 +165,10 @@ jobs: VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }} + VITE_USE_LOCAL_PROXY: ${{ secrets.VITE_USE_LOCAL_PROXY }} + VITE_REMOTE_CONTROL_WEB_ORIGIN: ${{ secrets.VITE_REMOTE_CONTROL_WEB_ORIGIN }} + VITE_REMOTE_CONTROL_LOCAL_API_URL: ${{ secrets.VITE_REMOTE_CONTROL_LOCAL_API_URL }} USE_NPM_INSTALL_BUN: 'true' # Verify built app contains Electron Framework @@ -347,7 +359,7 @@ jobs: files: | gh-release-assets/* - # Extract version from tag (e.g., v0.0.85 -> 0.0.91) + # Extract version from tag (e.g., v0.0.85 -> 1.0.0) - name: Extract version if: startsWith(github.ref, 'refs/tags/') id: version diff --git a/.github/workflows/pre-build-view.yml b/.github/workflows/pre-build-view.yml index 16bd870ac..5b45c9aec 100644 --- a/.github/workflows/pre-build-view.yml +++ b/.github/workflows/pre-build-view.yml @@ -126,6 +126,10 @@ jobs: VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }} + VITE_USE_LOCAL_PROXY: ${{ secrets.VITE_USE_LOCAL_PROXY }} + VITE_REMOTE_CONTROL_WEB_ORIGIN: ${{ secrets.VITE_REMOTE_CONTROL_WEB_ORIGIN }} + VITE_REMOTE_CONTROL_LOCAL_API_URL: ${{ secrets.VITE_REMOTE_CONTROL_LOCAL_API_URL }} USE_NPM_INSTALL_BUN: 'true' # Step for macOS builds with signing @@ -165,6 +169,10 @@ jobs: VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }} + VITE_USE_LOCAL_PROXY: ${{ secrets.VITE_USE_LOCAL_PROXY }} + VITE_REMOTE_CONTROL_WEB_ORIGIN: ${{ secrets.VITE_REMOTE_CONTROL_WEB_ORIGIN }} + VITE_REMOTE_CONTROL_LOCAL_API_URL: ${{ secrets.VITE_REMOTE_CONTROL_LOCAL_API_URL }} USE_NPM_INSTALL_BUN: 'true' # Step for Windows builds without signing @@ -180,6 +188,10 @@ jobs: VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }} + VITE_USE_LOCAL_PROXY: ${{ secrets.VITE_USE_LOCAL_PROXY }} + VITE_REMOTE_CONTROL_WEB_ORIGIN: ${{ secrets.VITE_REMOTE_CONTROL_WEB_ORIGIN }} + VITE_REMOTE_CONTROL_LOCAL_API_URL: ${{ secrets.VITE_REMOTE_CONTROL_LOCAL_API_URL }} USE_NPM_INSTALL_BUN: 'true' # Step for Linux builds @@ -195,6 +207,10 @@ jobs: VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }} + VITE_USE_LOCAL_PROXY: ${{ secrets.VITE_USE_LOCAL_PROXY }} + VITE_REMOTE_CONTROL_WEB_ORIGIN: ${{ secrets.VITE_REMOTE_CONTROL_WEB_ORIGIN }} + VITE_REMOTE_CONTROL_LOCAL_API_URL: ${{ secrets.VITE_REMOTE_CONTROL_LOCAL_API_URL }} USE_NPM_INSTALL_BUN: 'true' # Verify built app contains Electron Framework diff --git a/.github/workflows/pre-build.yml b/.github/workflows/pre-build.yml index 85d4077b5..da2d26718 100644 --- a/.github/workflows/pre-build.yml +++ b/.github/workflows/pre-build.yml @@ -134,6 +134,10 @@ jobs: VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }} + VITE_USE_LOCAL_PROXY: ${{ secrets.VITE_USE_LOCAL_PROXY }} + VITE_REMOTE_CONTROL_WEB_ORIGIN: ${{ secrets.VITE_REMOTE_CONTROL_WEB_ORIGIN }} + VITE_REMOTE_CONTROL_LOCAL_API_URL: ${{ secrets.VITE_REMOTE_CONTROL_LOCAL_API_URL }} USE_NPM_INSTALL_BUN: 'true' # Step for Windows builds without signing @@ -148,6 +152,10 @@ jobs: VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }} + VITE_USE_LOCAL_PROXY: ${{ secrets.VITE_USE_LOCAL_PROXY }} + VITE_REMOTE_CONTROL_WEB_ORIGIN: ${{ secrets.VITE_REMOTE_CONTROL_WEB_ORIGIN }} + VITE_REMOTE_CONTROL_LOCAL_API_URL: ${{ secrets.VITE_REMOTE_CONTROL_LOCAL_API_URL }} USE_NPM_INSTALL_BUN: 'true' # Step for Linux builds @@ -162,6 +170,10 @@ jobs: VITE_STACK_PROJECT_ID: ${{ secrets.VITE_STACK_PROJECT_ID }} VITE_STACK_PUBLISHABLE_CLIENT_KEY: ${{ secrets.VITE_STACK_PUBLISHABLE_CLIENT_KEY }} VITE_STACK_SECRET_SERVER_KEY: ${{ secrets.VITE_STACK_SECRET_SERVER_KEY }} + VITE_SITE_URL: ${{ secrets.VITE_SITE_URL }} + VITE_USE_LOCAL_PROXY: ${{ secrets.VITE_USE_LOCAL_PROXY }} + VITE_REMOTE_CONTROL_WEB_ORIGIN: ${{ secrets.VITE_REMOTE_CONTROL_WEB_ORIGIN }} + VITE_REMOTE_CONTROL_LOCAL_API_URL: ${{ secrets.VITE_REMOTE_CONTROL_LOCAL_API_URL }} USE_NPM_INSTALL_BUN: 'true' # Verify built app contains Electron Framework diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36c729b0a..0ed8f3d11 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,64 @@ 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 + + - name: Install frontend dependencies + run: npm install --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 + + - name: Install frontend dependencies + run: npm install --ignore-scripts + + - name: Run type check + run: npm run type-check + + - 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..9f284c5d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Logs logs *.log +camel_logs/ npm-debug.log* yarn-debug.log* yarn-error.log* @@ -10,6 +11,7 @@ node-compile-cache node_modules dist +dist-web !package/**/dist dist-ssr dist-electron @@ -40,7 +42,9 @@ yarn.lock .env.local .env.production .env.development +client_secret_*.json +.claude/ .cursor # Public directory (large media files) 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/.gitignore b/backend/.gitignore index 71ed26e2b..5565e343f 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,6 +1,7 @@ # Python-generated files __pycache__/ *.py[oc] +camel_logs/ build/ dist/ wheels/ diff --git a/backend/README.md b/backend/README.md index 237ae8dde..1a6127113 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..66abb74a7 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -12,17 +12,72 @@ # 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" + 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"] = ( + 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/agent_model.py b/backend/app/agent/agent_model.py index 93d43bc4c..771102161 100644 --- a/backend/app/agent/agent_model.py +++ b/backend/app/agent/agent_model.py @@ -24,7 +24,10 @@ 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.model.model_platform import ( + patch_azure_cloud_config, + patch_bedrock_cloud_config, +) from app.service.task import ActionCreateAgentData, Agents, get_task_lock from app.utils.event_loop_utils import _schedule_async_task @@ -89,15 +92,13 @@ def agent_model( effective_config["api_url"], extra_params = patch_bedrock_cloud_config( effective_config["api_url"], extra_params ) - # Cloud Azure: camel's AzureOpenAIModel raises ValueError if api_version - # is missing. The frontend doesn't pass one for cloud GPT models, so - # default it here. + # Cloud mode: default api_version for Azure-backed models so AzureOpenAI + # construction does not blow up when the frontend omits extra_params. if ( effective_config.get("model_platform") == "azure" and options.is_cloud() ): - extra_params = dict(extra_params) - extra_params.setdefault("api_version", "2024-12-01-preview") + extra_params = patch_azure_cloud_config(extra_params) init_param_keys = { "api_version", "azure_ad_token", diff --git a/backend/app/agent/factory/browser.py b/backend/app/agent/factory/browser.py index b8c810dd4..4aa9f9a00 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 @@ -36,14 +37,35 @@ 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.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: @@ -151,7 +173,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} " @@ -163,17 +188,23 @@ 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 + cdp_owned_by_hands = False - 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 ) 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): " @@ -181,62 +212,90 @@ def browser_agent(options: Chat): 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}" ) - else: + 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 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}" + + # 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", + "browser_upload_file", + "browser_download_file", + ], + ) + 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", - "browser_upload_file", - "browser_download_file", - ], - ) - - # 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, @@ -275,8 +334,6 @@ 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, @@ -284,13 +341,26 @@ def browser_agent(options: Chat): ] tool_names = [ SearchToolkit.toolkit_name(), - HybridBrowserToolkit.toolkit_name(), HumanToolkit.toolkit_name(), NoteTakingToolkit.toolkit_name(), - TerminalToolkit.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 = "" @@ -334,8 +404,12 @@ def browser_agent(options: Chat): prune_tool_calls_from_memory=True, 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, ) @@ -365,19 +439,42 @@ 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 + and getattr(agent_instance, "_cdp_owned_by_hands", False) + ): + 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 agent._browser_toolkit = web_toolkit_for_agent_registration + agent._cdp_owned_by_hands = cdp_owned_by_hands return agent diff --git a/backend/app/agent/factory/developer.py b/backend/app/agent/factory/developer.py index 0dd3a52c9..6cd1afbe2 100644 --- a/backend/app/agent/factory/developer.py +++ b/backend/app/agent/factory/developer.py @@ -33,12 +33,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} " @@ -69,16 +73,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, @@ -101,20 +95,32 @@ 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(), - TerminalToolkit.toolkit_name(), NoteTakingToolkit.toolkit_name(), WebDeployToolkit.toolkit_name(), ScreenshotToolkit.toolkit_name(), SkillToolkit.toolkit_name(), - SearchToolkit.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(), diff --git a/backend/app/agent/factory/document.py b/backend/app/agent/factory/document.py index 6fb8872a3..709e2f35d 100644 --- a/backend/app/agent/factory/document.py +++ b/backend/app/agent/factory/document.py @@ -36,12 +36,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} " @@ -85,35 +89,6 @@ 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) - - google_drive_tools = await GoogleDriveMCPToolkit.get_can_use_tools( - options.project_id, options.get_bun_env() - ) - - skill_toolkit = SkillToolkit( - options.project_id, - Agents.document_agent, - working_directory=working_directory, - user_id=options.skill_config_user_id(), - ) - skill_toolkit = message_integration.register_toolkits(skill_toolkit) - - 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) - else: - search_tools = [] - tools = [ *file_write_toolkit.get_tools(), *pptx_toolkit.get_tools(), @@ -123,11 +98,7 @@ async def document_agent(options: Chat): *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, ] tool_names = [ FileToolkit.toolkit_name(), @@ -136,12 +107,47 @@ async def document_agent(options: Chat): MarkItDownToolkit.toolkit_name(), ExcelToolkit.toolkit_name(), NoteTakingToolkit.toolkit_name(), - TerminalToolkit.toolkit_name(), ScreenshotToolkit.toolkit_name(), - GoogleDriveMCPToolkit.toolkit_name(), - SkillToolkit.toolkit_name(), - SearchToolkit.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() + ) + + skill_toolkit = SkillToolkit( + options.project_id, + Agents.document_agent, + working_directory=working_directory, + 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 = [] system_message = DOCUMENT_SYS_PROMPT.format( platform_system=platform.system(), platform_machine=platform.machine(), diff --git a/backend/app/agent/factory/mcp.py b/backend/app/agent/factory/mcp.py index 679e8078f..19b462d8a 100644 --- a/backend/app/agent/factory/mcp.py +++ b/backend/app/agent/factory/mcp.py @@ -21,6 +21,7 @@ from app.agent.factory.remote_sub_agent import ( attach_remote_sub_agent_if_enabled, + remote_sub_agent_enabled, ) from app.agent.listen_chat_agent import ListenChatAgent, logger from app.agent.prompt import MCP_SYS_PROMPT @@ -28,7 +29,10 @@ 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.model.model_platform import ( + patch_azure_cloud_config, + patch_bedrock_cloud_config, +) from app.service.task import ActionCreateAgentData, Agents, get_task_lock from app.utils.file_utils import get_working_directory @@ -39,11 +43,13 @@ async def mcp_agent(options: Chat): f"Creating MCP agent for project: {options.project_id} " f"with {len(options.installed_mcp['mcpServers'])} MCP servers" ) - message_integration = ToolkitMessageIntegration( - message_handler=HumanToolkit( - options.project_id, Agents.mcp_agent - ).send_message_to_user - ) + message_integration = None + if remote_sub_agent_enabled(options, working_directory): + message_integration = ToolkitMessageIntegration( + message_handler=HumanToolkit( + options.project_id, Agents.mcp_agent + ).send_message_to_user + ) tools = [ *McpSearchToolkit(options.project_id).get_tools(), ] @@ -100,11 +106,8 @@ async def mcp_agent(options: Chat): api_url, extra_params = patch_bedrock_cloud_config( api_url, extra_params ) - # Cloud Azure: camel's AzureOpenAIModel requires api_version; default it - # since the frontend doesn't pass one for cloud GPT models. if options.model_platform == "azure" and options.is_cloud(): - extra_params = dict(extra_params) - extra_params.setdefault("api_version", "2024-12-01-preview") + extra_params = patch_azure_cloud_config(extra_params) system_message = attach_remote_sub_agent_if_enabled( options=options, diff --git a/backend/app/agent/factory/multi_modal.py b/backend/app/agent/factory/multi_modal.py index d7c356ddd..93b3cb398 100644 --- a/backend/app/agent/factory/multi_modal.py +++ b/backend/app/agent/factory/multi_modal.py @@ -36,12 +36,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} " @@ -70,15 +74,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, @@ -108,13 +103,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", @@ -128,10 +143,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()) @@ -151,6 +164,7 @@ def multi_modal_agent(options: Chat): audio_analysis_toolkit ) tools.extend(audio_analysis_toolkit.get_tools()) + tool_names.append(AudioAnalysisToolkit.toolkit_name()) tool_names = [ VideoDownloaderToolkit.toolkit_name(), diff --git a/backend/app/agent/factory/single_agent.py b/backend/app/agent/factory/single_agent.py new file mode 100644 index 000000000..ac5e3a747 --- /dev/null +++ b/backend/app/agent/factory/single_agent.py @@ -0,0 +1,118 @@ +# ========= 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 platform +from dataclasses import dataclass +from typing import Literal + +from camel.messages import BaseMessage + +from app.agent.agent_model import agent_model +from app.agent.factory.toolkit_assembler import assemble_single_agent_toolkits +from app.agent.prompt import SINGLE_AGENT_SYS_PROMPT +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 + + +@dataclass(frozen=True) +class AgentRuntimeConfig: + role: Literal["root", "subagent"] = "root" + depth: int = 0 + max_depth: int = 1 + + @property + def can_delegate(self) -> bool: + return self.depth < self.max_depth + + +async def single_agent( + options: Chat, + *, + task_id: str | None = None, + hands: IHands | None = None, + pause_event: asyncio.Event | None = None, + runtime: AgentRuntimeConfig | None = None, +): + """Create the root Single Agent using CAMEL-first tool assembly.""" + + runtime = runtime or AgentRuntimeConfig() + working_directory = get_working_directory(options) + current_task_id = task_id or options.task_id + + assembly = await assemble_single_agent_toolkits( + options, + task_id=current_task_id, + working_directory=working_directory, + hands=hands, + can_delegate=runtime.can_delegate, + current_depth=runtime.depth, + max_depth=runtime.max_depth, + ) + + system_message = SINGLE_AGENT_SYS_PROMPT.format( + platform_system=platform.system(), + platform_machine=platform.machine(), + working_directory=working_directory, + now_str=NOW_STR, + ) + + agent = agent_model( + Agents.single_agent, + BaseMessage.make_assistant_message( + role_name="Single Agent", + content=system_message, + ), + options, + assembly.tools, + tool_names=assembly.tool_names, + toolkits_to_register_agent=assembly.toolkits_to_register_agent, + ) + if pause_event is not None: + agent.pause_event = pause_event + if assembly.observable_todo_toolkit is not None: + assembly.observable_todo_toolkit.agent_id = agent.agent_id + agent._observable_todo_toolkit = assembly.observable_todo_toolkit + + if assembly.browser_toolkit is not None: + agent._browser_toolkit = assembly.browser_toolkit + agent._cdp_port = assembly.browser_port + agent._cdp_url = assembly.browser_cdp_url + agent._cdp_session_id = assembly.browser_session_id + agent._cdp_task_id = options.task_id + agent._cdp_options = options + agent._cdp_owned_by_hands = assembly.browser_owned_by_hands + + def release_cdp_from_agent(agent_instance): + 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 options.cdp_browsers: + from app.agent.factory.browser import _cdp_pool_manager + + _cdp_pool_manager.release_browser(port, session_id) + elif hands is not None and getattr( + agent_instance, "_cdp_owned_by_hands", False + ): + try: + hands.release_resource("browser", session_id) + except Exception: + pass + + agent._cdp_acquire_callback = None + agent._cdp_release_callback = release_cdp_from_agent + return agent diff --git a/backend/app/agent/factory/toolkit_assembler.py b/backend/app/agent/factory/toolkit_assembler.py new file mode 100644 index 000000000..dcfff0237 --- /dev/null +++ b/backend/app/agent/factory/toolkit_assembler.py @@ -0,0 +1,442 @@ +# ========= 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 os +import uuid +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import urlparse + +from camel.toolkits import ( + FunctionTool, + MCPToolkit, + PlanningWorktreeToolkit, + RegisteredAgentToolkit, + ToolkitMessageIntegration, + WebFetchToolkit, +) + +from app.agent.toolkit.depth_limited_agent_toolkit import ( + DepthLimitedAgentToolkit, +) +from app.agent.toolkit.file_write_toolkit import FileToolkit +from app.agent.toolkit.human_toolkit import HumanToolkit +from app.agent.toolkit.hybrid_browser_toolkit import HybridBrowserToolkit +from app.agent.toolkit.observable_todo_toolkit import ObservableTodoToolkit +from app.agent.toolkit.screenshot_toolkit import ScreenshotToolkit +from app.agent.toolkit.search_toolkit import SearchToolkit +from app.agent.toolkit.skill_toolkit import SkillToolkit +from app.agent.toolkit.terminal_toolkit import TerminalToolkit +from app.agent.toolkit.web_deploy_toolkit import WebDeployToolkit +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.browser_launcher import normalize_cdp_url + +logger = logging.getLogger("toolkit_assembler") + +DEFAULT_SINGLE_AGENT_TOOLKIT_CONFIG: dict[str, Any] = { + "human": {"enabled": True}, + "file": {"enabled": True}, + "web_deploy": {"enabled": True}, + "screenshot": {"enabled": True}, + "skill": {"enabled": True}, + "todo": {"enabled": True}, + "search": {"enabled": True}, + "browser": {"enabled": True}, + "terminal": {"enabled": True}, + "web_fetch": {"enabled": True}, + "planning_worktree": {"enabled": True}, + "mcp": {"enabled": True}, + "agent": {"enabled": True}, +} + + +@dataclass +class ToolkitAssembly: + tools: list[FunctionTool | Callable] = field(default_factory=list) + tool_names: list[str] = field(default_factory=list) + toolkits_to_register_agent: list[RegisteredAgentToolkit] = field( + default_factory=list + ) + observable_todo_toolkit: ObservableTodoToolkit | None = None + browser_toolkit: HybridBrowserToolkit | None = None + browser_port: int | None = None + browser_cdp_url: str | None = None + browser_session_id: str | None = None + browser_owned_by_hands: bool = False + + def add_tools( + self, + tools: list[FunctionTool | Callable], + toolkit_name: str, + ) -> None: + if not tools: + return + _tag_tools(tools, toolkit_name) + self.tools.extend(tools) + if toolkit_name not in self.tool_names: + self.tool_names.append(toolkit_name) + + +def _merged_config(options: Chat) -> dict[str, Any]: + config = { + key: dict(value) if isinstance(value, dict) else value + for key, value in DEFAULT_SINGLE_AGENT_TOOLKIT_CONFIG.items() + } + for key, value in (options.toolkit_config or {}).items(): + config[key] = value + return config + + +def _enabled(config: dict[str, Any], name: str, default: bool = True) -> bool: + value = config.get(name) + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, dict): + return bool(value.get("enabled", default)) + return bool(value) + + +def _options(config: dict[str, Any], name: str) -> dict[str, Any]: + value = config.get(name) + if not isinstance(value, dict): + return {} + return {key: item for key, item in value.items() if key != "enabled"} + + +def _tag_tools( + tools: list[FunctionTool | Callable], toolkit_name: str +) -> None: + for tool in tools: + try: + tool._toolkit_name = toolkit_name + except Exception: + pass + + +def _get_browser_port(browser: dict) -> int: + 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: + 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)}" + + +def _browser_enabled_tools() -> list[str]: + return [ + "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", + "browser_upload_file", + "browser_download_file", + ] + + +def _mcp_config(options: Chat, hands: IHands | None) -> dict[str, Any] | None: + servers = dict((options.installed_mcp or {}).get("mcpServers", {})) + if not servers: + return None + + if hands is not None: + servers = { + name: cfg + for name, cfg in servers.items() + if hands.can_use_mcp(name) + } + if not servers: + logger.info("Skipping MCPToolkit: no MCP servers allowed") + return None + + normalized_servers = {} + for name, cfg in servers.items(): + server_cfg = dict(cfg) + server_env = dict(server_cfg.get("env", {})) + server_env.setdefault( + "MCP_REMOTE_CONFIG_DIR", + env("MCP_REMOTE_CONFIG_DIR", os.path.expanduser("~/.mcp-auth")), + ) + server_cfg["env"] = server_env + normalized_servers[name] = server_cfg + + return {"mcpServers": normalized_servers} + + +async def assemble_single_agent_toolkits( + options: Chat, + *, + task_id: str, + working_directory: str, + hands: IHands | None, + can_delegate: bool, + current_depth: int = 0, + max_depth: int = 1, +) -> ToolkitAssembly: + config = _merged_config(options) + assembly = ToolkitAssembly() + + human_toolkit = HumanToolkit(options.project_id, Agents.single_agent) + message_integration = ToolkitMessageIntegration( + message_handler=human_toolkit.send_message_to_user + ) + + if _enabled(config, "human"): + assembly.add_tools( + human_toolkit.get_tools(), HumanToolkit.toolkit_name() + ) + + if _enabled(config, "file"): + file_options = { + "working_directory": working_directory, + **_options(config, "file"), + } + toolkit = FileToolkit( + options.project_id, + **file_options, + ) + toolkit.agent_name = Agents.single_agent + toolkit = message_integration.register_toolkits(toolkit) + assembly.add_tools(toolkit.get_tools(), FileToolkit.toolkit_name()) + + if _enabled(config, "web_deploy"): + toolkit = WebDeployToolkit( + api_task_id=options.project_id, + **_options(config, "web_deploy"), + ) + toolkit.agent_name = Agents.single_agent + toolkit = message_integration.register_toolkits(toolkit) + assembly.add_tools( + toolkit.get_tools(), WebDeployToolkit.toolkit_name() + ) + + if _enabled(config, "screenshot"): + screenshot_options = { + "working_directory": working_directory, + "agent_name": Agents.single_agent, + **_options(config, "screenshot"), + } + toolkit = ScreenshotToolkit( + options.project_id, + **screenshot_options, + ) + assembly.toolkits_to_register_agent.append(toolkit) + registered = message_integration.register_toolkits(toolkit) + assembly.add_tools( + registered.get_tools(), ScreenshotToolkit.toolkit_name() + ) + + if _enabled(config, "skill"): + skill_options = { + "working_directory": working_directory, + "user_id": options.skill_config_user_id(), + **_options(config, "skill"), + } + toolkit = SkillToolkit( + options.project_id, + Agents.single_agent, + **skill_options, + ) + toolkit = message_integration.register_toolkits(toolkit) + assembly.add_tools(toolkit.get_tools(), SkillToolkit.toolkit_name()) + + if _enabled(config, "todo"): + todo_options = { + "working_dir": working_directory, + **_options(config, "todo"), + } + todo_toolkit = ObservableTodoToolkit( + api_task_id=options.project_id, + task_id=task_id, + **todo_options, + ) + todo_toolkit.agent_name = Agents.single_agent + assembly.observable_todo_toolkit = todo_toolkit + assembly.add_tools( + todo_toolkit.get_tools(), ObservableTodoToolkit.toolkit_name() + ) + + if _enabled(config, "search"): + search_tools = SearchToolkit.get_can_use_tools( + options.project_id, agent_name=Agents.single_agent + ) + if search_tools: + search_tools = message_integration.register_functions(search_tools) + assembly.add_tools(search_tools, SearchToolkit.toolkit_name()) + + if _enabled(config, "browser") and ( + hands is None or hands.can_use_browser() + ): + toolkit_session_id = str(uuid.uuid4())[:8] + selected_port: int | None = None + cdp_url: str | None = None + cdp_owned_by_hands = False + + if options.cdp_browsers: + # Reuse the same pool as the Browser Agent so concurrent projects + # do not accidentally claim the same CDP browser tab set. + from app.agent.factory.browser import _cdp_pool_manager + + selected_browser = _cdp_pool_manager.acquire_browser( + options.cdp_browsers, + toolkit_session_id, + options.task_id, + ) + if selected_browser is None: + selected_browser = options.cdp_browsers[0] + logger.warning( + "No available CDP browser in pool for Single Agent; " + "using first browser", + extra={ + "project_id": options.project_id, + "task_id": options.task_id, + }, + ) + selected_port = _get_browser_port(selected_browser) + cdp_url = _get_browser_endpoint(selected_browser) + else: + existing_cdp_url = env("EIGENT_CDP_URL", "").strip() + selected_port = int(env("browser_port", "9222")) + cdp_url = f"http://localhost:{selected_port}" + 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 = int(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}" + + cdp_keep_current = bool(options.cdp_browsers) + default_start_url = None if cdp_keep_current else "about:blank" + browser_options = { + "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_enabled_tools(), + **_options(config, "browser"), + } + toolkit = HybridBrowserToolkit(options.project_id, **browser_options) + toolkit.agent_name = Agents.single_agent + assembly.browser_toolkit = toolkit + assembly.browser_port = selected_port + assembly.browser_cdp_url = cdp_url + assembly.browser_session_id = toolkit_session_id + assembly.browser_owned_by_hands = cdp_owned_by_hands + assembly.toolkits_to_register_agent.append(toolkit) + registered = message_integration.register_toolkits(toolkit) + assembly.add_tools( + registered.get_tools(), HybridBrowserToolkit.toolkit_name() + ) + + if _enabled(config, "terminal") and ( + hands is None or hands.can_execute_terminal() + ): + terminal_options = { + "working_directory": working_directory, + "safe_mode": True, + "clone_current_env": True, + **_options(config, "terminal"), + } + toolkit = TerminalToolkit( + options.project_id, + Agents.single_agent, + **terminal_options, + ) + toolkit = message_integration.register_toolkits(toolkit) + assembly.add_tools(toolkit.get_tools(), TerminalToolkit.toolkit_name()) + + if _enabled(config, "web_fetch"): + toolkit = WebFetchToolkit(**_options(config, "web_fetch")) + assembly.toolkits_to_register_agent.append(toolkit) + assembly.add_tools(toolkit.get_tools(), "WebFetchToolkit") + + if _enabled(config, "planning_worktree"): + planning_options = { + "working_directory": working_directory, + **_options(config, "planning_worktree"), + } + toolkit = PlanningWorktreeToolkit( + **planning_options, + ) + assembly.add_tools(toolkit.get_tools(), "PlanningWorktreeToolkit") + + if _enabled(config, "mcp"): + mcp_config = _mcp_config(options, hands) + if mcp_config is not None: + mcp_options = { + "config_dict": mcp_config, + "timeout": 180, + **_options(config, "mcp"), + } + toolkit = MCPToolkit(**mcp_options) + try: + await toolkit.connect() + except Exception: + logger.error("Failed to connect MCPToolkit", exc_info=True) + else: + assembly.add_tools(toolkit.get_tools(), "MCPToolkit") + + if _enabled(config, "agent") and can_delegate: + toolkit = DepthLimitedAgentToolkit( + current_depth=current_depth, + max_depth=max_depth, + **_options(config, "agent"), + ) + assembly.toolkits_to_register_agent.append(toolkit) + assembly.add_tools(toolkit.get_tools(), toolkit.toolkit_name()) + + return assembly diff --git a/backend/app/agent/listen_chat_agent.py b/backend/app/agent/listen_chat_agent.py index 7fbaddc6a..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) @@ -709,7 +710,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 @@ -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/agent/prompt.py b/backend/app/agent/prompt.py index 634926d47..7ee1f905b 100644 --- a/backend/app/agent/prompt.py +++ b/backend/app/agent/prompt.py @@ -248,6 +248,7 @@ def build_remote_sub_agent_planning_notice() -> str: message_description parameters when calling tools. These optional parameters are available on all tools and will automatically notify the user of your progress. + @@ -274,7 +275,6 @@ def build_remote_sub_agent_planning_notice() -> str: - 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 @@ -388,6 +388,9 @@ def build_remote_sub_agent_planning_notice() -> str: 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 @@ -547,11 +550,15 @@ def build_remote_sub_agent_planning_notice() -> str: 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 @@ -582,8 +589,6 @@ def build_remote_sub_agent_planning_notice() -> str: `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 @@ -661,6 +666,54 @@ def build_remote_sub_agent_planning_notice() -> str: other agents can build upon your work. """ +SINGLE_AGENT_SYS_PROMPT = """\ + +You are Eigent's Single Agent, a focused autonomous assistant built on the +CAMEL agent framework. You solve the user's task directly using the available +tools and keep progress visible through the todo tool. + + + +- **System**: {platform_system} ({platform_machine}) +- **Working Directory**: `{working_directory}`. All local file operations must +occur here. Use absolute paths for local file operations. +- **Current date/time**: {now_str}. Use this for date-related tasks. + + + +- For any multi-step task, call `todo_write` before doing substantial work. +- Keep todos short and actionable. +- Mark exactly one todo as `in_progress` while actively working on it. +- Mark a todo `completed` immediately after it is done. +- Update todos when the plan changes. +- For simple conversational answers, a todo list is optional. + + + +- Use skills first when the user explicitly references a skill or the task +clearly matches an available skill. Call `list_skills`, then `load_skill`. +- Use terminal and file tools when the task requires local inspection, +implementation, verification, or artifact creation. +- Use search/browser tools when current external information is required. +- Use web fetch tools for URL-specific extraction and analysis when available. +- For browser tasks that require login, first open the target site with the +browser tools and ask the user to complete interactive login in the browser +only after you reach an authentication prompt. +- Use planning/worktree tools for explicit plan-mode or isolated worktree +workflows when available. +- You may delegate bounded independent work to a sub-agent when available, but +the sub-agent must solve its assigned task directly and must not create more +sub-agents. +- Ask the user only when blocked by ambiguity, credentials, permissions, or +manual verification. + + + +When the task is complete, respond with a concise summary of the outcome, +including important files or results when relevant. Avoid markdown tables +unless the user requested one. +""" + BROWSER_SYS_PROMPT = """\ You are a Senior Research Analyst, a key member of a multi-agent team. Your @@ -725,6 +778,12 @@ def build_remote_sub_agent_planning_notice() -> str: 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; @@ -734,6 +793,8 @@ def build_remote_sub_agent_planning_notice() -> str: 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/depth_limited_agent_toolkit.py b/backend/app/agent/toolkit/depth_limited_agent_toolkit.py new file mode 100644 index 000000000..87adf65f1 --- /dev/null +++ b/backend/app/agent/toolkit/depth_limited_agent_toolkit.py @@ -0,0 +1,81 @@ +# ========= 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 collections.abc import Callable + +from camel.toolkits import AgentToolkit, FunctionTool, RegisteredAgentToolkit + +from app.agent.toolkit.abstract_toolkit import AbstractToolkit + + +def _is_agent_tool(tool: FunctionTool | Callable) -> bool: + func = getattr(tool, "func", tool) + toolkit = getattr(func, "__self__", None) + return isinstance(toolkit, AgentToolkit) + + +class DepthLimitedAgentToolkit(AgentToolkit, AbstractToolkit): + """CAMEL AgentToolkit with delegated-agent recursion disabled. + + CAMEL's native AgentToolkit clones the parent tool set into child agents. + For Eigent single-agent mode we want root agents to delegate, while child + agents must not delegate again. This adapter keeps the CAMEL toolkit API + and removes AgentToolkit tools from child tool sets. + """ + + def __init__( + self, + *, + current_depth: int = 0, + max_depth: int = 1, + timeout: float | None = None, + ) -> None: + super().__init__(timeout=timeout) + self.current_depth = current_depth + self.max_depth = max_depth + + def _resolve_child_tools( + self, + parent, + ) -> tuple[ + list[FunctionTool | Callable] | None, + list[RegisteredAgentToolkit] | None, + ]: + tools, toolkits_to_register = super()._resolve_child_tools(parent) + if tools is None: + return None, toolkits_to_register + + return ( + [tool for tool in tools if not _is_agent_tool(tool)], + [ + toolkit + for toolkit in (toolkits_to_register or []) + if not isinstance(toolkit, AgentToolkit) + ], + ) + + def _build_system_message( + self, + subagent_type: str, + description: str, + ) -> str: + base = super()._build_system_message(subagent_type, description) + return ( + base + "\nYou are a child sub-agent. Complete the assigned task " + "directly and do not create or delegate to any further sub-agents." + ) + + @classmethod + def toolkit_name(cls) -> str: + return "AgentToolkit" diff --git a/backend/app/agent/toolkit/file_write_toolkit.py b/backend/app/agent/toolkit/file_write_toolkit.py index 403d119d5..0403e79fe 100644 --- a/backend/app/agent/toolkit/file_write_toolkit.py +++ b/backend/app/agent/toolkit/file_write_toolkit.py @@ -13,11 +13,15 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import os +from dataclasses import dataclass +from pathlib import Path +from typing import Literal from camel.toolkits import FileToolkit as BaseFileToolkit from app.agent.toolkit.abstract_toolkit import AbstractToolkit from app.component.environment import env +from app.run_context import RunContext from app.service.task import ( ActionWriteFileData, Agents, @@ -29,6 +33,33 @@ auto_listen_toolkit, listen_toolkit, ) +from app.utils.space_overlay_client import ( + path_write_lock, + post_overlay_write, + relative_to_workdir, + run_context_for_task, + sha256_of_file, + should_record_overlay, +) + + +@dataclass(frozen=True) +class OverlayWriteContext: + run_context: RunContext + rel_path: str + target_path: Path + + +@dataclass(frozen=True) +class PendingOverlayWrite: + run_context: RunContext + rel_path: str + target_path: Path + base_hash: str | None + status: Literal["added", "modified"] + file_hash: str | None + size: int + mode: int @auto_listen_toolkit(BaseFileToolkit) @@ -52,6 +83,20 @@ def __init__( ) self.api_task_id = api_task_id + def _overlay_write_context( + self, filename: str + ) -> OverlayWriteContext | None: + context = run_context_for_task(self.api_task_id) + if context is None: + return None + resolved = relative_to_workdir(context, filename) + if resolved is None: + return None + rel_path, target_path = resolved + if not should_record_overlay(context, target_path): + return None + return OverlayWriteContext(context, rel_path, target_path) + @listen_toolkit( BaseFileToolkit.write_to_file, lambda _, @@ -69,9 +114,57 @@ def write_to_file( encoding: str | None = None, use_latex: bool = False, ) -> str: - res = super().write_to_file( - title, content, filename, encoding, use_latex - ) + overlay_context = self._overlay_write_context(filename) + if overlay_context is None: + res = super().write_to_file( + title, content, filename, encoding, use_latex + ) + else: + pending_overlay_write: PendingOverlayWrite | None = None + with path_write_lock( + overlay_context.run_context.space_id, + overlay_context.run_context.project_id, + overlay_context.run_context.run_id, + overlay_context.rel_path, + ): + existed = overlay_context.target_path.exists() + base_hash = sha256_of_file(overlay_context.target_path) + res = super().write_to_file( + title, content, filename, encoding, use_latex + ) + if "Content successfully written to file: " in res: + written_path = Path( + res.replace( + "Content successfully written to file: ", "" + ) + ) + if not written_path.is_absolute(): + written_path = ( + Path(self.working_directory) / written_path + ) + written_hash = sha256_of_file(written_path) + written_stat = written_path.stat() + pending_overlay_write = PendingOverlayWrite( + run_context=overlay_context.run_context, + rel_path=overlay_context.rel_path, + target_path=written_path, + base_hash=base_hash, + status="modified" if existed else "added", + file_hash=written_hash, + size=written_stat.st_size, + mode=written_stat.st_mode, + ) + if pending_overlay_write is not None: + post_overlay_write( + pending_overlay_write.run_context, + pending_overlay_write.rel_path, + pending_overlay_write.target_path, + base_hash=pending_overlay_write.base_hash, + status=pending_overlay_write.status, + file_hash=pending_overlay_write.file_hash, + size=pending_overlay_write.size, + mode=pending_overlay_write.mode, + ) if "Content successfully written to file: " in res: task_lock = get_task_lock(self.api_task_id) # Capture ContextVar value before creating async task diff --git a/backend/app/agent/toolkit/google_calendar_toolkit.py b/backend/app/agent/toolkit/google_calendar_toolkit.py index a391ea331..200d5e1f3 100644 --- a/backend/app/agent/toolkit/google_calendar_toolkit.py +++ b/backend/app/agent/toolkit/google_calendar_toolkit.py @@ -58,18 +58,7 @@ def _build_canonical_token_path(cls) -> str: @classmethod def get_can_use_tools(cls, api_task_id: str): - from dotenv import load_dotenv - - # Force reload environment variables - default_env_path = os.path.join( - os.path.expanduser("~"), ".eigent", ".env" - ) - if os.path.exists(default_env_path): - load_dotenv(dotenv_path=default_env_path, override=True) - - if os.environ.get("GOOGLE_CLIENT_ID") and os.environ.get( - "GOOGLE_CLIENT_SECRET" - ): + if env("GOOGLE_CLIENT_ID") and env("GOOGLE_CLIENT_SECRET"): return cls(api_task_id).get_tools() else: return [] @@ -92,17 +81,9 @@ def _get_calendar_service(self): return build("calendar", "v3", credentials=creds) def _authenticate(self): - from dotenv import load_dotenv from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials - # Force reload environment variables from default .env file - default_env_path = os.path.join( - os.path.expanduser("~"), ".eigent", ".env" - ) - if os.path.exists(default_env_path): - load_dotenv(dotenv_path=default_env_path, override=True) - creds = None # First, try to load from token file @@ -142,12 +123,11 @@ def _authenticate(self): # If no token file, try environment variables if not creds: - client_id = os.environ.get("GOOGLE_CLIENT_ID") - client_secret = os.environ.get("GOOGLE_CLIENT_SECRET") - refresh_token = os.environ.get("GOOGLE_REFRESH_TOKEN") - token_uri = ( - os.environ.get("GOOGLE_TOKEN_URI") - or "https://oauth2.googleapis.com/token" + client_id = env("GOOGLE_CLIENT_ID") + client_secret = env("GOOGLE_CLIENT_SECRET") + refresh_token = env("GOOGLE_REFRESH_TOKEN") + token_uri = env( + "GOOGLE_TOKEN_URI", "https://oauth2.googleapis.com/token" ) if refresh_token and client_id and client_secret: @@ -204,19 +184,8 @@ def start_background_auth(api_task_id: str = "install_auth") -> str: Start background OAuth authorization flow with timeout Returns the status of the authorization """ - from dotenv import load_dotenv from google_auth_oauthlib.flow import InstalledAppFlow - # Force reload environment variables from default .env file - default_env_path = os.path.join( - os.path.expanduser("~"), ".eigent", ".env" - ) - if os.path.exists(default_env_path): - logger.info( - f"Reloading environment variables from {default_env_path}" - ) - load_dotenv(dotenv_path=default_env_path, override=True) - # Check if there's an existing authorization and force stop it old_state = oauth_state_manager.get_state("google_calendar") if old_state and old_state.status in ["pending", "authorizing"]: @@ -240,20 +209,10 @@ def auth_flow(): "google_calendar", "authorizing" ) - # Reload environment variables in this thread - from dotenv import load_dotenv - - default_env_path = os.path.join( - os.path.expanduser("~"), ".eigent", ".env" - ) - if os.path.exists(default_env_path): - load_dotenv(dotenv_path=default_env_path, override=True) - - client_id = os.environ.get("GOOGLE_CLIENT_ID") - client_secret = os.environ.get("GOOGLE_CLIENT_SECRET") - token_uri = ( - os.environ.get("GOOGLE_TOKEN_URI") - or "https://oauth2.googleapis.com/token" + client_id = env("GOOGLE_CLIENT_ID") + client_secret = env("GOOGLE_CLIENT_SECRET") + token_uri = env( + "GOOGLE_TOKEN_URI", "https://oauth2.googleapis.com/token" ) logger.info( 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..1045a55ec 100644 --- a/backend/app/agent/toolkit/hybrid_browser_toolkit.py +++ b/backend/app/agent/toolkit/hybrid_browser_toolkit.py @@ -18,6 +18,7 @@ import os import uuid from typing import Any +from urllib.parse import urlparse import websockets import websockets.exceptions @@ -36,15 +37,68 @@ logger = logging.getLogger("hybrid_browser_toolkit") -# Global navigation lock to prevent concurrent visit_page conflicts (ERR_ABORTED) -# This is needed because multiple sessions may share the same browser via CDP -_global_navigation_lock = asyncio.Lock() +# Navigation locks prevent concurrent visit_page conflicts (ERR_ABORTED) for +# sessions sharing the same browser/CDP endpoint. +_navigation_locks: dict[str, asyncio.Lock] = {} +_navigation_locks_guard = asyncio.Lock() +_browser_bringup_locks: dict[str, asyncio.Lock] = {} +_browser_bringup_locks_guard = asyncio.Lock() # Global registry: tab_id -> session_id (ensures each tab belongs to only one session) _global_tab_registry: dict[str, str] = {} _global_tab_registry_lock = asyncio.Lock() +def _timeout_value_to_seconds(value: Any, *, fallback_seconds: float) -> float: + if value is None: + return fallback_seconds + try: + timeout = float(value) + except (TypeError, ValueError): + return fallback_seconds + if timeout <= 0: + return fallback_seconds + # CAMEL browser timeout config is in milliseconds; local overrides may be + # provided in seconds. Values above 300 are treated as milliseconds. + if timeout > 300: + timeout = timeout / 1000.0 + return timeout + + +def _env_timeout_seconds(name: str, fallback_seconds: float) -> float: + return _timeout_value_to_seconds( + env(name, ""), fallback_seconds=fallback_seconds + ) + + +def _endpoint_lock_key(cdp_url: str | None) -> str: + if cdp_url: + parsed = urlparse(cdp_url) + if parsed.netloc: + return parsed.netloc + if parsed.path: + return parsed.path + return f"localhost:{env('browser_port', '9222')}" + + +async def _get_navigation_lock(key: str) -> asyncio.Lock: + async with _navigation_locks_guard: + lock = _navigation_locks.get(key) + if lock is None: + lock = asyncio.Lock() + _navigation_locks[key] = lock + return lock + + +async def _get_browser_bringup_lock(key: str) -> asyncio.Lock: + async with _browser_bringup_locks_guard: + lock = _browser_bringup_locks.get(key) + if lock is None: + lock = asyncio.Lock() + _browser_bringup_locks[key] = lock + return lock + + class SheetCell(TypedDict): row: int col: int @@ -60,11 +114,62 @@ def __init__(self, config: dict[str, Any] | None = None): self._session_tab_ids: set = set() self._wrapper_session_id: str = str(uuid.uuid4()) + def _fail_all_pending(self, exc: Exception) -> None: + for future in self._pending_responses.values(): + if not future.done(): + future.set_exception(exc) + self._pending_responses.clear() + + def _command_timeout_seconds(self, command: str) -> float: + override = env("BROWSER_COMMAND_TIMEOUT_SECONDS", "").strip() + if override: + return _timeout_value_to_seconds( + override, fallback_seconds=self._request_timeout + ) + + command_name = command.lower() + if any( + token in command_name + for token in ("visit", "navigate", "open", "reload", "wait") + ): + return _timeout_value_to_seconds( + self.config.get("navigationTimeout"), + fallback_seconds=self._request_timeout, + ) + return _timeout_value_to_seconds( + self.config.get("defaultTimeout"), + fallback_seconds=self._request_timeout, + ) + + def _navigation_lock_key(self) -> str: + cdp_url = str(self.config.get("cdpUrl") or "").strip() + return _endpoint_lock_key(cdp_url) + + def _navigation_lock_wait_seconds(self) -> float: + command_timeout = self._command_timeout_seconds("visit_page") + return _env_timeout_seconds( + "BROWSER_NAVIGATION_LOCK_TIMEOUT_SECONDS", + fallback_seconds=command_timeout + 5.0, + ) + + async def _close_current_websocket(self) -> None: + websocket = self.websocket + self.websocket = None + if websocket is None: + return + try: + await asyncio.wait_for(websocket.close(), timeout=1.0) + except Exception as exc: + logger.debug(f"Error closing browser websocket: {exc}") + def _ensure_local_no_proxy(self) -> None: local_hosts = ["localhost", "127.0.0.1", "::1"] for key in ("NO_PROXY", "no_proxy"): - current = os.environ.get(key, "") + current = env(key, "") if not current: + # Process-level proxy bypass for local CDP/WebSocket traffic. + # This is intentionally static host configuration, not + # per-run mutable state like API keys or artifact paths. os.environ[key] = ",".join(local_hosts) continue parts = [ @@ -82,6 +187,7 @@ async def _receive_loop(self): """Background task to receive messages from WebSocket with enhanced logging.""" logger.debug("WebSocket receive loop started") disconnect_reason = None + pending_error: Exception | None = None try: while self.websocket: @@ -115,12 +221,18 @@ async def _receive_loop(self): except asyncio.CancelledError: disconnect_reason = "Receive loop cancelled" + pending_error = ConnectionError( + f"browser ws closed: {disconnect_reason}" + ) logger.info(f"WebSocket disconnect: {disconnect_reason}") break except websockets.exceptions.ConnectionClosed as e: disconnect_reason = ( f"WebSocket closed: code={e.code}, reason={e.reason}" ) + pending_error = ConnectionError( + f"browser ws closed: {disconnect_reason}" + ) logger.warning( f"WebSocket disconnect: {disconnect_reason}" ) @@ -129,6 +241,9 @@ async def _receive_loop(self): disconnect_reason = ( f"WebSocket error: {type(e).__name__}: {e}" ) + pending_error = ConnectionError( + f"browser ws closed: {disconnect_reason}" + ) logger.error(f"WebSocket disconnect: {disconnect_reason}") break except json.JSONDecodeError as e: @@ -138,20 +253,20 @@ async def _receive_loop(self): disconnect_reason = ( f"Unexpected error: {type(e).__name__}: {e}" ) + pending_error = e logger.error( f"WebSocket disconnect: {disconnect_reason}", exc_info=True, ) - # Notify all pending futures of the error - for future in self._pending_responses.values(): - if not future.done(): - future.set_exception(e) - self._pending_responses.clear() break finally: logger.info( f"WebSocket receive loop terminated. Reason: {disconnect_reason or 'Normal shutdown'}" ) + self._fail_all_pending( + pending_error + or ConnectionError("browser ws receive loop ended") + ) # Mark the websocket as None to indicate disconnection self.websocket = None @@ -183,18 +298,39 @@ async def _send_command( logger.debug(f"Sending command '{command}' with params: {params}") - # Call parent's _send_command - result = await super()._send_command(command, params) + timeout = self._command_timeout_seconds(command) + + # Call parent's _send_command with an outer timeout so cancelled + # waits cannot leave pending futures stranded in this subclass. + result = await asyncio.wait_for( + super()._send_command(command, params), timeout=timeout + ) logger.debug(f"Command '{command}' completed successfully") return result + except TimeoutError as e: + message = ( + f"browser command '{command}' timed out after " + f"{self._command_timeout_seconds(command)}s" + ) + logger.error(message) + self._fail_all_pending(TimeoutError(message)) + await self._close_current_websocket() + raise RuntimeError(message) from e except RuntimeError as e: logger.error(f"Failed to send command '{command}': {e}") # Check if it's a connection issue - if "WebSocket" in str(e) or "connection" in str(e).lower(): + lower_error = str(e).lower() + if ( + "websocket" in lower_error + or "connection" in lower_error + or "timeout" in lower_error + or "timed out" in lower_error + ): # Mark connection as dead - self.websocket = None + self._fail_all_pending(e) + await self._close_current_websocket() raise except Exception as e: logger.error( @@ -210,21 +346,32 @@ async def visit_page(self, url: str) -> dict[str, Any]: blank page). This lock serializes navigation operations at the WebSocket wrapper level. """ - global _global_navigation_lock + lock_key = self._navigation_lock_key() + lock = await _get_navigation_lock(lock_key) + lock_wait = self._navigation_lock_wait_seconds() + acquired = False + try: + await asyncio.wait_for(lock.acquire(), timeout=lock_wait) + acquired = True + except TimeoutError as exc: + raise RuntimeError( + "navigation lock busy; browser may be stuck " + f"(key={lock_key}, waited={lock_wait}s)" + ) from exc - async with _global_navigation_lock: - logger.debug( - f"[visit_page] Acquired navigation lock, navigating to {url}" - ) - try: - result = await super().visit_page(url) - logger.debug( - "[visit_page] Navigation completed, releasing lock" - ) - return result - except Exception as e: - logger.error(f"[visit_page] Navigation failed: {e}") - raise + logger.debug( + f"[visit_page] Acquired navigation lock ({lock_key}), navigating to {url}" + ) + try: + result = await super().visit_page(url) + logger.debug("[visit_page] Navigation completed, releasing lock") + return result + except Exception as e: + logger.error(f"[visit_page] Navigation failed: {e}") + raise + finally: + if acquired: + lock.release() async def get_tab_info(self) -> list[dict[str, Any]]: """Override get_tab_info to track and filter tabs for session isolation. @@ -511,10 +658,51 @@ def __init__( cdp_keep_current_page=cdp_keep_current_page, full_visual_mode=full_visual_mode, ) + if self._default_timeout is not None: + self._ws_config["defaultTimeout"] = self._default_timeout + command_timeout_override = env( + "BROWSER_COMMAND_TIMEOUT_SECONDS", "" + ).strip() + if command_timeout_override: + self._ws_config["requestTimeout"] = _timeout_value_to_seconds( + command_timeout_override, + fallback_seconds=60.0, + ) logger.info( f"[HybridBrowserToolkit] Initialization complete for api_task_id: {self.api_task_id}" ) + def _ws_cdp_url(self) -> str: + return str( + self._ws_config.get("cdpUrl") + or self._ws_config.get("cdp_url") + or f"http://localhost:{env('browser_port', '9222')}" + ) + + def _should_prime_shared_cdp_tab(self) -> bool: + enabled = ( + env("EIGENT_INTERIM_SHARED_BROWSER_TAB_ISOLATION", "true") + .strip() + .lower() + ) + if enabled in {"0", "false", "no", "off"}: + return False + return bool( + self._ws_config.get("cdpUrl") or self._ws_config.get("cdp_url") + ) and not bool(self._ws_config.get("cdpKeepCurrentPage")) + + async def _prime_shared_cdp_tab(self, session_id: str) -> None: + if self._ws_wrapper is None: + return + # === INTERIM(shared-browser tab isolation) remove after camel upstream fix; see docs/REMOTE_CONTROL_SHARED_BROWSER_TAB_ISOLATION_2026-06-15.md === + sentinel_url = f"about:blank#eigent-{session_id}" + logger.info( + "[INTERIM shared-browser tab isolation] Priming session tab", + extra={"session_id": session_id, "sentinel_url": sentinel_url}, + ) + await self._ws_wrapper.visit_page(sentinel_url) + self._ws_wrapper._eigent_interim_shared_browser_primed = True + async def _ensure_ws_wrapper(self): """Ensure WebSocket wrapper is initialized using connection pool.""" logger.debug( @@ -527,30 +715,63 @@ async def _ensure_ws_wrapper(self): logger.debug(f"[HybridBrowserToolkit] Using session_id: {session_id}") # Log when connecting to browser - cdp_url = self._ws_config.get( - "cdp_url", f"http://localhost:{env('browser_port', '9222')}" - ) + cdp_url = self._ws_cdp_url() logger.info( f"[PROJECT BROWSER] Connecting to browser via CDP at {cdp_url}" ) - # Get or create connection from pool - self._ws_wrapper = await websocket_connection_pool.get_connection( - session_id, self._ws_config - ) - logger.info( - f"[HybridBrowserToolkit] WebSocket wrapper initialized for session: {session_id}" - ) - - # Additional health check - if self._ws_wrapper.websocket is None: - logger.warning( - f"WebSocket connection for session {session_id} is None after pool retrieval, recreating..." + should_prime = self._should_prime_shared_cdp_tab() + bringup_lock: asyncio.Lock | None = None + bringup_acquired = False + if should_prime: + bringup_lock = await _get_browser_bringup_lock( + _endpoint_lock_key(cdp_url) ) - await websocket_connection_pool.close_connection(session_id) + bringup_wait = _env_timeout_seconds( + "BROWSER_BRINGUP_LOCK_TIMEOUT_SECONDS", + fallback_seconds=45.0, + ) + try: + await asyncio.wait_for( + bringup_lock.acquire(), timeout=bringup_wait + ) + bringup_acquired = True + except TimeoutError as exc: + raise RuntimeError( + "browser bring-up lock busy; shared browser may be stuck " + f"(endpoint={cdp_url}, waited={bringup_wait}s)" + ) from exc + + try: + # Get or create connection from pool self._ws_wrapper = await websocket_connection_pool.get_connection( session_id, self._ws_config ) + logger.info( + f"[HybridBrowserToolkit] WebSocket wrapper initialized for session: {session_id}" + ) + + # Additional health check + if self._ws_wrapper.websocket is None: + logger.warning( + f"WebSocket connection for session {session_id} is None after pool retrieval, recreating..." + ) + await websocket_connection_pool.close_connection(session_id) + self._ws_wrapper = ( + await websocket_connection_pool.get_connection( + session_id, self._ws_config + ) + ) + + if should_prime and not getattr( + self._ws_wrapper, + "_eigent_interim_shared_browser_primed", + False, + ): + await self._prime_shared_cdp_tab(session_id) + finally: + if bringup_acquired and bringup_lock is not None: + bringup_lock.release() def clone_for_new_session( self, new_session_id: str | None = None @@ -568,6 +789,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 +805,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/linkedin_toolkit.py b/backend/app/agent/toolkit/linkedin_toolkit.py index 3b7aa2165..5ac45d11c 100644 --- a/backend/app/agent/toolkit/linkedin_toolkit.py +++ b/backend/app/agent/toolkit/linkedin_toolkit.py @@ -49,8 +49,10 @@ class LinkedInToolkit(BaseLinkedInToolkit, AbstractToolkit): def __init__(self, api_task_id: str, timeout: float | None = None): self.api_task_id = api_task_id self._token_path = self._build_canonical_token_path() - self._load_credentials() + access_token = self._load_credentials() super().__init__(timeout) + if access_token: + self._access_token = access_token @classmethod def _build_canonical_token_path(cls) -> str: @@ -63,74 +65,47 @@ def _build_canonical_token_path(cls) -> str: "linkedin_token.json", ) - def _load_credentials(self): - r"""Load credentials from token file or environment variables.""" - from dotenv import load_dotenv + @classmethod + def _load_token_from_file(cls, token_path: str) -> str | None: + if not os.path.exists(token_path): + return None - # Force reload environment variables from default .env file - default_env_path = os.path.join( - os.path.expanduser("~"), ".eigent", ".env" + try: + with open(token_path) as f: + token_data = json.load(f) + except Exception as e: + logger.warning(f"Could not load LinkedIn token file: {e}") + return None + + access_token = token_data.get("access_token") + if not access_token: + return None + + expires_at = token_data.get("expires_at") + if expires_at and int(time.time()) >= expires_at: + logger.warning( + "LinkedIn token file contains expired token, skipping load" + ) + return None + + logger.info( + "Loaded LinkedIn credentials from token file: %s", token_path ) - if os.path.exists(default_env_path): - load_dotenv(dotenv_path=default_env_path, override=True) + return access_token - # Try to load from token file first - if os.path.exists(self._token_path): - try: - with open(self._token_path) as f: - token_data = json.load(f) - access_token = token_data.get("access_token") - if access_token: - # Check if token is expired before loading - expires_at = token_data.get("expires_at") - if expires_at and int(time.time()) >= expires_at: - logger.warning( - "LinkedIn token file contains " - "expired token, skipping load" - ) - else: - os.environ["LINKEDIN_ACCESS_TOKEN"] = access_token - logger.info( - "Loaded LinkedIn credentials " - "from token file: " - f"{self._token_path}" - ) - except Exception as e: - logger.warning(f"Could not load LinkedIn token file: {e}") + def _load_credentials(self) -> str | None: + r"""Load credentials from token file or RunContext-aware env.""" + return self._load_token_from_file(self._token_path) or env( + "LINKEDIN_ACCESS_TOKEN" + ) @classmethod def get_can_use_tools(cls, api_task_id: str) -> list[FunctionTool]: r"""Return tools only if LinkedIn credentials are configured.""" - from dotenv import load_dotenv - - # Force reload environment variables - default_env_path = os.path.join( - os.path.expanduser("~"), ".eigent", ".env" - ) - if os.path.exists(default_env_path): - load_dotenv(dotenv_path=default_env_path, override=True) - - # Check for token file token_path = cls._build_canonical_token_path() - if os.path.exists(token_path): - try: - with open(token_path) as f: - token_data = json.load(f) - access_token = token_data.get("access_token") - if access_token: - # Check if token is expired before loading - expires_at = token_data.get("expires_at") - if expires_at and int(time.time()) >= expires_at: - logger.warning( - "LinkedIn token file contains " - "expired token, skipping load" - ) - else: - os.environ["LINKEDIN_ACCESS_TOKEN"] = access_token - except Exception: - pass - - if env("LINKEDIN_ACCESS_TOKEN"): + if cls._load_token_from_file(token_path) or env( + "LINKEDIN_ACCESS_TOKEN" + ): return LinkedInToolkit(api_task_id).get_tools() else: return [] @@ -168,12 +143,6 @@ def save_token(cls, token_data: dict) -> bool: json.dump(token_data, f, indent=2) logger.info(f"Saved LinkedIn token to {token_path}") - # Also update environment variable - if token_data.get("access_token"): - os.environ["LINKEDIN_ACCESS_TOKEN"] = token_data[ - "access_token" - ] - return True except Exception as e: logger.error(f"Failed to save LinkedIn token: {e}") @@ -200,10 +169,6 @@ def clear_token(cls) -> bool: f"Removed empty LinkedIn token directory: {token_dir}" ) - # Clear environment variable - if "LINKEDIN_ACCESS_TOKEN" in os.environ: - del os.environ["LINKEDIN_ACCESS_TOKEN"] - return True except Exception as e: logger.error(f"Failed to clear LinkedIn token: {e}") @@ -216,28 +181,11 @@ def is_authenticated(cls) -> bool: Returns: True if credentials are available, False otherwise """ - from dotenv import load_dotenv - - # Force reload environment variables - default_env_path = os.path.join( - os.path.expanduser("~"), ".eigent", ".env" - ) - if os.path.exists(default_env_path): - load_dotenv(dotenv_path=default_env_path, override=True) - - # Check token file token_path = cls._build_canonical_token_path() - if os.path.exists(token_path): - try: - with open(token_path) as f: - token_data = json.load(f) - if token_data.get("access_token"): - return True - except Exception: - pass - - # Check environment variable - return bool(env("LINKEDIN_ACCESS_TOKEN")) + return bool( + cls._load_token_from_file(token_path) + or env("LINKEDIN_ACCESS_TOKEN") + ) @classmethod def get_token_info(cls) -> dict | None: diff --git a/backend/app/agent/toolkit/observable_todo_toolkit.py b/backend/app/agent/toolkit/observable_todo_toolkit.py new file mode 100644 index 000000000..8a623d429 --- /dev/null +++ b/backend/app/agent/toolkit/observable_todo_toolkit.py @@ -0,0 +1,99 @@ +# ========= 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 camel.toolkits import FunctionTool, TodoToolkit +from camel.toolkits.todo_toolkit import TodoItem + +from app.agent.toolkit.abstract_toolkit import AbstractToolkit +from app.service.task import ActionTodoStateData, Agents, get_task_lock +from app.utils.listen.toolkit_listen import _safe_put_queue + +logger = logging.getLogger("observable_todo_toolkit") + + +class ObservableTodoToolkit(TodoToolkit, AbstractToolkit): + """CAMEL TodoToolkit with Eigent UI change events. + + This intentionally keeps CAMEL's todo data model and `todo_write` API as + the source of truth. Eigent only observes successful writes and emits an + SSE-compatible action for the frontend. + """ + + agent_name: str = Agents.single_agent + + def __init__( + self, + api_task_id: str, + task_id: str, + agent_id: str | None = None, + working_dir: str | None = None, + timeout: float | None = None, + ) -> None: + super().__init__(working_dir=working_dir, timeout=timeout) + self.api_task_id = api_task_id + self.task_id = task_id + self.agent_id = agent_id + + def todo_write(self, todos: list[TodoItem]) -> str: + result = super().todo_write(todos) + if not result.startswith("[ERROR]"): + self.emit_todo_state() + return result + + def emit_todo_state(self) -> None: + try: + task_lock = get_task_lock(self.api_task_id) + except Exception: + logger.warning( + "Could not emit todo_state because task lock is missing", + extra={"project_id": self.api_task_id}, + ) + return + + data = { + "project_id": self.api_task_id, + "task_id": self.task_id, + "agent_id": self.agent_id, + "todos": self.serialized_todos(), + } + _safe_put_queue(task_lock, ActionTodoStateData(data=data)) + + def serialized_todos(self) -> list[dict[str, Any]]: + serialized: list[dict[str, Any]] = [] + for index, item in enumerate(self.todos, start=1): + serialized.append( + { + "id": f"todo_{index}", + "content": item.content, + "active_form": item.active_form, + "status": item.status, + } + ) + return serialized + + def get_tools(self) -> list[FunctionTool]: + tools = [FunctionTool(self.todo_write)] + for tool in tools: + try: + tool._toolkit_name = self.toolkit_name() + except Exception: + pass + return tools + + @classmethod + def toolkit_name(cls) -> str: + return "TodoToolkit" 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/toolkit/search_toolkit.py b/backend/app/agent/toolkit/search_toolkit.py index 4b946ec7b..cdac791f4 100644 --- a/backend/app/agent/toolkit/search_toolkit.py +++ b/backend/app/agent/toolkit/search_toolkit.py @@ -13,7 +13,6 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import logging -import os from typing import Any import httpx @@ -94,6 +93,102 @@ def _load_user_search_config(self): # ) -> dict[str, Any]: # return super().search_linkup(query, depth, output_type, structured_output_schema) + def _search_google_with_config( + self, + query: str, + search_type: str, + number_of_result_pages: int, + start_page: int, + google_api_key: str, + search_engine_id: str, + ) -> list[dict[str, Any]]: + if start_page < 1: + raise ValueError("start_page must be a positive integer") + if number_of_result_pages < 1: + raise ValueError( + "number_of_result_pages must be a positive integer" + ) + if number_of_result_pages > 10: + logger.warning( + "Google API limits results to 10 per request. " + "Using 10 instead." + ) + number_of_result_pages = 10 + if search_type not in ("web", "image"): + raise ValueError("search_type must be either 'web' or 'image'") + + modified_query = query + if self.exclude_domains: + exclusion_terms = " ".join( + f"-site:{domain}" for domain in self.exclude_domains + ) + modified_query = f"{query} {exclusion_terms}" + + params: dict[str, str | int] = { + "key": google_api_key, + "cx": search_engine_id, + "q": modified_query, + "start": start_page, + "lr": "en", + "num": number_of_result_pages, + } + if search_type == "image": + params["searchType"] = "image" + + try: + response = httpx.get( + "https://www.googleapis.com/customsearch/v1", + params=params, + timeout=self.timeout, + ) + data = response.json() + except Exception as exc: + return [{"error": f"google search failed: {exc!s}"}] + + items = data.get("items") + if not items: + if "error" in data: + return [ + { + "error": "Google search failed - " + f"API response: {data.get('error', {})}" + } + ] + return [] + + results: list[dict[str, Any]] = [] + for index, item in enumerate(items, start=1): + if search_type == "image": + image_info = item.get("image", {}) + result: dict[str, Any] = { + "result_id": index, + "title": item.get("title"), + "image_url": item.get("link"), + "display_link": item.get("displayLink"), + "context_url": image_info.get("contextLink", ""), + } + if image_info.get("width"): + result["width"] = int(image_info["width"]) + if image_info.get("height"): + result["height"] = int(image_info["height"]) + results.append(result) + continue + + metatags = item.get("pagemap", {}).get("metatags", []) + long_description = ( + metatags[0].get("og:description") if metatags else "N/A" + ) + results.append( + { + "result_id": index, + "title": item.get("title"), + "description": item.get("snippet"), + "long_description": long_description or "N/A", + "url": item.get("link"), + } + ) + return results + @listen_toolkit( BaseSearchToolkit.search_google, lambda _, @@ -115,27 +210,14 @@ def search_google( # If user has configured their own Google API keys, use them if self._user_google_api_key and self._user_search_engine_id: logger.info("Using user-configured Google Search API") - # Temporarily set environment variables for this search - old_google_key = os.environ.get("GOOGLE_API_KEY") - old_search_id = os.environ.get("SEARCH_ENGINE_ID") - - try: - os.environ["GOOGLE_API_KEY"] = self._user_google_api_key - os.environ["SEARCH_ENGINE_ID"] = self._user_search_engine_id - return super().search_google( - query, search_type, number_of_result_pages, start_page - ) - finally: - # Restore original environment variables - if old_google_key is not None: - os.environ["GOOGLE_API_KEY"] = old_google_key - elif "GOOGLE_API_KEY" in os.environ: - del os.environ["GOOGLE_API_KEY"] - - if old_search_id is not None: - os.environ["SEARCH_ENGINE_ID"] = old_search_id - elif "SEARCH_ENGINE_ID" in os.environ: - del os.environ["SEARCH_ENGINE_ID"] + return self._search_google_with_config( + query, + search_type, + number_of_result_pages, + start_page, + self._user_google_api_key, + self._user_search_engine_id, + ) else: # Fallback to cloud search logger.info( diff --git a/backend/app/agent/toolkit/terminal_toolkit.py b/backend/app/agent/toolkit/terminal_toolkit.py index 87411cb12..724dd7fae 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.91" +APP_VERSION = "1.0.0" def get_terminal_base_venv_path() -> str: diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py index 1c20353a0..36bebbccb 100644 --- a/backend/app/agent/tools.py +++ b/backend/app/agent/tools.py @@ -43,12 +43,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}" @@ -81,6 +95,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) @@ -95,13 +127,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..c9a3d5a52 --- /dev/null +++ b/backend/app/auth/__init__.py @@ -0,0 +1,32 @@ +# ========= 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.brain_auth import ( + BrainAuthContext, + get_brain_auth_context, + get_brain_auth_provider, + set_brain_auth_provider, + with_brain_auth_provider, +) +from app.auth.interface import IAuthProvider, NoneAuth + +__all__ = [ + "BrainAuthContext", + "IAuthProvider", + "NoneAuth", + "get_brain_auth_context", + "get_brain_auth_provider", + "set_brain_auth_provider", + "with_brain_auth_provider", +] diff --git a/backend/app/auth/brain_auth.py b/backend/app/auth/brain_auth.py new file mode 100644 index 000000000..ba65153bd --- /dev/null +++ b/backend/app/auth/brain_auth.py @@ -0,0 +1,91 @@ +# ========= 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 collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass + +from fastapi import HTTPException, Request + +from app.auth.interface import IAuthProvider, NoneAuth + + +@dataclass(frozen=True) +class BrainAuthContext: + user_id: str + tenant_id: str + authorization_present: bool + token_scheme: str | None = None + + +_auth_provider: IAuthProvider = NoneAuth() + + +def set_brain_auth_provider(provider: IAuthProvider) -> None: + global _auth_provider + _auth_provider = provider + + +@contextmanager +def with_brain_auth_provider(provider: IAuthProvider) -> Iterator[None]: + previous = get_brain_auth_provider() + set_brain_auth_provider(provider) + try: + yield + finally: + set_brain_auth_provider(previous) + + +def get_brain_auth_provider() -> IAuthProvider: + return _auth_provider + + +async def get_brain_auth_context(request: Request) -> BrainAuthContext: + """ + Bridge auth dependency for Brain routes. + + Local Brain still uses NoneAuth, but every non-health route now has a + stable auth hook and request.state.brain_auth for future JWT/API-key + verification without changing controller call sites again. + """ + + authorization = request.headers.get("authorization") + token_scheme = None + if authorization: + token_scheme = authorization.split(" ", 1)[0].lower() + + provider = get_brain_auth_provider() + identity = await provider.authenticate( + { + "headers": dict(request.headers), + "state": getattr(request, "state", None), + "authorization_present": bool(authorization), + } + ) + if not identity.get("user_id") and not isinstance(provider, NoneAuth): + raise HTTPException( + status_code=401, + detail={ + "code": "brain_auth_required", + "message": "Brain authentication did not resolve a user.", + }, + ) + context = BrainAuthContext( + user_id=identity.get("user_id") or "local", + tenant_id=identity.get("tenant_id") or "default", + authorization_present=bool(authorization), + token_scheme=token_scheme, + ) + request.state.brain_auth = context + return context 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/component/environment.py b/backend/app/component/environment.py index 64812a243..c1d45faa6 100644 --- a/backend/app/component/environment.py +++ b/backend/app/component/environment.py @@ -21,7 +21,7 @@ from pathlib import Path from typing import Any, overload -from dotenv import load_dotenv +from dotenv import dotenv_values, load_dotenv from fastapi import APIRouter, FastAPI logger = logging.getLogger("env") @@ -29,6 +29,10 @@ # Thread-local storage for user-specific environment _thread_local = threading.local() +# Keys present before dotenv files are loaded remain authoritative. Values +# loaded from dotenv files can be refreshed from disk by env(). +_process_env_keys = set(os.environ.keys()) + # Safe base directory for user environment files env_base_dir = os.path.join(os.path.expanduser("~"), ".eigent") @@ -58,6 +62,8 @@ def _load_initial_env_files(paths: Iterable[Path]) -> list[Path]: 3. Earlier files in `paths`. """ original_env = dict(os.environ) + global _process_env_keys + _process_env_keys = set(original_env.keys()) loaded_paths: list[Path] = [] seen: set[str] = set() @@ -84,6 +90,26 @@ def _load_initial_env_files(paths: Iterable[Path]) -> list[Path]: return loaded_paths +def _load_live_env_values(paths: Iterable[Path]) -> dict[str, str]: + values: dict[str, str] = {} + seen: set[str] = set() + + for path in paths: + resolved = path.expanduser().resolve() + resolved_key = str(resolved) + if resolved_key in seen: + continue + seen.add(resolved_key) + if not resolved.exists(): + continue + + for key, value in dotenv_values(resolved).items(): + if value is not None: + values[key] = value + + return values + + _load_initial_env_files(_resolve_initial_env_paths()) @@ -213,6 +239,23 @@ def env(key: str, default=None): Security: Re-validates path at point of use to ensure integrity. """ + # Run-scoped values are the first source of truth for mutable runtime + # settings. This keeps legacy `env("file_save_path")` call sites working + # without relying on process-global os.environ during concurrent runs. + try: + # Inline import avoids a startup cycle: run_context imports no env + # helpers, but many early modules import env before the runtime package. + from app.run_context import get_run_env_override + + run_value = get_run_env_override(key) + if run_value is not None: + logger.debug( + f"Environment variable retrieved from RunContext: key={key}" + ) + return run_value + except ImportError: + pass + # If we have a user-specific environment path, try to reload it # to get latest values. if hasattr(_thread_local, "env_path"): @@ -241,8 +284,27 @@ def env(key: str, default=None): ) delattr(_thread_local, "env_path") - # Fall back to global environment - value = os.getenv(key, default) + # Keep real process / service-manager env vars authoritative, but allow + # dotenv-backed values to be refreshed after Electron writes them. + if key in _process_env_keys and key in os.environ: + value = os.environ[key] + logger.debug( + f"Environment variable retrieved from process env: key={key}, " + f"has_value={value is not None}" + ) + return value + + live_env_values = _load_live_env_values(_resolve_initial_env_paths()) + if key in live_env_values: + value = live_env_values[key] + logger.debug( + f"Environment variable retrieved from live dotenv config: " + f"key={key}, has_value={value is not None}" + ) + return value + + # Fall back to any value set programmatically after startup. + value = os.environ.get(key, default) logger.debug( f"Environment variable retrieved from global config: key={key}, " f"has_value={value is not None}, using_default={value == default}" diff --git a/backend/app/controller/chat_controller.py b/backend/app/controller/chat_controller.py index 87e42a70a..aae851d06 100644 --- a/backend/app/controller/chat_controller.py +++ b/backend/app/controller/chat_controller.py @@ -13,10 +13,11 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import asyncio +import inspect import logging import os -import re import time +from dataclasses import replace from pathlib import Path from dotenv import load_dotenv @@ -24,8 +25,9 @@ from fastapi.responses import StreamingResponse from app.component import code -from app.component.environment import sanitize_env_path, set_user_env_path +from app.component.environment import env, sanitize_env_path, set_user_env_path from app.exception.exception import UserException +from app.memory import get_memory_service from app.model.chat import ( AddTaskRequest, Chat, @@ -35,6 +37,11 @@ SupplementChat, sse_json, ) +from app.run_context import ( + RunContext, + apply_run_env_for_third_party, + stream_with_run_context, +) from app.service.chat_service import step_solve from app.service.task import ( Action, @@ -49,9 +56,22 @@ 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_endpoint, + is_cdp_url_available, + normalize_cdp_url, +) +from app.utils.cdp_browser_state import ( + clear_connected_cdp_browser_for_request, + get_connected_cdp_endpoint_for_request, +) +from app.utils.event_loop_utils import schedule_async_task_from_worker +from app.utils.workspace_paths import camel_log_root +from app.utils.workspace_resolver import get_workspace_resolver router = APIRouter() @@ -61,6 +81,177 @@ # SSE timeout configuration (60 minutes in seconds) SSE_TIMEOUT_SECONDS = 60 * 60 +# CAMEL reads this as a process-level logging toggle, not as per-run state. +os.environ.setdefault("CAMEL_MODEL_LOG_ENABLED", "true") + + +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 = ( + get_connected_cdp_endpoint_for_request(request) + or env("EIGENT_CDP_URL", "") + ).strip() + if existing_cdp_url: + is_available = await asyncio.to_thread( + is_cdp_url_available, existing_cdp_url + ) + if is_available: + normalized_endpoint, _, selected_port = normalize_cdp_url( + existing_cdp_url + ) + if request is not None: + request.state.browser_available = True + request.state.cdp_url = normalized_endpoint + request.state.browser_port = selected_port + return True + clear_connected_cdp_browser_for_request(request) + + if _is_remote_browser_hands(request): + if request is not None: + request.state.browser_available = True + request.state.cdp_url = None + request.state.browser_port = port + return True + + try: + endpoint = await asyncio.to_thread(ensure_cdp_browser_endpoint, port) + except Exception as e: + 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 + request.state.cdp_url = None + request.state.browser_port = port + return False + + if endpoint: + _, _, selected_port = normalize_cdp_url(endpoint) + if request is not None: + request.state.browser_available = True + request.state.cdp_url = endpoint + request.state.browser_port = selected_port + return True + + chat_logger.warning( + "CDP browser not available after ensure attempt", + extra={"port": port}, + ) + if request is not None: + request.state.browser_available = False + request.state.cdp_url = None + request.state.browser_port = port + return False + + +def _browser_prepare_timeout_seconds() -> float: + raw = env("BROWSER_PREPARE_TIMEOUT_SECONDS", "8") + try: + timeout = float(raw) + except (TypeError, ValueError): + return 8.0 + return timeout if timeout > 0 else 8.0 + + +async def _prepare_browser_for_request_with_timeout( + request: Request | None, + port: int, +) -> bool: + timeout = _browser_prepare_timeout_seconds() + try: + return await asyncio.wait_for( + _prepare_browser_for_request(request, port), + timeout=timeout, + ) + except TimeoutError: + chat_logger.warning( + "Timed out preparing CDP browser", + extra={"port": port, "timeout_seconds": timeout}, + ) + if request is not None: + request.state.browser_available = False + request.state.cdp_url = None + request.state.browser_port = port + return False + + +def _build_run_context( + data: Chat, + frozen_dirs, + request: Request, + camel_log: Path, +) -> RunContext: + api_base_url = data.api_url or "https://api.openai.com/v1" + browser_port = int( + getattr(request.state, "browser_port", data.browser_port) + ) + cdp_url = getattr(request.state, "cdp_url", None) + auth_header = request.headers.get("authorization") + return RunContext( + space_id=data.space_id or data.project_id, + project_id=data.project_id, + run_id=data.run_id or data.task_id, + task_id=data.task_id, + email=data.email, + user_id=str(data.user_id) if data.user_id is not None else None, + working_directory=frozen_dirs.working_directory, + task_output_root=frozen_dirs.task_output_root, + camel_log_dir=camel_log, + binding_source=frozen_dirs.binding_source, + workdir_mode=frozen_dirs.workdir_mode or data.workdir_mode, + browser_port=browser_port, + cdp_url=cdp_url, + api_key=data.api_key, + api_base_url=api_base_url, + cloud_api_key=data.api_key if data.is_cloud() else None, + server_url=data.server_url, + auth_header=auth_header, + search_config=data.search_config or {}, + extra_env={ + "baseSnapshotId": frozen_dirs.base_snapshot_id or "", + }, + ) + + +def _queue_action_from_worker(task_lock, action, description: str) -> None: + schedule_async_task_from_worker( + task_lock.put_queue(action), + timeout=5.0, + description=description, + ) + + +def _camel_log_dir( + email: str, + project_id: str, + task_id: str, + user_id: str | int | None = None, +) -> Path: + return camel_log_root(email, project_id, task_id, user_id) + async def _cleanup_task_lock_safe(task_lock, reason: str) -> bool: """Safely cleanup task lock with existence check. @@ -100,6 +291,25 @@ async def _cleanup_task_lock_safe(task_lock, reason: str) -> bool: return False +def _should_preserve_task_lock_on_cancel(task_lock) -> bool: + """Keep completed Project state alive for follow-up turns. + + The frontend closes the SSE stream after a run reaches `end`. That close is + reported to FastAPI as a cancellation, but for multi-turn Project semantics + it is not a user stop. The TaskLock carries the short-term conversation + context used by follow-up `/chat/{project_id}` requests, especially for the + single-agent harness, so completed locks with history must survive it. + """ + if not task_lock: + return False + if getattr(task_lock, "status", None) not in { + Status.done, + Status.confirming, + }: + return False + return bool(getattr(task_lock, "conversation_history", None)) + + async def timeout_stream_wrapper( stream_generator, timeout_seconds: int = SSE_TIMEOUT_SECONDS, @@ -150,6 +360,12 @@ async def timeout_stream_wrapper( chat_logger.info( "[STREAM-CANCELLED] Stream cancelled, triggering cleanup" ) + if _should_preserve_task_lock_on_cancel(task_lock): + chat_logger.info( + "[STREAM-CANCELLED] Preserving completed task lock for follow-up context", + extra={"task_id": getattr(task_lock, "id", None)}, + ) + raise if not cleanup_triggered: await _cleanup_task_lock_safe(task_lock, "CANCELLED") raise @@ -164,8 +380,14 @@ 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. + """ + # TODO(brain-auth): Phase B should derive canonical user_id from + # request.state.brain_auth, then verify/replace Chat.email before any + # workspace snapshot, artifact path, or task lock is resolved. chat_logger.info( "Starting new chat session", extra={ @@ -184,41 +406,64 @@ async def post(data: Chat, request: Request): if safe_env_path: load_dotenv(dotenv_path=safe_env_path) - os.environ["file_save_path"] = data.file_save_path() - os.environ["browser_port"] = str(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" - ) - os.environ["CAMEL_MODEL_LOG_ENABLED"] = "true" - - # Set user-specific search engine configuration if provided - if data.search_config: - for key, value in data.search_config.items(): - if value: - os.environ[key] = value - chat_logger.debug( - f"Set search config: {key}", - extra={"project_id": data.project_id}, - ) + resolver = get_workspace_resolver() + try: + frozen_dirs = resolver.freeze_task_directories(data, task_lock) + except ValueError as exc: + raise UserException(code.error, str(exc)) from exc - email_sanitized = re.sub( - r'[\\/*?:"<>|\s]', "_", data.email.split("@")[0] - ).strip(".") - camel_log = ( - Path.home() - / ".eigent" - / email_sanitized - / ("project_" + data.project_id) - / ("task_" + data.task_id) - / "camel_logs" - ) - camel_log.mkdir(parents=True, exist_ok=True) + try: + await asyncio.to_thread( + resolver.write_task_snapshot, + data.email, + frozen_dirs.snapshot, + ) + except Exception: + chat_logger.warning( + "Failed to persist task workspace snapshot", + extra={"project_id": data.project_id, "task_id": data.task_id}, + exc_info=True, + ) - os.environ["CAMEL_LOG_DIR"] = str(camel_log) + # 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: + await _prepare_browser_for_request_with_timeout( + request, data.browser_port + ) - if data.is_cloud(): - os.environ["cloud_api_key"] = data.api_key + camel_log = _camel_log_dir( + data.email, + data.project_id, + data.run_id or data.task_id, + data.user_id, + ) + camel_log.mkdir(parents=True, exist_ok=True) + run_context = _build_run_context(data, frozen_dirs, request, camel_log) + apply_run_env_for_third_party(run_context) + task_lock.run_context = run_context + + # Local memory: write Space/Project/Run scaffolding + append user prompt. + # Best-effort; MemoryService swallows write errors so chat keeps working. + memory_service = get_memory_service() + memory_mode = ( + "single_agent" if data.session_mode == "single-agent" else "workforce" + ) + memory_space_source = ( + "legacy" + if data.space_id and data.space_id.startswith("legacy_") + else ("folder" if data.space_root_path else "blank") + ) + memory_service.on_run_start( + run_context=run_context, + space_name=None, + project_name=None, + space_source_type=memory_space_source, + mode=memory_mode, + user_prompt=data.question, + prompt_source="chat", + ) + task_lock.memory_service = memory_service # Set the initial current_task_id in task_lock set_current_task_id(data.project_id, data.task_id) @@ -229,6 +474,7 @@ async def post(data: Chat, request: Request): data=ImprovePayload( question=data.question, attaches=data.attaches or [], + project_context=data.project_context, ), new_task_id=data.task_id, ) @@ -240,24 +486,64 @@ async def post(data: Chat, request: Request): "project_id": data.project_id, "task_id": data.task_id, "log_dir": str(camel_log), + "working_directory": str(frozen_dirs.working_directory), + "binding_source": frozen_dirs.binding_source, }, ) - return StreamingResponse( - timeout_stream_wrapper( - step_solve(data, request, task_lock), task_lock=task_lock + return timeout_stream_wrapper( + stream_with_run_context( + step_solve(data, request, task_lock), + lambda: getattr(task_lock, "run_context", run_context), ), + 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( + stream, media_type="text/event-stream", ) +@router.get("/chat/{project_id}/status", name="get chat status") +async def status(project_id: str): + task_lock = get_task_lock_if_exists(project_id) + if task_lock is None: + return { + "project_id": project_id, + "has_lock": False, + "status": "offline", + "current_task_id": None, + } + return { + "project_id": project_id, + "has_lock": True, + "status": task_lock.status.value, + "current_task_id": task_lock.current_task_id, + } + + @router.post("/chat/{id}", name="improve chat") -def improve(id: str, data: SupplementChat): +async 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) + # Reuse an existing endpoint when possible to avoid tearing down + # a browser that was manually connected through the Browser page. + current_context = getattr(task_lock, "run_context", None) + port = ( + current_context.browser_port + if isinstance(current_context, RunContext) + else int(env("browser_port", "9222")) + ) + await _prepare_browser_for_request_with_timeout(request, port) + # Allow continuing conversation even after task is done # This supports multi-turn conversation after complex task completion if task_lock.status == Status.done: @@ -287,39 +573,75 @@ def improve(id: str, data: SupplementChat): new_folder_path = None if data.task_id: try: - # Get current environment values needed to construct new path - current_email = None - - # Extract email from current file_save_path if available - current_file_save_path = os.environ.get("file_save_path", "") - if current_file_save_path: - path_parts = Path(current_file_save_path).parts - if len(path_parts) >= 3 and "eigent" in path_parts: - eigent_index = path_parts.index("eigent") - if eigent_index + 1 < len(path_parts): - current_email = path_parts[eigent_index + 1] + current_email = getattr(task_lock, "email", None) # If we have the necessary info, update # the file_save_path if current_email and id: - # Create new path using the existing - # pattern: email/project_{id}/task_{id} - new_folder_path = ( - Path.home() - / "eigent" - / current_email - / f"project_{id}" - / f"task_{data.task_id}" + resolver = get_workspace_resolver() + frozen_dirs = await asyncio.to_thread( + resolver.freeze_task_directories_for, + space_id=getattr(task_lock, "space_id", id), + project_id=id, + task_id=data.task_id, + email=current_email, + task_lock=task_lock, + user_id=getattr(task_lock, "user_id", None), + ) + try: + await asyncio.to_thread( + resolver.write_task_snapshot, + current_email, + frozen_dirs.snapshot, + ) + except Exception: + chat_logger.warning( + "Failed to persist task workspace snapshot", + extra={"project_id": id, "task_id": data.task_id}, + exc_info=True, + ) + new_folder_path = frozen_dirs.task_output_root + camel_log = _camel_log_dir( + current_email, + id, + data.task_id, + getattr(task_lock, "user_id", None), ) - new_folder_path.mkdir(parents=True, exist_ok=True) - os.environ["file_save_path"] = str(new_folder_path) + await asyncio.to_thread( + camel_log.mkdir, parents=True, exist_ok=True + ) + current_context = getattr(task_lock, "run_context", None) + if isinstance(current_context, RunContext): + updated_context = replace( + current_context, + run_id=data.task_id, + task_id=data.task_id, + working_directory=frozen_dirs.working_directory, + task_output_root=frozen_dirs.task_output_root, + camel_log_dir=camel_log, + binding_source=frozen_dirs.binding_source, + browser_port=int( + getattr(request.state, "browser_port", port) + ), + cdp_url=getattr( + request.state, "cdp_url", current_context.cdp_url + ), + ) + await asyncio.to_thread( + apply_run_env_for_third_party, updated_context + ) + task_lock.run_context = updated_context chat_logger.info( f"Updated file_save_path to: {new_folder_path}" ) # Store the new folder path in task_lock # for potential cleanup and persistence - task_lock.new_folder_path = new_folder_path + task_lock.new_folder_path = ( + new_folder_path + if frozen_dirs.binding_source == "default" + else None + ) else: chat_logger.warning( "Could not update" @@ -336,15 +658,67 @@ def improve(id: str, data: SupplementChat): f" {e}" ) - asyncio.run( - task_lock.put_queue( - ActionImproveData( - data=ImprovePayload( - question=data.question, - attaches=data.attaches or [], + # Local memory: this is a follow-up turn within the same Project. The + # original on_run_start ran when the chat first started; here we open a + # new Run record for the supplement turn so its conversation events are + # bound to the right run_id. + # + # Strict guard: only open a new durable Run when run_context was actually + # rotated to the supplied task_id. The workspace-rotation block above is + # wrapped in a best-effort try/except, so a missing email, a resolver + # failure, or any other swallowed exception can leave task_lock.run_context + # pointing at the previous (finalized) run id. Calling on_run_start in + # that state would reset the old run's status.json back to "running" and + # the finalize dedup set then blocks the next end-of-turn writer from + # closing it again -- leaving durable memory permanently divergent from + # the visible chat flow. + refreshed_context = getattr(task_lock, "run_context", None) + rotation_succeeded = ( + data.task_id + and isinstance(refreshed_context, RunContext) + and refreshed_context.run_id == data.task_id + ) + if rotation_succeeded: + await asyncio.to_thread( + get_memory_service().on_run_start, + run_context=refreshed_context, + space_name=None, + project_name=None, + space_source_type=( + "legacy" + if refreshed_context.space_id.startswith("legacy_") + else "blank" + ), + mode=None, # mode unchanged; preserve existing project.json value + user_prompt=data.question, + prompt_source="improve", + ) + elif data.task_id: + # The client wanted a fresh run but rotation failed upstream. Don't + # touch durable memory; the in-process turn still proceeds so the + # user gets a response, but we leave a breadcrumb for diagnosis. + chat_logger.warning( + "Skipped durable on_run_start: run_context did not rotate to" + " requested task_id", + extra={ + "project_id": id, + "requested_task_id": data.task_id, + "current_run_id": ( + refreshed_context.run_id + if isinstance(refreshed_context, RunContext) + else None ), - new_task_id=data.task_id, - ) + }, + ) + + await task_lock.put_queue( + ActionImproveData( + data=ImprovePayload( + question=data.question, + attaches=data.attaches or [], + project_context=data.project_context, + ), + new_task_id=data.task_id, ) ) chat_logger.info( @@ -360,13 +734,17 @@ def supplement(id: str, data: SupplementChat): task_lock = get_task_lock(id) if task_lock.status != Status.done: raise UserException(code.error, "Please wait task done") - asyncio.run(task_lock.put_queue(ActionSupplementData(data=data))) + _queue_action_from_worker( + task_lock, + ActionSupplementData(data=data), + "supplement task queue action", + ) chat_logger.debug("Supplement data queued", extra={"task_id": id}) return Response(status_code=201) @router.delete("/chat/{id}", name="stop chat") -def stop(id: str): +async def stop(id: str): """stop the task""" chat_logger.info("=" * 80) chat_logger.info( @@ -374,8 +752,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,32 +764,44 @@ 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: + await 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) @router.post("/chat/{id}/human-reply") -def human_reply(id: str, data: HumanReply): +async def human_reply(id: str, data: HumanReply): chat_logger.info( "Human reply received", extra={"task_id": id, "reply_length": len(data.reply)}, ) task_lock = get_task_lock(id) - asyncio.run(task_lock.put_human_input(data.agent, data.reply)) + try: + await task_lock.put_human_input(data.agent, data.reply) + except KeyError as exc: + chat_logger.warning( + "Human reply target is no longer waiting for input", + extra={"task_id": id, "agent": data.agent}, + ) + raise UserException( + code.error, + "This task is no longer waiting for a human reply. Please send a new message.", + ) from exc chat_logger.debug("Human reply processed", extra={"task_id": id}) return Response(status_code=201) @@ -426,10 +816,10 @@ def install_mcp(id: str, data: McpServers): }, ) task_lock = get_task_lock(id) - asyncio.run( - task_lock.put_queue( - ActionInstallMcpData(action=Action.install_mcp, data=data) - ) + _queue_action_from_worker( + task_lock, + ActionInstallMcpData(action=Action.install_mcp, data=data), + "install MCP queue action", ) chat_logger.info("MCP installation queued", extra={"task_id": id}) return Response(status_code=201) @@ -454,7 +844,11 @@ def add_task(id: str, data: AddTaskRequest): additional_info=data.additional_info, insert_position=data.insert_position, ) - asyncio.run(task_lock.put_queue(add_task_action)) + _queue_action_from_worker( + task_lock, + add_task_action, + "add task queue action", + ) return Response(status_code=201) except Exception as e: @@ -478,7 +872,11 @@ def remove_task(project_id: str, task_id: str): remove_task_action = ActionRemoveTaskData( task_id=task_id, project_id=project_id ) - asyncio.run(task_lock.put_queue(remove_task_action)) + _queue_action_from_worker( + task_lock, + remove_task_action, + "remove task queue action", + ) chat_logger.info( "Task removal request queued for" @@ -515,7 +913,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}," @@ -533,7 +937,11 @@ def skip_task(project_id: str): " (preserves context," " marks as done)" ) - asyncio.run(task_lock.put_queue(skip_task_action)) + _queue_action_from_worker( + task_lock, + skip_task_action, + "skip task queue action", + ) chat_logger.info( "[STOP-BUTTON] Skip request" diff --git a/backend/app/controller/file_controller.py b/backend/app/controller/file_controller.py new file mode 100644 index 000000000..c58ef0dd5 --- /dev/null +++ b/backend/app/controller/file_controller.py @@ -0,0 +1,397 @@ +# ========= 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 logging +import mimetypes +import re +import time +from functools import partial +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 starlette.concurrency import run_in_threadpool + +from app.component.environment import env +from app.utils.file_utils import list_files, resolve_under_base +from app.utils.workspace_resolver import get_workspace_resolver + +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}$") +FILE_LIST_SEMAPHORE = asyncio.Semaphore(4) +SLOW_FILE_LIST_LOG_MS = 300 + + +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())) + + +def _redacted_path_suffix(path: Path) -> str: + parts = path.parts[-2:] + return (".../" + "/".join(parts)) if parts else "..." + + +@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 _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() + 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 + + +def _resolve_file_root( + email: str, + project_id: str, + space_id: str | None = None, + user_id: str | int | None = None, +) -> Path: + if space_id: + resolver = get_workspace_resolver() + space_root = resolver.space_root( + space_id=space_id, + project_id=project_id, + email=email, + user_id=user_id, + ) + if space_root is not None: + return space_root + return _resolve_project_root(email, project_id) + + +@router.get("/files") +async def list_project_files( + project_id: str = Query(..., description="Project ID"), + email: str = Query(..., description="User email"), + space_id: str | None = Query(None, description="Optional Space ID"), + user_id: str | None = Query( + None, description="Optional canonical user ID" + ), + 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_file_root(email, project_id, space_id, user_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()) + stats: dict[str, float | int] = {} + started = time.perf_counter() + try: + async with FILE_LIST_SEMAPHORE: + paths = await run_in_threadpool( + partial( + list_files, + list_dir, + base=base_path, + max_entries=500, + stats=stats, + ) + ) + except Exception as e: + file_logger.warning("list_project_files failed: %s", e) + return [] + elapsed_ms = (time.perf_counter() - started) * 1000 + log = ( + file_logger.info + if elapsed_ms >= SLOW_FILE_LIST_LOG_MS + else file_logger.debug + ) + log( + "list_project_files: project_id=%s space_id=%s task_id=%s count=%d " + "elapsed_ms=%.1f scan_ms=%.1f realpath_ms=%.1f symlinks=%d root=%s", + project_id, + space_id, + task_id, + len(paths), + elapsed_ms, + float(stats.get("scan_elapsed_ms", 0)), + float(stats.get("realpath_elapsed_ms", 0)), + int(stats.get("symlink_count", 0)), + _redacted_path_suffix(project_root), + ) + result: list[dict] = [] + for abs_path in paths: + try: + 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( + { + "filename": Path(abs_path).name, + "url": ( + f"/files/stream?path={path_param}" + f"&project_id={quote(project_id)}" + f"&email={quote(email)}" + + (f"&space_id={quote(space_id)}" if space_id else "") + + (f"&user_id={quote(user_id)}" if user_id else "") + ), + "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"), + space_id: str | None = Query(None, description="Optional Space ID"), + user_id: str | None = Query( + None, description="Optional canonical user ID" + ), +): + """ + 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_file_root(email, project_id, space_id, user_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, + space_id: str | None = Query(None, description="Optional Space ID"), + user_id: str | None = Query( + None, description="Optional canonical user ID" + ), +): + """ + 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_file_root(email, project_id, space_id, user_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..8cdf80922 100644 --- a/backend/app/controller/health_controller.py +++ b/backend/app/controller/health_controller.py @@ -14,9 +14,13 @@ import logging -from fastapi import APIRouter +from fastapi import APIRouter, Query from pydantic import BaseModel +from app.component.environment import env +from app.router_layer.hands_resolver import get_environment_hands +from app.utils.browser_launcher import _is_cdp_available, is_cdp_url_available + logger = logging.getLogger("health_controller") router = APIRouter(tags=["Health"]) @@ -25,16 +29,35 @@ 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() + cdp_url = env("EIGENT_CDP_URL", "").strip() + if cdp_url: + cdp_reachable = is_cdp_url_available(cdp_url) + else: + try: + browser_port = int(env("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", - 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..c6ddbd925 --- /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: + raise HTTPException( + status_code=400, detail="Failed to read uploaded file" + ) + 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..ae087a9c3 100644 --- a/backend/app/controller/task_controller.py +++ b/backend/app/controller/task_controller.py @@ -12,7 +12,6 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import asyncio import logging from typing import Literal @@ -30,19 +29,44 @@ ActionTakeControl, ActionUpdateTaskData, get_task_lock, + get_task_lock_if_exists, task_locks, ) +from app.utils.event_loop_utils import schedule_async_task_from_worker logger = logging.getLogger("task_controller") router = APIRouter() +def _queue_action_from_worker(task_lock, action, description: str) -> None: + schedule_async_task_from_worker( + task_lock.put_queue(action), + timeout=5.0, + description=description, + ) + + +@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) logger.info("Starting task", extra={"task_id": id}) - asyncio.run(task_lock.put_queue(ActionStartData(action=Action.start))) + _queue_action_from_worker( + task_lock, + ActionStartData(action=Action.start), + "start task queue action", + ) logger.info("Task started successfully", extra={"task_id": id}) return Response(status_code=201) @@ -58,26 +82,45 @@ def put(id: str, data: UpdateData): extra={"task_id": id, "data": data.model_dump_json()}, ) task_lock = get_task_lock(id) - asyncio.run( - task_lock.put_queue( - ActionUpdateTaskData(action=Action.update_task, data=data) - ) + _queue_action_from_worker( + task_lock, + ActionUpdateTaskData(action=Action.update_task, data=data), + "update task queue action", ) logger.info("Task updated successfully", extra={"task_id": id}) return Response(status_code=201) 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: + _queue_action_from_worker( + task_lock, + ActionStopData(action=Action.stop), + "take-control stop queue action", + ) + else: + _queue_action_from_worker( + task_lock, + ActionTakeControl(action=data.action), + "take-control queue action", + ) logger.info( "Task control action completed", extra={"task_id": id, "action": data.action}, @@ -101,8 +144,10 @@ def add_agent(id: str, data: NewAgent): safe_env_path = sanitize_env_path(data.env_path) if safe_env_path: load_dotenv(dotenv_path=safe_env_path) - asyncio.run( - get_task_lock(id).put_queue(ActionNewAgent(**data.model_dump())) + _queue_action_from_worker( + get_task_lock(id), + ActionNewAgent(**data.model_dump()), + "add agent queue action", ) logger.info( "Agent added to task", extra={"task_id": id, "agent_name": data.name} @@ -114,6 +159,10 @@ def add_agent(id: str, data: NewAgent): def stop_all(): logger.warning("Stopping all tasks", extra={"task_count": len(task_locks)}) for task_lock in task_locks.values(): - asyncio.run(task_lock.put_queue(ActionStopData())) + _queue_action_from_worker( + task_lock, + ActionStopData(), + "stop all queue action", + ) logger.info("All tasks stopped", extra={"task_count": len(task_locks)}) return Response(status_code=204) diff --git a/backend/app/controller/tool_controller.py b/backend/app/controller/tool_controller.py index 89fbac87b..3db15abc7 100644 --- a/backend/app/controller/tool_controller.py +++ b/backend/app/controller/tool_controller.py @@ -12,18 +12,35 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import asyncio +import inspect 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_endpoint, +) +from app.utils.cdp_browser_state import ( + browser_owner_key as _browser_owner_key, + clear_connected_cdp_browser as _clear_connected_cdp_browser, + get_connected_cdp_meta, + get_connected_cdp_port as _get_connected_cdp_port, + list_connected_cdp_browsers as _list_connected_cdp_browsers, + set_connected_cdp_browser as _set_connected_cdp_browser, +) from app.utils.cookie_manager import CookieManager from app.utils.oauth_state_manager import oauth_state_manager @@ -39,6 +56,231 @@ class LinkedInTokenRequest(BaseModel): logger = logging.getLogger("tool_controller") router = APIRouter() +DEFAULT_LOGIN_BROWSER_CDP_PORT = 9323 + + +class CdpBrowserConnectRequest(BaseModel): + port: int + name: str | None = 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 _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 = 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 _release_remote_browser_if_needed( + owner_key: str, request: Request | None +) -> None: + meta = get_connected_cdp_meta(owner_key) 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(request: Request): + """List the currently connected CDP browser in web mode.""" + return _list_connected_cdp_browsers(_browser_owner_key(request)) + + +@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. + """ + owner_key = _browser_owner_key(request) + existing_browsers = _list_connected_cdp_browsers(owner_key) + 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: + logger.exception( + "Failed to acquire remote browser resource for session %s", + session_id, + ) + return { + "success": False, + "error": "Failed to acquire remote browser", + } + + browser = _set_connected_cdp_browser( + owner_key, + endpoint, + is_external=False, + resource_session_id=session_id, + managed_by="remote", + ) + return { + "success": True, + "port": browser["port"], + "browser": browser, + "endpoint": browser.get("endpoint"), + } + + 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 {DEFAULT_CDP_PORT} is already in use and is not exposing a compatible CDP browser.", + } + return { + "success": False, + "error": "Failed to launch browser. Ensure Chrome/Chromium is installed or run playwright install chromium.", + } + + browser = _set_connected_cdp_browser( + owner_key, + endpoint, + 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, request: Request +): + """ + 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( + _browser_owner_key(request), + 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. + """ + owner_key = _browser_owner_key(request) + current_port = _get_connected_cdp_port(owner_key) + 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(owner_key, request) + _clear_connected_cdp_browser(owner_key) + return {"success": True} @router.post("/install/tool/{tool}", name="install tool") @@ -661,12 +903,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 @@ -687,12 +928,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, @@ -826,15 +1062,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/controller/workspace_controller.py b/backend/app/controller/workspace_controller.py new file mode 100644 index 000000000..30b33fbba --- /dev/null +++ b/backend/app/controller/workspace_controller.py @@ -0,0 +1,456 @@ +# ========= 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 pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Query, Request +from pydantic import BaseModel + +from app.model.enums import Status +from app.router_layer.hands_resolver import get_environment_hands +from app.service.task import get_task_lock_if_exists +from app.utils.space_overlay_client import overlay_sync_failure_count +from app.utils.workspace_paths import ( + get_eigent_root, + runtime_owner_key, + sanitize_identity, +) +from app.utils.workspace_resolver import ( + _same_workspace_path, + get_workspace_resolver, +) + +router = APIRouter() +logger = logging.getLogger("workspace_controller") + + +class WorkspaceBindRequest(BaseModel): + space_id: str | None = None + project_id: str | None = None + email: str + user_id: str | int | None = None + path: str + + +class WorkspaceReconcileRequest(BaseModel): + email: str + user_id: str | int | None = None + active_space_ids: list[str] + + +class WorkspaceScratchRequest(BaseModel): + space_id: str + email: str + user_id: str | int | None = None + + +class WorkspaceProjectRefreshRequest(BaseModel): + email: str + user_id: str | int | None = None + force: bool = False + server_refresh_confirmed: bool = False + + +def _manifest_from_request(request: Request) -> dict[str, Any]: + hands = getattr(request.state, "hands", None) or get_environment_hands() + get_manifest = getattr(hands, "get_capability_manifest", None) + if get_manifest is None: + return {} + try: + manifest = get_manifest() + except Exception: + logger.warning( + "Failed to read hands capability manifest", exc_info=True + ) + return {} + return manifest if isinstance(manifest, dict) else {} + + +def _binding_enabled(manifest: dict[str, Any]) -> bool: + return manifest.get("deployment") == "local" + + +def _project_has_active_run(project_id: str) -> bool: + task_lock = get_task_lock_if_exists(project_id) + if task_lock is None: + return False + if task_lock.status != Status.done: + return True + return any(not task.done() for task in task_lock.background_tasks) + + +def _capability_payload(manifest: dict[str, Any]) -> dict[str, Any]: + binding_enabled = _binding_enabled(manifest) + return { + **manifest, + "binding_enabled": binding_enabled, + "binding_owner": "space", + "label": "Local Brain" if binding_enabled else "Cloud workspace", + "binding_persistence": "brain_local" if binding_enabled else "none", + } + + +def _status_for_reason(reason: str | None) -> int: + if reason in { + "filesystem_capability_denied", + "home_root_forbidden", + "home_resolve_failed", + "workspace_scope_denied", + }: + return 403 + if reason and reason.startswith("sensitive_path:"): + return 403 + return 400 + + +def _validate_bind_path(request: Request, path: str) -> Path: + hands = getattr(request.state, "hands", None) or get_environment_hands() + validator = getattr(hands, "validate_workspace_binding_path", None) + if validator is not None: + ok, reason = validator(path) + if not ok: + raise HTTPException( + status_code=_status_for_reason(reason), + detail={ + "code": "invalid_workspace_path", + "reason": reason or "path_not_allowed", + }, + ) + else: + can_access = getattr(hands, "can_access_filesystem", None) + if can_access is None or not can_access(path): + raise HTTPException( + status_code=403, + detail={ + "code": "invalid_workspace_path", + "reason": "filesystem_capability_denied", + }, + ) + candidate = Path(path).expanduser() + if not candidate.exists() or not candidate.is_dir(): + raise HTTPException( + status_code=400, + detail={ + "code": "invalid_workspace_path", + "reason": "path_not_directory", + }, + ) + try: + return Path(path).expanduser().resolve() + except (OSError, RuntimeError) as exc: + raise HTTPException( + status_code=400, + detail={ + "code": "invalid_workspace_path", + "reason": "path_resolve_failed", + }, + ) from exc + + +def _effective_space_id(payload: WorkspaceBindRequest) -> str: + space_id = payload.space_id or payload.project_id + if not space_id: + raise HTTPException( + status_code=422, + detail={ + "code": "space_id_required", + "message": "space_id is required for workspace binding.", + }, + ) + if not payload.space_id and payload.project_id: + logger.warning( + "Workspace bind received project_id without space_id; treating it as a legacy Space binding key", + extra={"project_id": payload.project_id}, + ) + return space_id + + +def _scratch_space_root( + email: str, space_id: str, user_id: str | int | None = None +) -> Path: + safe_space_id = sanitize_identity(space_id).removeprefix("space_") + return ( + get_eigent_root() + / runtime_owner_key(email, user_id) + / f"space_{safe_space_id or 'scratch'}" + ) + + +@router.get("/workspace/capabilities") +async def workspace_capabilities(request: Request) -> dict[str, Any]: + return _capability_payload(_manifest_from_request(request)) + + +@router.get("/workspace/diagnostics") +async def workspace_diagnostics() -> dict[str, Any]: + return { + "overlay_sync_failures": overlay_sync_failure_count(), + } + + +@router.get("/workspace/current") +async def workspace_current( + space_id: str = Query(..., description="Space ID"), + email: str = Query(..., description="User email"), + user_id: str | None = Query(None, description="Canonical user ID"), +) -> dict[str, Any]: + # TODO(brain-auth): Phase B should derive canonical user_id from + # request.state.brain_auth and treat this email only as a legacy/display key. + resolver = get_workspace_resolver() + binding = resolver.store.get_binding(email, space_id, user_id) + binding_active = ( + binding is not None + and Path(binding.workspace_root).expanduser().is_dir() + ) + return { + "space_id": space_id, + "email": email, + "user_id": user_id, + "bound": binding_active, + "workspace_root": None if binding is None else binding.workspace_root, + "binding": None if binding is None else binding.__dict__.copy(), + } + + +@router.post("/workspace/scratch") +async def workspace_scratch( + payload: WorkspaceScratchRequest, request: Request +) -> dict[str, Any]: + # Creates a Brain-owned local folder for a blank Space. This keeps + # frontend/backend separation intact: renderers ask Brain for a workspace + # root instead of relying on Electron-only filesystem IPC. + manifest = _manifest_from_request(request) + if not _binding_enabled(manifest): + raise HTTPException( + status_code=412, + detail={ + "code": "workspace_binding_disabled", + "capabilities": _capability_payload(manifest), + }, + ) + + resolver = get_workspace_resolver() + existing = resolver.store.get_binding( + payload.email, payload.space_id, payload.user_id + ) + if existing is not None: + existing_path = Path(existing.workspace_root).expanduser() + existing_path.mkdir(parents=True, exist_ok=True) + return { + "space_id": payload.space_id, + "email": payload.email, + "user_id": payload.user_id, + "bound": existing_path.is_dir(), + "workspace_root": existing.workspace_root, + "binding": existing.__dict__.copy(), + } + + root = _scratch_space_root( + payload.email, payload.space_id, payload.user_id + ).resolve() + root.mkdir(parents=True, exist_ok=True) + binding = resolver.ensure_space_binding( + payload.email, + payload.space_id, + str(root), + user_id=payload.user_id, + ) + return { + "space_id": payload.space_id, + "email": payload.email, + "user_id": payload.user_id, + "bound": True, + "workspace_root": binding.workspace_root, + "binding": binding.__dict__.copy(), + } + + +@router.post("/workspace/bind") +async def workspace_bind( + payload: WorkspaceBindRequest, request: Request +) -> dict[str, Any]: + # TODO(brain-auth): Phase B must cross-check payload.email against + # request.state.brain_auth.user_id before writing any binding mirror. + manifest = _manifest_from_request(request) + if not _binding_enabled(manifest): + raise HTTPException( + status_code=412, + detail={ + "code": "workspace_binding_disabled", + "capabilities": _capability_payload(manifest), + }, + ) + + space_id = _effective_space_id(payload) + resolver = get_workspace_resolver() + existing = resolver.store.get_binding( + payload.email, space_id, payload.user_id + ) + if existing is not None: + if _same_workspace_path(existing.workspace_root, payload.path): + return { + "space_id": space_id, + "email": payload.email, + "user_id": payload.user_id, + "bound": Path(existing.workspace_root).expanduser().is_dir(), + "workspace_root": existing.workspace_root, + "binding": existing.__dict__.copy(), + } + raise HTTPException( + status_code=409, + detail={ + "code": "workspace_already_bound", + "message": ( + "This Space already has a workspace folder. " + "Create a new Space to select another folder." + ), + }, + ) + + resolved = _validate_bind_path(request, payload.path) + + conflict = next( + ( + b + for b in resolver.store.list_bindings( + payload.email, payload.user_id + ) + if b.space_id != space_id + and _same_workspace_path(b.workspace_root, str(resolved)) + ), + None, + ) + if conflict is not None: + raise HTTPException( + status_code=409, + detail={ + "code": "folder_already_bound_to_other_space", + "other_space_id": conflict.space_id, + "workspace_root": conflict.workspace_root, + "message": ( + "This folder is already used by another Space. " + "Open that Space to continue, or pick a different folder." + ), + }, + ) + + binding = resolver.ensure_space_binding( + payload.email, + space_id, + str(resolved), + user_id=payload.user_id, + ) + return { + "space_id": space_id, + "email": payload.email, + "user_id": payload.user_id, + "bound": True, + "workspace_root": binding.workspace_root, + "binding": binding.__dict__.copy(), + } + + +@router.delete("/workspace/{space_id}") +async def workspace_unbind( + space_id: str, + email: str = Query(..., description="User email"), + user_id: str | None = Query(None, description="Canonical user ID"), +) -> dict[str, Any]: + # TODO(brain-auth): Phase B must derive the binding owner from + # request.state.brain_auth.user_id instead of trusting the email query. + resolver = get_workspace_resolver() + resolver.store.delete_binding(email, space_id, user_id) + return { + "space_id": space_id, + "email": email, + "user_id": user_id, + "bound": False, + "workspace_root": None, + "binding": None, + } + + +@router.post("/workspace/reconcile") +async def workspace_reconcile( + payload: WorkspaceReconcileRequest, +) -> dict[str, Any]: + # TODO(brain-auth): Phase B should enforce auth-context ownership here + # first; reconcile is destructive and must not trust payload.email. + resolver = get_workspace_resolver() + removed = resolver.store.reconcile_bindings( + payload.email, + set(payload.active_space_ids), + payload.user_id, + ) + return { + "email": payload.email, + "user_id": payload.user_id, + "active_space_ids": payload.active_space_ids, + "removed_space_ids": [binding.space_id for binding in removed], + "removed_count": len(removed), + } + + +@router.post("/workspace/{space_id}/projects/{project_id}/refresh") +async def workspace_project_refresh( + space_id: str, + project_id: str, + payload: WorkspaceProjectRefreshRequest, +) -> dict[str, Any]: + # TODO(brain-auth): Phase B must derive the binding owner from + # request.state.brain_auth.user_id instead of trusting payload.email. + # TODO(cloud-brain): replace the client-provided boolean with a short-lived + # server-signed refresh token that Brain verifies before deleting workdirs. + if not payload.server_refresh_confirmed: + raise HTTPException( + status_code=409, + detail={ + "code": "server_refresh_precondition_required", + "message": ( + "Brain workdir refresh must be called only after the " + "control server has checked pending overlays." + ), + }, + ) + if _project_has_active_run(project_id): + raise HTTPException( + status_code=409, + detail={ + "code": "project_running", + "message": "Project workdir cannot be refreshed while a run is active.", + }, + ) + resolver = get_workspace_resolver() + try: + base_snapshot_id = resolver.refresh_project_workdir( + space_id=space_id, + project_id=project_id, + email=payload.email, + user_id=payload.user_id, + ) + except ValueError as exc: + raise HTTPException( + status_code=409, + detail={ + "code": "workspace_refresh_failed", + "message": str(exc), + }, + ) from exc + return { + "space_id": space_id, + "project_id": project_id, + "base_snapshot_id": base_snapshot_id, + } 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..82b4a276a --- /dev/null +++ b/backend/app/hands/capabilities.py @@ -0,0 +1,244 @@ +# ========= 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 env("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: Path = field( + default_factory=lambda: Path("~/.eigent/workspace").expanduser() + ) + """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=Path( + env("EIGENT_WORKSPACE", "~/.eigent/workspace") + ).expanduser(), + 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=Path( + env("EIGENT_WORKSPACE", "~/.eigent/workspace") + ).expanduser(), + 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=Path( + env("EIGENT_WORKSPACE", "~/.eigent/workspace") + ).expanduser(), + 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..002fea120 --- /dev/null +++ b/backend/app/hands/sandbox_hands.py @@ -0,0 +1,89 @@ +# ========= 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 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/memory/__init__.py b/backend/app/memory/__init__.py new file mode 100644 index 000000000..08063a3b8 --- /dev/null +++ b/backend/app/memory/__init__.py @@ -0,0 +1,80 @@ +# ========= 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. ========= + +"""Local-first Project memory (see docs/core/space-project-memory-local-first-design.md). + +Provides schema dataclasses, path helpers, LocalMemoryStore, context assembly, +and best-effort runtime lifecycle hooks for chat_controller, Single Agent, and +Workforce. +""" + +from app.memory.context_builder import ( + AgentContextBundle, + ContextMode, + ProjectContextBuilder, +) +from app.memory.events import ( + SCHEMA_VERSION, + ConversationEvent, + MemoryArtifact, + MemoryFact, + ProjectMemory, + RunMemory, + RunStatus, + SpaceMemory, + SyncSettings, + ToolEvent, +) +from app.memory.local_store import LocalMemoryStore +from app.memory.paths import ( + canonical_user_id, + memory_root, + project_dir, + run_dir, + space_dir, + user_dir, +) +from app.memory.service import ( + MemoryService, + build_durable_context_for_task_lock, + finalize_task_lock_run_memory, + get_memory_service, +) + +__all__ = [ + "SCHEMA_VERSION", + "AgentContextBundle", + "ContextMode", + "ConversationEvent", + "LocalMemoryStore", + "MemoryArtifact", + "MemoryFact", + "MemoryService", + "ProjectContextBuilder", + "ProjectMemory", + "RunMemory", + "RunStatus", + "SpaceMemory", + "SyncSettings", + "ToolEvent", + "build_durable_context_for_task_lock", + "canonical_user_id", + "finalize_task_lock_run_memory", + "get_memory_service", + "memory_root", + "project_dir", + "run_dir", + "space_dir", + "user_dir", +] diff --git a/backend/app/memory/context_builder.py b/backend/app/memory/context_builder.py new file mode 100644 index 000000000..7734dee7f --- /dev/null +++ b/backend/app/memory/context_builder.py @@ -0,0 +1,382 @@ +# ========= 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. ========= + +"""ProjectContextBuilder — assemble durable context for the agent (§8 design). + +The runtime entry point is `build(...)` which returns an `AgentContextBundle`. +Callers usually do not consume the bundle directly; they call +`bundle.to_prompt(mode)` to get a string ready to splice into the system +prompt. Keeping rendering separate from data lets future callers (audit, +diagnostics, alternative model formats) reuse the same fetched payload. + +Token budget is a rough char-based proxy: 1 token ~= 4 chars matches what +chatStore.ts uses on the frontend side. Section weights are tuned so the most +important continuity signal -- recent assistant/user turns -- gets the +majority share. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +from app.memory.events import ConversationEvent, MemoryArtifact, MemoryFact +from app.memory.local_store import LocalMemoryStore + +# Section weights when allocating a token budget. They sum to ~1.0 plus a +# small slack so the final assembled prompt rarely overshoots. +_HEADER_WEIGHT = 0.20 # space + project summary + facts overview +_RECENT_CONVO_WEIGHT = 0.65 # most recent conversation tail +_ARTIFACTS_WEIGHT = 0.10 +_TODOS_WEIGHT = 0.05 + +# Hard caps to keep any one section from dominating regardless of budget. +_MAX_RECENT_CONVO_EVENTS = 24 + +ContextMode = Literal[ + "single_agent", + "workforce_coordinator", + "workforce_worker", +] + + +def _chars_for(budget_tokens: int, weight: float) -> int: + return max(0, int(budget_tokens * 4 * weight)) + + +def _truncate(text: str, max_chars: int, ellipsis: str = "...") -> str: + if max_chars <= 0: + return "" + if len(text) <= max_chars: + return text + return text[: max(0, max_chars - len(ellipsis))] + ellipsis + + +@dataclass +class AgentContextBundle: + """Mode-agnostic context payload assembled from the local memory store.""" + + space_name: str + space_summary: str + project_name: str + project_summary: str + recent_conversation: list[ConversationEvent] = field(default_factory=list) + relevant_facts: list[MemoryFact] = field(default_factory=list) + relevant_artifacts: list[MemoryArtifact] = field(default_factory=list) + open_todos: list[str] = field(default_factory=list) + current_run_instruction: str = "" + + def is_empty(self) -> bool: + """True when the bundle has no durable signal to inject. + + Callers use this to decide whether to fall back to the legacy + `project_context` bridge. + """ + + return ( + not self.space_summary + and not self.project_summary + and not self.recent_conversation + and not self.relevant_facts + and not self.relevant_artifacts + and not self.open_todos + ) + + def to_prompt(self, mode: ContextMode) -> str: + """Render the bundle into a string ready to splice into a system prompt.""" + + if mode == "single_agent": + return self._render_single_agent() + if mode == "workforce_coordinator": + return self._render_workforce_coordinator() + if mode == "workforce_worker": + return self._render_workforce_worker() + return self._render_single_agent() + + # ----- Renderers ----- + + def _render_single_agent(self) -> str: + # §9 single agent profile: continuous narrative. + sections: list[str] = [] + sections.append("=== Persisted Project Context ===") + if self.space_name or self.space_summary: + sections.append( + _section("Space", self.space_name, self.space_summary) + ) + if self.project_name or self.project_summary: + sections.append( + _section("Project", self.project_name, self.project_summary) + ) + if self.relevant_facts: + lines = ["Known facts:"] + for fact in self.relevant_facts: + lines.append(f"- {fact.text}") + sections.append("\n".join(lines)) + if self.relevant_artifacts: + lines = ["Relevant artifacts:"] + for art in self.relevant_artifacts: + lines.append(f"- {art.path} ({art.kind})") + sections.append("\n".join(lines)) + if self.recent_conversation: + lines = ["Recent conversation:"] + for event in self.recent_conversation: + tag = event.role.capitalize() + lines.append(f"{tag}: {event.content}") + sections.append("\n".join(lines)) + if self.open_todos: + lines = ["Open todos:"] + for todo in self.open_todos: + lines.append(f"- {todo}") + sections.append("\n".join(lines)) + if self.current_run_instruction: + sections.append( + _section("Current turn", "", self.current_run_instruction) + ) + sections.append("=== End Persisted Project Context ===") + return "\n\n".join(s for s in sections if s) + + def _render_workforce_coordinator(self) -> str: + # §10 coordinator profile: planning view. Minimal first-cut -- + # full per-role split is a follow-up branch (M6+). + return self._render_single_agent() + + def _render_workforce_worker(self) -> str: + # §10 worker profile: only assignment + narrow facts. Skeleton until + # the workforce milestone wires assignments through. + sections: list[str] = ["=== Worker Assignment ==="] + if self.current_run_instruction: + sections.append(self.current_run_instruction) + if self.relevant_artifacts: + lines = ["Relevant artifacts:"] + for art in self.relevant_artifacts: + lines.append(f"- {art.path} ({art.kind})") + sections.append("\n".join(lines)) + if self.relevant_facts: + lines = ["Narrow facts:"] + for fact in self.relevant_facts: + lines.append(f"- {fact.text}") + sections.append("\n".join(lines)) + sections.append("=== End Worker Assignment ===") + return "\n\n".join(sections) + + +def _section(label: str, name: str, body: str) -> str: + title = f"{label}: {name}".strip(": ").rstrip() if name else label + body = body.strip() + if not body: + return "" + return f"{title}\n{body}" + + +class ProjectContextBuilder: + """Reads LocalMemoryStore + assembles a budgeted AgentContextBundle.""" + + def __init__(self, store: LocalMemoryStore) -> None: + self._store = store + + def build( + self, + *, + user_key: str, + space_id: str, + project_id: str, + run_id: str, + mode: ContextMode, + token_budget: int, + current_user_prompt: str, + ) -> AgentContextBundle: + """Return a bundle sized to roughly `token_budget` tokens. + + `run_id` is accepted for future filtering (e.g. exclude the in-flight + run's own user prompt from recent_conversation) but is otherwise + informational in this milestone. + """ + + space = self._store.read_space(user_key, space_id) + project = self._store.read_project(user_key, space_id, project_id) + project_summary_raw = self._store.read_project_summary( + user_key, space_id, project_id + ) + facts_raw = self._store.read_facts(user_key, space_id, project_id) + artifacts_raw = self._store.read_artifacts( + user_key, space_id, project_id + ) + recent_conv_raw = self._store.read_conversation_tail( + user_key, space_id, project_id, limit=_MAX_RECENT_CONVO_EVENTS + ) + + # Drop debug_only / audit_only events from the context view -- only + # `context`-visibility turns may show up in prompts. + recent_conv_raw = [ + event + for event in recent_conv_raw + if event.visibility == "context" + and event.run_id + != run_id # exclude the in-flight run's own prompt + ] + + # Drop runtime-log artifacts; only context-eligible ones make it in. + artifacts_in_context = [ + art for art in artifacts_raw if art.eligible_for_context + ] + + header_budget = _chars_for(token_budget, _HEADER_WEIGHT) + convo_budget = _chars_for(token_budget, _RECENT_CONVO_WEIGHT) + artifacts_budget = _chars_for(token_budget, _ARTIFACTS_WEIGHT) + todos_budget = _chars_for(token_budget, _TODOS_WEIGHT) + + # Header: split between space + project summary roughly evenly. + space_summary = _truncate( + "", # space-level summary not authored yet in this milestone + header_budget // 2, + ) + project_summary = _truncate(project_summary_raw, header_budget // 2) + + # Recent conversation, fit newest-first. + trimmed_recent = self._fit_conversation_to_budget( + recent_conv_raw, convo_budget + ) + + # Facts: take top-confidence, char-trim by artifacts/todos budget. + relevant_facts = self._top_facts(facts_raw, todos_budget) + + relevant_artifacts = self._top_artifacts( + artifacts_in_context, artifacts_budget + ) + + bundle = AgentContextBundle( + space_name=space.name if space is not None else space_id, + space_summary=space_summary, + project_name=project.name if project is not None else project_id, + project_summary=project_summary, + recent_conversation=trimmed_recent, + relevant_facts=relevant_facts, + relevant_artifacts=relevant_artifacts, + open_todos=[], # todos pipeline not in M3; reserved for follow-up + current_run_instruction=current_user_prompt.strip(), + ) + return bundle + + # ----- Section selectors ----- + + @staticmethod + def _fit_conversation_to_budget( + events: list[ConversationEvent], char_budget: int + ) -> list[ConversationEvent]: + if char_budget <= 0 or not events: + return [] + # Walk newest-first, keep events until budget exhausted, then flip + # back so the prompt reads oldest-first. + framing_overhead = 16 # "Role: " etc. framing per event in the prompt + truncation_marker = "\n... [truncated to fit context budget]" + kept: list[ConversationEvent] = [] + used = 0 + for event in reversed(events): + cost = len(event.content) + framing_overhead + remaining = char_budget - used + if used + cost <= char_budget: + kept.append(event) + used += cost + continue + if kept: + # Budget exhausted; older events are dropped. + break + # The single newest event is larger than the entire section + # budget. Truncate it instead of injecting it whole, otherwise + # one oversized final_result (HTML report, CSV dump) blows past + # the prompt token budget the caller asked us to honor. + allowed_content = ( + remaining - framing_overhead - len(truncation_marker) + ) + if allowed_content <= 0: + return [] + truncated = ConversationEvent( + event_id=event.event_id, + run_id=event.run_id, + timestamp=event.timestamp, + role=event.role, + content=event.content[:allowed_content] + truncation_marker, + source=event.source, + visibility=event.visibility, + hash=event.hash, + ) + kept.append(truncated) + break + kept.reverse() + return kept + + @staticmethod + def _top_facts( + facts: list[MemoryFact], char_budget: int + ) -> list[MemoryFact]: + if char_budget <= 0 or not facts: + return [] + framing = 4 # "- " prefix + newline + marker = "... [truncated]" + ranked = sorted(facts, key=lambda f: f.confidence, reverse=True) + kept: list[MemoryFact] = [] + used = 0 + for fact in ranked: + cost = len(fact.text) + framing + remaining = char_budget - used + if used + cost <= char_budget: + kept.append(fact) + used += cost + continue + if kept: + break + # The top-ranked fact alone exceeds the budget. Truncate the text + # in a copy rather than injecting the whole thing -- future + # Memory Toolkit writers can produce arbitrarily long fact bodies. + allowed = remaining - framing - len(marker) + if allowed <= 0: + return [] + from dataclasses import replace as _replace + + kept.append(_replace(fact, text=fact.text[:allowed] + marker)) + break + return kept + + @staticmethod + def _top_artifacts( + artifacts: list[MemoryArtifact], char_budget: int + ) -> list[MemoryArtifact]: + if char_budget <= 0 or not artifacts: + return [] + framing = 8 # "- " + " (kind)" overhead + marker = "... [truncated]" + # Most recent first; created_at is ISO string so lex sort works. + ranked = sorted(artifacts, key=lambda a: a.created_at, reverse=True) + kept: list[MemoryArtifact] = [] + used = 0 + for art in ranked: + cost = len(art.path) + len(art.kind) + framing + remaining = char_budget - used + if used + cost <= char_budget: + kept.append(art) + used += cost + continue + if kept: + break + # Single oversized artifact path -- truncate the path so the + # bundle never blows past the section budget even on pathological + # inputs. + allowed = remaining - framing - len(art.kind) - len(marker) + if allowed <= 0: + return [] + from dataclasses import replace as _replace + + kept.append(_replace(art, path=art.path[:allowed] + marker)) + break + return kept diff --git a/backend/app/memory/events.py b/backend/app/memory/events.py new file mode 100644 index 000000000..e2afe0c67 --- /dev/null +++ b/backend/app/memory/events.py @@ -0,0 +1,153 @@ +# ========= 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. ========= + +"""Memory schema dataclasses (§6 of space-project-memory-local-first-design). + +All payloads are frozen and JSON-serializable via dataclasses.asdict. The +LocalMemoryStore round-trips by calling cls(**payload) on each read; for that +reason every dataclass keeps a flat shape (no nested dataclasses) and the +Literal fields are validated at construction only via type hints, not runtime +guards. The store treats any unrecognised payload as "schema drift" and either +ignores extra keys or raises -- see LocalMemoryStore for the policy. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + +SCHEMA_VERSION: int = 1 + +# ----- Event records (append-only lines in *.jsonl) ----- + + +@dataclass(frozen=True) +class ConversationEvent: + """One human/assistant/system turn appended to Project conversation.jsonl.""" + + event_id: str + run_id: str + timestamp: str # ISO 8601 UTC + role: Literal["user", "assistant", "system"] + content: str + source: Literal["chat", "trigger", "improve", "imported"] + visibility: Literal["context", "audit_only", "debug_only"] + hash: str # sha256: + + +@dataclass(frozen=True) +class ToolEvent: + """One tool invocation appended to Run tool_events.jsonl.""" + + event_id: str + run_id: str + timestamp: str + tool_name: str + arguments: dict[str, Any] + result_summary: str + visibility: Literal["context", "audit_only", "debug_only"] + + +# ----- Project / Space level records (small JSON files, full rewrite) ----- + + +@dataclass(frozen=True) +class MemoryFact: + fact_id: str + text: str + scope: Literal["space", "project"] + source_event_ids: list[str] + confidence: float + created_at: str + updated_at: str + + +@dataclass(frozen=True) +class MemoryArtifact: + artifact_id: str + run_id: str + path: str # relative to project root (e.g. "task_/report.html") + kind: Literal[ + "html_report", + "csv", + "screenshot", + "source_change", + "runtime_log", + "other", + ] + visible_to_user: bool + eligible_for_context: bool + hash: str # sha256: or empty when unavailable + created_at: str + + +@dataclass(frozen=True) +class RunStatus: + run_id: str + state: Literal["running", "done", "failed", "cancelled"] + started_at: str + ended_at: str | None + last_error: str | None + + +# ----- Root JSON shapes (§6 of design doc) ----- + + +@dataclass(frozen=True) +class SyncSettings: + """Cloud sync settings (§12). Always written; defaults keep things local.""" + + enabled: bool = False + scope: Literal[ + "local_only", "metadata_only", "summary_only", "full_memory" + ] = "local_only" + + +@dataclass(frozen=True) +class SpaceMemory: + space_id: str + user_id: str + name: str + source_type: Literal["folder", "blank", "legacy"] + created_at: str + updated_at: str + root_fingerprint: str | None = None + sync: SyncSettings = field(default_factory=SyncSettings) + schema_version: int = SCHEMA_VERSION + + +@dataclass(frozen=True) +class ProjectMemory: + project_id: str + space_id: str + name: str + created_at: str + updated_at: str + mode: Literal["single_agent", "workforce"] | None = None + last_run_id: str | None = None + sync: SyncSettings = field(default_factory=SyncSettings) + schema_version: int = SCHEMA_VERSION + + +@dataclass(frozen=True) +class RunMemory: + """Per-run header. Tool events / conversation live in sibling *.jsonl files.""" + + run_id: str + project_id: str + space_id: str + mode: Literal["single_agent", "workforce"] | None + user_prompt: str + started_at: str + schema_version: int = SCHEMA_VERSION diff --git a/backend/app/memory/local_store.py b/backend/app/memory/local_store.py new file mode 100644 index 000000000..7e356b1b4 --- /dev/null +++ b/backend/app/memory/local_store.py @@ -0,0 +1,504 @@ +# ========= 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. ========= + +"""Local filesystem-backed memory store (§22.1 of design doc). + +Owns ~/.eigent/memory/users//... layout. All small JSON files are +written atomically (tmp + os.replace). Append-only *.jsonl files are protected +by a per-path threading.Lock so concurrent writers in the same Brain process +do not interleave lines (cross-process writes are out of scope -- single Brain +per user is the design invariant). + +Reads tolerate missing files: return None for `read_project`/`read_space`, +return [] for list-returning helpers. First-time callers do not need a +special "init" step -- writes auto-create parent directories. +""" + +from __future__ import annotations + +import json +import logging +import os +import tempfile +import threading +import weakref +from dataclasses import asdict, fields, is_dataclass +from pathlib import Path +from typing import Any, TypeVar + +from app.memory.events import ( + ConversationEvent, + MemoryArtifact, + MemoryFact, + ProjectMemory, + RunMemory, + RunStatus, + SpaceMemory, + SyncSettings, + ToolEvent, +) +from app.memory.paths import ( + memory_root, + project_dir, + run_dir, + space_dir, + user_dir, +) + +logger = logging.getLogger("memory.local_store") + +T = TypeVar("T") + +# Per-path locks so two coroutines / threads cannot interleave lines in the +# same jsonl file. WeakValueDictionary so unused locks GC after their last +# holder releases. +_PATH_LOCKS: weakref.WeakValueDictionary[str, threading.Lock] = ( + weakref.WeakValueDictionary() +) +_PATH_LOCKS_GUARD = threading.Lock() + + +def _path_lock(path: Path) -> threading.Lock: + key = str(path) + with _PATH_LOCKS_GUARD: + lock = _PATH_LOCKS.get(key) + if lock is None: + lock = threading.Lock() + _PATH_LOCKS[key] = lock + return lock + + +def _atomic_write_text(path: Path, text: str) -> None: + """Write `text` to `path` atomically. Parent dirs created on demand.""" + + path.parent.mkdir(parents=True, exist_ok=True) + # delete=False so we control the rename; suffix keeps inspection easy if + # the process crashes between flush and replace. + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=str(path.parent), + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as tmp: + tmp.write(text) + tmp.flush() + os.fsync(tmp.fileno()) + tmp_name = tmp.name + os.replace(tmp_name, path) + + +def _atomic_write_json(path: Path, payload: Any) -> None: + _atomic_write_text(path, json.dumps(payload, indent=2, ensure_ascii=False)) + + +def _read_json(path: Path) -> Any | None: + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + return None + except OSError: + logger.warning( + "memory.local_store: failed to read %s", path, exc_info=True + ) + return None + if not text.strip(): + return None + try: + return json.loads(text) + except json.JSONDecodeError: + logger.warning( + "memory.local_store: malformed JSON at %s; treating as missing", + path, + ) + return None + + +def _filter_known_fields( + cls: type[T], payload: dict[str, Any] +) -> dict[str, Any]: + """Drop unknown keys before instantiating a dataclass. + + Lets us evolve the schema by adding fields without rejecting older files. + Unknown keys are dropped silently; missing required fields raise from the + dataclass constructor (caller handles). + """ + + if not is_dataclass(cls): + return payload + known = {f.name for f in fields(cls)} + return {k: v for k, v in payload.items() if k in known} + + +def _from_dataclass_payload(cls: type[T], payload: Any) -> T | None: + if not isinstance(payload, dict): + return None + cleaned = _filter_known_fields(cls, payload) + # Nested SyncSettings on SpaceMemory / ProjectMemory + if cls in (SpaceMemory, ProjectMemory) and isinstance( + cleaned.get("sync"), dict + ): + cleaned["sync"] = SyncSettings( + **_filter_known_fields(SyncSettings, cleaned["sync"]) + ) + try: + return cls(**cleaned) + except TypeError: + logger.warning( + "memory.local_store: payload at incompatible shape for %s; ignoring", + cls.__name__, + ) + return None + + +def _to_dataclass_dict(value: Any) -> Any: + """asdict() variant that handles our top-level dataclasses safely.""" + + if is_dataclass(value): + return asdict(value) + return value + + +def _append_jsonl(path: Path, payload: Any) -> None: + """Append one JSON line to `path`, atomic-per-line under a per-path lock.""" + + path.parent.mkdir(parents=True, exist_ok=True) + line = json.dumps(payload, ensure_ascii=False) + "\n" + with _path_lock(path): + with path.open("a", encoding="utf-8") as fh: + fh.write(line) + fh.flush() + os.fsync(fh.fileno()) + + +def _read_jsonl_lines(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + out: list[dict[str, Any]] = [] + try: + with path.open("r", encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except json.JSONDecodeError: + logger.warning( + "memory.local_store: skipping malformed jsonl line in %s", + path, + ) + except OSError: + logger.warning( + "memory.local_store: failed to read %s", path, exc_info=True + ) + return [] + return out + + +class LocalMemoryStore: + """Filesystem-backed Project memory store. + + Construction is cheap and does NOT touch the filesystem -- writes lazily + create the directory tree on demand. Pass a custom `root` to redirect the + whole tree for tests; default is `~/.eigent/memory`. + """ + + def __init__(self, root: Path | None = None) -> None: + self._root = root or memory_root() + + @property + def root(self) -> Path: + return self._root + + # ----- Path helpers (exposed for tests / debug logging) ----- + + def user_path(self, user_key: str) -> Path: + return user_dir(user_key, self._root) + + def space_path(self, user_key: str, space_id: str) -> Path: + return space_dir(user_key, space_id, self._root) + + def project_path( + self, user_key: str, space_id: str, project_id: str + ) -> Path: + return project_dir(user_key, space_id, project_id, self._root) + + def run_path( + self, + user_key: str, + space_id: str, + project_id: str, + run_id: str, + ) -> Path: + return run_dir(user_key, space_id, project_id, run_id, self._root) + + # ----- Space-level ----- + + def read_space(self, user_key: str, space_id: str) -> SpaceMemory | None: + payload = _read_json( + self.space_path(user_key, space_id) / "space.json" + ) + return _from_dataclass_payload(SpaceMemory, payload) + + def write_space(self, user_key: str, payload: SpaceMemory) -> None: + target = self.space_path(user_key, payload.space_id) / "space.json" + _atomic_write_json(target, _to_dataclass_dict(payload)) + + # ----- Project-level ----- + + def read_project( + self, user_key: str, space_id: str, project_id: str + ) -> ProjectMemory | None: + payload = _read_json( + self.project_path(user_key, space_id, project_id) / "project.json" + ) + return _from_dataclass_payload(ProjectMemory, payload) + + def write_project(self, user_key: str, payload: ProjectMemory) -> None: + target = ( + self.project_path(user_key, payload.space_id, payload.project_id) + / "project.json" + ) + _atomic_write_json(target, _to_dataclass_dict(payload)) + + def append_conversation( + self, + user_key: str, + space_id: str, + project_id: str, + event: ConversationEvent, + ) -> None: + target = ( + self.project_path(user_key, space_id, project_id) + / "conversation.jsonl" + ) + _append_jsonl(target, asdict(event)) + + def read_conversation_tail( + self, + user_key: str, + space_id: str, + project_id: str, + limit: int, + ) -> list[ConversationEvent]: + if limit <= 0: + return [] + target = ( + self.project_path(user_key, space_id, project_id) + / "conversation.jsonl" + ) + rows = _read_jsonl_lines(target) + if not rows: + return [] + tail = rows[-limit:] + out: list[ConversationEvent] = [] + for row in tail: + event = _from_dataclass_payload(ConversationEvent, row) + if event is not None: + out.append(event) + return out + + def read_project_summary( + self, user_key: str, space_id: str, project_id: str + ) -> str: + path = self.project_path(user_key, space_id, project_id) / "summary.md" + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + return "" + except OSError: + logger.warning( + "memory.local_store: failed to read project summary %s", + path, + exc_info=True, + ) + return "" + + def write_project_summary( + self, + user_key: str, + space_id: str, + project_id: str, + text: str, + ) -> None: + path = self.project_path(user_key, space_id, project_id) / "summary.md" + _atomic_write_text(path, text) + + def read_facts( + self, user_key: str, space_id: str, project_id: str + ) -> list[MemoryFact]: + payload = _read_json( + self.project_path(user_key, space_id, project_id) / "facts.json" + ) + if not isinstance(payload, dict): + return [] + rows = payload.get("facts", []) + out: list[MemoryFact] = [] + for row in rows if isinstance(rows, list) else []: + fact = _from_dataclass_payload(MemoryFact, row) + if fact is not None: + out.append(fact) + return out + + def upsert_fact( + self, + user_key: str, + space_id: str, + project_id: str, + fact: MemoryFact, + ) -> None: + path = self.project_path(user_key, space_id, project_id) / "facts.json" + with _path_lock(path): + payload = _read_json(path) + rows = ( + payload.get("facts", []) if isinstance(payload, dict) else [] + ) + existing: list[MemoryFact] = [] + for row in rows if isinstance(rows, list) else []: + existing_fact = _from_dataclass_payload(MemoryFact, row) + if existing_fact is not None: + existing.append(existing_fact) + by_id = {f.fact_id: f for f in existing} + by_id[fact.fact_id] = fact + _atomic_write_json( + path, {"facts": [asdict(f) for f in by_id.values()]} + ) + + def read_artifacts( + self, user_key: str, space_id: str, project_id: str + ) -> list[MemoryArtifact]: + payload = _read_json( + self.project_path(user_key, space_id, project_id) + / "artifacts.json" + ) + if not isinstance(payload, dict): + return [] + rows = payload.get("artifacts", []) + out: list[MemoryArtifact] = [] + for row in rows if isinstance(rows, list) else []: + artifact = _from_dataclass_payload(MemoryArtifact, row) + if artifact is not None: + out.append(artifact) + return out + + def upsert_artifact( + self, + user_key: str, + space_id: str, + project_id: str, + artifact: MemoryArtifact, + ) -> None: + path = ( + self.project_path(user_key, space_id, project_id) + / "artifacts.json" + ) + with _path_lock(path): + payload = _read_json(path) + rows = ( + payload.get("artifacts", []) + if isinstance(payload, dict) + else [] + ) + existing: list[MemoryArtifact] = [] + for row in rows if isinstance(rows, list) else []: + existing_artifact = _from_dataclass_payload( + MemoryArtifact, row + ) + if existing_artifact is not None: + existing.append(existing_artifact) + by_id = {a.artifact_id: a for a in existing} + by_id[artifact.artifact_id] = artifact + _atomic_write_json( + path, {"artifacts": [asdict(a) for a in by_id.values()]} + ) + + # ----- Run-level ----- + + def write_run(self, user_key: str, payload: RunMemory) -> None: + target = ( + self.run_path( + user_key, payload.space_id, payload.project_id, payload.run_id + ) + / "run.json" + ) + _atomic_write_json(target, _to_dataclass_dict(payload)) + + def read_run( + self, + user_key: str, + space_id: str, + project_id: str, + run_id: str, + ) -> RunMemory | None: + payload = _read_json( + self.run_path(user_key, space_id, project_id, run_id) / "run.json" + ) + return _from_dataclass_payload(RunMemory, payload) + + def append_tool_event( + self, + user_key: str, + space_id: str, + project_id: str, + run_id: str, + event: ToolEvent, + ) -> None: + target = ( + self.run_path(user_key, space_id, project_id, run_id) + / "tool_events.jsonl" + ) + _append_jsonl(target, asdict(event)) + + def write_run_status( + self, + user_key: str, + space_id: str, + project_id: str, + run_id: str, + status: RunStatus, + ) -> None: + target = ( + self.run_path(user_key, space_id, project_id, run_id) + / "status.json" + ) + _atomic_write_json(target, _to_dataclass_dict(status)) + + def read_run_status( + self, + user_key: str, + space_id: str, + project_id: str, + run_id: str, + ) -> RunStatus | None: + payload = _read_json( + self.run_path(user_key, space_id, project_id, run_id) + / "status.json" + ) + return _from_dataclass_payload(RunStatus, payload) + + def write_run_summary( + self, + user_key: str, + space_id: str, + project_id: str, + run_id: str, + text: str, + ) -> None: + path = ( + self.run_path(user_key, space_id, project_id, run_id) + / "summary.md" + ) + _atomic_write_text(path, text) diff --git a/backend/app/memory/paths.py b/backend/app/memory/paths.py new file mode 100644 index 000000000..1e89322ad --- /dev/null +++ b/backend/app/memory/paths.py @@ -0,0 +1,96 @@ +# ========= 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. ========= + +"""Path resolution for the local memory tree (§5, §23 of the design doc). + +Layout: + + ~/.eigent/memory/ + users/{canonical_user_id}/ + spaces/{space_id}/ + projects/{project_id}/ + runs/{run_id}/ + +`canonical_user_id` must match the on-disk owner key used by +`Chat.file_save_path`, otherwise memory and artifacts would land under +different roots for the same user. The helper here intentionally mirrors that +derivation rule and is the single place to change if the owner-key policy +changes. +""" + +from __future__ import annotations + +import re +from pathlib import Path + + +def memory_root() -> Path: + """Top-level memory directory. Not created on import (lazy).""" + + return Path.home() / ".eigent" / "memory" + + +def canonical_user_id( + user_id: str | int | None, + email: str | None = None, +) -> str: + """Derive the on-disk owner key for memory files. + + Mirrors `Chat.file_save_path` (backend/app/model/chat.py): prefer + `user_` when a canonical user id is supplied, otherwise sanitise the + email local-part the same way file_save_path's legacy fallback does. + + Raises ValueError when both inputs are empty -- callers must surface this + rather than write under an unowned root. + """ + + if user_id is not None and str(user_id).strip(): + sanitized = re.sub(r'[\\/*?:"<>|\s]', "_", str(user_id)).strip(".") + if sanitized: + return f"user_{sanitized}" + + if email and email.strip(): + local_part = email.split("@")[0] + sanitized = re.sub(r'[\\/*?:"<>|\s]', "_", local_part).strip(".") + if sanitized: + return sanitized + + raise ValueError("canonical_user_id requires non-empty user_id or email") + + +def user_dir(user_key: str, root: Path | None = None) -> Path: + return (root or memory_root()) / "users" / user_key + + +def space_dir(user_key: str, space_id: str, root: Path | None = None) -> Path: + return user_dir(user_key, root) / "spaces" / space_id + + +def project_dir( + user_key: str, + space_id: str, + project_id: str, + root: Path | None = None, +) -> Path: + return space_dir(user_key, space_id, root) / "projects" / project_id + + +def run_dir( + user_key: str, + space_id: str, + project_id: str, + run_id: str, + root: Path | None = None, +) -> Path: + return project_dir(user_key, space_id, project_id, root) / "runs" / run_id diff --git a/backend/app/memory/service.py b/backend/app/memory/service.py new file mode 100644 index 000000000..e71f5c0cf --- /dev/null +++ b/backend/app/memory/service.py @@ -0,0 +1,677 @@ +# ========= 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. ========= + +"""MemoryService — Run/Project/Space lifecycle hooks (§7 of design doc). + +Thin orchestration layer above LocalMemoryStore that callers (chat_controller, +single_agent_service) use without having to know how the on-disk layout works. + +Every hook is wrapped so a memory write failure logs and returns silently -- +chat must not break because the memory writer hit an OSError. Callers can +treat the return values as best-effort. + +Backward compatibility: +- existing Phase-0 path (`project_context` bridge + in-process `agent_memory` + snapshots) keeps working; this service simply also persists a durable copy. +- on_run_start is idempotent on Space/Project json files: it only overwrites + fields known to be authoritative right now (name, updated_at, last_run_id); + other fields are preserved if the file already exists. +""" + +from __future__ import annotations + +import hashlib +import logging +import os +import uuid +from datetime import UTC, datetime +from typing import Any, Literal + +from app.memory.context_builder import ContextMode, ProjectContextBuilder +from app.memory.events import ( + ConversationEvent, + MemoryArtifact, + ProjectMemory, + RunMemory, + RunStatus, + SpaceMemory, +) +from app.memory.local_store import LocalMemoryStore +from app.memory.paths import canonical_user_id +from app.run_context import RunContext + +logger = logging.getLogger("memory.service") + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat() + + +def _sha256(content: str) -> str: + return "sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest() + + +def _resolve_user_key(run_context: RunContext) -> str | None: + try: + return canonical_user_id(run_context.user_id, email=run_context.email) + except ValueError: + logger.warning( + "memory.service: no user identity on RunContext; skipping write", + extra={ + "project_id": run_context.project_id, + "run_id": run_context.run_id, + }, + ) + return None + + +def _new_event_id() -> str: + return "evt_" + uuid.uuid4().hex[:16] + + +def _new_artifact_id() -> str: + return "art_" + uuid.uuid4().hex[:16] + + +def _default_memory_token_budget() -> int: + raw = os.environ.get("EIGENT_MEMORY_TOKEN_BUDGET") + if not raw: + return 8000 + try: + return int(raw) + except ValueError: + logger.warning( + "Invalid EIGENT_MEMORY_TOKEN_BUDGET=%r; using default 8000", raw + ) + return 8000 + + +def build_durable_context_for_task_lock( + task_lock: Any, + *, + mode: ContextMode, + current_user_prompt: str, + token_budget: int | None = None, +) -> str | None: + """Read the durable Project memory bundle for the run on this task lock. + + Returns a rendered prompt fragment (string) when the bundle has any + signal, else None. Best-effort: any read error logs + returns None so + chat never breaks on a memory glitch. + + Shared by Single Agent and Workforce paths so both modes recover from + `~/.eigent/memory` after restart with the same code path. The mode arg + drives how the bundle is rendered (single_agent narrative vs + workforce_coordinator planning view). + """ + + run_context = getattr(task_lock, "run_context", None) + if run_context is None: + return None + + service = getattr(task_lock, "memory_service", None) + if service is None: + return None + + try: + user_key = canonical_user_id( + run_context.user_id, email=run_context.email + ) + except ValueError: + return None + + budget = ( + token_budget + if token_budget is not None + else _default_memory_token_budget() + ) + try: + builder = ProjectContextBuilder(service.store) + bundle = builder.build( + user_key=user_key, + space_id=run_context.space_id, + project_id=run_context.project_id, + run_id=run_context.run_id, + mode=mode, + token_budget=budget, + current_user_prompt=current_user_prompt, + ) + except Exception: # noqa: BLE001 — best-effort read + logger.warning( + "memory.context_builder: build failed; falling back to legacy context", + extra={ + "project_id": run_context.project_id, + "run_id": run_context.run_id, + "mode": mode, + }, + exc_info=True, + ) + return None + + if bundle.is_empty(): + return None + return bundle.to_prompt(mode) + + +def finalize_task_lock_run_memory( + task_lock: Any, + *, + state: Literal["done", "failed", "cancelled"], + final_result: str | None = None, + summary: str | None = None, + error: str | None = None, +) -> bool: + """Finalize durable memory for the Run currently attached to a TaskLock. + + Shared by Single Agent and Workforce paths. The helper is intentionally + best-effort and idempotent per run id so duplicate SSE end/finally paths do + not rewrite a successful `done` as `cancelled`. + """ + + service = getattr(task_lock, "memory_service", None) + run_context = getattr(task_lock, "run_context", None) + if service is None or run_context is None: + return False + + finalized = getattr(task_lock, "_memory_finalized_runs", None) + if finalized is None: + finalized = set() + task_lock._memory_finalized_runs = finalized + if run_context.run_id in finalized: + return False + + try: + service.register_runtime_log_artifact( + run_context=run_context, + relative_path=f"task_{run_context.run_id}/camel_logs", + ) + service.on_run_end( + run_context=run_context, + state=state, + final_result=final_result, + summary=summary, + error=error, + ) + return True + except Exception: # noqa: BLE001 + logger.warning( + "memory finalize for task lock failed", + extra={ + "project_id": getattr(run_context, "project_id", None), + "run_id": getattr(run_context, "run_id", None), + "state": state, + }, + exc_info=True, + ) + return False + finally: + finalized.add(run_context.run_id) + + +class MemoryService: + """Lifecycle facade over LocalMemoryStore. + + One service instance per Brain process is enough; the LocalMemoryStore + underneath is filesystem-backed and concurrency-safe per path. + """ + + def __init__(self, store: LocalMemoryStore | None = None) -> None: + self._store = store or LocalMemoryStore() + + @property + def store(self) -> LocalMemoryStore: + return self._store + + # ----- Run Start ----- + + def on_run_start( + self, + *, + run_context: RunContext, + space_name: str | None, + project_name: str | None, + space_source_type: Literal["folder", "blank", "legacy"] = "blank", + mode: Literal["single_agent", "workforce"] | None, + user_prompt: str, + prompt_source: Literal[ + "chat", "trigger", "improve", "imported" + ] = "chat", + ) -> str | None: + """Initialise Space/Project/Run records and append the user prompt. + + Returns the conversation event_id for the appended user message, or + None if memory write was skipped (no identity / disk error). + """ + + user_key = _resolve_user_key(run_context) + if user_key is None: + return None + now = _utc_now() + + try: + self._ensure_space( + user_key=user_key, + run_context=run_context, + name=space_name, + source_type=space_source_type, + now=now, + ) + self._ensure_project( + user_key=user_key, + run_context=run_context, + name=project_name, + mode=mode, + now=now, + ) + self._write_run_header( + user_key=user_key, + run_context=run_context, + mode=mode, + user_prompt=user_prompt, + now=now, + ) + self._set_run_status( + user_key=user_key, + run_context=run_context, + state="running", + started_at=now, + ended_at=None, + error=None, + ) + event_id = self._append_conversation( + user_key=user_key, + run_context=run_context, + role="user", + content=user_prompt, + source=prompt_source, + now=now, + ) + return event_id + except Exception: # noqa: BLE001 — service is best-effort + logger.warning( + "memory.service.on_run_start: write failed; chat continues", + extra={ + "project_id": run_context.project_id, + "run_id": run_context.run_id, + }, + exc_info=True, + ) + return None + + # ----- Per-turn writes ----- + + def on_assistant_message( + self, + *, + run_context: RunContext, + content: str, + source: Literal["chat", "trigger", "improve", "imported"] = "chat", + ) -> str | None: + # TODO(memory-streaming): wire this when assistant chunks/coordinator + # narration are persisted incrementally instead of only at on_run_end. + if not content.strip(): + return None + user_key = _resolve_user_key(run_context) + if user_key is None: + return None + try: + return self._append_conversation( + user_key=user_key, + run_context=run_context, + role="assistant", + content=content, + source=source, + now=_utc_now(), + ) + except Exception: # noqa: BLE001 + logger.warning( + "memory.service.on_assistant_message: append failed", + extra={ + "project_id": run_context.project_id, + "run_id": run_context.run_id, + }, + exc_info=True, + ) + return None + + # ----- Run End ----- + + def on_run_end( + self, + *, + run_context: RunContext, + state: Literal["done", "failed", "cancelled"], + final_result: str | None = None, + summary: str | None = None, + error: str | None = None, + ) -> None: + user_key = _resolve_user_key(run_context) + if user_key is None: + return + now = _utc_now() + try: + # Persist the assistant's final response on the Project transcript + # so cross-restart continuation sees it. + if final_result and final_result.strip(): + self._append_conversation( + user_key=user_key, + run_context=run_context, + role="assistant", + content=final_result, + source="chat", + now=now, + ) + self._set_run_status( + user_key=user_key, + run_context=run_context, + state=state, + started_at=None, + ended_at=now, + error=error, + ) + if summary: + self._store.write_run_summary( + user_key, + run_context.space_id, + run_context.project_id, + run_context.run_id, + summary, + ) + + # Touch project.json so updated_at + last_run_id stay current. + self._touch_project( + user_key=user_key, + run_context=run_context, + now=now, + ) + except Exception: # noqa: BLE001 + logger.warning( + "memory.service.on_run_end: write failed", + extra={ + "project_id": run_context.project_id, + "run_id": run_context.run_id, + "state": state, + }, + exc_info=True, + ) + + def register_runtime_log_artifact( + self, + *, + run_context: RunContext, + relative_path: str, + kind: Literal["runtime_log"] = "runtime_log", + ) -> None: + """Register a runtime log (e.g. camel_logs) so it shows up in the + artifact manifest but stays excluded from user file UI and context.""" + + user_key = _resolve_user_key(run_context) + if user_key is None: + return + try: + self._store.upsert_artifact( + user_key, + run_context.space_id, + run_context.project_id, + MemoryArtifact( + artifact_id=_new_artifact_id(), + run_id=run_context.run_id, + path=relative_path, + kind=kind, + visible_to_user=False, + eligible_for_context=False, + hash="", + created_at=_utc_now(), + ), + ) + except Exception: # noqa: BLE001 + logger.warning( + "memory.service.register_runtime_log_artifact failed", + extra={ + "project_id": run_context.project_id, + "run_id": run_context.run_id, + "relative_path": relative_path, + }, + exc_info=True, + ) + + # ----- Internals ----- + + def _ensure_space( + self, + *, + user_key: str, + run_context: RunContext, + name: str | None, + source_type: Literal["folder", "blank", "legacy"], + now: str, + ) -> None: + existing = self._store.read_space(user_key, run_context.space_id) + canonical_name = (name or "").strip() + if existing is None: + payload = SpaceMemory( + space_id=run_context.space_id, + user_id=str(run_context.user_id or ""), + name=canonical_name or run_context.space_id, + source_type=( + "legacy" + if run_context.space_id.startswith("legacy_") + else source_type + ), + created_at=now, + updated_at=now, + ) + self._store.write_space(user_key, payload) + return + # Only update name + updated_at when a meaningful name comes in. + if canonical_name and canonical_name != existing.name: + updated = SpaceMemory( + space_id=existing.space_id, + user_id=existing.user_id, + name=canonical_name, + source_type=existing.source_type, + created_at=existing.created_at, + updated_at=now, + root_fingerprint=existing.root_fingerprint, + sync=existing.sync, + schema_version=existing.schema_version, + ) + self._store.write_space(user_key, updated) + + def _ensure_project( + self, + *, + user_key: str, + run_context: RunContext, + name: str | None, + mode: Literal["single_agent", "workforce"] | None, + now: str, + ) -> None: + existing = self._store.read_project( + user_key, run_context.space_id, run_context.project_id + ) + canonical_name = (name or "").strip() + if existing is None: + payload = ProjectMemory( + project_id=run_context.project_id, + space_id=run_context.space_id, + name=canonical_name or run_context.project_id, + created_at=now, + updated_at=now, + mode=mode, + last_run_id=run_context.run_id, + ) + self._store.write_project(user_key, payload) + return + # Refresh name + mode + last_run_id; never resurrect a missing + # created_at. + new_name = ( + canonical_name + if canonical_name and canonical_name != existing.name + else existing.name + ) + new_mode = mode if mode is not None else existing.mode + payload = ProjectMemory( + project_id=existing.project_id, + space_id=existing.space_id, + name=new_name, + created_at=existing.created_at, + updated_at=now, + mode=new_mode, + last_run_id=run_context.run_id, + sync=existing.sync, + schema_version=existing.schema_version, + ) + self._store.write_project(user_key, payload) + + def _touch_project( + self, + *, + user_key: str, + run_context: RunContext, + now: str, + ) -> None: + existing = self._store.read_project( + user_key, run_context.space_id, run_context.project_id + ) + if existing is None: + return + payload = ProjectMemory( + project_id=existing.project_id, + space_id=existing.space_id, + name=existing.name, + created_at=existing.created_at, + updated_at=now, + mode=existing.mode, + last_run_id=run_context.run_id, + sync=existing.sync, + schema_version=existing.schema_version, + ) + self._store.write_project(user_key, payload) + + def _write_run_header( + self, + *, + user_key: str, + run_context: RunContext, + mode: Literal["single_agent", "workforce"] | None, + user_prompt: str, + now: str, + ) -> None: + # Follow-up turns (improve / supplement) pass mode=None to avoid + # overwriting project.json. The run header still needs the right mode + # so ContextBuilder can pick the matching profile -- inherit from + # project.json when available. + if mode is None: + existing_project = self._store.read_project( + user_key, run_context.space_id, run_context.project_id + ) + if ( + existing_project is not None + and existing_project.mode is not None + ): + mode = existing_project.mode + self._store.write_run( + user_key, + RunMemory( + run_id=run_context.run_id, + project_id=run_context.project_id, + space_id=run_context.space_id, + mode=mode, + user_prompt=user_prompt, + started_at=now, + ), + ) + + def _set_run_status( + self, + *, + user_key: str, + run_context: RunContext, + state: Literal["running", "done", "failed", "cancelled"], + started_at: str | None, + ended_at: str | None, + error: str | None, + ) -> None: + # Preserve started_at across status transitions. + existing = self._store.read_run_status( + user_key, + run_context.space_id, + run_context.project_id, + run_context.run_id, + ) + resolved_start = started_at or ( + existing.started_at if existing is not None else _utc_now() + ) + self._store.write_run_status( + user_key, + run_context.space_id, + run_context.project_id, + run_context.run_id, + RunStatus( + run_id=run_context.run_id, + state=state, + started_at=resolved_start, + ended_at=ended_at, + last_error=error, + ), + ) + + def _append_conversation( + self, + *, + user_key: str, + run_context: RunContext, + role: Literal["user", "assistant", "system"], + content: str, + source: Literal["chat", "trigger", "improve", "imported"], + now: str, + ) -> str: + event_id = _new_event_id() + self._store.append_conversation( + user_key, + run_context.space_id, + run_context.project_id, + ConversationEvent( + event_id=event_id, + run_id=run_context.run_id, + timestamp=now, + role=role, + content=content, + source=source, + visibility="context", + hash=_sha256(content), + ), + ) + return event_id + + +# Module-level singleton for callers that don't need to inject a custom store. +_DEFAULT_SERVICE: MemoryService | None = None + + +def get_memory_service() -> MemoryService: + global _DEFAULT_SERVICE + if _DEFAULT_SERVICE is None: + _DEFAULT_SERVICE = MemoryService() + return _DEFAULT_SERVICE + + +def _reset_memory_service_for_tests( + service: MemoryService | None = None, +) -> None: + """Test seam — replace or clear the singleton.""" + + global _DEFAULT_SERVICE + _DEFAULT_SERVICE = service diff --git a/backend/app/model/chat.py b/backend/app/model/chat.py index c07fd2960..1aee1382f 100644 --- a/backend/app/model/chat.py +++ b/backend/app/model/chat.py @@ -16,7 +16,7 @@ import logging import re from pathlib import Path -from typing import Literal +from typing import Any, Literal from camel.types import ModelType, RoleType from pydantic import BaseModel, Field, field_validator @@ -53,6 +53,12 @@ class QuestionAnalysisResult(BaseModel): class Chat(BaseModel): task_id: str project_id: str + space_id: str | None = None + run_id: str | None = None + space_root_path: str | None = None + workdir_mode: ( + Literal["worktree", "copy", "direct-write", "artifact-only"] | None + ) = None question: str email: str attaches: list[str] = [] @@ -78,8 +84,16 @@ class Chat(BaseModel): # (e.g., GOOGLE_API_KEY, SEARCH_ENGINE_ID) search_config: dict[str, str] | None = None # User identifier for user-specific skill configurations - user_id: str | None = None + user_id: str | int | 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 + session_mode: Literal["workforce", "single-agent"] = "workforce" + toolkit_config: dict[str, Any] | None = None remote_sub_agent_config: RemoteSubAgentConfig | None = None + # Durable Project context reconstructed from persisted runs after restart. + # In-process follow-ups still prefer TaskLock.conversation_history. + project_context: str | None = None @field_validator("model_type") @classmethod @@ -126,19 +140,40 @@ def is_cloud(self): ) def file_save_path(self, path: str | None = None): - email = re.sub(r'[\\/*?:"<>|\s]', "_", self.email.split("@")[0]).strip( - "." - ) + legacy_owner_key = re.sub( + r'[\\/*?:"<>|\s]', "_", self.email.split("@")[0] + ).strip(".") + if self.user_id is not None and str(self.user_id).strip(): + owner_key = "user_" + re.sub( + r'[\\/*?:"<>|\s]', "_", str(self.user_id) + ).strip(".") + else: + owner_key = legacy_owner_key + run_id = self.run_id or self.task_id # Use project-based structure: project_{project_id}/task_{task_id} - save_path = ( + project_base = ( + Path.home() + / "eigent" + / owner_key + / f"project_{self.project_id}" + / f"task_{run_id}" + ) + legacy_project_base = ( Path.home() / "eigent" - / email + / legacy_owner_key / f"project_{self.project_id}" - / f"task_{self.task_id}" + / f"task_{run_id}" ) - if path is not None: - save_path = save_path / path + if ( + owner_key != legacy_owner_key + and not project_base.exists() + and legacy_project_base.exists() + ): + # Bridge old installs whose artifacts were written under + # ~/eigent/{email_sanitized} before user_id-owned roots existed. + project_base = legacy_project_base + save_path = project_base / path if path is not None else project_base save_path.mkdir(parents=True, exist_ok=True) return str(save_path) @@ -148,6 +183,7 @@ class SupplementChat(BaseModel): question: str task_id: str | None = None attaches: list[str] = [] + project_context: str | None = None class HumanReply(BaseModel): diff --git a/backend/app/model/model_platform.py b/backend/app/model/model_platform.py index a4194e4df..2e16856ae 100644 --- a/backend/app/model/model_platform.py +++ b/backend/app/model/model_platform.py @@ -17,7 +17,7 @@ from pydantic import BeforeValidator PLATFORM_ALIAS_MAPPING: Final[dict[str, str]] = { - "z.ai": "zhipu", + "z.ai": "zhipuai", "ModelArk": "openai-compatible-model", "grok": "openai-compatible-model", "ernie": "qianfan", @@ -29,6 +29,11 @@ # Bedrock Converse requires a region during model initialization. BEDROCK_CONVERSE_REGION: Final[str] = "us-west-2" +# Azure OpenAI requires an api_version. The cloud proxy accepts any modern +# version; this default keeps cloud-mode requests working when the frontend +# does not surface api_version in extra_params. +AZURE_DEFAULT_API_VERSION: Final[str] = "2024-10-21" + def patch_bedrock_cloud_config( api_url: str, extra_params: dict @@ -45,6 +50,20 @@ def patch_bedrock_cloud_config( return api_url, extra_params +def patch_azure_cloud_config(extra_params: dict) -> dict: + """Default Azure `api_version` for cloud mode. + + The cloud proxy fronts Azure OpenAI but the frontend sends an empty + `extra_params` for cloud, leaving `api_version` unset. Camel's + `AzureOpenAIModel` raises if neither the kwarg nor `AZURE_API_VERSION` + env var is provided — inject a sensible default here so cloud-mode + GPT models (gpt-5.4, gpt-5.5, gpt-5-mini, ...) construct cleanly. + """ + extra_params = dict(extra_params) + extra_params.setdefault("api_version", AZURE_DEFAULT_API_VERSION) + return extra_params + + def normalize_model_platform(platform: str) -> str: """Normalize provider aliases to supported model platform names.""" return PLATFORM_ALIAS_MAPPING.get(platform, platform) diff --git a/backend/app/router.py b/backend/app/router.py index 638e85398..026026b3d 100644 --- a/backend/app/router.py +++ b/backend/app/router.py @@ -19,15 +19,21 @@ import logging -from fastapi import FastAPI +from fastapi import Depends, FastAPI +from app.auth import get_brain_auth_context from app.controller import ( chat_controller, + file_controller, health_controller, + mcp_controller, + message_controller, model_controller, remote_sub_agent_controller, + skill_controller, task_controller, tool_controller, + workspace_controller, ) logger = logging.getLogger("router") @@ -52,11 +58,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"], @@ -77,6 +103,11 @@ def register_routers(app: FastAPI, prefix: str = "") -> None: "tags": ["tool"], "description": "Tool installation and management", }, + { + "router": workspace_controller.router, + "tags": ["workspace"], + "description": "Space-level local workspace binding", + }, ] app.include_router(health_controller.router, tags=["Health"]) @@ -85,8 +116,16 @@ def register_routers(app: FastAPI, prefix: str = "") -> None: ) for config in routers_config: + dependencies = ( + [] + if config["tags"] == ["Health"] + else [Depends(get_brain_auth_context)] + ) app.include_router( - config["router"], prefix=prefix, tags=config["tags"] + config["router"], + prefix=prefix, + tags=config["tags"], + dependencies=dependencies, ) route_count = len(config["router"].routes) logger.info( 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..49b3606ae --- /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(__name__) + +# 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..d1b145965 --- /dev/null +++ b/backend/app/router_layer/message_router.py @@ -0,0 +1,189 @@ +# ========= 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 time +import uuid +from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING + +from app.component.environment import env +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", + # RunContext-aware env() keeps this fallback task-scoped when + # route_in is called from an active RunContext. + api_key=payload.get("api_key") or env("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..862dff2e2 --- /dev/null +++ b/backend/app/router_layer/middleware.py @@ -0,0 +1,118 @@ +# ========= 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", + "remote_control", + } +) + + +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/run_context/__init__.py b/backend/app/run_context/__init__.py new file mode 100644 index 000000000..e075b5fc7 --- /dev/null +++ b/backend/app/run_context/__init__.py @@ -0,0 +1,33 @@ +# ========= 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.run_context.context import ( + RunContext, + apply_run_env_for_third_party, + current_run_context, + get_current_run_context, + get_run_env_override, + run_context_scope, + stream_with_run_context, +) + +__all__ = [ + "RunContext", + "apply_run_env_for_third_party", + "current_run_context", + "get_current_run_context", + "get_run_env_override", + "run_context_scope", + "stream_with_run_context", +] diff --git a/backend/app/run_context/context.py b/backend/app/run_context/context.py new file mode 100644 index 000000000..38e2bf274 --- /dev/null +++ b/backend/app/run_context/context.py @@ -0,0 +1,141 @@ +# ========= 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 os +from collections.abc import AsyncIterator, Callable, Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from pathlib import Path + +THIRD_PARTY_OS_ENV_KEYS = ("CAMEL_LOG_DIR", "CAMEL_WORKDIR") + + +@dataclass(frozen=True) +class RunContext: + """Task-scoped runtime values that must not live in process globals.""" + + space_id: str + project_id: str + run_id: str + task_id: str + email: str + user_id: str | None + working_directory: Path + task_output_root: Path + camel_log_dir: Path + binding_source: str + workdir_mode: str | None + browser_port: int + cdp_url: str | None = None + api_key: str | None = None + api_base_url: str | None = None + cloud_api_key: str | None = None + server_url: str | None = None + auth_header: str | None = None + search_config: dict[str, str] = field(default_factory=dict) + extra_env: dict[str, str] = field(default_factory=dict) + + def env_overrides(self) -> dict[str, str]: + values: dict[str, str] = { + "file_save_path": str(self.task_output_root), + "browser_port": str(self.browser_port), + "CAMEL_LOG_DIR": str(self.camel_log_dir), + "CAMEL_WORKDIR": str(self.task_output_root), + } + if self.cdp_url: + values["EIGENT_CDP_URL"] = self.cdp_url + if self.api_key: + values["OPENAI_API_KEY"] = self.api_key + if self.api_base_url: + values["OPENAI_API_BASE_URL"] = self.api_base_url + if self.cloud_api_key: + values["cloud_api_key"] = self.cloud_api_key + if self.server_url: + values["SERVER_URL"] = self.server_url + values.update( + {key: value for key, value in self.search_config.items() if value} + ) + values.update( + {key: value for key, value in self.extra_env.items() if value} + ) + return values + + def env_value(self, key: str) -> str | None: + return self.env_overrides().get(key) + + +current_run_context: ContextVar[RunContext | None] = ContextVar( + "current_run_context", default=None +) + + +def get_current_run_context() -> RunContext | None: + return current_run_context.get() + + +def get_run_env_override(key: str) -> str | None: + context = get_current_run_context() + if context is None: + return None + return context.env_value(key) + + +def apply_run_env_for_third_party(context: RunContext) -> None: + """Publish the small env subset third-party libraries read directly. + + First-party code must use app.component.environment.env(), which reads + RunContext before os.environ. Some third-party libraries, notably CAMEL's + model logging, call os.environ.get(...) themselves and cannot see our + ContextVar. Keep this shim intentionally tiny and auditable. + """ + + overrides = context.env_overrides() + for key in THIRD_PARTY_OS_ENV_KEYS: + value = overrides.get(key) + if value: + os.environ[key] = value + + +@contextmanager +def run_context_scope(context: RunContext) -> Iterator[RunContext]: + token = current_run_context.set(context) + try: + yield context + finally: + current_run_context.reset(token) + + +async def stream_with_run_context( + stream: AsyncIterator[str], + context_getter: Callable[[], RunContext | None], +) -> AsyncIterator[str]: + iterator = stream.__aiter__() + while True: + context = context_getter() + if context is None: + try: + yield await iterator.__anext__() + except StopAsyncIteration: + return + continue + + token = current_run_context.set(context) + try: + item = await iterator.__anext__() + except StopAsyncIteration: + return + finally: + current_run_context.reset(token) + yield item diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py index aa8d9218b..0adcd463f 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 @@ -48,7 +49,13 @@ 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.memory import ( + build_durable_context_for_task_lock, + finalize_task_lock_run_memory, +) from app.model.chat import Chat, NewAgent, Status, TaskContent, sse_json +from app.service.single_agent_service import single_agent_solve from app.service.task import ( Action, ActionDecomposeProgressData, @@ -61,6 +68,12 @@ delete_task_lock, set_current_task_id, ) +from app.utils.agent_memory import ( + build_memory_context, + estimate_memory_size, + record_agent_memory_snapshot, + record_workforce_memory_snapshot, +) from app.utils.event_loop_utils import set_main_event_loop from app.utils.file_utils import get_working_directory, list_files from app.utils.server.sync_step import sync_step @@ -69,6 +82,75 @@ logger = logging.getLogger("chat_service") +SUMMARY_TASK_NAME_MAX_LENGTH = 80 +SUMMARY_TASK_SUMMARY_MAX_LENGTH = 240 + + +def _truncate_summary_part(value: str, max_length: int) -> str: + text = " ".join((value or "").replace("|", " ").split()) + if len(text) <= max_length: + return text + return text[: max_length - 3].rstrip() + "..." + + +def normalize_summary_task( + summary_task_content: str, + fallback_content: str = "", +) -> str: + raw = " ".join((summary_task_content or "").split()) + if "|" in raw: + raw_name, raw_summary = raw.split("|", 1) + else: + raw_name = "Task" + raw_summary = raw + + name = _truncate_summary_part( + raw_name or "Task", + SUMMARY_TASK_NAME_MAX_LENGTH, + ) + summary = _truncate_summary_part( + raw_summary or fallback_content or name, + SUMMARY_TASK_SUMMARY_MAX_LENGTH, + ) + return f"{name or 'Task'}|{summary or name or 'Task'}" + + +def _extract_stream_chunk_content(chunk: Any) -> str: + """Return user-visible text from a streaming chunk. + + Some CAMEL streaming chunks carry planning text in ``reasoning_content``. + Falling back to ``str(chunk)`` leaks internal ``BaseMessage(...)`` + representations to the UI, so only explicit text fields are displayable. + """ + if chunk is None: + return "" + if isinstance(chunk, str): + return chunk + + def message_text(message: Any) -> str: + for attr in ("content", "reasoning_content"): + value = getattr(message, attr, None) + if isinstance(value, str) and value: + return value + return "" + + msg = getattr(chunk, "msg", None) + content = message_text(msg) + if content: + return content + + msgs = getattr(chunk, "msgs", None) + if msgs: + contents = [ + item_content + for item in msgs + if (item_content := message_text(item)) + ] + if contents: + return "".join(contents) + + return "" + def format_task_context( task_data: dict, seen_files: set | None = None, skip_files: bool = False @@ -199,6 +281,7 @@ def check_conversation_history_length( total_length = 0 for entry in task_lock.conversation_history: total_length += len(entry.get("content", "")) + total_length += estimate_memory_size(task_lock) is_exceeded = total_length > max_length @@ -211,6 +294,48 @@ def check_conversation_history_length( return is_exceeded, total_length +def _trim_in_process_history(task_lock: TaskLock, keep_recent: int = 4) -> int: + """Compact in-process conversation + agent snapshot history. + + Memory feature already persists the full transcript to + ``~/.eigent/memory/<...>/conversation.jsonl`` at every Run end; the + in-process ``conversation_history`` and ``agent_memory_history`` lists + exist only to feed the next workforce turn's prompt. When they grow past + the 200K-char guard we drop the older entries here and append a marker + to ``memory_summary`` so subsequent prompts still know that earlier + context exists (and where to recover it from). + + Returns the number of entries dropped across both lists. Returns 0 when + nothing needed trimming, which lets callers distinguish "compaction + bought us space" from "single turn alone is already over budget". + """ + + convo = getattr(task_lock, "conversation_history", None) or [] + snaps = getattr(task_lock, "agent_memory_history", None) or [] + dropped = 0 + if len(convo) > keep_recent: + dropped += len(convo) - keep_recent + task_lock.conversation_history = convo[-keep_recent:] + if len(snaps) > keep_recent: + dropped += len(snaps) - keep_recent + task_lock.agent_memory_history = snaps[-keep_recent:] + if dropped == 0: + return 0 + marker = ( + f"\n[memory] Compacted {dropped} older in-process turn(s); the full " + f"transcript is preserved in ~/.eigent/memory under this Project." + ) + summary = getattr(task_lock, "memory_summary", "") or "" + if marker.strip() not in summary: + task_lock.memory_summary = (summary + marker).strip() + logger.info( + f"Compacted {dropped} in-process history entries; " + f"conversation_history kept={len(task_lock.conversation_history)} " + f"agent_memory_history kept={len(task_lock.agent_memory_history)}" + ) + return dropped + + def build_conversation_context( task_lock: TaskLock, header: str = "=== CONVERSATION HISTORY ===" ) -> str: @@ -273,6 +398,10 @@ def build_conversation_context( context += "\n" + memory_context = build_memory_context(task_lock) + if memory_context: + context += memory_context + return context @@ -282,11 +411,26 @@ def build_context_for_workforce( task_content: str | None = None, ) -> str: """Build context information for workforce. - Instructs coordinator to actively load skills using list_skills/load_skill tools. + + Prepends durable Project memory (from LocalMemoryStore) when available so + Workforce recovers cross-restart context the same way Single Agent does + via _build_single_agent_context. The in-process conversation_history is + still appended afterward because it is the live state for the current + session and may contain turns not yet flushed to disk. """ - return build_conversation_context( + durable = build_durable_context_for_task_lock( + task_lock, + mode="workforce_coordinator", + current_user_prompt=task_content or "", + ) + in_process = build_conversation_context( task_lock, header="=== CONVERSATION HISTORY ===" ) + if durable and in_process: + return durable + "\n\n" + in_process + if durable: + return durable + "\n\n" + return in_process @sync_step @@ -314,6 +458,10 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): task_lock.conversation_history = [] if not hasattr(task_lock, "last_task_result"): task_lock.last_task_result = "" + if not hasattr(task_lock, "agent_memory_history"): + task_lock.agent_memory_history = [] + if not hasattr(task_lock, "memory_summary"): + task_lock.memory_summary = "" if not hasattr(task_lock, "question_agent"): task_lock.question_agent = None if not hasattr(task_lock, "summary_generated"): @@ -340,6 +488,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", @@ -351,9 +502,17 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): extra={ "task_id": options.task_id, "model_platform": options.model_platform, + "session_mode": options.session_mode, }, ) + if options.session_mode == "single-agent": + async for chunk in single_agent_solve( + options, request, task_lock, hands=hands + ): + yield chunk + return + while True: loop_iteration += 1 logger.debug( @@ -387,6 +546,11 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): else: logger.info("[LIFECYCLE] Workforce is None, no need to stop") task_lock.status = Status.done + finalize_task_lock_run_memory( + task_lock, + state="cancelled", + final_result="Client disconnectedClient disconnected", + ) try: await delete_task_lock(task_lock.id) logger.info( @@ -477,28 +641,41 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): is_exceeded, total_length = check_conversation_history_length( task_lock ) + if is_exceeded: + # The durable transcript on disk already has everything; + # drop older in-process turns and try again before we + # refuse the user's prompt. + dropped = _trim_in_process_history(task_lock) + if dropped: + is_exceeded, total_length = ( + check_conversation_history_length(task_lock) + ) if is_exceeded: logger.error( - "Conversation history too long", + "Conversation history too long even after compaction", extra={ "project_id": options.project_id, "current_length": total_length, - "max_length": 100000, + "max_length": 200000, }, ) ctx_msg = ( - "The conversation history " - "is too long. Please create" - " a new project to continue." + "This single turn is larger than the per-Run budget." + " Try breaking the prompt into smaller steps." ) yield sse_json( "context_too_long", { "message": ctx_msg, "current_length": total_length, - "max_length": 100000, + "max_length": 200000, }, ) + finalize_task_lock_run_memory( + task_lock, + state="failed", + error="conversation_history_too_long", + ) continue # Determine task complexity: attachments @@ -538,10 +715,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 " @@ -550,6 +730,14 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): "right now." ) + record_agent_memory_snapshot( + task_lock, + question_agent, + scope="question_agent", + task_id=options.task_id, + task_content=question, + task_result=answer_content, + ) task_lock.add_conversation("assistant", answer_content) yield sse_json( @@ -638,11 +826,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 @@ -683,10 +875,10 @@ def on_stream_batch( def on_stream_text(chunk): try: accumulated_content = ( - chunk.msg.content - if hasattr(chunk, "msg") and chunk.msg - else str(chunk) + _extract_stream_chunk_content(chunk) ) + if not accumulated_content: + return last_content = stream_state["last_content"] # Calculate delta: new content @@ -760,37 +952,21 @@ async def run_decomposition(): }, ) task_lock.summary_generated = True - content_preview = ( - camel_task.content - if hasattr(camel_task, "content") - else "" + content_preview = getattr( + camel_task, "content", "" ) - if content_preview is None: - content_preview = "" - if len(content_preview) > 80: - cp = content_preview[:80] - summary_task_content = cp + "..." - else: - summary_task_content = content_preview - summary_task_content = ( - f"Task|{summary_task_content}" + summary_task_content = normalize_summary_task( + f"Task|{content_preview}", + content_preview, ) except Exception: task_lock.summary_generated = True - content_preview = ( - camel_task.content - if hasattr(camel_task, "content") - else "" + content_preview = getattr( + camel_task, "content", "" ) - if content_preview is None: - content_preview = "" - if len(content_preview) > 80: - cp = content_preview[:80] - summary_task_content = cp + "..." - else: - summary_task_content = content_preview - summary_task_content = ( - f"Task|{summary_task_content}" + summary_task_content = normalize_summary_task( + f"Task|{content_preview}", + content_preview, ) state_holder["summary_task"] = summary_task_content @@ -1006,6 +1182,17 @@ async def run_decomposition(): else: task_content: str = f"Task {options.task_id}" + if workforce is not None: + record_workforce_memory_snapshot( + task_lock, + workforce, + task_id=camel_task.id + if camel_task + else options.task_id, + task_content=task_content, + task_result=end_message, + ) + task_lock.add_conversation( "task_result", { @@ -1016,6 +1203,12 @@ async def run_decomposition(): ), }, ) + finalize_task_lock_run_memory( + task_lock, + state="done", + final_result=end_message, + summary=getattr(task_lock, "summary_task_content", ""), + ) # Clear camel_task as well # (workforce is cleared, so @@ -1042,27 +1235,35 @@ async def run_decomposition(): is_exceeded, total_length = check_conversation_history_length( task_lock ) + if is_exceeded: + dropped = _trim_in_process_history(task_lock) + if dropped: + is_exceeded, total_length = ( + check_conversation_history_length(task_lock) + ) if is_exceeded: logger.error( - "Cannot start task: " - "conversation history too " - f"long ({total_length} chars)" - " for project " - f"{options.project_id}" + "Cannot start task: conversation history too long" + f" even after compaction ({total_length} chars)" + f" for project {options.project_id}" ) ctx_msg = ( - "The conversation history " - "is too long. Please create" - " a new project to continue." + "This single turn is larger than the per-Run budget." + " Try breaking the prompt into smaller steps." ) yield sse_json( "context_too_long", { "message": ctx_msg, "current_length": total_length, - "max_length": 100000, + "max_length": 200000, }, ) + finalize_task_lock_run_memory( + task_lock, + state="failed", + error="conversation_history_too_long", + ) continue if workforce is not None: @@ -1136,6 +1337,15 @@ async def run_decomposition(): "=== CURRENT TASK ===" )[-1].strip() + if workforce is not None: + record_workforce_memory_snapshot( + task_lock, + workforce, + task_id=camel_task.id, + task_content=old_task_content_clean, + task_result=old_task_result, + ) + task_lock.add_conversation( "task_result", { @@ -1229,14 +1439,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 " @@ -1245,6 +1456,14 @@ async def run_decomposition(): " right now." ) + record_agent_memory_snapshot( + task_lock, + question_agent, + scope="question_agent", + task_id=options.task_id, + task_content=new_task_content, + task_result=answer_content, + ) task_lock.add_conversation( "assistant", answer_content ) @@ -1334,12 +1553,11 @@ def on_stream_batch( def on_stream_text(chunk): try: - has_msg = hasattr(chunk, "msg") and chunk.msg accumulated_content = ( - chunk.msg.content - if has_msg - else str(chunk) + _extract_stream_chunk_content(chunk) ) + if not accumulated_content: + return last_content = stream_state["last_content"] if accumulated_content.startswith( @@ -1417,13 +1635,10 @@ def on_stream_text(chunk): ) # Fallback to descriptive but not generic summary task_content_for_summary = new_task_content - tc = task_content_for_summary - if len(tc) > 100: - new_summary_content = ( - f"Follow-up Task|{tc[:97]}..." - ) - else: - new_summary_content = f"Follow-up Task|{tc}" + new_summary_content = normalize_summary_task( + f"Follow-up Task|{task_content_for_summary}", + task_content_for_summary, + ) except Exception as e: logger.error( "Error generating multi-turn " @@ -1431,13 +1646,10 @@ def on_stream_text(chunk): ) # Fallback to descriptive but not generic summary task_content_for_summary = new_task_content - tc = task_content_for_summary - if len(tc) > 100: - new_summary_content = ( - f"Follow-up Task|{tc[:97]}..." - ) - else: - new_summary_content = f"Follow-up Task|{tc}" + new_summary_content = normalize_summary_task( + f"Follow-up Task|{task_content_for_summary}", + task_content_for_summary, + ) # Emit final subtasks once when # decomposition is complete @@ -1568,7 +1780,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: @@ -1672,6 +1884,17 @@ def on_stream_text(chunk): else: task_content: str = f"Task {options.task_id}" + if workforce is not None: + record_workforce_memory_snapshot( + task_lock, + workforce, + task_id=camel_task.id + if camel_task + else options.task_id, + task_content=task_content, + task_result=final_result, + ) + task_lock.add_conversation( "task_result", { @@ -1682,6 +1905,12 @@ def on_stream_text(chunk): ), }, ) + finalize_task_lock_run_memory( + task_lock, + state="done", + final_result=final_result, + summary=getattr(task_lock, "summary_task_content", ""), + ) yield sse_json("end", final_result) @@ -1796,6 +2025,11 @@ def on_stream_text(chunk): " at stop action for project" f" {options.project_id}" ) + finalize_task_lock_run_memory( + task_lock, + state="cancelled", + final_result="Task stoppedTask stopped by user", + ) logger.info("[LIFECYCLE] Deleting task lock") await delete_task_lock(task_lock.id) logger.info( @@ -1826,6 +2060,11 @@ def on_stream_text(chunk): exc_info=True, ) yield sse_json("error", {"message": str(e)}) + finalize_task_lock_run_memory( + task_lock, + state="failed", + error=str(e), + ) if ( "workforce" in locals() and workforce is not None @@ -1840,6 +2079,11 @@ def on_stream_text(chunk): exc_info=True, ) yield sse_json("error", {"message": str(e)}) + finalize_task_lock_run_memory( + task_lock, + state="failed", + error=str(e), + ) # Continue processing other items instead of breaking @@ -1963,18 +2207,12 @@ async def question_confirm( Is this a complex task? (yes/no):""" try: - resp = agent.step(full_prompt) + resp = await _run_agent_step(agent, 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 @@ -2000,17 +2238,20 @@ async def summary_task(agent: ListenChatAgent, task: Task) -> str: {task.to_string()} --- Your instructions are: -1. Come up with a short and descriptive name for this task. -2. Create a concise summary of the task's main points and objectives. +1. Come up with a short and descriptive name for this task, max {SUMMARY_TASK_NAME_MAX_LENGTH} characters. +2. Create a concise summary of the task's main points and objectives, max {SUMMARY_TASK_SUMMARY_MAX_LENGTH} characters. 3. Return the task name and the summary, separated by a vertical bar (|). Example format: "Task Name|This is the summary of the task." -Do not include any other text or formatting. +Do not include any other text or formatting. Keep the output on one line. """ logger.debug("Generating task summary", extra={"task_id": task.id}) try: - res = agent.step(prompt) - summary = res.msgs[0].content + res = await _run_agent_step(agent, prompt) + summary = normalize_summary_task( + _extract_agent_response_content(res) or "", + getattr(task, "content", ""), + ) logger.info("Task summary generated", extra={"summary": summary}) return summary except Exception as e: @@ -2061,8 +2302,8 @@ async def summary_subtasks_result(agent: ListenChatAgent, task: Task) -> str: Summary: """ - res = agent.step(prompt) - summary = res.msgs[0].content + res = await _run_agent_step(agent, prompt) + summary = _extract_agent_response_content(res) or "" logger.info( "Generated subtasks summary for " @@ -2073,6 +2314,100 @@ 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 _run_agent_step(agent: ListenChatAgent, prompt: str): + """Run one model step with backward-compatible priority. + + Some call sites and tests still stub synchronous ``step`` while newer paths + provide ``astep``. Prefer ``step`` when available to preserve existing + behavior, and fall back to ``astep``. + """ + step_fn = getattr(agent, "step", None) + if callable(step_fn): + result = step_fn(prompt) + if asyncio.iscoroutine(result): + return await result + return result + + astep_fn = getattr(agent, "astep", None) + if callable(astep_fn): + return await astep_fn(prompt) + + raise AttributeError("Agent has neither step nor astep") + + +def _render_subtask_report(task: Task, *, is_failure: bool) -> str: + """Build a per-subtask report. + + Used in two places: + - Partial-failure aggregation when Workforce stops on a failed subtask -- + ``task.result`` is often empty there and the UI would otherwise render + nothing. ``is_failure=True`` adds the warning banner. + - Successful single-subtask fallback when Workforce leaves ``task.result`` + empty even though the lone subtask succeeded. ``is_failure=False`` + keeps the banner off so the user does not see a misleading + "partially failed" header on a healthy run. + """ + + def _state_name(state: object) -> str: + if state is None: + return "UNKNOWN" + return str(getattr(state, "name", state) or "UNKNOWN") + + def _is_failed(state: object) -> bool: + return "fail" in _state_name(state).lower() + + subtasks = list(task.subtasks or []) + lines: list[str] = [] + if is_failure: + lines.append( + "⚠️ Task partially failed — showing the subtasks that did finish:" + ) + lines.append("") + for idx, subtask in enumerate(subtasks, 1): + state = getattr(subtask, "state", None) + state_label = _state_name(state) + emoji = "❌" if _is_failed(state) else "✅" + content = str(getattr(subtask, "content", "") or "").strip() + sub_result = str(getattr(subtask, "result", "") or "").strip() + lines.append(f"{emoji} **Subtask {idx} ({state_label})**") + if content: + preview = content if len(content) <= 200 else content[:200] + "…" + lines.append(f"_Task:_ {preview}") + if sub_result: + lines.append("") + lines.append(sub_result) + else: + lines.append("_No output captured._") + lines.append("") + parent_result = str(task.result or "").strip() + if parent_result: + lines.append("---") + lines.append("**Overall task result**") + lines.append("") + lines.append(parent_result) + return "\n".join(lines).rstrip() + "\n" + + async def get_task_result_with_optional_summary( task: Task, options: Chat ) -> str: @@ -2088,6 +2423,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, concatenating per-subtask results", + task.id, + ) + return _render_subtask_report(task, is_failure=True) + if task.subtasks and len(task.subtasks) > 1: logger.info( f"Task {task.id} has " @@ -2109,18 +2462,32 @@ async def get_task_result_with_optional_summary( parts = result.split("Result ---", 1) if len(parts) > 1: result = parts[1].strip() + if not result.strip(): + # Workforce sometimes leaves task.result empty even when the lone + # subtask succeeded -- the user then sees a "Done 1" task card with + # no body text and memory skips the assistant turn. Fall back to + # the per-subtask render so the UI always gets something. + logger.info( + "Task %s has empty task.result; falling back to subtask render", + task.id, + ) + result = _render_subtask_report(task, is_failure=False) return result 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", @@ -2257,10 +2624,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: @@ -2268,13 +2637,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, @@ -2378,7 +2740,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={ @@ -2396,22 +2762,25 @@ async def new_agent_model(data: NewAgent | ActionNewAgent, options: Chat): item for item in data.tools if item != "remote_sub_agent_toolkit" ] tools = [ - *await get_toolkits(requested_tools, data.name, options.project_id) + *await get_toolkits( + requested_tools, data.name, options.project_id, hands=hands + ) ] for item in requested_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/single_agent_service.py b/backend/app/service/single_agent_service.py new file mode 100644 index 000000000..6d963c9a7 --- /dev/null +++ b/backend/app/service/single_agent_service.py @@ -0,0 +1,482 @@ +# ========= 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 logging +import os +from typing import Any + +from camel.agents.chat_agent import AsyncStreamingChatAgentResponse +from camel.responses import ChatAgentResponse +from fastapi import Request + +from app.agent.factory.single_agent import single_agent +from app.hands.interface import IHands +from app.memory import ( + build_durable_context_for_task_lock, + finalize_task_lock_run_memory, +) +from app.model.chat import Chat, sse_json +from app.model.enums import Status +from app.service.task import ( + Action, + ActionData, + ActionImproveData, + TaskLock, + delete_task_lock, + set_current_task_id, +) +from app.utils.agent_memory import ( + build_memory_context, + record_agent_memory_snapshot, +) +from app.utils.file_utils import get_working_directory + +logger = logging.getLogger("single_agent_service") + +# Char budget for the durable memory bundle (~32k chars at 4 chars/token). +# Override via EIGENT_MEMORY_TOKEN_BUDGET if you need to tune in the field. +try: + _MEMORY_TOKEN_BUDGET = int( + os.environ.get("EIGENT_MEMORY_TOKEN_BUDGET", "8000") + ) +except ValueError: + _MEMORY_TOKEN_BUDGET = 8000 + + +def _build_single_agent_context( + task_lock: TaskLock, + project_context: str | None = None, + current_user_prompt: str = "", +) -> str: + # 1. Durable cross-restart context from LocalMemoryStore (M4 path). + durable = build_durable_context_for_task_lock( + task_lock, + mode="single_agent", + current_user_prompt=current_user_prompt, + token_budget=_MEMORY_TOKEN_BUDGET, + ) + if durable: + return durable + "\n\n" + + # 2. In-process conversation history (hot follow-up turns). + if getattr(task_lock, "conversation_history", None): + lines = ["=== Previous Conversation ==="] + for entry in task_lock.conversation_history: + role = entry.get("role", "") + content = entry.get("content", "") + if role == "task_result" and isinstance(content, dict): + task_content = content.get("task_content") + task_result = content.get("task_result") + if task_content: + lines.append(f"Previous task: {task_content}") + if task_result: + lines.append(f"Previous result: {task_result}") + elif content: + lines.append(f"{role}: {content}") + memory_context = build_memory_context(task_lock) + if memory_context: + lines.append(memory_context.rstrip()) + lines.append("=== End Previous Conversation ===") + return "\n".join(lines) + "\n\n" + + # 3. Phase-0 bridge fallback (frontend-sent project_context). + durable_context = (project_context or "").strip() + if not durable_context: + return "" + return ( + "=== Persisted Project Context ===\n" + f"{durable_context}\n" + "=== End Persisted Project Context ===\n\n" + ) + + +def _finalize_memory_for_turn( + task_lock: TaskLock, + *, + state: str, + final_result: str | None = None, + error: str | None = None, +) -> None: + """Best-effort end-of-run memory write.""" + + finalize_task_lock_run_memory( + task_lock, + state=state, # type: ignore[arg-type] + final_result=final_result, + error=error, + ) + + +def _build_single_agent_prompt( + task_lock: TaskLock, + question: str, + attaches: list[str], + project_context: str | None = None, +) -> str: + context = _build_single_agent_context( + task_lock, project_context, current_user_prompt=question + ) + attachment_context = "" + if attaches: + attachment_context = "Attachments:\n" + "\n".join( + f"- {path}" for path in attaches + ) + attachment_context += "\n\n" + return f"{context}{attachment_context}User task:\n{question}" + + +async def _response_content( + response: ChatAgentResponse | AsyncStreamingChatAgentResponse, +) -> tuple[str, int]: + def extract_tokens(response_chunk: Any) -> int: + if response_chunk is None: + return 0 + info = getattr(response_chunk, "info", None) or {} + usage_info = info.get("usage") or info.get("token_usage") or {} + return int(usage_info.get("total_tokens", 0) or 0) + + if isinstance(response, AsyncStreamingChatAgentResponse): + content = "" + last_chunk = None + async for chunk in response: + last_chunk = chunk + if chunk.msg and chunk.msg.content: + content += chunk.msg.content + return content, extract_tokens(last_chunk) + + msg = getattr(response, "msg", None) + usage_tokens = extract_tokens(response) + if msg is not None and getattr(msg, "content", None): + return msg.content, usage_tokens + + msgs = getattr(response, "msgs", None) + if msgs: + return getattr(msgs[-1], "content", "") or "", usage_tokens + + return "", usage_tokens + + +def _action_to_sse(item: ActionData) -> str | None: + if item.action == Action.create_agent: + return sse_json("create_agent", item.data) + if item.action == Action.activate_agent: + return sse_json("activate_agent", item.data) + if item.action == Action.deactivate_agent: + return sse_json("deactivate_agent", item.data) + if item.action == Action.assign_task: + return sse_json("assign_task", item.data) + if item.action == Action.activate_toolkit: + return sse_json("activate_toolkit", item.data) + if item.action == Action.deactivate_toolkit: + return sse_json("deactivate_toolkit", item.data) + if item.action == Action.write_file: + return sse_json( + "write_file", + { + "file_path": item.data, + "process_task_id": item.process_task_id, + }, + ) + if item.action == Action.ask: + return sse_json("ask", item.data) + if item.action == Action.notice: + return sse_json( + "notice", + { + "notice": item.data, + "process_task_id": item.process_task_id, + }, + ) + if item.action == Action.terminal: + return sse_json( + "terminal", + { + "output": item.data, + "process_task_id": item.process_task_id, + }, + ) + if item.action == Action.todo_state: + return sse_json("todo_state", item.data) + if item.action == Action.budget_not_enough: + return sse_json( + Action.budget_not_enough, {"message": "budget not enough"} + ) + return None + + +async def single_agent_solve( + options: Chat, + request: Request, + task_lock: TaskLock, + hands: IHands | None = None, +): + pause_event = asyncio.Event() + pause_event.set() + agent = None + running_turn: asyncio.Task[tuple[str, int]] | None = None + current_task_id = options.task_id + + async def ensure_agent(task_id: str): + nonlocal agent + if agent is None: + agent = await single_agent( + options, + task_id=task_id, + hands=hands, + pause_event=pause_event, + ) + observable_todo = getattr(agent, "_observable_todo_toolkit", None) + if observable_todo is not None: + observable_todo.task_id = task_id + observable_todo.agent_id = agent.agent_id + observable_todo.emit_todo_state() + return agent + + async def run_turn( + question: str, + attaches: list[str], + task_id: str, + project_context: str | None = None, + ) -> tuple[str, int]: + turn_agent = await ensure_agent(task_id) + turn_agent.process_task_id = task_id + prompt = _build_single_agent_prompt( + task_lock, + question, + attaches, + project_context, + ) + response = await turn_agent.astep(prompt) + content, total_tokens = await _response_content(response) + record_agent_memory_snapshot( + task_lock, + turn_agent, + scope="single_agent", + task_id=task_id, + task_content=question, + task_result=content, + ) + task_lock.add_conversation( + "task_result", + { + "task_content": question, + "task_result": content, + "working_directory": get_working_directory(options, task_lock), + }, + ) + return content, total_tokens + + pending_queue_get: asyncio.Task[Any] = asyncio.create_task( + task_lock.get_queue() + ) + + try: + while True: + if await request.is_disconnected(): + logger.info( + "Single Agent client disconnected; pausing session", + extra={"project_id": options.project_id}, + ) + pause_event.clear() + task_lock.status = Status.confirming + if running_turn and not running_turn.done(): + running_turn.cancel() + break + + wait_for = {pending_queue_get} + if running_turn is not None: + wait_for.add(running_turn) + + done, _ = await asyncio.wait( + wait_for, + timeout=1.0, + return_when=asyncio.FIRST_COMPLETED, + ) + if not done: + continue + + if pending_queue_get in done: + item = pending_queue_get.result() + pending_queue_get = asyncio.create_task(task_lock.get_queue()) + + if item.action == Action.improve: + assert isinstance(item, ActionImproveData) + if item.new_task_id: + current_task_id = item.new_task_id + set_current_task_id( + options.project_id, current_task_id + ) + + if running_turn is not None and not running_turn.done(): + yield sse_json( + "error", + { + "message": ( + "Single Agent is already processing a task." + ) + }, + ) + continue + + pause_event.set() + task_lock.status = Status.processing + yield sse_json( + "confirmed", {"question": item.data.question} + ) + running_turn = asyncio.create_task( + run_turn( + item.data.question, + item.data.attaches or [], + current_task_id, + item.data.project_context + or options.project_context, + ) + ) + task_lock.add_background_task(running_turn) + continue + + if item.action == Action.pause: + pause_event.clear() + task_lock.status = Status.confirming + continue + + if item.action == Action.resume: + pause_event.set() + task_lock.status = Status.processing + continue + + if item.action == Action.skip_task: + pause_event.clear() + stop_message = ( + "Task stoppedTask stopped by user" + ) + cancelled_turn = running_turn + # Drop our reference first so the next asyncio.wait does + # not block on the cancelled task, and so the duplicate + # "end" path further down cannot re-surface it. + running_turn = None + if ( + cancelled_turn is not None + and not cancelled_turn.done() + ): + cancelled_turn.cancel() + + # Attach a done callback that swallows CancelledError / + # whatever exception the turn surfaces post-cancel, so + # the asyncio loop does not log "Task exception was + # never retrieved". We deliberately do NOT await the + # task here: model HTTP calls, browser actions, or + # MCP tool calls may not propagate CancelledError + # promptly, and awaiting would block the SSE response + # generator -- the user would press Skip and see + # nothing happen. + def _swallow(task: asyncio.Task) -> None: + try: + task.result() + except (asyncio.CancelledError, Exception): + pass + + cancelled_turn.add_done_callback(_swallow) + task_lock.status = Status.done + _finalize_memory_for_turn( + task_lock, + state="cancelled", + final_result=stop_message, + ) + yield sse_json("end", stop_message) + continue + + if item.action == Action.stop: + pause_event.clear() + if agent is not None and getattr( + agent, "stop_event", None + ): + agent.stop_event.set() + if running_turn is not None and not running_turn.done(): + running_turn.cancel() + await delete_task_lock(task_lock.id) + break + + payload = _action_to_sse(item) + if payload is not None: + if item.action == Action.budget_not_enough: + pause_event.clear() + task_lock.status = Status.confirming + yield payload + continue + + if running_turn is not None and running_turn in done: + try: + final_result, total_tokens = running_turn.result() + except asyncio.CancelledError: + final_result = "Task pausedTask paused" + total_tokens = 0 + except Exception as e: + logger.error( + "Single Agent turn failed", + extra={ + "project_id": options.project_id, + "task_id": current_task_id, + }, + exc_info=True, + ) + pause_event.clear() + task_lock.status = Status.confirming + _finalize_memory_for_turn( + task_lock, state="failed", error=str(e) + ) + yield sse_json("error", {"message": str(e)}) + running_turn = None + continue + + task_lock.status = Status.done + running_turn = None + _finalize_memory_for_turn( + task_lock, + state="done", + final_result=final_result, + ) + yield sse_json( + "end", + {"message": final_result, "tokens": total_tokens}, + ) + continue + finally: + if pending_queue_get is not None and not pending_queue_get.done(): + pending_queue_get.cancel() + if running_turn is not None and not running_turn.done(): + pause_event.clear() + task_lock.status = Status.confirming + running_turn.cancel() + # If the loop exits without a clean done/failed end-of-turn (client + # disconnect, stop, exception), record a cancelled run. The + # `_memory_finalized_runs` set on task_lock makes this idempotent: + # a prior done/failed write wins, this only catches the unfinished + # case. + _finalize_memory_for_turn(task_lock, state="cancelled") + if agent is not None: + release_cdp = getattr(agent, "_cdp_release_callback", None) + if callable(release_cdp): + try: + release_cdp(agent) + except Exception: + logger.warning( + "Failed to release Single Agent browser resource", + extra={ + "project_id": options.project_id, + "task_id": current_task_id, + }, + exc_info=True, + ) 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..f65f2f593 --- /dev/null +++ b/backend/app/service/skill_service.py @@ -0,0 +1,300 @@ +# ========= 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 re +import shutil +import tempfile +import zipfile +from pathlib import Path + +SKILLS_ROOT = Path.home() / ".eigent" / "skills" +SKILL_FILE = "SKILL.md" +logger = logging.getLogger("skill_service") + + +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: + return {"success": False, "error": "Invalid zip file"} + except Exception: + logger.exception("Failed to import skills from zip archive") + return {"success": False, "error": "Failed to import skills"} + finally: + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/backend/app/service/task.py b/backend/app/service/task.py index 604fbc717..eb0883bba 100644 --- a/backend/app/service/task.py +++ b/backend/app/service/task.py @@ -33,9 +33,12 @@ UpdateData, ) from app.model.enums import Status +from app.run_context import RunContext logger = logging.getLogger("task_service") +TASK_LOCK_CLEANUP_SENTINEL = "__task_lock_cleanup__" + class Action(str, Enum): improve = "improve" # user -> backend @@ -58,6 +61,7 @@ class Action(str, Enum): search_mcp = "search_mcp" # backend -> user install_mcp = "install_mcp" # backend -> user terminal = "terminal" # backend -> user + todo_state = "todo_state" # backend -> user end = "end" # backend -> user stop = "stop" # user -> backend supplement = "supplement" # user -> backend @@ -76,6 +80,7 @@ class ImprovePayload(BaseModel): question: str attaches: list[str] = [] + project_context: str | None = None class ActionImproveData(BaseModel): @@ -219,6 +224,11 @@ class ActionTerminalData(BaseModel): data: str +class ActionTodoStateData(BaseModel): + action: Literal[Action.todo_state] = Action.todo_state + data: dict + + class ActionStopData(BaseModel): action: Literal[Action.stop] = Action.stop @@ -296,6 +306,7 @@ class ActionSkipTaskData(BaseModel): | ActionSearchMcpData | ActionInstallMcpData | ActionTerminalData + | ActionTodoStateData | ActionStopData | ActionEndData | ActionTimeoutData @@ -321,6 +332,7 @@ class Agents(str, Enum): multi_modal_agent = "multi_modal_agent" social_media_agent = "social_media_agent" mcp_agent = "mcp_agent" + single_agent = "single_agent" class TaskLock: @@ -343,14 +355,46 @@ class TaskLock: # Context management fields conversation_history: list[dict[str, Any]] """Store conversation history for context""" + agent_memory_history: list[dict[str, Any]] + """Serialized ChatAgent memory snapshots for session continuity""" + memory_summary: str + """Compressed summary of older serialized agent memory""" last_task_result: str """Store the last task execution result""" + last_task_summary: str + """Store the last generated task summary""" question_agent: Any | None """Persistent question confirmation agent""" summary_generated: bool """Track if summary has been generated for this project""" current_task_id: str | None """Current task ID to be used in SSE responses""" + run_context: RunContext | None + """Current task-scoped runtime context for this Project.""" + user_id: str | int | None + """Canonical user id when provided by the control plane.""" + working_directory: str | None + """Resolved source/work directory for the current Run.""" + task_output_root: str | None + """Resolved artifact/output directory for the current Run.""" + task_start_time: float | None + """Timestamp captured when the current Run directories were frozen.""" + email: str | None + """Legacy/display user email associated with the current Run.""" + project_id: str | None + """Project id associated with the current Run.""" + space_id: str | None + """Space id associated with the current Run.""" + workdir_mode: str | None + """Actual workdir mode used by the current Run.""" + base_snapshot_id: str | None + """Project workdir baseline snapshot id, when available.""" + new_folder_path: Any | None + """Legacy cleanup marker for default output directories.""" + memory_service: Any | None + """MemoryService bound for this Run; used by single_agent_service for on_run_end.""" + _memory_finalized_runs: set[str] + """Run ids whose durable memory lifecycle has already been finalized.""" def __init__( self, id: str, queue: asyncio.Queue, human_input: dict @@ -365,10 +409,26 @@ def __init__( # Initialize context management fields self.conversation_history = [] + self.agent_memory_history = [] + self.memory_summary = "" self.last_task_result = "" self.last_task_summary = "" self.question_agent = None + self.summary_generated = False self.current_task_id = None + self.run_context = None + self.user_id = None + self.working_directory = None + self.task_output_root = None + self.task_start_time = None + self.email = None + self.project_id = None + self.space_id = None + self.workdir_mode = None + self.base_snapshot_id = None + self.new_folder_path = None + self.memory_service = None + self._memory_finalized_runs = set() logger.info( "Task lock initialized", @@ -444,6 +504,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: @@ -512,6 +582,18 @@ def add_conversation(self, role: str, content: str | dict): } ) + def add_agent_memory_snapshot(self, snapshot: dict[str, Any]) -> None: + logger.debug( + "Adding agent memory snapshot", + extra={ + "task_id": self.id, + "scope": snapshot.get("scope"), + "agent_name": snapshot.get("agent_name"), + "message_count": len(snapshot.get("messages", [])), + }, + ) + self.agent_memory_history.append(snapshot) + def get_recent_context(self, max_entries: int = None) -> str: """Get recent conversation context as a formatted string""" if not self.conversation_history: diff --git a/backend/app/utils/agent_memory.py b/backend/app/utils/agent_memory.py new file mode 100644 index 000000000..be02fe5bb --- /dev/null +++ b/backend/app/utils/agent_memory.py @@ -0,0 +1,363 @@ +# ========= 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 datetime +import json +import logging +import os +from typing import Any + +logger = logging.getLogger("agent_memory") + + +# Per-message snapshot caps (override via env). The defaults are tuned so a +# workforce single-task snapshot stays well under the 200K in-process budget +# even with 6+ agents + accumulator duplication. Apply only to the snapshot +# accumulator, NOT to what's fed to the live agent (live prompts keep full +# fidelity via memory.get_context()). +def _env_int(name: str, default: int) -> int: + raw = os.environ.get(name) + if raw is None or not raw.strip(): + return default + try: + return int(raw) + except ValueError: + logger.warning( + "Invalid %s=%r; falling back to default %d", name, raw, default + ) + return default + + +_SNAPSHOT_MESSAGE_CONTENT_CAP = _env_int("EIGENT_SNAPSHOT_MESSAGE_CAP", 4000) +_SNAPSHOT_TOOL_ARG_CAP = _env_int("EIGENT_SNAPSHOT_TOOL_ARG_CAP", 2000) +_SNAPSHOT_TASK_FIELD_CAP = _env_int("EIGENT_SNAPSHOT_TASK_FIELD_CAP", 8000) +_TRUNCATION_MARKER = "... [snapshot truncated]" + + +def _value(obj: Any, key: str, default: Any = None) -> Any: + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + +def _truncate_for_snapshot(text: str, cap: int) -> str: + """Cap a single string at `cap` chars. Live prompts go through the + untruncated `memory.get_context()`; only the snapshot copy gets trimmed. + """ + + if cap <= 0 or len(text) <= cap: + return text + keep = max(0, cap - len(_TRUNCATION_MARKER)) + return text[:keep] + _TRUNCATION_MARKER + + +def _stringify_content(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + try: + return json.dumps(content, ensure_ascii=False) + except Exception: + return str(content) + + +def _shrink_tool_arguments(arguments: Any) -> Any: + """Recursively cap long string fields inside tool_call arguments. + + Tool calls like ``write_file(content=)`` or screenshot tools + that pass base64 blobs in their arguments are the dominant contributors + to snapshot bloat -- a single tool call can be tens of kB. We keep the + structure intact so the snapshot is still readable. + """ + + if isinstance(arguments, str): + return _truncate_for_snapshot(arguments, _SNAPSHOT_TOOL_ARG_CAP) + if isinstance(arguments, dict): + return {k: _shrink_tool_arguments(v) for k, v in arguments.items()} + if isinstance(arguments, list): + return [_shrink_tool_arguments(v) for v in arguments] + return arguments + + +def serialize_tool_call(tool_call: Any) -> dict[str, Any]: + function = _value(tool_call, "function", tool_call) + arguments = _value(function, "arguments", {}) + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except json.JSONDecodeError: + arguments = {"raw": arguments} + arguments = _shrink_tool_arguments(arguments) + + return { + "id": _value(tool_call, "id"), + "function": { + "name": _value(function, "name", "unknown"), + "arguments": arguments, + }, + } + + +def serialize_message(message: Any) -> dict[str, Any]: + tool_calls = _value(message, "tool_calls", None) or [] + content_str = _stringify_content(_value(message, "content", "")) + result = { + "role": _value(message, "role", "assistant"), + "content": _truncate_for_snapshot( + content_str, _SNAPSHOT_MESSAGE_CONTENT_CAP + ), + "tool_calls": [ + serialize_tool_call(tool_call) for tool_call in tool_calls + ], + } + tool_call_id = _value(message, "tool_call_id", None) + if tool_call_id is not None: + result["tool_call_id"] = tool_call_id + return result + + +def serialize_agent_memory(agent: Any) -> list[dict[str, Any]]: + memory = getattr(agent, "memory", None) + if memory is None or not hasattr(memory, "get_context"): + return [] + + try: + messages, _ = memory.get_context() + except Exception as e: + logger.warning( + "Failed to serialize agent memory", + extra={ + "agent_name": getattr(agent, "agent_name", None), + "error": str(e), + }, + ) + return [] + + return [serialize_message(message) for message in messages] + + +def build_agent_memory_snapshot( + agent: Any, + *, + scope: str, + task_id: str | None = None, + task_content: str | None = None, + task_result: str | None = None, +) -> dict[str, Any] | None: + messages = serialize_agent_memory(agent) + if not messages: + return None + + return { + "scope": scope, + "task_id": task_id, + "agent_name": getattr(agent, "agent_name", None) + or getattr(agent, "role_name", None) + or agent.__class__.__name__, + "agent_id": getattr(agent, "agent_id", None), + "task_content": _truncate_for_snapshot( + task_content or "", _SNAPSHOT_TASK_FIELD_CAP + ) + if task_content + else task_content, + "task_result": _truncate_for_snapshot( + task_result or "", _SNAPSHOT_TASK_FIELD_CAP + ) + if task_result + else task_result, + "messages": messages, + "timestamp": datetime.datetime.now().isoformat(), + } + + +def record_agent_memory_snapshot( + task_lock: Any, + agent: Any, + *, + scope: str, + task_id: str | None = None, + task_content: str | None = None, + task_result: str | None = None, +) -> dict[str, Any] | None: + snapshot = build_agent_memory_snapshot( + agent, + scope=scope, + task_id=task_id, + task_content=task_content, + task_result=task_result, + ) + if snapshot is None: + return None + + add_snapshot = getattr(task_lock, "add_agent_memory_snapshot", None) + if callable(add_snapshot): + add_snapshot(snapshot) + else: + task_lock.agent_memory_history = getattr( + task_lock, "agent_memory_history", [] + ) + task_lock.agent_memory_history.append(snapshot) + return snapshot + + +def _iter_workforce_agents(workforce: Any): + for attr, label in ( + ("coordinator_agent", "workforce_coordinator"), + ("task_agent", "workforce_task_planner"), + ("new_worker_agent", "workforce_new_worker"), + ): + agent = getattr(workforce, attr, None) + if agent is not None: + yield label, agent + + for child in getattr(workforce, "_children", []) or []: + worker = getattr(child, "worker", None) + if worker is not None: + yield "workforce_worker_template", worker + accumulator = getattr(child, "_conversation_accumulator", None) + if accumulator is not None: + yield "workforce_worker_accumulator", accumulator + + +def _message_dedup_key(msg: dict[str, Any]) -> str: + return json.dumps(msg, ensure_ascii=False, sort_keys=True) + + +def _append_snapshot_to_task_lock( + task_lock: Any, snapshot: dict[str, Any] +) -> None: + add_snapshot = getattr(task_lock, "add_agent_memory_snapshot", None) + if callable(add_snapshot): + add_snapshot(snapshot) + return + task_lock.agent_memory_history = ( + getattr(task_lock, "agent_memory_history", None) or [] + ) + task_lock.agent_memory_history.append(snapshot) + + +def record_workforce_memory_snapshot( + task_lock: Any, + workforce: Any, + *, + task_id: str | None = None, + task_content: str | None = None, + task_result: str | None = None, +) -> list[dict[str, Any]]: + """Snapshot every workforce-side agent, with cross-agent message dedup. + + Workforce enumerates ~6 agents (coordinator / planner / template worker / + per-child worker + accumulator). Most of them re-record the same + conversation under different scopes -- accumulators are near-clones of + their workers, and coordinator/planner often echo each other. Without + dedup this is the dominant bloat source in `agent_memory_history`. + + Strategy: build all snapshots first; for each subsequent agent keep only + messages we have NOT already seen in an earlier scope. Append only the + dedup'd snapshot to `task_lock.agent_memory_history`, so the in-process + history matches what we return. + """ + + snapshots: list[dict[str, Any]] = [] + seen_message_keys: set[str] = set() + for scope, agent in _iter_workforce_agents(workforce): + snapshot = build_agent_memory_snapshot( + agent, + scope=scope, + task_id=task_id, + task_content=task_content, + task_result=task_result, + ) + if snapshot is None: + continue + if seen_message_keys: + original_count = len(snapshot["messages"]) + snapshot["messages"] = [ + msg + for msg in snapshot["messages"] + if _message_dedup_key(msg) not in seen_message_keys + ] + dropped = original_count - len(snapshot["messages"]) + if dropped: + snapshot["dedup_dropped_from_earlier_agent"] = dropped + if not snapshot["messages"]: + continue + for msg in snapshot["messages"]: + seen_message_keys.add(_message_dedup_key(msg)) + _append_snapshot_to_task_lock(task_lock, snapshot) + snapshots.append(snapshot) + return snapshots + + +def _truncate(text: str, limit: int) -> str: + if len(text) <= limit: + return text + return text[:limit] + f"... (truncated, total length: {len(text)} chars)" + + +def build_memory_context( + task_lock: Any, + *, + max_snapshots: int = 3, + max_messages_per_snapshot: int = 12, + max_chars_per_message: int = 1200, +) -> str: + snapshots = getattr(task_lock, "agent_memory_history", []) or [] + summary = getattr(task_lock, "memory_summary", "") or "" + if not snapshots and not summary: + return "" + + lines = ["=== Serialized Agent Memory ==="] + if summary: + lines.append("Memory Summary:") + lines.append(_truncate(summary, max_chars_per_message * 2)) + + for snapshot in snapshots[-max_snapshots:]: + agent_name = snapshot.get("agent_name") or "agent" + scope = snapshot.get("scope") or "agent" + task_id = snapshot.get("task_id") or "" + lines.append(f"[{scope}] {agent_name} task_id={task_id}".strip()) + + messages = snapshot.get("messages") or [] + for message in messages[-max_messages_per_snapshot:]: + role = message.get("role", "assistant") + content = _truncate( + message.get("content", ""), max_chars_per_message + ) + tool_calls = message.get("tool_calls") or [] + if tool_calls: + names = [ + call.get("function", {}).get("name", "unknown") + for call in tool_calls + ] + lines.append(f"{role} tool_calls: {', '.join(names)}") + if content: + lines.append(f"{role}: {content}") + + lines.append("=== End Serialized Agent Memory ===") + return "\n".join(lines) + "\n\n" + + +def estimate_memory_size(task_lock: Any) -> int: + snapshots = getattr(task_lock, "agent_memory_history", []) or [] + summary = getattr(task_lock, "memory_summary", "") or "" + total = len(summary) + for snapshot in snapshots: + total += len(snapshot.get("task_content") or "") + total += len(snapshot.get("task_result") or "") + for message in snapshot.get("messages") or []: + total += len(message.get("content") or "") + total += len(json.dumps(message.get("tool_calls") or [])) + return total diff --git a/backend/app/utils/browser_launcher.py b/backend/app/utils/browser_launcher.py new file mode 100644 index 000000000..be0127068 --- /dev/null +++ b/backend/app/utils/browser_launcher.py @@ -0,0 +1,382 @@ +# ========= 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 json +import logging +import os +import platform +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 +FALLBACK_CDP_PORT_START = 9223 +FALLBACK_CDP_PORT_END = 9299 +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.""" + 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 + 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) + if r.status_code != 200: + return False + return _is_supported_cdp_version(r.json(), normalized) + except Exception: + return False + + +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 Playwright-compatible CDP browser is listening.""" + try: + import httpx + + r = httpx.get(f"http://127.0.0.1:{port}/json/version", timeout=2.0) + 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() + + # 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 + + +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/app/utils/cdp_browser_state.py b/backend/app/utils/cdp_browser_state.py new file mode 100644 index 000000000..8d3f2458e --- /dev/null +++ b/backend/app/utils/cdp_browser_state.py @@ -0,0 +1,167 @@ +# ========= 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 time +from typing import Any + +from fastapi import Request + +from app.component.environment import env +from app.utils.browser_launcher import ( + _is_cdp_available, + is_cdp_url_available, + is_local_cdp_host, + normalize_cdp_url, +) + +logger = logging.getLogger("cdp_browser_state") + +_web_cdp_browser_meta_by_owner: dict[str, dict[str, Any]] = {} + + +def browser_owner_key(request: Request | None) -> str: + auth = getattr(getattr(request, "state", None), "brain_auth", None) + user_id = getattr(auth, "user_id", None) + if user_id and user_id != "local": + return str(user_id) + + # Bridge release fallback: NoneAuth still emits "local", while the + # frontend already sends X-User-ID. Phase B auth will make this token-only. + if request is not None: + header_user_id = request.headers.get("x-user-id") + if header_user_id: + return header_user_id + return "local" + + +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[str, Any]: + 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(owner_key: str) -> str | None: + if owner_key in _web_cdp_browser_meta_by_owner: + return _web_cdp_browser_meta_by_owner[owner_key].get("endpoint") + cdp_url = env("EIGENT_CDP_URL") + if cdp_url: + return cdp_url + return None + + +def get_connected_cdp_endpoint_for_request( + request: Request | None, +) -> str | None: + return get_connected_cdp_endpoint(browser_owner_key(request)) + + +def get_connected_cdp_meta(owner_key: str) -> dict[str, Any] | None: + return _web_cdp_browser_meta_by_owner.get(owner_key) + + +def get_connected_cdp_port(owner_key: str) -> int | None: + cdp_url = get_connected_cdp_endpoint(owner_key) + 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( + owner_key: str, + endpoint: str, + *, + is_external: bool, + name: str | None = None, + resource_session_id: str | None = None, + managed_by: str = "local", +) -> dict[str, Any]: + normalized_endpoint, _, _ = normalize_cdp_url(endpoint) + browser = build_web_cdp_browser( + normalized_endpoint, + is_external=is_external, + name=name, + resource_session_id=resource_session_id, + managed_by=managed_by, + ) + _web_cdp_browser_meta_by_owner[owner_key] = browser + return browser + + +def clear_connected_cdp_browser(owner_key: str) -> None: + _web_cdp_browser_meta_by_owner.pop(owner_key, None) + + +def clear_connected_cdp_browser_for_request(request: Request | None) -> None: + clear_connected_cdp_browser(browser_owner_key(request)) + + +def is_cdp_endpoint_available(endpoint: str) -> bool: + _, host, port = normalize_cdp_url(endpoint) + if is_local_cdp_host(host): + return _is_cdp_available(port) + + return is_cdp_url_available(endpoint) + + +def list_connected_cdp_browsers(owner_key: str) -> list[dict[str, Any]]: + meta = _web_cdp_browser_meta_by_owner.get(owner_key) + endpoint = get_connected_cdp_endpoint(owner_key) + if endpoint is None: + return [] + + if not is_cdp_endpoint_available(endpoint): + if meta and meta.get("endpoint") == endpoint: + clear_connected_cdp_browser(owner_key) + return [] + + if meta and meta.get("endpoint") == endpoint: + return [meta] + + return [build_web_cdp_browser(endpoint, is_external=True)] diff --git a/backend/app/utils/event_loop_utils.py b/backend/app/utils/event_loop_utils.py index 13207054e..3eea34e82 100644 --- a/backend/app/utils/event_loop_utils.py +++ b/backend/app/utils/event_loop_utils.py @@ -13,9 +13,12 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import asyncio +import concurrent.futures import contextvars import logging +from collections.abc import Coroutine from threading import Lock +from typing import Any # Thread-safe reference to main event loop using contextvars # This ensures each request has its own event loop reference, avoiding race conditions @@ -42,28 +45,81 @@ def set_main_event_loop(loop: asyncio.AbstractEventLoop | None): _GLOBAL_MAIN_LOOP = loop +def _get_registered_main_loop() -> asyncio.AbstractEventLoop | None: + main_loop = _main_event_loop_var.get() + if main_loop is None: + with _GLOBAL_MAIN_LOOP_LOCK: + main_loop = _GLOBAL_MAIN_LOOP + if main_loop is not None and main_loop.is_running(): + return main_loop + return None + + def _schedule_async_task(coro): """Schedule an async coroutine as a task, thread-safe. This function handles scheduling from both the main event loop thread and from worker threads (e.g., when using asyncio.to_thread). """ + main_loop = _get_registered_main_loop() try: - # Try to get the running loop (works in main event loop thread) + # Try to get the running loop. If this is a secondary loop, schedule + # back onto the registered main loop so TaskLock queue wakeups happen + # on the consumer loop. loop = asyncio.get_running_loop() - loop.create_task(coro) + if main_loop is not None and main_loop is not loop: + return asyncio.run_coroutine_threadsafe(coro, main_loop) + return loop.create_task(coro) except RuntimeError: # No running loop in this thread (we're in a worker thread) - # First try contextvars, then fallback to global reference - main_loop = _main_event_loop_var.get() - if main_loop is None: - with _GLOBAL_MAIN_LOOP_LOCK: - main_loop = _GLOBAL_MAIN_LOOP - if main_loop is not None and main_loop.is_running(): - asyncio.run_coroutine_threadsafe(coro, main_loop) + if main_loop is not None: + return 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." ) + return None + + +def schedule_async_task_from_worker( + coro: Coroutine[Any, Any, Any], + *, + timeout: float = 5.0, + description: str = "async task", +) -> Any: + """Run a coroutine on the registered main loop from a sync worker. + + Sync FastAPI endpoints run in Starlette's thread pool, but TaskLock queues + are consumed on the main event loop. Calling ``asyncio.run`` in those + worker threads creates a temporary loop and can fail to wake the main-loop + queue consumer. This helper schedules the coroutine on the registered main + loop and waits briefly so callers get a real failure instead of a silent + dropped event. + """ + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + close = getattr(coro, "close", None) + if callable(close): + close() + raise RuntimeError( + f"{description} must be awaited directly from async code" + ) + + future = _schedule_async_task(coro) + if future is None: + raise RuntimeError(f"Could not schedule {description}") + + try: + return future.result(timeout=timeout) + except concurrent.futures.TimeoutError as exc: + raise TimeoutError( + f"Timed out waiting for {description} after {timeout}s" + ) from exc diff --git a/backend/app/utils/file_utils.py b/backend/app/utils/file_utils.py index 7ffad28af..032e3a49c 100644 --- a/backend/app/utils/file_utils.py +++ b/backend/app/utils/file_utils.py @@ -18,11 +18,13 @@ import os import platform import shutil +import time from pathlib import Path from app.component.environment import env from app.exception.exception import PathEscapesBaseError from app.model.chat import Chat +from app.run_context import get_current_run_context logger = logging.getLogger("file_utils") @@ -31,7 +33,7 @@ MAX_PATH_LENGTH_UNIX = 4096 # Default directory names to skip when listing (list_files) DEFAULT_SKIP_DIRS = frozenset( - {".git", "node_modules", "__pycache__", "venv", ".venv"} + {".git", "node_modules", "__pycache__", "venv", ".venv", "camel_logs"} ) # Default file extensions to skip when listing (list_files) DEFAULT_SKIP_EXTENSIONS: tuple[str, ...] = (".pyc", ".tmp", ".temp") @@ -195,6 +197,7 @@ def list_files( skip_dirs: set[str] | None = None, skip_extensions: tuple[str, ...] = DEFAULT_SKIP_EXTENSIONS, skip_prefix: str = ".", + stats: dict[str, float | int] | None = None, ) -> list[str]: """List files under dir_path with optional base confinement and filters. If base is set, only returns paths that resolve under base (no traversal). @@ -228,8 +231,19 @@ def list_files( except OSError: return [] base_real = os.path.realpath(resolve_base) - skip_dirs = set(DEFAULT_SKIP_DIRS) if skip_dirs is None else skip_dirs + skip_dirs = set(DEFAULT_SKIP_DIRS).union(skip_dirs or set()) result: list[str] = [] + scan_started = time.perf_counter() + realpath_elapsed = 0.0 + symlink_count = 0 + + def record_stats() -> None: + if stats is None: + return + stats["scan_elapsed_ms"] = (time.perf_counter() - scan_started) * 1000 + stats["realpath_elapsed_ms"] = realpath_elapsed * 1000 + stats["symlink_count"] = symlink_count + try: for root, dirs, files in os.walk(resolved_dir, followlinks=False): dirs[:] = [ @@ -242,22 +256,33 @@ def list_files( continue try: file_path = os.path.join(root, name) - real_path = os.path.realpath(file_path) - if not _is_under_base(real_path, base_real): - logger.debug( - "list_files: skipping %r (escapes base)", file_path + if os.path.islink(file_path): + symlink_count += 1 + realpath_started = time.perf_counter() + real_path = os.path.realpath(file_path) + realpath_elapsed += ( + time.perf_counter() - realpath_started ) - continue - result.append(real_path) + if not _is_under_base(real_path, base_real): + logger.debug( + "list_files: skipping %r (escapes base)", + file_path, + ) + continue + result.append(real_path) + else: + result.append(os.path.normpath(file_path)) if len(result) >= max_entries: logger.debug( "list_files hit max_entries=%d", max_entries ) + record_stats() return result except OSError: continue except OSError as e: logger.warning("list_files failed for %r: %s", dir_path, e) + record_stats() return result @@ -280,6 +305,12 @@ def get_working_directory(options: Chat, task_lock=None) -> str: and task_lock.new_folder_path ): raw = Path(task_lock.new_folder_path) + elif task_lock and getattr(task_lock, "working_directory", None): + raw = Path(task_lock.working_directory) + elif ( + context := get_current_run_context() + ) is not None and context.project_id == options.project_id: + raw = context.working_directory else: raw = Path(env("file_save_path", options.file_save_path())) diff --git a/backend/app/utils/listen/toolkit_listen.py b/backend/app/utils/listen/toolkit_listen.py index 1c2c9bbc6..d73cc3a82 100644 --- a/backend/app/utils/listen/toolkit_listen.py +++ b/backend/app/utils/listen/toolkit_listen.py @@ -15,8 +15,6 @@ import asyncio import json import logging -import queue -import threading from collections.abc import Callable from datetime import datetime from functools import wraps @@ -30,6 +28,7 @@ get_task_lock, process_task, ) +from app.utils.event_loop_utils import _schedule_async_task logger = logging.getLogger("toolkit_listen") @@ -192,63 +191,57 @@ def _filter_kwargs_for_callable( def _safe_put_queue(task_lock, data): """Safely put data to the queue, handling both sync and async contexts""" try: - # Try to get current event loop - asyncio.get_running_loop() - - # We're in an async context, create a task - task = asyncio.create_task(task_lock.put_queue(data)) + try: + asyncio.get_running_loop() + has_running_loop = True + except RuntimeError: + has_running_loop = False + + scheduled = _schedule_async_task(task_lock.put_queue(data)) + if scheduled is None: + logger.error( + "[SAFE_PUT_QUEUE] Failed to schedule queue event", + extra={"event_type": data.__class__.__name__}, + ) + return - if hasattr(task_lock, "add_background_task"): - task_lock.add_background_task(task) + if isinstance(scheduled, asyncio.Task): + if hasattr(task_lock, "add_background_task"): + task_lock.add_background_task(scheduled) - # Add done callback to handle any exceptions - def handle_task_result(t): - try: - t.result() - except Exception as e: - logger.error(f"[SAFE_PUT_QUEUE] Background task failed: {e}") + def handle_task_result(t): + try: + t.result() + except Exception as e: + logger.error( + f"[SAFE_PUT_QUEUE] Background task failed: {e}" + ) - task.add_done_callback(handle_task_result) + scheduled.add_done_callback(handle_task_result) + return - except RuntimeError: - # No running event loop, run in a separate thread - try: - result_queue = queue.Queue() + if has_running_loop: - def run_in_thread(): + def handle_threadsafe_result(t): try: - new_loop = asyncio.new_event_loop() - asyncio.set_event_loop(new_loop) - try: - new_loop.run_until_complete(task_lock.put_queue(data)) - result_queue.put(("success", None)) - except Exception as e: - logger.error(f"[SAFE_PUT_QUEUE] put_queue failed: {e}") - result_queue.put(("error", e)) - finally: - new_loop.close() + t.result() except Exception as e: - logger.error(f"[SAFE_PUT_QUEUE] Thread failed: {e}") - result_queue.put(("error", e)) + logger.error(f"[SAFE_PUT_QUEUE] put_queue failed: {e}") - thread = threading.Thread(target=run_in_thread, daemon=False) - thread.start() - - # Wait briefly for completion - try: - status, error = result_queue.get(timeout=1.0) - if status == "error": - logger.error( - f"[SAFE_PUT_QUEUE] Thread execution failed: {error}" - ) - except queue.Empty: - logger.warning( - f"[SAFE_PUT_QUEUE] Thread timeout after 1s " - f"for {data.__class__.__name__}" - ) + scheduled.add_done_callback(handle_threadsafe_result) + return + try: + scheduled.result(timeout=1.0) + except TimeoutError: + logger.warning( + f"[SAFE_PUT_QUEUE] Thread-safe queue scheduling did not " + f"finish within 1s for {data.__class__.__name__}" + ) except Exception as e: - logger.error(f"[SAFE_PUT_QUEUE] Failed to send data to queue: {e}") + logger.error(f"[SAFE_PUT_QUEUE] put_queue failed: {e}") + except Exception as e: + logger.error(f"[SAFE_PUT_QUEUE] Failed to send data to queue: {e}") def listen_toolkit( 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/single_agent_worker.py b/backend/app/utils/single_agent_worker.py index d276326fd..98e080b82 100644 --- a/backend/app/utils/single_agent_worker.py +++ b/backend/app/utils/single_agent_worker.py @@ -31,6 +31,8 @@ from colorama import Fore from app.agent.listen_chat_agent import ListenChatAgent +from app.service.task import get_task_lock +from app.utils.agent_memory import record_agent_memory_snapshot logger = logging.getLogger("single_agent_worker") @@ -269,6 +271,19 @@ async def _process_task( f"Failed to transfer conversation to accumulator: {e}" ) + try: + task_lock = get_task_lock(worker_agent.api_task_id) + record_agent_memory_snapshot( + task_lock, + worker_agent, + scope="workforce_worker", + task_id=task.id, + task_content=task.content, + task_result=response_content, + ) + except Exception as e: + logger.warning(f"Failed to record worker memory snapshot: {e}") + except Exception as e: logger.error( f"Error processing task {task.id}: {type(e).__name__}: {e}" diff --git a/backend/app/utils/space_overlay_client.py b/backend/app/utils/space_overlay_client.py new file mode 100644 index 000000000..0d364debc --- /dev/null +++ b/backend/app/utils/space_overlay_client.py @@ -0,0 +1,231 @@ +# ========= 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 hashlib +import logging +import threading +import weakref +from pathlib import Path, PurePosixPath +from typing import Literal + +import httpx + +from app.run_context import RunContext, get_current_run_context +from app.service.task import get_task_lock_if_exists + +logger = logging.getLogger("space_overlay") + +HASH_CHUNK_SIZE = 1024 * 1024 + +_PATH_LOCKS: weakref.WeakValueDictionary[ + tuple[str, str, str, str], threading.Lock +] = weakref.WeakValueDictionary() +_PATH_LOCKS_GUARD = threading.Lock() +_OVERLAY_SYNC_FAILURES = 0 +_OVERLAY_SYNC_FAILURES_GUARD = threading.Lock() + + +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 sha256_of_file(path: Path) -> str | None: + if not path.exists(): + return None + if path.is_symlink() or not path.is_file(): + raise ValueError(f"Cannot hash non-regular file: {path}") + digest = hashlib.sha256() + with path.open("rb") as handle: + while chunk := handle.read(HASH_CHUNK_SIZE): + digest.update(chunk) + return digest.hexdigest() + + +def normalize_relative_path(path: str) -> str: + normalized = PurePosixPath(path.replace("\\", "/")) + if ( + not normalized.parts + or normalized.is_absolute() + or ".." in normalized.parts + ): + raise ValueError("Invalid relative path") + return str(normalized) + + +def path_write_lock( + space_id: str, + project_id: str, + run_id: str, + rel_path: str, +) -> threading.Lock: + """Return the per-run/path writer lock. + + The lock cache is weakly held to avoid unbounded growth. Callers must keep + the returned lock strongly referenced for the whole critical section, + preferably as `with path_write_lock(...):`. + """ + + key = (space_id, project_id, run_id, rel_path) + with _PATH_LOCKS_GUARD: + lock = _PATH_LOCKS.get(key) + if lock is None: + lock = threading.Lock() + _PATH_LOCKS[key] = lock + return lock + + +def overlay_sync_failure_count() -> int: + with _OVERLAY_SYNC_FAILURES_GUARD: + return _OVERLAY_SYNC_FAILURES + + +def _record_overlay_sync_failure( + *, + reason: str, + context: RunContext, + rel_path: str, + error_message: str, +) -> None: + global _OVERLAY_SYNC_FAILURES + with _OVERLAY_SYNC_FAILURES_GUARD: + _OVERLAY_SYNC_FAILURES += 1 + failure_count = _OVERLAY_SYNC_FAILURES + logger.error( + "space_overlay_sync_failed", + extra={ + "overlay_reason": reason, + "overlay_space_id": context.space_id, + "overlay_project_id": context.project_id, + "overlay_run_id": context.run_id, + "overlay_path": rel_path, + "overlay_failure_count": failure_count, + "overlay_error_message": error_message, + }, + ) + + +def run_context_for_task(api_task_id: str) -> RunContext | None: + context = get_current_run_context() + if context is not None: + return context + task_lock = get_task_lock_if_exists(api_task_id) + return getattr(task_lock, "run_context", None) if task_lock else None + + +def relative_to_workdir( + context: RunContext, path: str | Path +) -> tuple[str, Path] | None: + workdir = context.working_directory.expanduser().resolve() + target = Path(path).expanduser() + if not target.is_absolute(): + target = workdir / target + target = target.resolve() + try: + rel = target.relative_to(workdir) + except ValueError: + return None + return normalize_relative_path(rel.as_posix()), target + + +def should_record_overlay(context: RunContext, target: Path) -> bool: + if not context.server_url or not context.auth_header: + return False + if context.workdir_mode in {"direct-write", "artifact-only"}: + return False + if ( + context.working_directory.resolve() + == context.task_output_root.resolve() + ): + return False + try: + target.relative_to(context.task_output_root.expanduser().resolve()) + return False + except ValueError: + return True + + +def post_overlay_write( + context: RunContext, + rel_path: str, + target_path: Path, + *, + base_hash: str | None, + status: Literal["added", "modified", "deleted"], + file_hash: str | None = None, + size: int | None = None, + mode: int | None = None, +) -> bool: + if not should_record_overlay(context, target_path): + return True + + server_url = normalize_server_url(context.server_url) + if not server_url: + return True + + if status == "deleted": + file_hash = None + elif file_hash is None: + file_hash = sha256_of_file(target_path) + if (size is None or mode is None) and target_path.exists(): + stat_result = target_path.stat() + size = stat_result.st_size if size is None else size + mode = stat_result.st_mode if mode is None else mode + payload = { + "run_id": context.run_id, + "path": rel_path, + "status": status, + "hash": file_hash, + "base_hash": base_hash, + "base_snapshot_id": context.extra_env.get("baseSnapshotId"), + "size": size, + "mode": mode, + "source_path": str(target_path), + "source_root": str(context.working_directory.expanduser().resolve()), + "metadata": {}, + } + url = ( + f"{server_url}/spaces/{context.space_id}/projects/" + f"{context.project_id}/overlays" + ) + headers = {"Authorization": context.auth_header} + if context.user_id: + headers["X-User-ID"] = context.user_id + + try: + with httpx.Client(timeout=5.0) as client: + response = client.post(url, json=payload, headers=headers) + if response.is_error: + _record_overlay_sync_failure( + reason=f"http_{response.status_code}", + context=context, + rel_path=rel_path, + error_message=response.text[:500], + ) + return False + return True + except Exception as exc: # noqa: BLE001 - overlay sync must not fail the tool write. + _record_overlay_sync_failure( + reason="exception", + context=context, + rel_path=rel_path, + error_message=str(exc), + ) + return False 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..31efa9cb4 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,11 @@ 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) + task = _schedule_async_task(task_lock.put_queue(ActionEndData())) + if task is not None: + task_lock.add_background_task(task) logger.info("[WF-LIFECYCLE] ✅ ActionEndData queued") def stop_gracefully(self) -> None: diff --git a/backend/app/utils/workspace_paths.py b/backend/app/utils/workspace_paths.py new file mode 100644 index 000000000..86790e2f9 --- /dev/null +++ b/backend/app/utils/workspace_paths.py @@ -0,0 +1,169 @@ +# ========= 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 +from pathlib import Path + +from app.component.environment import env + + +def get_workspace_root() -> Path: + return Path(env("EIGENT_WORKSPACE", "~/.eigent/workspace")).expanduser() + + +def get_eigent_root() -> Path: + eigent = Path.home() / "eigent" + if eigent.exists(): + return eigent + dot_eigent = Path.home() / ".eigent" + if dot_eigent.exists(): + return dot_eigent + return eigent + + +def sanitize_email(email: str) -> str: + return re.sub(r'[\\/*?:"<>|\s]', "_", email.split("@")[0]).strip(".") + + +def sanitize_identity(value: str | int | None) -> str: + if value is None: + return "" + return re.sub(r'[\\/*?:"<>|\s]', "_", str(value)).strip(".") + + +def runtime_owner_key(email: str, user_id: str | int | None = None) -> str: + user_key = sanitize_identity(user_id) + if user_key: + return f"user_{user_key}" + return sanitize_email(email) + + +def project_root( + email: str, project_id: str, user_id: str | int | None = None +) -> Path: + return ( + get_eigent_root() + / runtime_owner_key(email, user_id) + / f"project_{project_id}" + ) + + +def project_task_root( + email: str, + project_id: str, + task_id: str, + user_id: str | int | None = None, +) -> Path: + return project_root(email, project_id, user_id) / f"task_{task_id}" + + +def legacy_task_root( + email: str, task_id: str, user_id: str | int | None = None +) -> Path: + return ( + get_eigent_root() + / runtime_owner_key(email, user_id) + / f"task_{task_id}" + ) + + +def camel_log_root( + email: str, + project_id: str, + task_id: str, + user_id: str | int | None = None, +) -> Path: + return ( + Path.home() + / ".eigent" + / runtime_owner_key(email, user_id) + / f"project_{project_id}" + / f"task_{task_id}" + / "camel_logs" + ) + + +def legacy_camel_log_root( + email: str, task_id: str, user_id: str | int | None = None +) -> Path: + return ( + Path.home() + / ".eigent" + / runtime_owner_key(email, user_id) + / f"task_{task_id}" + / "camel_logs" + ) + + +def runtime_task_root( + email: str, + project_id: str, + task_id: str, + user_id: str | int | None = None, +) -> Path: + return ( + Path.home() + / ".eigent" + / runtime_owner_key(email, user_id) + / "runtime" + / f"project_{project_id}" + / f"task_{task_id}" + ) + + +def run_output_root( + email: str, + space_id: str, + project_id: str, + run_id: str, + user_id: str | int | None = None, +) -> Path: + return ( + Path.home() + / ".eigent" + / runtime_owner_key(email, user_id) + / "spaces" + / space_id + / "projects" + / project_id + / "runs" + / run_id + ) + + +def project_workdir_root( + email: str, + space_id: str, + project_id: str, + user_id: str | int | None = None, +) -> Path: + return ( + Path.home() + / ".eigent" + / runtime_owner_key(email, user_id) + / "spaces" + / space_id + / "projects" + / project_id + / "workdir" + ) + + +def workspace_state_root(email: str, user_id: str | int | None = None) -> Path: + return ( + Path.home() + / ".eigent" + / "workspaces" + / runtime_owner_key(email, user_id) + ) diff --git a/backend/app/utils/workspace_resolver.py b/backend/app/utils/workspace_resolver.py new file mode 100644 index 000000000..2eef4b317 --- /dev/null +++ b/backend/app/utils/workspace_resolver.py @@ -0,0 +1,670 @@ +# ========= 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 +import os +import shutil +import time +from contextlib import contextmanager +from dataclasses import asdict, dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Literal +from uuid import uuid4 + +from app.model.chat import Chat +from app.router_layer.hands_resolver import get_environment_hands +from app.utils.workspace_paths import ( + camel_log_root, + legacy_camel_log_root, + legacy_task_root, + project_root, + project_task_root, + project_workdir_root, + run_output_root, + workspace_state_root, +) + +try: + import fcntl +except ImportError: # pragma: no cover - Windows fallback + fcntl = None + +logger = logging.getLogger("workspace_resolver") +BindingSource = Literal["space_local_brain", "default"] +WORKDIR_MARKER = ".eigent-workdir.json" +COPY_IGNORE_DIRS = { + ".git", + ".hg", + ".svn", + "node_modules", + ".venv", + "venv", + "dist", + "build", + ".next", + ".cache", + "__pycache__", +} +MAX_COPY_FILE_SIZE = 25 * 1024 * 1024 + + +@contextmanager +def _filesystem_space_lock(source_root: Path): + """Cooperate with server-side Apply while copying a live Space root. + + This uses an advisory lock on the root directory itself, so it does not add + files to the user's repository. It only coordinates with processes that + take the same lock; unsupported platforms fall back to no-op locking. + """ + + if fcntl is None: + yield + return + + fd: int | None = None + try: + fd = os.open(source_root, os.O_RDONLY) + fcntl.flock(fd, fcntl.LOCK_EX) + yield + finally: + if fd is not None: + try: + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) + + +def _binding_enabled_for_current_environment() -> bool: + hands = get_environment_hands() + get_manifest = getattr(hands, "get_capability_manifest", None) + if get_manifest is None: + return False + try: + manifest = get_manifest() + except Exception: + logger.warning( + "Failed to read hands capability manifest for workspace binding", + exc_info=True, + ) + return False + if not isinstance(manifest, dict): + return False + return manifest.get("deployment") == "local" + + +def _same_workspace_path(left: str, right: str) -> bool: + try: + return ( + Path(left).expanduser().resolve() + == Path(right).expanduser().resolve() + ) + except (OSError, RuntimeError): + return False + + +def _folder_fingerprint(path: Path) -> dict[str, Any]: + stat = path.stat() + return { + "kind": "local_folder", + "path": str(path), + "device": stat.st_dev, + "inode": stat.st_ino, + "mtime_ns": stat.st_mtime_ns, + "ctime_ns": stat.st_ctime_ns, + } + + +def _read_workdir_marker(workdir: Path) -> dict[str, Any] | None: + marker = workdir / WORKDIR_MARKER + if not marker.exists(): + return None + try: + return json.loads(marker.read_text(encoding="utf-8")) + except Exception: + logger.warning("Failed to read Project workdir marker: %s", marker) + return None + + +def _copy_space_baseline(source_root: Path, workdir: Path) -> str: + with _filesystem_space_lock(source_root): + existing_marker = _read_workdir_marker(workdir) + if existing_marker and existing_marker.get("base_snapshot_id"): + return str(existing_marker["base_snapshot_id"]) + + workdir.mkdir(parents=True, exist_ok=True) + _copy_tree_limited(source_root, workdir) + + base_snapshot_id = f"snapshot_{uuid4().hex}" + marker = workdir / WORKDIR_MARKER + marker.write_text( + json.dumps( + { + "base_snapshot_id": base_snapshot_id, + "source_root": str(source_root), + "created_at": datetime.now(UTC).isoformat(), + "copy_ignore_dirs": sorted(COPY_IGNORE_DIRS), + "max_copy_file_size": MAX_COPY_FILE_SIZE, + }, + indent=2, + ), + encoding="utf-8", + ) + return base_snapshot_id + + +def _copy_tree_limited(source_root: Path, target_root: Path) -> None: + target_root.mkdir(parents=True, exist_ok=True) + for item in source_root.iterdir(): + if item.name in COPY_IGNORE_DIRS or item.name == WORKDIR_MARKER: + continue + target = target_root / item.name + try: + if item.is_symlink(): + continue + if item.is_dir(): + _copy_tree_limited(item, target) + continue + if item.is_file() and item.stat().st_size <= MAX_COPY_FILE_SIZE: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, target) + except OSError: + logger.warning( + "Failed to copy Space baseline item into Project workdir: %s", + item, + exc_info=True, + ) + + +@dataclass(frozen=True) +class WorkspaceBinding: + space_id: str + workspace_root: str + source: str + created_at: str + updated_at: str + root_fingerprint: dict[str, Any] | None = None + version: int = 2 + + +@dataclass(frozen=True) +class TaskSnapshot: + task_id: str + project_id: str + space_id: str + user_id: str | int | None + working_directory: str + task_output_root: str + task_start_time: float + binding_source: BindingSource + created_at: str + workdir_mode: str | None = None + base_snapshot_id: str | None = None + version: int = 2 + + +class WorkspaceStore: + def _state_roots( + self, email: str, user_id: str | int | None = None + ) -> tuple[Path, ...]: + primary = workspace_state_root(email, user_id) + legacy = workspace_state_root(email) + if user_id is not None and primary != legacy: + return (primary, legacy) + return (primary,) + + def _space_path( + self, email: str, space_id: str, user_id: str | int | None = None + ) -> Path: + return ( + self._state_roots(email, user_id)[0] + / "spaces" + / f"{space_id}.json" + ) + + def _space_paths( + self, email: str, space_id: str, user_id: str | int | None = None + ) -> tuple[Path, ...]: + return tuple( + root / "spaces" / f"{space_id}.json" + for root in self._state_roots(email, user_id) + ) + + def _task_path( + self, email: str, task_id: str, user_id: str | int | None = None + ) -> Path: + return ( + self._state_roots(email, user_id)[0] / "tasks" / f"{task_id}.json" + ) + + def _task_paths( + self, email: str, task_id: str, user_id: str | int | None = None + ) -> tuple[Path, ...]: + return tuple( + root / "tasks" / f"{task_id}.json" + for root in self._state_roots(email, user_id) + ) + + def get_binding( + self, email: str, space_id: str, user_id: str | int | None = None + ) -> WorkspaceBinding | None: + for path in self._space_paths(email, space_id, user_id): + if not path.exists(): + continue + try: + data = json.loads(path.read_text(encoding="utf-8")) + if "space_id" not in data and "project_id" in data: + data["space_id"] = data.pop("project_id") + return WorkspaceBinding(**data) + except Exception: + logger.warning("Failed to read workspace binding: %s", path) + return None + + def save_binding( + self, + email: str, + space_id: str, + workspace_root: str, + *, + user_id: str | int | None = None, + root_fingerprint: dict[str, Any] | None = None, + ) -> WorkspaceBinding: + now = datetime.now(UTC).isoformat() + existing = self.get_binding(email, space_id, user_id) + binding = WorkspaceBinding( + space_id=space_id, + workspace_root=workspace_root, + source="space_local_brain", + created_at=existing.created_at if existing else now, + updated_at=now, + root_fingerprint=root_fingerprint, + ) + primary_path = self._space_path(email, space_id, user_id) + self._atomic_write(primary_path, asdict(binding)) + for legacy_path in self._space_paths(email, space_id, user_id)[1:]: + if legacy_path != primary_path and legacy_path.exists(): + legacy_path.unlink() + return binding + + def _promote_binding_to_user_path( + self, + email: str, + user_id: str | int | None, + binding: WorkspaceBinding, + ) -> None: + if user_id is None: + return + primary_path = self._space_path(email, binding.space_id, user_id) + self._atomic_write(primary_path, asdict(binding)) + for legacy_path in self._space_paths(email, binding.space_id, user_id)[ + 1: + ]: + if legacy_path != primary_path and legacy_path.exists(): + legacy_path.unlink() + + def delete_binding( + self, email: str, space_id: str, user_id: str | int | None = None + ) -> None: + for path in self._space_paths(email, space_id, user_id): + if path.exists(): + path.unlink() + + def reconcile_bindings( + self, + email: str, + active_space_ids: set[str], + user_id: str | int | None = None, + ) -> list[WorkspaceBinding]: + if not active_space_ids: + logger.warning( + "Skipping workspace binding reconciliation with no active Space ids", + extra={"email": email}, + ) + return [] + + removed: list[WorkspaceBinding] = [] + for binding in self.list_bindings(email, user_id): + if binding.space_id in active_space_ids: + self._promote_binding_to_user_path(email, user_id, binding) + continue + self.delete_binding(email, binding.space_id, user_id) + removed.append(binding) + return removed + + def list_bindings( + self, email: str, user_id: str | int | None = None + ) -> list[WorkspaceBinding]: + bindings_by_space: dict[str, WorkspaceBinding] = {} + for root in self._state_roots(email, user_id): + spaces_dir = root / "spaces" + if not spaces_dir.exists(): + continue + for path in spaces_dir.glob("*.json"): + try: + data = json.loads(path.read_text(encoding="utf-8")) + if "space_id" not in data and "project_id" in data: + data["space_id"] = data.pop("project_id") + binding = WorkspaceBinding(**data) + bindings_by_space.setdefault(binding.space_id, binding) + except Exception: + logger.warning( + "Failed to read workspace binding: %s", path + ) + return list(bindings_by_space.values()) + + def get_snapshot( + self, email: str, task_id: str, user_id: str | int | None = None + ) -> TaskSnapshot | None: + for path in self._task_paths(email, task_id, user_id): + if not path.exists(): + continue + try: + data = json.loads(path.read_text(encoding="utf-8")) + if "space_id" not in data: + data["space_id"] = data.get("project_id", "") + data.setdefault("user_id", None) + data.setdefault("workdir_mode", None) + data.setdefault("base_snapshot_id", None) + return TaskSnapshot(**data) + except Exception: + logger.warning("Failed to read task snapshot: %s", path) + return None + + def save_snapshot(self, email: str, snapshot: TaskSnapshot) -> None: + self._atomic_write( + self._task_path(email, snapshot.task_id, snapshot.user_id), + asdict(snapshot), + ) + + def _atomic_write(self, path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(path.suffix + ".tmp") + tmp_path.write_text( + json.dumps(data, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + tmp_path.replace(path) + + +@dataclass(frozen=True) +class FrozenTaskDirectories: + working_directory: Path + task_output_root: Path + task_start_time: float + binding_source: BindingSource + workdir_mode: str | None + base_snapshot_id: str | None + snapshot: TaskSnapshot + + +class WorkspaceResolver: + def __init__(self, store: WorkspaceStore | None = None) -> None: + self.store = store or WorkspaceStore() + + def ensure_space_binding( + self, + email: str, + space_id: str, + root_path: str, + user_id: str | int | None = None, + ) -> WorkspaceBinding: + if not _binding_enabled_for_current_environment(): + raise ValueError( + "Workspace folder binding is disabled in this deployment" + ) + + resolved = Path(root_path).expanduser().resolve() + if not resolved.exists() or not resolved.is_dir(): + raise ValueError("Space root_path is not a readable directory") + + existing = self.store.get_binding(email, space_id, user_id) + if existing is not None: + if _same_workspace_path(existing.workspace_root, str(resolved)): + return existing + raise ValueError("Space is already bound to a different folder") + + return self.store.save_binding( + email, + space_id, + str(resolved), + user_id=user_id, + root_fingerprint=_folder_fingerprint(resolved), + ) + + def space_root( + self, + space_id: str, + project_id: str, + email: str, + user_id: str | int | None = None, + ) -> Path | None: + binding = self.store.get_binding(email, space_id, user_id) + if binding: + bound_path = Path(binding.workspace_root).expanduser() + if bound_path.is_dir(): + return bound_path + + legacy_project = project_root(email, project_id, user_id) + if user_id is not None and not legacy_project.exists(): + legacy_project = project_root(email, project_id) + if legacy_project.exists(): + return legacy_project + return None + + def task_output_root( + self, + space_id: str, + project_id: str, + task_id: str, + email: str, + user_id: str | int | None = None, + ) -> Path: + snapshot = self.store.get_snapshot(email, task_id, user_id) + if snapshot: + return Path(snapshot.task_output_root).expanduser() + + old_project_task = project_task_root( + email, project_id, task_id, user_id + ) + if old_project_task.exists(): + return old_project_task + if user_id is not None: + legacy_project_task = project_task_root(email, project_id, task_id) + if legacy_project_task.exists(): + return legacy_project_task + + old_legacy_task = legacy_task_root(email, task_id, user_id) + if old_legacy_task.exists(): + return old_legacy_task + if user_id is not None: + legacy_email_task = legacy_task_root(email, task_id) + if legacy_email_task.exists(): + return legacy_email_task + + binding = self.store.get_binding(email, space_id, user_id) + if binding: + bound_path = Path(binding.workspace_root).expanduser() + if bound_path.is_dir(): + return run_output_root( + email, space_id, project_id, task_id, user_id + ) + + return old_project_task + + def log_root( + self, + project_id: str, + task_id: str, + email: str, + user_id: str | int | None = None, + ) -> Path: + root = camel_log_root(email, project_id, task_id, user_id) + if root.exists(): + return root + if user_id is not None: + legacy_project_root = camel_log_root(email, project_id, task_id) + if legacy_project_root.exists(): + return legacy_project_root + legacy = legacy_camel_log_root(email, task_id, user_id) + if legacy.exists(): + return legacy + if user_id is not None: + legacy_email = legacy_camel_log_root(email, task_id) + if legacy_email.exists(): + return legacy_email + return root + + def freeze_task_directories( + self, options: Chat, task_lock + ) -> FrozenTaskDirectories: + space_id = options.space_id or options.project_id + task_lock.workdir_mode = options.workdir_mode + if options.space_root_path: + self.ensure_space_binding( + options.email, + space_id, + options.space_root_path, + user_id=options.user_id, + ) + return self.freeze_task_directories_for( + space_id=space_id, + project_id=options.project_id, + task_id=options.task_id, + email=options.email, + task_lock=task_lock, + fallback_task_root=options.file_save_path(), + user_id=options.user_id, + ) + + def freeze_task_directories_for( + self, + space_id: str, + project_id: str, + task_id: str, + email: str, + task_lock, + fallback_task_root: str | Path | None = None, + user_id: str | int | None = None, + ) -> FrozenTaskDirectories: + binding = self.store.get_binding(email, space_id, user_id) + if binding and Path(binding.workspace_root).expanduser().is_dir(): + source_root = Path(binding.workspace_root).expanduser().resolve() + task_output = run_output_root( + email, space_id, project_id, task_id, user_id + ) + workdir_mode = ( + getattr(task_lock, "workdir_mode", None) or "direct-write" + ) + if workdir_mode == "artifact-only": + working_directory = task_output + base_snapshot_id = None + elif workdir_mode == "direct-write": + working_directory = source_root + base_snapshot_id = None + else: + working_directory = project_workdir_root( + email, + space_id, + project_id, + user_id, + ) + base_snapshot_id = _copy_space_baseline( + source_root, working_directory + ) + binding_source: BindingSource = "space_local_brain" + elif fallback_task_root is not None: + working_directory = Path(fallback_task_root).expanduser() + task_output = working_directory + binding_source = "default" + workdir_mode = getattr(task_lock, "workdir_mode", None) + base_snapshot_id = None + else: + working_directory = project_task_root( + email, project_id, task_id, user_id + ) + task_output = working_directory + binding_source = "default" + workdir_mode = getattr(task_lock, "workdir_mode", None) + base_snapshot_id = None + + working_directory.mkdir(parents=True, exist_ok=True) + task_output.mkdir(parents=True, exist_ok=True) + task_start_time = time.time() + snapshot = TaskSnapshot( + task_id=task_id, + project_id=project_id, + space_id=space_id, + user_id=user_id, + working_directory=str(working_directory), + task_output_root=str(task_output), + task_start_time=task_start_time, + binding_source=binding_source, + workdir_mode=workdir_mode, + base_snapshot_id=base_snapshot_id, + created_at=datetime.now(UTC).isoformat(), + ) + + task_lock.working_directory = str(working_directory) + task_lock.task_output_root = str(task_output) + task_lock.task_start_time = task_start_time + task_lock.email = email + task_lock.user_id = user_id + task_lock.project_id = project_id + task_lock.space_id = space_id + task_lock.current_task_id = task_id + task_lock.workdir_mode = workdir_mode + task_lock.base_snapshot_id = base_snapshot_id + + return FrozenTaskDirectories( + working_directory=working_directory, + task_output_root=task_output, + task_start_time=task_start_time, + binding_source=binding_source, + workdir_mode=workdir_mode, + base_snapshot_id=base_snapshot_id, + snapshot=snapshot, + ) + + def write_task_snapshot(self, email: str, snapshot: TaskSnapshot) -> None: + self.store.save_snapshot(email, snapshot) + + def refresh_project_workdir( + self, + *, + space_id: str, + project_id: str, + email: str, + user_id: str | int | None = None, + ) -> str: + binding = self.store.get_binding(email, space_id, user_id) + if binding is None: + raise ValueError("Space is not bound in this Brain") + source_root = Path(binding.workspace_root).expanduser().resolve() + if not source_root.is_dir(): + raise ValueError("Bound Space root is not available") + workdir = project_workdir_root(email, space_id, project_id, user_id) + if workdir.exists(): + shutil.rmtree(workdir) + return _copy_space_baseline(source_root, workdir) + + +_resolver: WorkspaceResolver | None = None + + +def get_workspace_resolver() -> WorkspaceResolver: + global _resolver + if _resolver is None: + _resolver = WorkspaceResolver() + return _resolver 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 00321d984..304ce42dd 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 @@ -43,8 +44,14 @@ 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" +_fallback_camel_log_dir = ( + pathlib.Path.home() / ".eigent" / "fallback" / "camel_logs" +) +_fallback_camel_log_dir.mkdir(parents=True, exist_ok=True) +os.environ.setdefault("CAMEL_LOG_DIR", str(_fallback_camel_log_dir)) app_logger = logging.getLogger("main") @@ -55,6 +62,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") @@ -101,9 +111,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, @@ -113,8 +130,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(): @@ -143,18 +162,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}") + # 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}") -signal.signal(signal.SIGTERM, signal_handler) -signal.signal(signal.SIGINT, signal_handler) + set_main_event_loop(None) + app_logger.info("All resources cleaned up successfully") # Register cleanup on exit with safe synchronous wrapper @@ -174,3 +218,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/agent/factory/test_browser.py b/backend/tests/app/agent/factory/test_browser.py index aab7aaadf..6182600ad 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,117 @@ 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" + ) + + +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/agent/test_agent_model.py b/backend/tests/app/agent/test_agent_model.py index f58343d79..68c129b3f 100644 --- a/backend/tests/app/agent/test_agent_model.py +++ b/backend/tests/app/agent/test_agent_model.py @@ -13,7 +13,7 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import sys -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -37,6 +37,7 @@ def test_agent_model_creation(self, sample_chat_data): mock_task_lock = MagicMock() task_locks[options.task_id] = mock_task_lock + mock_task_lock.put_queue = AsyncMock() _m = sys.modules["app.agent.agent_model"] with ( @@ -84,6 +85,7 @@ async def test_full_agent_workflow(self, sample_chat_data): # Create task lock mock_task_lock = MagicMock() + mock_task_lock.put_queue = AsyncMock() task_locks[api_task_id] = mock_task_lock # Create agent diff --git a/backend/tests/app/agent/toolkit/test_depth_limited_agent_toolkit.py b/backend/tests/app/agent/toolkit/test_depth_limited_agent_toolkit.py new file mode 100644 index 000000000..d8b956585 --- /dev/null +++ b/backend/tests/app/agent/toolkit/test_depth_limited_agent_toolkit.py @@ -0,0 +1,48 @@ +# ========= 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 camel.toolkits import FunctionTool + +from app.agent.toolkit.depth_limited_agent_toolkit import ( + DepthLimitedAgentToolkit, +) + + +def _normal_tool() -> str: + return "ok" + + +class DummyParent: + def __init__(self): + self.agent_toolkit = DepthLimitedAgentToolkit() + + def _clone_tools(self): + return ( + [ + FunctionTool(self.agent_toolkit.agent_run_subagent), + FunctionTool(_normal_tool), + ], + [self.agent_toolkit], + ) + + +def test_depth_limited_agent_toolkit_filters_child_delegation_tools(): + toolkit = DepthLimitedAgentToolkit(current_depth=0, max_depth=1) + + tools, toolkits_to_register = toolkit._resolve_child_tools(DummyParent()) + + tool_names = [tool.get_function_name() for tool in tools] + assert "agent_run_subagent" not in tool_names + assert "_normal_tool" in tool_names + assert toolkits_to_register == [] diff --git a/backend/tests/app/agent/toolkit/test_observable_todo_toolkit.py b/backend/tests/app/agent/toolkit/test_observable_todo_toolkit.py new file mode 100644 index 000000000..472758245 --- /dev/null +++ b/backend/tests/app/agent/toolkit/test_observable_todo_toolkit.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 asyncio + +import pytest + +from app.agent.toolkit.observable_todo_toolkit import ObservableTodoToolkit +from app.service.task import Action, TaskLock, task_locks + + +@pytest.mark.unit +def test_todo_write_emits_todo_state(tmp_path): + project_id = "project_single_agent_todo" + task_id = "task_single_agent_todo" + task_locks[project_id] = TaskLock(project_id, asyncio.Queue(), {}) + + try: + toolkit = ObservableTodoToolkit( + api_task_id=project_id, + task_id=task_id, + agent_id="agent-1", + working_dir=str(tmp_path), + ) + + result = toolkit.todo_write( + [ + { + "content": "Inspect the task", + "active_form": "Inspecting the task", + "status": "in_progress", + } + ] + ) + + assert result == "Todos have been modified successfully." + queued = task_locks[project_id].queue.get_nowait() + assert queued.action == Action.todo_state + assert queued.data["project_id"] == project_id + assert queued.data["task_id"] == task_id + assert queued.data["agent_id"] == "agent-1" + assert queued.data["todos"] == [ + { + "id": "todo_1", + "content": "Inspect the task", + "active_form": "Inspecting the task", + "status": "in_progress", + } + ] + finally: + task_locks.pop(project_id, None) diff --git a/backend/tests/app/component/test_environment.py b/backend/tests/app/component/test_environment.py index 7d4bea78f..2a426a65e 100644 --- a/backend/tests/app/component/test_environment.py +++ b/backend/tests/app/component/test_environment.py @@ -18,8 +18,10 @@ import pytest +import app.component.environment as environment from app.component.environment import ( _load_initial_env_files, + env, env_base_dir, sanitize_env_path, ) @@ -226,3 +228,39 @@ def test_initial_env_files_precedence(monkeypatch, temp_dir: Path): assert os.environ["GLOBAL_ONLY"] == "from_global" assert os.environ["DEVELOPMENT_ONLY"] == "from_development" assert os.environ["PROCESS_KEY"] == "from_process" + + +def test_env_reads_live_dotenv_updates(monkeypatch, temp_dir: Path): + """env() should see dotenv changes written after process startup.""" + env_file = temp_dir / ".env" + env_file.write_text("DYNAMIC_ENV_KEY=first") + + monkeypatch.delenv("DYNAMIC_ENV_KEY", raising=False) + monkeypatch.setattr( + environment, "_resolve_initial_env_paths", lambda: (env_file,) + ) + monkeypatch.setattr( + environment, "_process_env_keys", set(os.environ.keys()) + ) + + assert env("DYNAMIC_ENV_KEY") == "first" + + env_file.write_text("DYNAMIC_ENV_KEY=second") + + assert env("DYNAMIC_ENV_KEY") == "second" + + +def test_env_process_value_overrides_live_dotenv(monkeypatch, temp_dir: Path): + """Real process env remains higher priority than dotenv files.""" + env_file = temp_dir / ".env" + env_file.write_text("PROCESS_PRIORITY_KEY=from_file") + + monkeypatch.setenv("PROCESS_PRIORITY_KEY", "from_process") + monkeypatch.setattr( + environment, "_resolve_initial_env_paths", lambda: (env_file,) + ) + monkeypatch.setattr( + environment, "_process_env_keys", {"PROCESS_PRIORITY_KEY"} + ) + + assert env("PROCESS_PRIORITY_KEY") == "from_process" diff --git a/backend/tests/app/controller/test_chat_controller.py b/backend/tests/app/controller/test_chat_controller.py index 5366b6dc3..8f9bed38d 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 @@ -58,6 +59,10 @@ async def test_post_chat_endpoint_success( ) 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_with_timeout", + new=AsyncMock(return_value=True), + ), patch("pathlib.Path.mkdir"), patch("pathlib.Path.home", return_value=MagicMock()), ): @@ -74,10 +79,10 @@ async def mock_generator(): assert response.media_type == "text/event-stream" @pytest.mark.asyncio - async def test_post_chat_sets_environment_variables( + async def test_post_chat_sets_run_context_and_third_party_env( self, sample_chat_data, mock_request, mock_task_lock ): - """Test that environment variables are properly set.""" + """Run-scoped values stay in RunContext; CAMEL path keys are published.""" chat_data = Chat(**sample_chat_data) with ( @@ -90,6 +95,59 @@ 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), + ): + + async def mock_generator(): + yield "data: test_response\n\n" + + mock_step_solve.return_value = mock_generator() + + await post(chat_data, mock_request) + + run_context = mock_task_lock.run_context + assert run_context.api_key == "test_key" + assert run_context.api_base_url == "https://api.openai.com/v1" + assert run_context.browser_port == 8080 + assert os.environ.get("CAMEL_LOG_DIR") == str( + run_context.camel_log_dir + ) + assert os.environ.get("CAMEL_WORKDIR") == str( + run_context.task_output_root + ) + + @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.is_cdp_url_available", + return_value=False, + ), + patch( + "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"), patch("pathlib.Path.mkdir"), patch("pathlib.Path.home", return_value=MagicMock()), patch.dict(os.environ, {}, clear=True), @@ -102,16 +160,107 @@ async def mock_generator(): await post(chat_data, mock_request) - # Check environment variables were set - assert os.environ.get("OPENAI_API_KEY") == "test_key" assert ( - os.environ.get("OPENAI_API_BASE_URL") - == "https://api.openai.com/v1" + mock_task_lock.run_context.cdp_url == "http://127.0.0.1:8080" + ) + assert mock_task_lock.run_context.browser_port == 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.is_cdp_url_available", + return_value=False, + ), + patch( + "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"), + 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 mock_task_lock.run_context.cdp_url is None + assert mock_task_lock.run_context.browser_port == 8080 + assert mock_request.state.browser_available is False + + @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_endpoint", + ) 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 ( + mock_task_lock.run_context.cdp_url == "http://worker-17:9222" ) - assert os.environ.get("CAMEL_MODEL_LOG_ENABLED") == "true" - assert os.environ.get("browser_port") == "8080" + assert mock_task_lock.run_context.browser_port == 9222 + assert mock_request.state.browser_available is True + mock_ensure_browser.assert_not_called() - def test_improve_chat_success(self, mock_task_lock): + @pytest.mark.asyncio + async 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") @@ -122,17 +271,21 @@ def test_improve_chat_success(self, mock_task_lock): "app.controller.chat_controller.get_task_lock", return_value=mock_task_lock, ), - patch("asyncio.run") as mock_run, + patch( + "app.controller.chat_controller._prepare_browser_for_request_with_timeout", + new=AsyncMock(return_value=True), + ), ): - response = improve(task_id, supplement_data) + response = await improve(task_id, supplement_data, mock_request) assert isinstance(response, Response) assert response.status_code == 201 - mock_run.assert_called_once() - # put_queue is invoked when creating the coroutine passed to asyncio.run - mock_task_lock.put_queue.assert_called_once() + mock_task_lock.put_queue.assert_awaited_once() - def test_improve_chat_task_done_resets_to_confirming(self, mock_task_lock): + @pytest.mark.asyncio + async 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") @@ -143,14 +296,87 @@ def test_improve_chat_task_done_resets_to_confirming(self, mock_task_lock): "app.controller.chat_controller.get_task_lock", return_value=mock_task_lock, ), - patch("asyncio.run") as mock_run, + patch( + "app.controller.chat_controller._prepare_browser_for_request_with_timeout", + new=AsyncMock(return_value=True), + ), ): - response = improve(task_id, supplement_data) + response = await improve(task_id, supplement_data, mock_request) assert mock_task_lock.status == Status.confirming assert isinstance(response, Response) assert response.status_code == 201 + @pytest.mark.asyncio + async def test_improve_skips_durable_on_run_start_when_rotation_failed( + self, mock_task_lock, mock_request + ): + """R27-1 regression: if run_context did not rotate to data.task_id + (e.g. freeze_task_directories_for raised inside the swallowing + try/except), we must NOT call MemoryService.on_run_start against the + previous, already-finalized run id -- doing so would reset its + status.json back to "running" and the finalize dedup set then blocks + future end-of-turn writers from closing it again. + """ + + # Use a real RunContext-shaped stand-in so getattr access works. + stale_run = SimpleNamespace( + run_id="stale_run_from_previous_turn", + space_id="blank_space_x", + project_id="project_x", + task_id="stale_task_id", + browser_port=9222, + cdp_url=None, + user_id="42", + email="u@example.com", + ) + # Make isinstance(stale_run, RunContext) return True via patching. + mock_task_lock.run_context = stale_run + mock_task_lock.status = Status.processing + mock_task_lock.email = "u@example.com" + + memory_service = MagicMock() + supplement_data = SupplementChat( + question="next turn", task_id="brand_new_task_id" + ) + + with ( + patch( + "app.controller.chat_controller.get_task_lock", + return_value=mock_task_lock, + ), + patch( + "app.controller.chat_controller.RunContext", + new=SimpleNamespace, + ), + patch( + "app.controller.chat_controller.get_workspace_resolver", + side_effect=RuntimeError( + "simulated resolver failure -- rotation never happens" + ), + ), + patch( + "app.controller.chat_controller.get_memory_service", + return_value=memory_service, + ), + patch( + "app.controller.chat_controller._prepare_browser_for_request_with_timeout", + new=AsyncMock(return_value=True), + ), + ): + response = await improve( + "project_x", supplement_data, mock_request + ) + + assert isinstance(response, Response) + # The improve request itself still succeeds -- chat must not break + # because durable memory is unhappy. + assert response.status_code == 201 + # Critical assertion: on_run_start was NOT called against the stale + # context. The R26 fix only checked data.task_id; R27 strengthens + # it to compare refreshed_context.run_id == data.task_id. + memory_service.on_run_start.assert_not_called() + def test_supplement_chat_success(self, mock_task_lock): """Test successful chat supplementation.""" task_id = "test_task_123" @@ -162,13 +388,15 @@ def test_supplement_chat_success(self, mock_task_lock): "app.controller.chat_controller.get_task_lock", return_value=mock_task_lock, ), - patch("asyncio.run") as mock_run, + patch( + "app.controller.chat_controller._queue_action_from_worker" + ) as mock_queue_action, ): response = supplement(task_id, supplement_data) assert isinstance(response, Response) assert response.status_code == 201 - mock_run.assert_called_once() + mock_queue_action.assert_called_once() def test_supplement_chat_task_not_done_error(self, mock_task_lock): """Test supplementation fails when task is not done.""" @@ -183,24 +411,25 @@ def test_supplement_chat_task_not_done_error(self, mock_task_lock): with pytest.raises(UserException): supplement(task_id, supplement_data) - def test_stop_chat_success(self, mock_task_lock): + @pytest.mark.asyncio + async def test_stop_chat_success(self, mock_task_lock): """Test successful chat stopping.""" task_id = "test_task_123" 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, ): - response = stop(task_id) + response = await stop(task_id) assert isinstance(response, Response) assert response.status_code == 204 - mock_run.assert_called_once() + mock_task_lock.put_queue.assert_awaited_once() - def test_human_reply_success(self, mock_task_lock): + @pytest.mark.asyncio + async def test_human_reply_success(self, mock_task_lock): """Test successful human reply.""" task_id = "test_task_123" reply_data = HumanReply(agent="test_agent", reply="This is my reply") @@ -210,13 +439,14 @@ def test_human_reply_success(self, mock_task_lock): "app.controller.chat_controller.get_task_lock", return_value=mock_task_lock, ), - patch("asyncio.run") as mock_run, ): - response = human_reply(task_id, reply_data) + response = await human_reply(task_id, reply_data) assert isinstance(response, Response) assert response.status_code == 201 - mock_run.assert_called_once() + mock_task_lock.put_human_input.assert_awaited_once_with( + "test_agent", "This is my reply" + ) def test_install_mcp_success(self, mock_task_lock): """Test successful MCP installation.""" @@ -230,13 +460,15 @@ def test_install_mcp_success(self, mock_task_lock): "app.controller.chat_controller.get_task_lock", return_value=mock_task_lock, ), - patch("asyncio.run") as mock_run, + patch( + "app.controller.chat_controller._queue_action_from_worker" + ) as mock_queue_action, ): response = install_mcp(task_id, mcp_data) assert isinstance(response, Response) assert response.status_code == 201 - mock_run.assert_called_once() + mock_queue_action.assert_called_once() @pytest.mark.integration @@ -256,6 +488,10 @@ def test_chat_endpoint_integration( ) 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_with_timeout", + new=AsyncMock(return_value=True), + ), patch("pathlib.Path.mkdir"), patch("pathlib.Path.home", return_value=MagicMock()), ): @@ -285,10 +521,14 @@ def test_improve_chat_endpoint_integration(self, client: TestClient): patch( "app.controller.chat_controller.get_task_lock" ) as mock_get_lock, - patch("asyncio.run"), + patch( + "app.controller.chat_controller._prepare_browser_for_request_with_timeout", + new=AsyncMock(return_value=True), + ), ): mock_task_lock = MagicMock() mock_task_lock.status = Status.processing + mock_task_lock.put_queue = AsyncMock() mock_get_lock.return_value = mock_task_lock response = client.post(f"/chat/{task_id}", json=supplement_data) @@ -304,7 +544,7 @@ def test_supplement_chat_endpoint_integration(self, client: TestClient): patch( "app.controller.chat_controller.get_task_lock" ) as mock_get_lock, - patch("asyncio.run"), + patch("app.controller.chat_controller._queue_action_from_worker"), ): mock_task_lock = MagicMock() mock_task_lock.status = Status.done @@ -320,11 +560,11 @@ def test_stop_chat_endpoint_integration(self, client: TestClient): with ( patch( - "app.controller.chat_controller.get_task_lock" + "app.controller.chat_controller.get_task_lock_if_exists" ) as mock_get_lock, - patch("asyncio.run"), ): mock_task_lock = MagicMock() + mock_task_lock.put_queue = AsyncMock() mock_get_lock.return_value = mock_task_lock response = client.delete(f"/chat/{task_id}") @@ -340,9 +580,9 @@ def test_human_reply_endpoint_integration(self, client: TestClient): patch( "app.controller.chat_controller.get_task_lock" ) as mock_get_lock, - patch("asyncio.run"), ): mock_task_lock = MagicMock() + mock_task_lock.put_human_input = AsyncMock() mock_get_lock.return_value = mock_task_lock response = client.post( @@ -360,7 +600,7 @@ def test_install_mcp_endpoint_integration(self, client: TestClient): patch( "app.controller.chat_controller.get_task_lock" ) as mock_get_lock, - patch("asyncio.run"), + patch("app.controller.chat_controller._queue_action_from_worker"), ): mock_task_lock = MagicMock() mock_get_lock.return_value = mock_task_lock @@ -424,17 +664,19 @@ async def test_post_with_invalid_data(self, mock_request): # If future validation moves to endpoint level, keep logic placeholder below. # (Intentionally not calling post with invalid Chat object since creation fails.) - def test_improve_with_nonexistent_task(self): + @pytest.mark.asyncio + async 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) + await improve(task_id, supplement_data, request) def test_supplement_with_empty_question(self, mock_task_lock): """Test supplement endpoint with empty question.""" @@ -447,7 +689,7 @@ def test_supplement_with_empty_question(self, mock_task_lock): "app.controller.chat_controller.get_task_lock", return_value=mock_task_lock, ), - patch("asyncio.run"), + patch("app.controller.chat_controller._queue_action_from_worker"), ): # Should handle empty question gracefully or raise appropriate error response = supplement(task_id, supplement_data) 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_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/controller/test_task_controller.py b/backend/tests/app/controller/test_task_controller.py index 3da13c805..d5f5f91f3 100644 --- a/backend/tests/app/controller/test_task_controller.py +++ b/backend/tests/app/controller/test_task_controller.py @@ -42,13 +42,15 @@ def test_start_task_success(self, mock_task_lock): "app.controller.task_controller.get_task_lock", return_value=mock_task_lock, ), - patch("asyncio.run") as mock_run, + patch( + "app.controller.task_controller._queue_action_from_worker" + ) as mock_queue_action, ): response = start(task_id) assert isinstance(response, Response) assert response.status_code == 201 - mock_run.assert_called_once() + mock_queue_action.assert_called_once() def test_update_task_success(self, mock_task_lock): """Test successful task update.""" @@ -65,13 +67,15 @@ def test_update_task_success(self, mock_task_lock): "app.controller.task_controller.get_task_lock", return_value=mock_task_lock, ), - patch("asyncio.run") as mock_run, + patch( + "app.controller.task_controller._queue_action_from_worker" + ) as mock_queue_action, ): response = put(task_id, update_data) assert isinstance(response, Response) assert response.status_code == 201 - mock_run.assert_called_once() + mock_queue_action.assert_called_once() def test_take_control_pause_success(self, mock_task_lock): """Test successful task pause control.""" @@ -80,16 +84,18 @@ def test_take_control_pause_success(self, mock_task_lock): with ( patch( - "app.controller.task_controller.get_task_lock", + "app.controller.task_controller.get_task_lock_if_exists", return_value=mock_task_lock, ), - patch("asyncio.run") as mock_run, + patch( + "app.controller.task_controller._queue_action_from_worker" + ) as mock_queue_action, ): response = take_control(task_id, control_data) assert isinstance(response, Response) assert response.status_code == 204 - mock_run.assert_called_once() + mock_queue_action.assert_called_once() def test_take_control_resume_success(self, mock_task_lock): """Test successful task resume control.""" @@ -98,16 +104,18 @@ def test_take_control_resume_success(self, mock_task_lock): with ( patch( - "app.controller.task_controller.get_task_lock", + "app.controller.task_controller.get_task_lock_if_exists", return_value=mock_task_lock, ), - patch("asyncio.run") as mock_run, + patch( + "app.controller.task_controller._queue_action_from_worker" + ) as mock_queue_action, ): response = take_control(task_id, control_data) assert isinstance(response, Response) assert response.status_code == 204 - mock_run.assert_called_once() + mock_queue_action.assert_called_once() def test_add_agent_success(self, mock_task_lock): """Test successful agent addition.""" @@ -126,13 +134,15 @@ def test_add_agent_success(self, mock_task_lock): return_value=mock_task_lock, ), patch("app.controller.task_controller.load_dotenv"), - patch("asyncio.run") as mock_run, + patch( + "app.controller.task_controller._queue_action_from_worker" + ) as mock_queue_action, ): response = add_agent(task_id, new_agent) assert isinstance(response, Response) assert response.status_code == 204 - mock_run.assert_called_once() + mock_queue_action.assert_called_once() def test_start_task_nonexistent_task(self): """Test start task with nonexistent task ID.""" @@ -155,13 +165,15 @@ def test_update_task_empty_data(self, mock_task_lock): "app.controller.task_controller.get_task_lock", return_value=mock_task_lock, ), - patch("asyncio.run") as mock_run, + patch( + "app.controller.task_controller._queue_action_from_worker" + ) as mock_queue_action, ): response = put(task_id, update_data) assert isinstance(response, Response) assert response.status_code == 201 - mock_run.assert_called_once() + mock_queue_action.assert_called_once() def test_add_agent_with_mcp_tools(self, mock_task_lock): """Test adding agent with MCP tools.""" @@ -180,13 +192,15 @@ def test_add_agent_with_mcp_tools(self, mock_task_lock): return_value=mock_task_lock, ), patch("app.controller.task_controller.load_dotenv"), - patch("asyncio.run") as mock_run, + patch( + "app.controller.task_controller._queue_action_from_worker" + ) as mock_queue_action, ): response = add_agent(task_id, new_agent) assert isinstance(response, Response) assert response.status_code == 204 - mock_run.assert_called_once() + mock_queue_action.assert_called_once() @pytest.mark.integration @@ -201,7 +215,7 @@ def test_start_task_endpoint_integration(self, client: TestClient): patch( "app.controller.task_controller.get_task_lock" ) as mock_get_lock, - patch("asyncio.run"), + patch("app.controller.task_controller._queue_action_from_worker"), ): mock_task_lock = MagicMock() mock_get_lock.return_value = mock_task_lock @@ -224,7 +238,7 @@ def test_update_task_endpoint_integration(self, client: TestClient): patch( "app.controller.task_controller.get_task_lock" ) as mock_get_lock, - patch("asyncio.run"), + patch("app.controller.task_controller._queue_action_from_worker"), ): mock_task_lock = MagicMock() mock_get_lock.return_value = mock_task_lock @@ -240,9 +254,9 @@ def test_take_control_pause_endpoint_integration(self, client: TestClient): with ( patch( - "app.controller.task_controller.get_task_lock" + "app.controller.task_controller.get_task_lock_if_exists" ) as mock_get_lock, - patch("asyncio.run"), + patch("app.controller.task_controller._queue_action_from_worker"), ): mock_task_lock = MagicMock() mock_get_lock.return_value = mock_task_lock @@ -262,9 +276,9 @@ def test_take_control_resume_endpoint_integration( with ( patch( - "app.controller.task_controller.get_task_lock" + "app.controller.task_controller.get_task_lock_if_exists" ) as mock_get_lock, - patch("asyncio.run"), + patch("app.controller.task_controller._queue_action_from_worker"), ): mock_task_lock = MagicMock() mock_get_lock.return_value = mock_task_lock @@ -291,7 +305,7 @@ def test_add_agent_endpoint_integration(self, client: TestClient): "app.controller.task_controller.get_task_lock" ) as mock_get_lock, patch("app.controller.task_controller.load_dotenv"), - patch("asyncio.run"), + patch("app.controller.task_controller._queue_action_from_worker"), ): mock_task_lock = MagicMock() mock_get_lock.return_value = mock_task_lock @@ -316,7 +330,10 @@ def test_start_task_async_error(self, mock_task_lock): "app.controller.task_controller.get_task_lock", return_value=mock_task_lock, ), - patch("asyncio.run", side_effect=Exception("Async error")), + patch( + "app.controller.task_controller._queue_action_from_worker", + side_effect=Exception("Async error"), + ), ): with pytest.raises(Exception, match="Async error"): start(task_id) @@ -337,7 +354,7 @@ def test_update_task_with_invalid_task_content(self, mock_task_lock): "app.controller.task_controller.get_task_lock", return_value=mock_task_lock, ), - patch("asyncio.run") as mock_run, + patch("app.controller.task_controller._queue_action_from_worker"), ): # Should handle invalid data gracefully or raise appropriate error response = put(task_id, update_data) @@ -370,7 +387,7 @@ def test_add_agent_env_load_failure(self, mock_task_lock): "app.controller.task_controller.load_dotenv", side_effect=Exception("Env load failed"), ), - patch("asyncio.run"), + patch("app.controller.task_controller._queue_action_from_worker"), ): # Should handle environment load failure gracefully or raise error with pytest.raises(Exception, match="Env load failed"): @@ -393,7 +410,7 @@ def test_add_agent_with_empty_name(self, mock_task_lock): return_value=mock_task_lock, ), patch("app.controller.task_controller.load_dotenv"), - patch("asyncio.run"), + patch("app.controller.task_controller._queue_action_from_worker"), ): # Should handle empty name appropriately response = add_agent(task_id, new_agent) @@ -415,7 +432,7 @@ def side_effect(): "app.controller.task_controller.get_task_lock", return_value=mock_task_lock, ), - patch("asyncio.run") as mock_run, + patch("app.controller.task_controller._queue_action_from_worker"), ): response = start(task_id) assert response.status_code == 201 diff --git a/backend/tests/app/controller/test_tool_controller.py b/backend/tests/app/controller/test_tool_controller.py index 22b78e8e8..dd977e6b2 100644 --- a/backend/tests/app/controller/test_tool_controller.py +++ b/backend/tests/app/controller/test_tool_controller.py @@ -12,13 +12,16 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +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 +from app.utils import cdp_browser_state @pytest.mark.unit @@ -139,6 +142,87 @@ 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._clear_connected_cdp_browser("local") + request = SimpleNamespace( + state=SimpleNamespace(hands=_FakeRemoteHands()), + headers={}, + ) + + 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" + + tool_controller._clear_connected_cdp_browser("local") + + @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.utils.cdp_browser_state.is_cdp_url_available", + return_value=True, + ) as is_cdp_url_available: + assert cdp_browser_state.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: @@ -198,6 +282,80 @@ 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._clear_connected_cdp_browser("local") + + with patch( + "app.controller.tool_controller.ensure_cdp_browser_endpoint", + return_value="http://127.0.0.1:9222", + ): + 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("local") == 9222 + + tool_controller._clear_connected_cdp_browser("local") + + def test_connect_list_and_disconnect_cdp_browser_endpoints( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.delenv("EIGENT_CDP_URL", raising=False) + tool_controller._clear_connected_cdp_browser("local") + + with ( + patch( + "app.controller.tool_controller._is_cdp_available", + return_value=True, + ), + patch( + "app.utils.cdp_browser_state._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("local") 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._clear_connected_cdp_browser("local") + + 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/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/memory/__init__.py b/backend/tests/app/memory/__init__.py new file mode 100644 index 000000000..fa7455a0c --- /dev/null +++ b/backend/tests/app/memory/__init__.py @@ -0,0 +1,13 @@ +# ========= 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. ========= diff --git a/backend/tests/app/memory/test_context_builder.py b/backend/tests/app/memory/test_context_builder.py new file mode 100644 index 000000000..7558c073b --- /dev/null +++ b/backend/tests/app/memory/test_context_builder.py @@ -0,0 +1,430 @@ +# ========= 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. ========= + +"""ProjectContextBuilder unit tests.""" + +from __future__ import annotations + +import pytest + +from app.memory import ( + ConversationEvent, + LocalMemoryStore, + MemoryArtifact, + MemoryFact, + ProjectContextBuilder, + ProjectMemory, + SpaceMemory, +) + + +@pytest.fixture +def store(tmp_path) -> LocalMemoryStore: + return LocalMemoryStore(root=tmp_path) + + +@pytest.fixture +def ids() -> dict[str, str]: + return { + "user_key": "user_42", + "space_id": "space_x", + "project_id": "project_x", + "run_id": "run_current", + } + + +@pytest.fixture +def seeded_store(store, ids) -> LocalMemoryStore: + store.write_space( + ids["user_key"], + SpaceMemory( + space_id=ids["space_id"], + user_id="42", + name="Workspace X", + source_type="blank", + created_at="2026-05-27T09:00:00Z", + updated_at="2026-05-27T09:00:00Z", + ), + ) + store.write_project( + ids["user_key"], + ProjectMemory( + project_id=ids["project_id"], + space_id=ids["space_id"], + name="Q2 retro", + created_at="2026-05-27T09:30:00Z", + updated_at="2026-05-27T09:30:00Z", + mode="single_agent", + ), + ) + store.write_project_summary( + ids["user_key"], + ids["space_id"], + ids["project_id"], + "Investigating why Q2 retention dropped after pricing change.", + ) + + # Past run: a brief user/assistant turn pair. + store.append_conversation( + ids["user_key"], + ids["space_id"], + ids["project_id"], + ConversationEvent( + event_id="evt_user_1", + run_id="run_past_1", + timestamp="2026-05-27T09:31:00Z", + role="user", + content="What dashboards do we have for Q2 retention?", + source="chat", + visibility="context", + hash="sha256:1", + ), + ) + store.append_conversation( + ids["user_key"], + ids["space_id"], + ids["project_id"], + ConversationEvent( + event_id="evt_assist_1", + run_id="run_past_1", + timestamp="2026-05-27T09:31:10Z", + role="assistant", + content="Three Looker dashboards: cohort, churn, pricing-impact.", + source="chat", + visibility="context", + hash="sha256:2", + ), + ) + # In-flight run: must be excluded from recent_conversation. + store.append_conversation( + ids["user_key"], + ids["space_id"], + ids["project_id"], + ConversationEvent( + event_id="evt_user_current", + run_id=ids["run_id"], + timestamp="2026-05-27T10:00:00Z", + role="user", + content="Now pull the pricing-impact dashboard.", + source="chat", + visibility="context", + hash="sha256:3", + ), + ) + # debug_only must never make it into context. + store.append_conversation( + ids["user_key"], + ids["space_id"], + ids["project_id"], + ConversationEvent( + event_id="evt_debug", + run_id="run_past_1", + timestamp="2026-05-27T09:31:05Z", + role="system", + content="raw tool dump: ...", + source="imported", + visibility="debug_only", + hash="sha256:4", + ), + ) + + store.upsert_fact( + ids["user_key"], + ids["space_id"], + ids["project_id"], + MemoryFact( + fact_id="fact_pricing", + text="Pricing change shipped 2026-04-15.", + scope="project", + source_event_ids=["evt_assist_1"], + confidence=0.95, + created_at="2026-05-27T09:32:00Z", + updated_at="2026-05-27T09:32:00Z", + ), + ) + # Lower-confidence fact, should rank below. + store.upsert_fact( + ids["user_key"], + ids["space_id"], + ids["project_id"], + MemoryFact( + fact_id="fact_weak", + text="Maybe one dashboard is owned by marketing.", + scope="project", + source_event_ids=[], + confidence=0.3, + created_at="2026-05-27T09:32:00Z", + updated_at="2026-05-27T09:32:00Z", + ), + ) + + # Visible artifact (eligible) and a runtime log (excluded). + store.upsert_artifact( + ids["user_key"], + ids["space_id"], + ids["project_id"], + MemoryArtifact( + artifact_id="art_report", + run_id="run_past_1", + path="task_run_past_1/q2-report.html", + kind="html_report", + visible_to_user=True, + eligible_for_context=True, + hash="sha256:r", + created_at="2026-05-27T09:35:00Z", + ), + ) + store.upsert_artifact( + ids["user_key"], + ids["space_id"], + ids["project_id"], + MemoryArtifact( + artifact_id="art_log", + run_id="run_past_1", + path="task_run_past_1/camel_logs/", + kind="runtime_log", + visible_to_user=False, + eligible_for_context=False, + hash="", + created_at="2026-05-27T09:35:01Z", + ), + ) + return store + + +# ----- Empty store ----- + + +class TestEmptyStore: + def test_build_on_empty_returns_empty_bundle(self, store, ids): + bundle = ProjectContextBuilder(store).build( + user_key=ids["user_key"], + space_id=ids["space_id"], + project_id=ids["project_id"], + run_id=ids["run_id"], + mode="single_agent", + token_budget=4000, + current_user_prompt="hi", + ) + assert bundle.is_empty() + assert bundle.current_run_instruction == "hi" + # Names fall back to ids when no Space/Project memory exists. + assert bundle.space_name == ids["space_id"] + assert bundle.project_name == ids["project_id"] + + +# ----- Seeded store ----- + + +class TestSeededBuild: + def test_picks_up_summary_and_recent_pair(self, seeded_store, ids): + bundle = ProjectContextBuilder(seeded_store).build( + user_key=ids["user_key"], + space_id=ids["space_id"], + project_id=ids["project_id"], + run_id=ids["run_id"], + mode="single_agent", + token_budget=4000, + current_user_prompt="Now pull the pricing-impact dashboard.", + ) + assert not bundle.is_empty() + assert bundle.project_name == "Q2 retro" + assert "Q2 retention" in bundle.project_summary + # Past run pair survives; current-run prompt is excluded. + ids_present = [e.event_id for e in bundle.recent_conversation] + assert "evt_user_1" in ids_present + assert "evt_assist_1" in ids_present + assert "evt_user_current" not in ids_present + + def test_excludes_debug_only_events(self, seeded_store, ids): + bundle = ProjectContextBuilder(seeded_store).build( + user_key=ids["user_key"], + space_id=ids["space_id"], + project_id=ids["project_id"], + run_id=ids["run_id"], + mode="single_agent", + token_budget=4000, + current_user_prompt="x", + ) + assert all( + e.event_id != "evt_debug" for e in bundle.recent_conversation + ) + + def test_excludes_runtime_log_artifacts(self, seeded_store, ids): + bundle = ProjectContextBuilder(seeded_store).build( + user_key=ids["user_key"], + space_id=ids["space_id"], + project_id=ids["project_id"], + run_id=ids["run_id"], + mode="single_agent", + token_budget=4000, + current_user_prompt="x", + ) + ids_present = [a.artifact_id for a in bundle.relevant_artifacts] + assert "art_report" in ids_present + assert "art_log" not in ids_present + + def test_facts_ranked_by_confidence(self, seeded_store, ids): + bundle = ProjectContextBuilder(seeded_store).build( + user_key=ids["user_key"], + space_id=ids["space_id"], + project_id=ids["project_id"], + run_id=ids["run_id"], + mode="single_agent", + token_budget=4000, + current_user_prompt="x", + ) + ranked_ids = [f.fact_id for f in bundle.relevant_facts] + # High-confidence first + assert ranked_ids.index("fact_pricing") < ranked_ids.index("fact_weak") + + def test_to_prompt_single_agent_contains_key_sections( + self, seeded_store, ids + ): + bundle = ProjectContextBuilder(seeded_store).build( + user_key=ids["user_key"], + space_id=ids["space_id"], + project_id=ids["project_id"], + run_id=ids["run_id"], + mode="single_agent", + token_budget=4000, + current_user_prompt="Now pull the pricing-impact dashboard.", + ) + rendered = bundle.to_prompt("single_agent") + assert "=== Persisted Project Context ===" in rendered + assert "Project: Q2 retro" in rendered + assert "Q2 retention" in rendered + assert "Pricing change shipped 2026-04-15." in rendered + assert "User: What dashboards" in rendered + assert "Assistant: Three Looker dashboards" in rendered + assert "Now pull the pricing-impact dashboard." in rendered + assert "=== End Persisted Project Context ===" in rendered + + +# ----- Token budget ----- + + +class TestBudget: + def test_tiny_budget_keeps_at_least_one_recent_event( + self, seeded_store, ids + ): + # Even a stingy budget should let one tail event through so callers + # always have *some* continuity signal. + bundle = ProjectContextBuilder(seeded_store).build( + user_key=ids["user_key"], + space_id=ids["space_id"], + project_id=ids["project_id"], + run_id=ids["run_id"], + mode="single_agent", + token_budget=64, + current_user_prompt="x", + ) + assert len(bundle.recent_conversation) >= 1 + + def test_huge_budget_includes_everything_eligible(self, seeded_store, ids): + bundle = ProjectContextBuilder(seeded_store).build( + user_key=ids["user_key"], + space_id=ids["space_id"], + project_id=ids["project_id"], + run_id=ids["run_id"], + mode="single_agent", + token_budget=100_000, + current_user_prompt="x", + ) + # Past pair, both facts, the one eligible artifact. + assert len(bundle.recent_conversation) == 2 + assert len(bundle.relevant_facts) == 2 + assert len(bundle.relevant_artifacts) == 1 + + def test_oversized_single_event_is_truncated_not_injected_whole( + self, store, ids + ): + # One assistant turn whose content alone vastly exceeds the section + # budget. Previously the loop's `and kept` guard let the newest event + # through even when it dwarfed the budget, so a 1MB HTML report would + # be injected verbatim into the next prompt. After R26-3, we keep + # the event but truncate its content to fit. + store.write_space( + ids["user_key"], + SpaceMemory( + space_id=ids["space_id"], + user_id="42", + name="W", + source_type="blank", + created_at="2026-05-27T09:00:00Z", + updated_at="2026-05-27T09:00:00Z", + ), + ) + store.write_project( + ids["user_key"], + ProjectMemory( + project_id=ids["project_id"], + space_id=ids["space_id"], + name="P", + created_at="2026-05-27T09:30:00Z", + updated_at="2026-05-27T09:30:00Z", + mode="single_agent", + ), + ) + # Bind to a *prior* run id so the in-flight-run filter doesn't drop it. + store.append_conversation( + ids["user_key"], + ids["space_id"], + ids["project_id"], + ConversationEvent( + event_id="ev_huge", + run_id="run_prior", + timestamp="2026-05-27T11:00:00Z", + role="assistant", + content="X" * 200_000, + source="chat", + visibility="context", + hash="sha256:deadbeef", + ), + ) + bundle = ProjectContextBuilder(store).build( + user_key=ids["user_key"], + space_id=ids["space_id"], + project_id=ids["project_id"], + run_id=ids["run_id"], + mode="single_agent", + token_budget=2000, # ~ small section budget + current_user_prompt="x", + ) + assert len(bundle.recent_conversation) == 1 + content = bundle.recent_conversation[0].content + assert "truncated to fit context budget" in content + # Truncated content must be smaller than the original 200K payload. + assert len(content) < 20_000 + + +# ----- Mode-specific render ----- + + +class TestRender: + def test_worker_mode_omits_recent_conversation(self, seeded_store, ids): + bundle = ProjectContextBuilder(seeded_store).build( + user_key=ids["user_key"], + space_id=ids["space_id"], + project_id=ids["project_id"], + run_id=ids["run_id"], + mode="workforce_worker", + token_budget=4000, + current_user_prompt="fetch the dashboard", + ) + rendered = bundle.to_prompt("workforce_worker") + assert "=== Worker Assignment ===" in rendered + assert "fetch the dashboard" in rendered + # Workers must not see full conversation. + assert "Recent conversation:" not in rendered diff --git a/backend/tests/app/memory/test_local_store.py b/backend/tests/app/memory/test_local_store.py new file mode 100644 index 000000000..dc7be4c82 --- /dev/null +++ b/backend/tests/app/memory/test_local_store.py @@ -0,0 +1,528 @@ +# ========= 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. ========= + +"""LocalMemoryStore acceptance tests (§25 of design doc). + +Exercises the M1 surface on a tmpdir root so no real ~/.eigent is touched. +""" + +from __future__ import annotations + +import json +import threading + +import pytest + +from app.memory import ( + ConversationEvent, + LocalMemoryStore, + MemoryArtifact, + MemoryFact, + ProjectMemory, + RunMemory, + RunStatus, + SpaceMemory, + SyncSettings, + canonical_user_id, +) + + +@pytest.fixture +def store(tmp_path) -> LocalMemoryStore: + return LocalMemoryStore(root=tmp_path) + + +@pytest.fixture +def ids() -> dict[str, str]: + return { + "user_key": "user_42", + "space_id": "space_test", + "project_id": "project_test", + "run_id": "run_test_1", + } + + +# ----- canonical_user_id ----- + + +class TestCanonicalUserId: + def test_prefers_user_id_with_user_prefix(self): + assert canonical_user_id("42") == "user_42" + + def test_integer_user_id_supported(self): + assert canonical_user_id(42) == "user_42" + + def test_falls_back_to_email_local_part(self): + assert canonical_user_id(None, email="alice@example.com") == "alice" + + def test_sanitises_unsafe_characters(self): + assert canonical_user_id(None, email="al ice@example.com") == "al_ice" + + def test_raises_when_neither_supplied(self): + with pytest.raises(ValueError): + canonical_user_id(None) + + def test_raises_when_user_id_blank_and_no_email(self): + with pytest.raises(ValueError): + canonical_user_id(" ") + + +# ----- Construction / lazy init ----- + + +class TestLazyInit: + def test_construct_does_not_touch_disk(self, tmp_path): + # Using a fresh subdir that should not be created on construction. + empty = tmp_path / "fresh" + LocalMemoryStore(root=empty) + assert not empty.exists() + + def test_root_defaults_to_home_eigent_memory(self): + from pathlib import Path + + from app.memory import memory_root + + s = LocalMemoryStore() + assert s.root == memory_root() + assert s.root == Path.home() / ".eigent" / "memory" + + +# ----- Space-level ----- + + +class TestSpaceRoundtrip: + def test_read_missing_returns_none(self, store, ids): + assert store.read_space(ids["user_key"], ids["space_id"]) is None + + def test_write_then_read_roundtrip(self, store, ids): + payload = SpaceMemory( + space_id=ids["space_id"], + user_id="42", + name="Test Space", + source_type="blank", + created_at="2026-05-27T10:00:00Z", + updated_at="2026-05-27T10:00:00Z", + ) + store.write_space(ids["user_key"], payload) + loaded = store.read_space(ids["user_key"], ids["space_id"]) + assert loaded == payload + # Sync defaults to local_only + assert loaded.sync == SyncSettings() + + def test_write_creates_parent_dirs(self, store, ids): + payload = SpaceMemory( + space_id=ids["space_id"], + user_id="42", + name="x", + source_type="blank", + created_at="t", + updated_at="t", + ) + # Tree does not exist yet + assert not store.space_path(ids["user_key"], ids["space_id"]).exists() + store.write_space(ids["user_key"], payload) + assert ( + store.space_path(ids["user_key"], ids["space_id"]) / "space.json" + ).exists() + + +# ----- Project-level ----- + + +class TestProjectRoundtrip: + def test_write_read_project(self, store, ids): + payload = ProjectMemory( + project_id=ids["project_id"], + space_id=ids["space_id"], + name="P", + created_at="t", + updated_at="t", + mode="single_agent", + last_run_id=None, + ) + store.write_project(ids["user_key"], payload) + assert ( + store.read_project( + ids["user_key"], ids["space_id"], ids["project_id"] + ) + == payload + ) + + def test_unknown_fields_in_file_are_ignored(self, store, ids): + # Simulate an older file with an extra unknown field. + path = ( + store.project_path( + ids["user_key"], ids["space_id"], ids["project_id"] + ) + / "project.json" + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "project_id": ids["project_id"], + "space_id": ids["space_id"], + "name": "P", + "created_at": "t", + "updated_at": "t", + "mode": None, + "last_run_id": None, + "unknown_future_field": "ignore-me", + "schema_version": 1, + } + ), + encoding="utf-8", + ) + loaded = store.read_project( + ids["user_key"], ids["space_id"], ids["project_id"] + ) + assert loaded is not None + assert loaded.name == "P" + + def test_malformed_json_treated_as_missing(self, store, ids): + path = ( + store.project_path( + ids["user_key"], ids["space_id"], ids["project_id"] + ) + / "project.json" + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{ this is not json", encoding="utf-8") + assert ( + store.read_project( + ids["user_key"], ids["space_id"], ids["project_id"] + ) + is None + ) + + +# ----- Conversation log ----- + + +class TestConversationLog: + def test_append_and_read_tail_preserves_order(self, store, ids): + for i in range(5): + store.append_conversation( + ids["user_key"], + ids["space_id"], + ids["project_id"], + ConversationEvent( + event_id=f"evt_{i}", + run_id=ids["run_id"], + timestamp=f"2026-05-27T10:00:0{i}Z", + role="user" if i % 2 == 0 else "assistant", + content=f"message {i}", + source="chat", + visibility="context", + hash=f"sha256:{i}", + ), + ) + + tail = store.read_conversation_tail( + ids["user_key"], ids["space_id"], ids["project_id"], limit=3 + ) + assert [e.event_id for e in tail] == ["evt_2", "evt_3", "evt_4"] + + def test_read_tail_when_empty(self, store, ids): + assert ( + store.read_conversation_tail( + ids["user_key"], ids["space_id"], ids["project_id"], limit=10 + ) + == [] + ) + + def test_read_tail_zero_limit_returns_empty(self, store, ids): + store.append_conversation( + ids["user_key"], + ids["space_id"], + ids["project_id"], + ConversationEvent( + event_id="evt_1", + run_id=ids["run_id"], + timestamp="t", + role="user", + content="hi", + source="chat", + visibility="context", + hash="sha256:1", + ), + ) + assert ( + store.read_conversation_tail( + ids["user_key"], ids["space_id"], ids["project_id"], limit=0 + ) + == [] + ) + + def test_concurrent_appends_do_not_interleave(self, store, ids): + # Two threads each append 50 events; no line should be partial or lost. + def writer(start: int) -> None: + for i in range(50): + store.append_conversation( + ids["user_key"], + ids["space_id"], + ids["project_id"], + ConversationEvent( + event_id=f"evt_{start + i}", + run_id=ids["run_id"], + timestamp="t", + role="user", + content=f"c {start + i}", + source="chat", + visibility="context", + hash="sha256:x", + ), + ) + + threads = [ + threading.Thread(target=writer, args=(0,)), + threading.Thread(target=writer, args=(1000,)), + ] + for t in threads: + t.start() + for t in threads: + t.join() + + path = ( + store.project_path( + ids["user_key"], ids["space_id"], ids["project_id"] + ) + / "conversation.jsonl" + ) + lines = path.read_text(encoding="utf-8").splitlines() + assert len(lines) == 100 + for line in lines: + json.loads(line) # would raise if any line was torn + + def test_malformed_line_is_skipped_not_fatal(self, store, ids): + path = ( + store.project_path( + ids["user_key"], ids["space_id"], ids["project_id"] + ) + / "conversation.jsonl" + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "event_id": "ok", + "run_id": ids["run_id"], + "timestamp": "t", + "role": "user", + "content": "x", + "source": "chat", + "visibility": "context", + "hash": "sha256:1", + } + ) + + "\n" + + "{ not json\n", + encoding="utf-8", + ) + tail = store.read_conversation_tail( + ids["user_key"], ids["space_id"], ids["project_id"], limit=10 + ) + assert [e.event_id for e in tail] == ["ok"] + + +# ----- Facts / artifacts upsert ----- + + +class TestFactsArtifacts: + def _fact(self, fact_id: str, text: str) -> MemoryFact: + return MemoryFact( + fact_id=fact_id, + text=text, + scope="project", + source_event_ids=[], + confidence=0.9, + created_at="t", + updated_at="t", + ) + + def test_upsert_fact_dedupes_by_id(self, store, ids): + store.upsert_fact( + ids["user_key"], + ids["space_id"], + ids["project_id"], + self._fact("f1", "v1"), + ) + store.upsert_fact( + ids["user_key"], + ids["space_id"], + ids["project_id"], + self._fact("f1", "v2-updated"), + ) + facts = store.read_facts( + ids["user_key"], ids["space_id"], ids["project_id"] + ) + assert [(f.fact_id, f.text) for f in facts] == [("f1", "v2-updated")] + + def test_artifact_upsert_dedupes_by_id(self, store, ids): + a1 = MemoryArtifact( + artifact_id="a1", + run_id=ids["run_id"], + path="task_run_test_1/report.html", + kind="html_report", + visible_to_user=True, + eligible_for_context=True, + hash="sha256:1", + created_at="t", + ) + a1_updated = MemoryArtifact( + artifact_id="a1", + run_id=ids["run_id"], + path="task_run_test_1/report.html", + kind="html_report", + visible_to_user=True, + eligible_for_context=False, + hash="sha256:2", + created_at="t", + ) + store.upsert_artifact( + ids["user_key"], ids["space_id"], ids["project_id"], a1 + ) + store.upsert_artifact( + ids["user_key"], ids["space_id"], ids["project_id"], a1_updated + ) + loaded = store.read_artifacts( + ids["user_key"], ids["space_id"], ids["project_id"] + ) + assert len(loaded) == 1 + assert loaded[0].eligible_for_context is False + + def test_concurrent_artifact_upserts_do_not_drop_rows(self, store, ids): + def writer(start: int) -> None: + for i in range(25): + artifact_id = f"a{start + i}" + store.upsert_artifact( + ids["user_key"], + ids["space_id"], + ids["project_id"], + MemoryArtifact( + artifact_id=artifact_id, + run_id=ids["run_id"], + path=f"task_{ids['run_id']}/{artifact_id}.txt", + kind="other", + visible_to_user=True, + eligible_for_context=True, + hash=f"sha256:{artifact_id}", + created_at="t", + ), + ) + + threads = [ + threading.Thread(target=writer, args=(0,)), + threading.Thread(target=writer, args=(1000,)), + ] + for t in threads: + t.start() + for t in threads: + t.join() + + loaded = store.read_artifacts( + ids["user_key"], ids["space_id"], ids["project_id"] + ) + assert len(loaded) == 50 + + +# ----- Run-level ----- + + +class TestRunLevel: + def test_run_status_roundtrip(self, store, ids): + status = RunStatus( + run_id=ids["run_id"], + state="running", + started_at="2026-05-27T10:00:00Z", + ended_at=None, + last_error=None, + ) + store.write_run_status( + ids["user_key"], + ids["space_id"], + ids["project_id"], + ids["run_id"], + status, + ) + assert ( + store.read_run_status( + ids["user_key"], + ids["space_id"], + ids["project_id"], + ids["run_id"], + ) + == status + ) + + def test_write_run_creates_subtree(self, store, ids): + run = RunMemory( + run_id=ids["run_id"], + project_id=ids["project_id"], + space_id=ids["space_id"], + mode="single_agent", + user_prompt="hello", + started_at="t", + ) + store.write_run(ids["user_key"], run) + loaded = store.read_run( + ids["user_key"], + ids["space_id"], + ids["project_id"], + ids["run_id"], + ) + assert loaded == run + + def test_run_summary_write_then_read(self, store, ids): + store.write_run_summary( + ids["user_key"], + ids["space_id"], + ids["project_id"], + ids["run_id"], + "Final: succeeded.", + ) + path = ( + store.run_path( + ids["user_key"], + ids["space_id"], + ids["project_id"], + ids["run_id"], + ) + / "summary.md" + ) + assert path.read_text(encoding="utf-8") == "Final: succeeded." + + +# ----- Project summary plain text ----- + + +class TestProjectSummary: + def test_write_then_read(self, store, ids): + store.write_project_summary( + ids["user_key"], ids["space_id"], ids["project_id"], "summary line" + ) + assert ( + store.read_project_summary( + ids["user_key"], ids["space_id"], ids["project_id"] + ) + == "summary line" + ) + + def test_missing_returns_empty_string(self, store, ids): + assert ( + store.read_project_summary( + ids["user_key"], ids["space_id"], ids["project_id"] + ) + == "" + ) diff --git a/backend/tests/app/memory/test_service_lifecycle.py b/backend/tests/app/memory/test_service_lifecycle.py new file mode 100644 index 000000000..d9a66a402 --- /dev/null +++ b/backend/tests/app/memory/test_service_lifecycle.py @@ -0,0 +1,387 @@ +# ========= 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. ========= + +"""MemoryService end-to-end lifecycle tests (M2 + M4 + M5). + +Verifies: +- on_run_start writes the full Space/Project/Run tree + the user prompt +- on_run_end persists the assistant final response + status=done +- runtime-log artifact (camel_logs) registered and marked not-eligible +- a *fresh* MemoryService instance reading the same root sees the prior + Project's summary and recent conversation -- simulating an app restart +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app.memory import ( + LocalMemoryStore, + MemoryService, + ProjectContextBuilder, + finalize_task_lock_run_memory, +) +from app.run_context import RunContext + + +def _make_run_context(tmp_path: Path) -> RunContext: + return RunContext( + space_id="space_lifecycle", + project_id="project_lifecycle", + run_id="run_lifecycle_1", + task_id="task_lifecycle_1", + email="alice@example.com", + user_id="42", + working_directory=tmp_path / "work", + task_output_root=tmp_path / "out", + camel_log_dir=tmp_path / "logs", + binding_source="default", + workdir_mode="artifact-only", + browser_port=9222, + ) + + +@pytest.fixture +def store(tmp_path) -> LocalMemoryStore: + return LocalMemoryStore(root=tmp_path / "memory") + + +@pytest.fixture +def service(store) -> MemoryService: + return MemoryService(store=store) + + +@pytest.fixture +def run_context(tmp_path) -> RunContext: + return _make_run_context(tmp_path) + + +class TestRunStart: + def test_on_run_start_creates_full_tree(self, service, run_context): + service.on_run_start( + run_context=run_context, + space_name="My Workspace", + project_name="Memory Trial", + space_source_type="blank", + mode="single_agent", + user_prompt="Investigate Q2 dip", + ) + store = service.store + space = store.read_space("user_42", run_context.space_id) + assert space is not None + assert space.name == "My Workspace" + project = store.read_project( + "user_42", run_context.space_id, run_context.project_id + ) + assert project is not None + assert project.name == "Memory Trial" + assert project.last_run_id == run_context.run_id + run = store.read_run( + "user_42", + run_context.space_id, + run_context.project_id, + run_context.run_id, + ) + assert run is not None + assert run.user_prompt == "Investigate Q2 dip" + status = store.read_run_status( + "user_42", + run_context.space_id, + run_context.project_id, + run_context.run_id, + ) + assert status is not None + assert status.state == "running" + # First conversation event is the user prompt. + tail = store.read_conversation_tail( + "user_42", run_context.space_id, run_context.project_id, limit=10 + ) + assert len(tail) == 1 + assert tail[0].role == "user" + assert tail[0].content == "Investigate Q2 dip" + assert tail[0].run_id == run_context.run_id + + def test_on_run_start_with_no_identity_is_noop(self, service, tmp_path): + ctx_no_id = RunContext( + space_id="s", + project_id="p", + run_id="r", + task_id="t", + email="", + user_id=None, + working_directory=tmp_path / "work", + task_output_root=tmp_path / "out", + camel_log_dir=tmp_path / "logs", + binding_source="default", + workdir_mode=None, + browser_port=9222, + ) + result = service.on_run_start( + run_context=ctx_no_id, + space_name=None, + project_name=None, + mode="single_agent", + user_prompt="hi", + ) + assert result is None + # Nothing written -- memory root has no users/ subtree + assert not (service.store.root / "users").exists() + + def test_followup_run_inherits_mode_from_project( + self, service, run_context + ): + # First turn declares workforce mode. + service.on_run_start( + run_context=run_context, + space_name="W", + project_name="P", + mode="workforce", + user_prompt="kick off", + ) + first_run = service.store.read_run( + "user_42", + run_context.space_id, + run_context.project_id, + run_context.run_id, + ) + assert first_run is not None and first_run.mode == "workforce" + + # Follow-up turn (improve path) passes mode=None to avoid clobbering + # project.json -- run.json should still record the inherited mode. + from dataclasses import replace + + followup_ctx = replace(run_context, run_id="run_lifecycle_2") + service.on_run_start( + run_context=followup_ctx, + space_name=None, + project_name=None, + mode=None, # caller defers to project.json + user_prompt="follow-up", + ) + followup_run = service.store.read_run( + "user_42", + followup_ctx.space_id, + followup_ctx.project_id, + followup_ctx.run_id, + ) + assert followup_run is not None + # Must NOT be None -- otherwise ContextBuilder picks the wrong profile. + assert followup_run.mode == "workforce" + + def test_legacy_space_id_forces_source_type_legacy( + self, service, tmp_path + ): + ctx = _make_run_context(tmp_path) + # Override with a legacy-prefixed space id. + from dataclasses import replace + + ctx = replace(ctx, space_id="legacy_42") + service.on_run_start( + run_context=ctx, + space_name=None, + project_name=None, + space_source_type="blank", # caller said blank but... + mode="single_agent", + user_prompt="x", + ) + space = service.store.read_space("user_42", "legacy_42") + assert space is not None + # ...legacy id wins. + assert space.source_type == "legacy" + + +class TestRunEnd: + def test_on_run_end_done_appends_assistant_and_status( + self, service, run_context + ): + service.on_run_start( + run_context=run_context, + space_name="W", + project_name="P", + mode="single_agent", + user_prompt="user q", + ) + service.on_run_end( + run_context=run_context, + state="done", + final_result="assistant answer", + ) + tail = service.store.read_conversation_tail( + "user_42", run_context.space_id, run_context.project_id, limit=10 + ) + # user prompt + assistant final + assert [e.role for e in tail] == ["user", "assistant"] + assert tail[-1].content == "assistant answer" + status = service.store.read_run_status( + "user_42", + run_context.space_id, + run_context.project_id, + run_context.run_id, + ) + assert status is not None + assert status.state == "done" + assert status.ended_at is not None + + def test_on_run_end_failed_records_error(self, service, run_context): + service.on_run_start( + run_context=run_context, + space_name="W", + project_name="P", + mode="single_agent", + user_prompt="q", + ) + service.on_run_end( + run_context=run_context, + state="failed", + error="boom", + ) + status = service.store.read_run_status( + "user_42", + run_context.space_id, + run_context.project_id, + run_context.run_id, + ) + assert status is not None + assert status.state == "failed" + assert status.last_error == "boom" + + +class TestCamelLogsArtifact: + def test_register_runtime_log_marks_not_eligible( + self, service, run_context + ): + service.on_run_start( + run_context=run_context, + space_name="W", + project_name="P", + mode="single_agent", + user_prompt="q", + ) + service.register_runtime_log_artifact( + run_context=run_context, + relative_path=f"task_{run_context.run_id}/camel_logs", + ) + arts = service.store.read_artifacts( + "user_42", run_context.space_id, run_context.project_id + ) + assert len(arts) == 1 + assert arts[0].kind == "runtime_log" + assert arts[0].visible_to_user is False + assert arts[0].eligible_for_context is False + assert arts[0].path.endswith("camel_logs") + + +class TestTaskLockFinalizer: + def test_finalize_task_lock_run_memory_writes_done_once( + self, service, run_context + ): + class DummyTaskLock: + pass + + task_lock = DummyTaskLock() + task_lock.memory_service = service + task_lock.run_context = run_context + task_lock._memory_finalized_runs = set() + + service.on_run_start( + run_context=run_context, + space_name="W", + project_name="P", + mode="single_agent", + user_prompt="q", + ) + + assert finalize_task_lock_run_memory( + task_lock, + state="done", + final_result="answer", + ) + assert not finalize_task_lock_run_memory( + task_lock, + state="cancelled", + final_result="late cancel", + ) + + status = service.store.read_run_status( + "user_42", + run_context.space_id, + run_context.project_id, + run_context.run_id, + ) + assert status is not None + assert status.state == "done" + tail = service.store.read_conversation_tail( + "user_42", run_context.space_id, run_context.project_id, limit=10 + ) + assert [event.content for event in tail] == ["q", "answer"] + + +class TestCrossRestartRecovery: + def test_fresh_service_recovers_durable_context( + self, tmp_path, run_context + ): + """Simulate app restart: original service writes, a new service + instance bound to the same root reads + ContextBuilder assembles a + non-empty bundle.""" + + root = tmp_path / "memory" + + # --- Original "run" --- + first_store = LocalMemoryStore(root=root) + first_service = MemoryService(store=first_store) + first_service.on_run_start( + run_context=run_context, + space_name="W", + project_name="P", + mode="single_agent", + user_prompt="investigate Q2 drop", + ) + first_service.on_run_end( + run_context=run_context, + state="done", + final_result="Checked dashboards; pricing change is the cause.", + ) + first_service.store.write_project_summary( + "user_42", + run_context.space_id, + run_context.project_id, + "Pricing change shipped 2026-04-15 caused Q2 retention drop.", + ) + + # --- Simulated restart: brand-new service + new run_id --- + from dataclasses import replace + + second_store = LocalMemoryStore(root=root) + builder = ProjectContextBuilder(second_store) + new_run_context = replace(run_context, run_id="run_lifecycle_2") + bundle = builder.build( + user_key="user_42", + space_id=new_run_context.space_id, + project_id=new_run_context.project_id, + run_id=new_run_context.run_id, + mode="single_agent", + token_budget=4000, + current_user_prompt="What was the cause again?", + ) + assert not bundle.is_empty() + assert "Pricing change" in bundle.project_summary + # The first run's user + assistant turns survived; they belong to a + # different run_id so the in-flight exclusion does not drop them. + roles = [e.role for e in bundle.recent_conversation] + assert roles == ["user", "assistant"] + rendered = bundle.to_prompt("single_agent") + assert "Pricing change" in rendered + assert "What was the cause again?" in rendered diff --git a/backend/tests/app/model/test_chat.py b/backend/tests/app/model/test_chat.py index b42690e1b..592e19d3a 100644 --- a/backend/tests/app/model/test_chat.py +++ b/backend/tests/app/model/test_chat.py @@ -13,6 +13,8 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= """Unit tests for Chat and AgentModelConfig model configuration.""" +from pathlib import Path + from app.model.chat import AgentModelConfig, Chat, NewAgent @@ -203,3 +205,61 @@ def test_is_cloud_false_for_user_configured_endpoints(self): def test_is_cloud_false_when_url_missing(self): assert not self._chat_with_url(None).is_cloud() + + +class TestFileSavePath: + """Tests for Chat file output path compatibility.""" + + def _chat(self) -> Chat: + return Chat( + task_id="task-1", + run_id="run-1", + project_id="project-1", + question="q", + email="alice@example.com", + user_id="42", + model_platform="openai", + model_type="gpt-4o", + api_key="k", + ) + + def test_file_save_path_prefers_user_id_root(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + resolved = Path(self._chat().file_save_path()) + + assert resolved == ( + tmp_path + / "eigent" + / "user_42" + / "project_project-1" + / "task_run-1" + ) + assert resolved.exists() + + def test_file_save_path_falls_back_to_legacy_email_root( + self, tmp_path, monkeypatch + ): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + legacy_path = ( + tmp_path / "eigent" / "alice" / "project_project-1" / "task_run-1" + ) + legacy_path.mkdir(parents=True) + + resolved = Path(self._chat().file_save_path()) + + assert resolved == legacy_path + + def test_file_save_path_keeps_legacy_root_for_subpaths( + self, tmp_path, monkeypatch + ): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + legacy_path = ( + tmp_path / "eigent" / "alice" / "project_project-1" / "task_run-1" + ) + legacy_path.mkdir(parents=True) + + resolved = Path(self._chat().file_save_path("screenshots")) + + assert resolved == legacy_path / "screenshots" + assert resolved.exists() diff --git a/backend/tests/app/model/test_model_platform.py b/backend/tests/app/model/test_model_platform.py index e9b08ada3..7211a2403 100644 --- a/backend/tests/app/model/test_model_platform.py +++ b/backend/tests/app/model/test_model_platform.py @@ -24,7 +24,7 @@ def test_normalize_model_platform_maps_known_aliases(): assert normalize_model_platform("grok") == "openai-compatible-model" - assert normalize_model_platform("z.ai") == "zhipu" + assert normalize_model_platform("z.ai") == "zhipuai" assert normalize_model_platform("ModelArk") == "openai-compatible-model" assert normalize_model_platform("ernie") == "qianfan" assert normalize_model_platform("llama.cpp") == "openai-compatible-model" 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/backend/tests/app/service/test_chat_service.py b/backend/tests/app/service/test_chat_service.py index ab666f3fd..180a84c56 100644 --- a/backend/tests/app/service/test_chat_service.py +++ b/backend/tests/app/service/test_chat_service.py @@ -20,14 +20,19 @@ from app.model.chat import Chat, NewAgent from app.service.chat_service import ( + _extract_stream_chunk_content, + _render_subtask_report, + _trim_in_process_history, add_sub_tasks, build_context_for_workforce, + check_conversation_history_length, collect_previous_task_context, construct_workforce, format_agent_description, format_task_context, install_mcp, new_agent_model, + normalize_summary_task, question_confirm, step_solve, summary_task, @@ -45,6 +50,60 @@ ) +class _StreamMsg: + def __init__(self, content="", reasoning_content=""): + self.content = content + self.reasoning_content = reasoning_content + + +class _StreamChunk: + def __init__(self, msg=None, msgs=None): + self.msg = msg + self.msgs = msgs + + def __str__(self): + return "msgs=[BaseMessage(role_name='System', reasoning_content='We')]" + + +@pytest.mark.unit +class TestExtractStreamChunkContent: + def test_extracts_single_message_content(self): + chunk = _StreamChunk(msg=_StreamMsg("Clean desktop")) + + assert _extract_stream_chunk_content(chunk) == ( + "Clean desktop" + ) + + def test_extracts_single_message_reasoning_content(self): + chunk = _StreamChunk( + msg=_StreamMsg( + reasoning_content="We need to organize the desktop." + ) + ) + + assert ( + _extract_stream_chunk_content(chunk) + == "We need to organize the desktop." + ) + + def test_extracts_message_list_content(self): + chunk = _StreamChunk( + msgs=[ + _StreamMsg(reasoning_content="We need "), + _StreamMsg("Group files"), + ] + ) + + assert _extract_stream_chunk_content(chunk) == ( + "We need Group files" + ) + + def test_ignores_metadata_only_chunk(self): + chunk = _StreamChunk() + + assert _extract_stream_chunk_content(chunk) == "" + + @pytest.mark.unit class TestFormatTaskContext: """Test cases for format_task_context function.""" @@ -598,6 +657,246 @@ def test_format_agent_description_no_description(self): assert "A specialized agent" in result # Default description +@pytest.mark.unit +class TestInProcessHistoryCompaction: + """`_trim_in_process_history` keeps the workforce loop alive for long + Projects without losing durable transcript on disk.""" + + def _make_task_lock(self, convo_entries=0, snapshot_entries=0): + lock = MagicMock(spec=[]) + lock.conversation_history = [ + {"role": "assistant", "content": f"turn {i} result"} + for i in range(convo_entries) + ] + lock.agent_memory_history = [ + { + "agent_name": "worker", + "scope": "workforce_worker", + "task_id": f"t{i}", + "task_content": f"task {i}", + "task_result": f"result {i}", + "messages": [], + } + for i in range(snapshot_entries) + ] + lock.memory_summary = "" + return lock + + def test_returns_zero_when_nothing_to_trim(self): + lock = self._make_task_lock(convo_entries=2, snapshot_entries=2) + assert _trim_in_process_history(lock, keep_recent=4) == 0 + assert len(lock.conversation_history) == 2 + assert len(lock.agent_memory_history) == 2 + assert lock.memory_summary == "" + + def test_drops_oldest_entries_and_records_marker(self): + lock = self._make_task_lock(convo_entries=10, snapshot_entries=10) + dropped = _trim_in_process_history(lock, keep_recent=4) + # 6 dropped from convo + 6 dropped from snapshots. + assert dropped == 12 + assert len(lock.conversation_history) == 4 + assert len(lock.agent_memory_history) == 4 + # Tail preserved, head discarded. + assert lock.conversation_history[-1]["content"] == "turn 9 result" + assert lock.conversation_history[0]["content"] == "turn 6 result" + assert ( + "[memory] Compacted 12 older in-process turn" + in lock.memory_summary + ) + assert "~/.eigent/memory" in lock.memory_summary + + def test_marker_not_duplicated_across_compactions(self): + lock = self._make_task_lock(convo_entries=10, snapshot_entries=10) + _trim_in_process_history(lock, keep_recent=4) + first_summary = lock.memory_summary + # Add more entries then compact again. + lock.conversation_history.extend( + [{"role": "assistant", "content": f"new {i}"} for i in range(6)] + ) + _trim_in_process_history(lock, keep_recent=4) + # We append a marker each compaction (different count), but the + # earlier marker text is still there once -- no duplicate-for-identical. + assert lock.memory_summary.count("[memory] Compacted 12") == 1 + + def test_only_runs_when_both_lists_exceed_keep_recent(self): + # Only convo exceeds; snapshots do not -- compaction only trims convo. + lock = self._make_task_lock(convo_entries=10, snapshot_entries=2) + dropped = _trim_in_process_history(lock, keep_recent=4) + assert dropped == 6 # only from convo + assert len(lock.conversation_history) == 4 + assert len(lock.agent_memory_history) == 2 + + def test_check_conversation_history_length_with_trimmed_state(self): + # After trimming, check_conversation_history_length should reflect the + # shorter total -- if not, the compaction didn't really happen. + lock = self._make_task_lock(convo_entries=10, snapshot_entries=0) + lock.conversation_history = [ + {"role": "assistant", "content": "x" * 50_000} for _ in range(6) + ] + before_exceeded, before_total = check_conversation_history_length( + lock, max_length=200_000 + ) + assert before_exceeded is True + assert before_total > 200_000 + _trim_in_process_history(lock, keep_recent=2) + after_exceeded, after_total = check_conversation_history_length( + lock, max_length=200_000 + ) + assert after_exceeded is False + assert after_total < before_total + + +@pytest.mark.unit +class TestPartialFailureRender: + """`_render_partial_failure_result` ships the captured subtask output + even when Workforce skipped the LLM summary because one subtask failed. + Previously the SSE end event got `task.result or ""` (often empty) and + the UI rendered nothing.""" + + def _subtask(self, *, state_name, content, result): + sub = MagicMock(spec=[]) + sub.state = MagicMock(name=state_name) + sub.state.name = state_name + sub.content = content + sub.result = result + return sub + + def _task(self, subtasks, result=""): + task = MagicMock(spec=[]) + task.subtasks = subtasks + task.result = result + task.id = "task_partial" + return task + + def test_mixed_success_and_failure_returns_per_subtask_report(self): + task = self._task( + [ + self._subtask( + state_name="DONE", + content="Generate v2 CSV with different dates", + result="Saved to mock_bank_transfers_v2.csv (10 rows)", + ), + self._subtask( + state_name="FAILED", + content="Run quality check on the CSV", + result="", + ), + ] + ) + out = _render_subtask_report(task, is_failure=True) + assert out.startswith("⚠️ Task partially failed") + # Both subtasks listed, with the right markers + content + result. + assert "✅ **Subtask 1 (DONE)**" in out + assert "Generate v2 CSV with different dates" in out + assert "Saved to mock_bank_transfers_v2.csv" in out + assert "❌ **Subtask 2 (FAILED)**" in out + assert "Run quality check on the CSV" in out + assert "_No output captured._" in out + + def test_parent_task_result_appended_when_present(self): + task = self._task( + [ + self._subtask( + state_name="DONE", + content="x", + result="ok", + ), + self._subtask( + state_name="FAILED", + content="y", + result="", + ), + ], + result="Overall: partial completion", + ) + out = _render_subtask_report(task, is_failure=True) + assert "**Overall task result**" in out + assert "Overall: partial completion" in out + + def test_no_subtasks_still_produces_banner(self): + task = self._task([], result="") + out = _render_subtask_report(task, is_failure=True) + assert "⚠️ Task partially failed" in out + # Output is non-empty so the SSE end event isn't blank -- this is + # the whole point: the UI must have *something* to render. + assert out.strip() + + def test_long_subtask_content_truncates_in_header(self): + long_text = "x" * 500 + task = self._task( + [ + self._subtask( + state_name="DONE", + content=long_text, + result="done", + ), + self._subtask(state_name="FAILED", content="y", result=""), + ] + ) + out = _render_subtask_report(task, is_failure=True) + # 200-char cap + ellipsis + assert "x" * 200 + "…" in out + # Result body unaffected + assert "done" in out + + +@pytest.mark.unit +class TestSingleSubtaskEmptyResultFallback: + """Regression: workforce sometimes finishes a single-subtask task with + empty task.result. Previously the SSE end event got "" and the UI showed + no body text under the "Done 1" card. Now we fall back to the per-subtask + render so the user always sees what the subtask produced.""" + + import pytest as _pytest + + @_pytest.mark.asyncio + async def test_empty_task_result_falls_back_to_subtask_render(self): + from app.service.chat_service import ( + get_task_result_with_optional_summary, + ) + + subtask = MagicMock(spec=[]) + subtask.state = MagicMock(name="DONE") + subtask.state.name = "DONE" + subtask.content = "Generate v2 CSV" + subtask.result = "Saved mock_bank_transfers_v2.csv (10 rows)" + + task = MagicMock(spec=[]) + task.subtasks = [subtask] + task.result = "" # the bug condition + task.id = "task_empty_result" + + options = MagicMock(spec=[]) + out = await get_task_result_with_optional_summary(task, options) + # Fallback kicked in -- output is non-empty + contains subtask body. + assert out.strip(), "result must not be empty" + assert "Saved mock_bank_transfers_v2.csv" in out + # Successful run must not show the failure banner. + assert "Task partially failed" not in out + + @_pytest.mark.asyncio + async def test_non_empty_task_result_is_unchanged(self): + from app.service.chat_service import ( + get_task_result_with_optional_summary, + ) + + subtask = MagicMock(spec=[]) + subtask.state = MagicMock(name="DONE") + subtask.state.name = "DONE" + subtask.content = "x" + subtask.result = "y" + + task = MagicMock(spec=[]) + task.subtasks = [subtask] + task.result = "Direct task result body" + task.id = "task_direct" + + options = MagicMock(spec=[]) + out = await get_task_result_with_optional_summary(task, options) + # The original task.result wins; no fallback inserted. + assert out == "Direct task result body" + + @pytest.mark.unit class TestChatServiceAgentOperations: """Test cases for agent-related chat service operations.""" @@ -646,6 +945,19 @@ async def test_summary_task(self, mock_camel_agent): ) mock_camel_agent.step.assert_called_once() + def test_normalize_summary_task_limits_name_and_summary(self): + """Test summary_task output is normalized before it reaches UI state.""" + result = normalize_summary_task( + ("Long Task Name " * 20) + "|" + ("Long summary text " * 40), + "fallback", + ) + name, summary = result.split("|", 1) + + assert len(name) <= 80 + assert len(summary) <= 240 + assert name.endswith("...") + assert summary.endswith("...") + @pytest.mark.asyncio async def test_new_agent_model_creation(self, sample_chat_data): """Test new_agent_model creates agent with proper configuration.""" @@ -666,6 +978,10 @@ async def test_new_agent_model_creation(self, sample_chat_data): patch( "app.service.chat_service.agent_model", return_value=mock_agent ), + patch( + "app.agent.toolkit.human_toolkit.get_task_lock", + return_value=MagicMock(), + ), ): result = await new_agent_model(agent_data, options) diff --git a/backend/tests/app/service/test_single_agent_service.py b/backend/tests/app/service/test_single_agent_service.py new file mode 100644 index 000000000..0e3b3fe88 --- /dev/null +++ b/backend/tests/app/service/test_single_agent_service.py @@ -0,0 +1,146 @@ +# ========= 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. ========= + +"""single_agent_service skip lifecycle regression tests.""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +pytestmark = pytest.mark.unit + + +def _parse_sse(line: str) -> tuple[str, object]: + """Parse a `data: {...}\\n\\n` SSE line into (step, payload).""" + + assert line.startswith("data: "), line + payload = json.loads(line[len("data: ") :].strip()) + return payload.get("step", ""), payload.get("data") + + +@pytest.mark.asyncio +async def test_skip_task_emits_end_without_blocking_on_cancellation(): + """R27-2 regression: pressing Skip while the turn is mid-flight in a + non-cooperative coroutine (e.g. stuck inside a model HTTP call that does + not propagate CancelledError) must still produce the user-facing "end" + event promptly. The previous R26 fix added `await running_turn` after + cancel(), which would block this generator until the turn actually + finished cleaning up. + """ + + from app.model.chat import Chat + from app.service.single_agent_service import single_agent_solve + from app.service.task import ( + ActionImproveData, + ActionSkipTaskData, + ImprovePayload, + ) + + # A running_turn that ignores cancellation -- mimics a stuck model call. + async def never_resolves(): + try: + await asyncio.sleep(60) + except asyncio.CancelledError: + # Pretend the underlying tool swallowed the cancel; keep sleeping. + await asyncio.sleep(60) + raise + + # Fake agent whose astep returns the never-resolving coroutine. + fake_agent = MagicMock() + fake_agent.astep = lambda prompt: never_resolves() + fake_agent.agent_id = "fake_single_agent" + fake_agent._observable_todo_toolkit = None + + # Queue: improve action first, then skip after the turn is in flight. + queue: asyncio.Queue = asyncio.Queue() + + improve_item = ActionImproveData( + data=ImprovePayload( + question="do something slow", + attaches=[], + project_context=None, + ), + new_task_id="task_skip", + ) + skip_item = ActionSkipTaskData(project_id="project_skip") + await queue.put(improve_item) + + task_lock = MagicMock() + task_lock.id = "task_skip" + task_lock.email = "u@example.com" + task_lock.status = "OPEN" + task_lock.conversation_history = [] + task_lock.last_task_result = "" + task_lock.agent_memory_history = [] + task_lock.memory_summary = "" + task_lock.summary_generated = False + task_lock.run_context = None # disables durable read + + async def get_queue(): + return await queue.get() + + task_lock.get_queue = get_queue + task_lock.add_background_task = MagicMock() + + request = MagicMock() + request.is_disconnected = AsyncMock(return_value=False) + + options = MagicMock(spec=Chat) + options.project_id = "project_skip" + options.task_id = "task_skip" + options.project_context = None + + with ( + patch( + "app.service.single_agent_service.single_agent", + new=AsyncMock(return_value=fake_agent), + ), + patch("app.service.single_agent_service.set_current_task_id"), + patch("app.service.single_agent_service.record_agent_memory_snapshot"), + patch( + "app.service.single_agent_service.build_memory_context", + return_value="", + ), + patch("app.service.single_agent_service._finalize_memory_for_turn"), + patch( + "app.service.single_agent_service._build_single_agent_context", + return_value="", + ), + ): + agen = single_agent_solve(options, request, task_lock) + + # First frame: "confirmed" after the improve action lands. + confirmed = await asyncio.wait_for(agen.__anext__(), timeout=3.0) + event, _ = _parse_sse(confirmed) + assert event == "confirmed", confirmed + + # The turn is now running and stuck. Send skip. + await queue.put(skip_item) + + # Critical assertion: the "end" event arrives quickly, even though + # the running_turn would block for ~60s. Use a tight 3s timeout -- + # before R27-2 this would hang at `await running_turn` until the + # never-resolving coroutine completed. + end_frame = await asyncio.wait_for(agen.__anext__(), timeout=3.0) + event, payload = _parse_sse(end_frame) + assert event == "end", end_frame + assert "stopped by user" in str(payload).lower(), payload + + # Cleanup: close the generator so the underlying task gets cancelled + # and pytest does not warn about pending tasks. + await agen.aclose() diff --git a/backend/tests/app/utils/test_agent_memory.py b/backend/tests/app/utils/test_agent_memory.py new file mode 100644 index 000000000..9dc198052 --- /dev/null +++ b/backend/tests/app/utils/test_agent_memory.py @@ -0,0 +1,106 @@ +# ========= 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 app.service.task import TaskLock +from app.utils.agent_memory import ( + build_agent_memory_snapshot, + build_memory_context, + record_agent_memory_snapshot, + serialize_agent_memory, +) + + +class FakeMemory: + def get_context(self): + return ( + [ + { + "role": "user", + "content": "Inspect the repository", + "tool_calls": [], + }, + { + "role": "assistant", + "content": "I will inspect it.", + "tool_calls": [ + { + "id": "call_1", + "function": { + "name": "shell_exec", + "arguments": '{"cmd": "pwd"}', + }, + } + ], + }, + { + "role": "tool", + "content": "/tmp/project", + "tool_call_id": "call_1", + }, + ], + 0, + ) + + +class FakeAgent: + agent_name = "single_agent" + agent_id = "agent_1" + memory = FakeMemory() + + +def test_serialize_agent_memory_includes_tool_calls_and_tool_results(): + messages = serialize_agent_memory(FakeAgent()) + + assert messages[1]["tool_calls"][0]["function"]["name"] == "shell_exec" + assert messages[1]["tool_calls"][0]["function"]["arguments"] == { + "cmd": "pwd" + } + assert messages[2]["tool_call_id"] == "call_1" + + +def test_record_agent_memory_snapshot_on_task_lock(): + task_lock = TaskLock("project_1", asyncio.Queue(), {}) + + snapshot = record_agent_memory_snapshot( + task_lock, + FakeAgent(), + scope="single_agent", + task_id="task_1", + task_content="Inspect", + task_result="Done", + ) + + assert snapshot is not None + assert len(task_lock.agent_memory_history) == 1 + assert task_lock.agent_memory_history[0]["agent_name"] == "single_agent" + + +def test_build_memory_context_formats_recent_snapshots(): + task_lock = TaskLock("project_1", asyncio.Queue(), {}) + snapshot = build_agent_memory_snapshot( + FakeAgent(), + scope="single_agent", + task_id="task_1", + task_content="Inspect", + task_result="Done", + ) + task_lock.add_agent_memory_snapshot(snapshot) + + context = build_memory_context(task_lock) + + assert "Serialized Agent Memory" in context + assert "single_agent" in context + assert "assistant tool_calls: shell_exec" in context 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/backend/tests/app/utils/test_file_utils.py b/backend/tests/app/utils/test_file_utils.py index 7b506dee9..42125ae66 100644 --- a/backend/tests/app/utils/test_file_utils.py +++ b/backend/tests/app/utils/test_file_utils.py @@ -289,6 +289,7 @@ def test_get_working_directory_no_new_folder_path_uses_env(temp_dir): options = MagicMock() task_lock = MagicMock() task_lock.new_folder_path = None + task_lock.working_directory = None with patch("app.utils.file_utils.env", return_value=str(temp_dir)): result = get_working_directory(options, task_lock) assert os.path.realpath(result) == os.path.realpath(str(temp_dir)) diff --git a/backend/tests/app/utils/test_workspace_resolver_direct_write.py b/backend/tests/app/utils/test_workspace_resolver_direct_write.py new file mode 100644 index 000000000..e71a5e9c6 --- /dev/null +++ b/backend/tests/app/utils/test_workspace_resolver_direct_write.py @@ -0,0 +1,127 @@ +# ========= 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. ========= + +"""WorkspaceResolver direct-write default regression tests. + +Locks in the contract for folder-backed Spaces after the copy → direct-write +default switch (see docs/core/space-folder-output-copy-bug-analysis.md): +- if task_lock has no workdir_mode, freeze defaults to direct-write so the + agent's working_directory == the selected folder (no Project workdir copy) +- explicit workdir_mode='copy' on task_lock still triggers the copy path so + existing rows in the server DB keep working +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from app.utils.workspace_resolver import ( + WorkspaceBinding, + WorkspaceResolver, + WorkspaceStore, +) + + +@pytest.fixture +def bound_resolver(tmp_path: Path): + """A resolver whose binding store returns a folder pointing at tmp_path. + + tmp_path stands in for the user's selected local folder. + """ + + source_root = tmp_path / "selected_folder" + source_root.mkdir() + (source_root / "existing_file.txt").write_text("source content") + + binding = WorkspaceBinding( + space_id="space_folder", + workspace_root=str(source_root), + source="local", + created_at="2026-05-28T00:00:00Z", + updated_at="2026-05-28T00:00:00Z", + ) + + store = WorkspaceStore() + with ( + patch.object(store, "get_binding", return_value=binding), + patch.object(store, "save_snapshot"), + ): + resolver = WorkspaceResolver(store=store) + yield resolver, source_root + + +def test_freeze_defaults_to_direct_write_when_task_lock_has_no_workdir_mode( + bound_resolver, +): + """The key regression: no explicit workdir_mode + folder binding must + resolve working_directory to the selected folder, NOT to a Project + workdir copy under ~/.eigent/.../workdir.""" + + resolver, source_root = bound_resolver + task_lock = SimpleNamespace() # NB: no .workdir_mode attribute at all + + with patch( + "app.utils.workspace_resolver._copy_space_baseline" + ) as copy_baseline: + frozen = resolver.freeze_task_directories_for( + space_id="space_folder", + project_id="proj_x", + task_id="task_x", + email="u@example.com", + task_lock=task_lock, + user_id="42", + ) + + assert frozen.working_directory == source_root + assert frozen.workdir_mode == "direct-write" + assert frozen.base_snapshot_id is None + copy_baseline.assert_not_called() + + +def test_freeze_honors_explicit_copy_workdir_mode_from_task_lock( + bound_resolver, +): + """Back-compat: a Project row in the server DB with workdir_mode='copy' + (created before the direct-write default flip) must still produce a copy. + Otherwise existing Projects would silently start writing to the user's + folder.""" + + resolver, source_root = bound_resolver + task_lock = SimpleNamespace(workdir_mode="copy") + + with patch( + "app.utils.workspace_resolver._copy_space_baseline", + return_value="snapshot_abc", + ) as copy_baseline: + frozen = resolver.freeze_task_directories_for( + space_id="space_folder", + project_id="proj_x", + task_id="task_x", + email="u@example.com", + task_lock=task_lock, + user_id="42", + ) + + assert frozen.working_directory != source_root + assert frozen.workdir_mode == "copy" + assert frozen.base_snapshot_id == "snapshot_abc" + copy_baseline.assert_called_once() + # The copy target must be the project workdir, not the selected folder. + copy_src, copy_dst = copy_baseline.call_args.args + assert copy_src == source_root + assert ".eigent" in str(copy_dst) or "/projects/" in str(copy_dst) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cc27f27ed..1e4538bc6 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -15,8 +15,10 @@ import asyncio import os import tempfile +import threading 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 +195,7 @@ def mock_request(): """Mock FastAPI Request object.""" request = AsyncMock() request.is_disconnected = AsyncMock(return_value=False) + request.state = SimpleNamespace() return request @@ -279,6 +282,36 @@ def event_loop(): loop.close() +@pytest.fixture(scope="session", autouse=True) +def registered_main_event_loop(): + """Provide the running main loop expected by worker-thread queue helpers.""" + from app.utils.event_loop_utils import set_main_event_loop + + loop = asyncio.new_event_loop() + ready = threading.Event() + + def run_loop() -> None: + asyncio.set_event_loop(loop) + ready.set() + loop.run_forever() + + thread = threading.Thread( + target=run_loop, + name="pytest-main-event-loop", + daemon=True, + ) + thread.start() + ready.wait(timeout=5) + set_main_event_loop(loop) + + yield loop + + set_main_event_loop(None) + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=5) + loop.close() + + @pytest.fixture def mock_environment_variables(): """Mock environment variables for testing.""" diff --git a/docs/CONTENT_STRUCTURE.md b/docs/CONTENT_STRUCTURE.md new file mode 100644 index 000000000..4fcd458b7 --- /dev/null +++ b/docs/CONTENT_STRUCTURE.md @@ -0,0 +1,136 @@ +# Eigent Documentation Structure + +This file is the editorial map for the public Mintlify documentation. Navigation is defined in `docs/docs.json`. Writing and media conventions are defined in `docs/STYLE_GUIDE.md`. + +## Information Architecture + +### Get Started + +- Welcome and product positioning +- Installation +- Quick start +- Self-hosting +- Core concepts + +### Dashboard + +- Dashboard overview +- Spaces +- Projects +- Tasks +- Search, sort, grid, list, and board views + +### Projects + +- Space, Project, Session, and Run hierarchy +- Project creation +- Workspace landing +- Context and files +- Live sessions +- Multi-run follow-ups +- Single Agent mode +- Workforce mode + +### Agents + +- Agent capabilities +- Workers +- Agent Skills +- Remote sub-agents +- Memory roadmap + +### Models + +- Model selection overview +- Eigent Cloud +- Bring Your Own Key +- Local models +- Provider reference +- Provider-specific setup guides + +### Connectors + +- Connector overview +- MCP marketplace +- Custom local and remote MCP servers +- Google Search +- Tool assignment + +### Browser + +- Browser automation overview +- CDP browser connections +- Browser cookie management +- Browser Plugins roadmap + +### Automation + +- Automation overview +- Scheduled triggers +- Webhook and Slack triggers +- Dispatch and remote control +- Execution logs and retries + +### Settings + +- Account, language, proxy, and updates +- Appearance and custom themes +- Privacy and data handling + +### Open Source + +- Repository and contribution overview +- Self-hosting +- Brain architecture + +## Provider Coverage + +The provider reference must remain synchronized with `src/lib/llm.ts` and `src/pages/Agents/localModels.ts`. + +Cloud and BYOK coverage: + +- Gemini +- OpenAI +- Anthropic +- OrcaRouter +- OpenRouter +- Qwen +- DeepSeek +- MiniMax +- Z.ai +- Moonshot +- ModelArk +- SambaNova +- Grok +- Mistral +- AWS Bedrock +- AWS Bedrock Converse +- Azure +- ERNIE +- OpenAI-compatible endpoints + +Local runtime coverage: + +- Ollama +- vLLM +- SGLang +- LM Studio +- LLaMA.cpp + +## Editorial Priorities + +1. Capture current screenshots for Dashboard, Workspace, Context, Models, Connectors, Browser, and Triggers. +2. Replace outline language with task-focused procedures. +3. Add provider credential and endpoint examples. +4. Add self-hosting commands and environment configuration. +5. Add troubleshooting links from every setup guide. +6. Mark coming-soon features clearly and remove those labels only when shipped. + +## Maintenance Checks + +- Every route in `docs/docs.json` must resolve to a Markdown file. +- Every page must include `title` and `description` frontmatter. +- Feature names should match current UI labels. +- Model providers should be audited whenever `src/lib/llm.ts` changes. +- Local runtimes should be audited whenever `src/pages/Agents/localModels.ts` changes. +- Coming-soon features must not be described as generally available. diff --git a/docs/STYLE_GUIDE.md b/docs/STYLE_GUIDE.md new file mode 100644 index 000000000..b3cabb267 --- /dev/null +++ b/docs/STYLE_GUIDE.md @@ -0,0 +1,117 @@ +# Eigent Documentation Style Guide + +Use this guide when writing or reviewing pages in `docs/`. + +## Documentation types + +Organize content around the reader's goal: + +- **Tutorial:** Helps a new user complete a guided learning experience. +- **How-to guide:** Provides the shortest reliable procedure for a specific goal. +- **Reference:** Describes available options, fields, statuses, or providers. +- **Explanation:** Clarifies concepts, architecture, or design decisions. + +Most product pages can combine a short explanation with one or more focused how-to procedures and a compact reference section. + +## Standard page structure + +Use the following order when it fits the topic: + +1. Frontmatter with `title` and `description` +2. One-paragraph overview +3. Prerequisites or **Before you begin** +4. Primary task procedure +5. Feature or option reference +6. Security, privacy, or limitations +7. Troubleshooting +8. Related guides or next steps + +Avoid adding sections that do not help the reader complete or understand the task. + +## Procedures + +- Use numbered steps for multi-step tasks. +- Start each step with an imperative verb. +- State where the action happens before the action. +- Keep one primary action in each step. +- Explain the result after the action when it helps the reader continue. +- Document the shortest accessible path. +- Link to repeated procedures instead of copying them. + +## Headings + +- Use sentence case. +- Make headings describe the section's content or task. +- Keep headings short. +- Do not skip heading levels. +- Avoid identical headings on the same page. + +## Product language + +- Match current interface labels. +- Use **Space**, **Project**, **Session**, **Run**, **Context**, **Single Agent**, and **Workforce** consistently. +- Use second person for instructions. +- Prefer present tense and active voice. +- Avoid claims such as “always,” “never,” “best,” or “secure” unless the product guarantees them. +- Mark unavailable features as **Coming soon** and do not provide procedures for them. + +## Screenshots + +Use a screenshot only when it clarifies a visual UI or a control that is difficult to locate. + +When the asset is not ready, leave this visible note: + +```md +> **Screenshot placeholder:** Add a screenshot of the relevant UI. Hide credentials and personal data. +``` + +When adding the final screenshot: + +- Crop it to the relevant UI. +- Use consistent operating-system and theme settings. +- Remove personal information with an opaque overlay. +- Add concise, contextual alt text. +- Describe complex information in the surrounding text. +- Do not use a screenshot for code, commands, or terminal output. + +## Videos + +Prefer MP4 over animated GIF for product walkthroughs. + +When the asset is not ready, leave this visible note: + +```md +> **Video placeholder:** Add a short MP4 walkthrough. Include captions. +``` + +When adding the final video: + +- Keep it focused on one workflow. +- Include captions. +- Provide a transcript for longer or instructional videos. +- Avoid unnecessary cursor movement and waiting time. +- Use test data and accounts. + +## Security and privacy + +- Never publish API keys, tokens, cookies, private endpoints, or webhook secrets. +- Use sample data in screenshots and videos. +- State when an action sends data to an external provider. +- Include least-privilege and credential-revocation guidance where relevant. + +## Open-source documentation + +Keep product docs connected to repository onboarding: + +- Explain what the project does and why it is useful. +- Provide a clear setup path. +- Link to support and troubleshooting. +- Document how to run tests and contribute. +- Keep license, README, contributing guidance, and code-of-conduct information discoverable. + +## Research references + +- [Diátaxis documentation framework](https://diataxis.fr/) +- [Google developer documentation: Procedures](https://developers.google.com/style/procedures) +- [Google developer documentation: Images](https://developers.google.com/style/images) +- [Open Source Guides: Starting an Open Source Project](https://opensource.guide/starting-a-project/) diff --git a/docs/agents/memory.md b/docs/agents/memory.md new file mode 100644 index 000000000..2604c1a2f --- /dev/null +++ b/docs/agents/memory.md @@ -0,0 +1,58 @@ +--- +title: Agent Memory +description: Understand the context Eigent uses today and the planned direction for persistent agent memory. +icon: brain +--- + +The Agent Memory page is present in the Agents dashboard but is currently marked **Coming soon**. + + +Do not rely on this page for persistent cross-project memory. The feature is not generally available in the current product. + + +## What Eigent remembers today + +Current context can come from: + +- The active Project conversation +- Earlier Runs in the same Project +- Files in the active Space +- Uploaded task attachments +- Project instructions, rules, and tone +- Enabled Agent Skills +- Tool and connector configuration + +These sources are different from a dedicated long-term memory system. + +## Expected memory controls + +The final feature design is not yet available. Persistent memory is expected to +include controls for: + +- What information an agent can store +- Whether memory is scoped to a user, Space, Project, or agent +- How a memory is created +- How users review and edit memories +- How users delete or disable memories +- Retention and synchronization behavior +- Sensitive-data controls +- How memory affects prompts and model usage + +**Screenshot placeholder:** Add a screenshot only after the Memory page contains active controls. Do not use the current Coming soon screen as the primary product image. + +**Video placeholder:** Add a video only after users can create, review, and delete a memory. The video should include all three actions and explain scope. + +## Use current alternatives + +Until Agent Memory is available: + +- Put stable project behavior in project instructions. +- Put reusable expertise in Agent Skills. +- Keep related follow-ups in one Project. +- Store durable reference material in the Space workspace. + +## Related guides + +- [Workspace instructions](/projects/workspace) +- [Agent Skills](/core/agent-skills) +- [Project runs](/core/project-runs) diff --git a/docs/agents/overview.md b/docs/agents/overview.md new file mode 100644 index 000000000..eea94619e --- /dev/null +++ b/docs/agents/overview.md @@ -0,0 +1,98 @@ +--- +title: Agents overview +description: Configure the models, Skills, sub-agents, tools, and workers that execute Eigent tasks. +icon: bot +--- + +Agents perform the work requested in an Eigent Project. Their behavior depends on the selected model, assigned tools, Skills, project context, and execution mode. + +Use the Agents dashboard to configure reusable capabilities before starting a task. + +## Open the Agents dashboard + +1. Open the Eigent dashboard. +2. Select **Agents**. +3. Choose **Models**, **Skills**, **Sub-agents**, or **Memory**. + +> **Screenshot placeholder:** Add a screenshot of the Agents dashboard with the four navigation items visible. Use a configured model and example Skills, but hide all API keys. + +## Configure models + +Models provide reasoning and generation capabilities. + +Eigent supports: + +- Eigent Cloud models +- Cloud providers using your own API keys +- OpenAI-compatible endpoints +- Local inference runtimes + +Configure at least one valid model before starting a task. See [Models overview](/models/overview). + +## Add Agent Skills + +Skills are reusable packages that provide instructions, scripts, templates, and domain knowledge. Eigent loads relevant Skills when a task matches their description. + +Use Skills for repeatable workflows such as: + +- Writing a specific report format +- Following an engineering process +- Creating branded presentations +- Operating a specialized tool +- Applying organization-specific rules + +See [Agent Skills](/core/agent-skills). + +## Configure workers + +Workers are specialized roles used by Workforce mode. A worker combines: + +- Name and role description +- One or more tools +- Model access +- Instructions and Skills + +Eigent includes built-in Developer, Browser, Document, and Multimodal workers. Add custom workers when a task needs a recurring role or connector. + +## Connect remote sub-agents + +Remote sub-agents let Eigent delegate work to an externally hosted agent. The current interface supports a Gemini remote sub-agent configuration. + +Use a remote sub-agent when the external system has capabilities or context that should remain outside the local Eigent runtime. + +## Agent Memory status + +The Memory page is currently marked **Coming soon**. Project conversation history, instructions, files, and Skills are available today, but the separate persistent Agent Memory configuration is not generally available. + +## Recommended setup order + +1. Configure and validate a model. +2. Install required connectors or MCP servers. +3. Add Skills for reusable workflows. +4. Create or edit Workforce workers. +5. Optional: Configure a remote sub-agent. +6. Start a test Project with a small task. + +> **Video placeholder:** Add a 90-second MP4 showing a model setup, Skill upload, custom worker creation, and a successful Workforce task. Include captions and a transcript. + +## Security + +- Use least-privilege credentials for models and tools. +- Audit untrusted Skills before enabling them. +- Review MCP commands and remote URLs. +- Limit local-folder Spaces to directories the agents should access. +- Remove unused provider keys, cookies, and connector credentials. + +## Related guides + + + + Create specialized Workforce roles and assign tools. + + + Add reusable instructions, scripts, and resources. + + + Choose managed, BYOK, or local models. + + diff --git a/docs/agents/remote-sub-agents.md b/docs/agents/remote-sub-agents.md new file mode 100644 index 000000000..caa923316 --- /dev/null +++ b/docs/agents/remote-sub-agents.md @@ -0,0 +1,93 @@ +--- +title: Remote sub-agents +description: Connect a remote Gemini agent and delegate work from Eigent. +icon: network-wired +--- + +Remote sub-agents let Eigent delegate a unit of work to an externally hosted agent and poll for the result. Use them when another agent platform provides specialized capabilities or isolated execution. + +The current interface supports a Gemini remote sub-agent provider. + +## Before you begin + +Prepare: + +- A Gemini API key +- The remote service base URL +- The configured remote agent name +- A maximum wall-time value +- A polling interval + +Use a dedicated credential with the minimum required permissions. + +## Open Sub-agents + +1. Open the Eigent dashboard. +2. Select **Agents**. +3. Select **Sub-agents**. +4. Select the Gemini provider. + +> **Screenshot placeholder:** Add a screenshot of the Sub-agents configuration page. Blur the API key and any private endpoint. + +## Configure the provider + +1. Enter the Gemini API key. +2. Confirm or change the base URL. +3. Enter the remote agent name. +4. Set the maximum wall time in seconds. +5. Set the polling interval in seconds. +6. Select **Save**. + +Eigent validates the connection before saving an enabled provider. + +## Enable the provider + +Use the provider switch to enable or disable delegation. + +When enabling an existing provider, Eigent validates required fields and the remote connection. If validation fails, the provider returns to its previous state. + +## Choose timing values + +### Maximum wall time + +Maximum wall time limits how long Eigent waits for the remote task to finish. Set it high enough for the expected work, but low enough to prevent indefinite jobs. + +### Poll interval + +Poll interval controls how frequently Eigent checks the remote task. Short intervals produce faster updates but increase request volume. + +Start with conservative values and adjust them after observing typical task duration. + +## Reset the provider + +1. Open the remote sub-agent configuration. +2. Select the reset action. +3. Confirm the removal. + +Reset deletes the stored provider record and restores the default form. + +> **Video placeholder:** Add a 45-60 second MP4 showing provider configuration, validation, enable and disable, and reset. Use test credentials and include captions. + +## Troubleshooting + +### Required-fields error + +Confirm the API key, base URL, agent name, maximum wall time, and polling interval. Timing values must be positive numbers. + +### Validation fails + +Check the API key, endpoint, agent name, network proxy, and provider availability. + +### Remote tasks time out + +Increase maximum wall time or reduce the scope of delegated work. + +### Polling causes rate limits + +Increase the polling interval. + +## Related guides + +- [Agents overview](/agents/overview) +- [General settings](/settings/general) +- [Privacy](/settings/privacy) diff --git a/docs/automation/dispatch.md b/docs/automation/dispatch.md new file mode 100644 index 000000000..9629b64e2 --- /dev/null +++ b/docs/automation/dispatch.md @@ -0,0 +1,96 @@ +--- +title: Dispatch and remote control +description: Control an active Eigent Space from a shareable remote web session. +icon: tower-broadcast +--- + +Dispatch provides channels for interacting with Eigent away from the desktop application. + +The currently implemented channel is Remote Control. Telegram, Lark, and WhatsApp appear as future channels. + +## Open Dispatch + +1. Open a Space. +2. In the project sidebar, select **Dispatch**. + +Remote Control requires an active Space because commands and desktop targets are scoped to that Space. + +> **Screenshot placeholder:** Add a screenshot of Dispatch with the Remote Control card, connection status, and activity log visible. + +## Start a Remote Control session + +1. In Dispatch, find **Remote Control**. +2. Select **Start**. +3. Wait for Eigent to create the session. +4. Select **Copy link**. +5. Open the link on the remote device. + +The remote page connects to the desktop bridge and targets the selected Space. + +## Send a remote follow-up + +1. Open the remote link. +2. Confirm the connected desktop target. +3. Enter a follow-up command. +4. Send it. +5. Review status updates on the remote page or desktop. + +Remote commands become task input on the desktop side. + +## Review activity + +The Dispatch activity log can show: + +- Session creation +- Remote connection +- Command delivery +- Command status +- Errors +- Session stop + +Use it to determine whether a problem occurred in the remote page, server, desktop bridge, or active task. + +## Stop a session + +1. Return to Dispatch. +2. Select **Stop**. + +Stop sessions when remote access is no longer required. A copied link should not be treated as permanent access. + +> **Video placeholder:** Add a 60-90 second MP4 showing session creation on desktop, opening the link on mobile, sending a follow-up, receiving it on desktop, and stopping the session. Include captions. + +## Messaging channels + +Telegram, Lark, and WhatsApp are displayed as Coming soon. The separate Channels dashboard also marks channel support as Coming soon. + + +Do not document these messaging channels as available until setup and connection controls are enabled. + + +## Security + +- Share remote links only with intended users. +- Stop the session after use. +- Avoid sending credentials through remote prompts. +- Confirm the active Space before creating the link. +- Review activity logs for unexpected commands. + +## Troubleshooting + +### Remote Control cannot start + +Open a Space and confirm the desktop can reach the Eigent server. + +### The remote page is disconnected + +Confirm that the desktop application is running and the bridge is connected. + +### A command does not reach the task + +Review Dispatch logs, confirm the target, and retry after the desktop bridge reconnects. + +## Related guides + +- [Automation overview](/automation/overview) +- [Projects overview](/projects/overview) +- [Privacy](/settings/privacy) diff --git a/docs/automation/overview.md b/docs/automation/overview.md new file mode 100644 index 000000000..9b1095286 --- /dev/null +++ b/docs/automation/overview.md @@ -0,0 +1,99 @@ +--- +title: Automation overview +description: Run Eigent tasks on schedules, webhooks, app events, or remote-control commands. +icon: bolt +--- + +Automation turns a project prompt into reusable work that can run without manually opening the task composer. + +Eigent supports scheduled triggers, webhooks, selected application events, and remote-control commands. + +## Open Automations + +Use either entry point: + +- In the project sidebar, select **Scheduled**. +- In the Home dashboard, select **Triggers**. + +The project sidebar focuses on the active Space and Project. The Home dashboard provides a cross-project trigger view. + +> **Screenshot placeholder:** Add a screenshot of the Scheduled tab with the trigger list and execution log panel visible. + +## Choose an automation type + +### Scheduled trigger + +Runs a prompt once or on a daily, weekly, monthly, or custom cron schedule. + +### Webhook trigger + +Creates an HTTP endpoint that starts a task when an external system sends a request. + +### Slack trigger + +Starts a task from a configured Slack event. Availability depends on connector and trigger configuration. + +### Remote control + +Dispatch creates a shareable web session that can send follow-up commands to a desktop task in the selected Space. + +## Create a trigger + +1. Open **Scheduled**. +2. Select **Create**. +3. Enter a trigger name. +4. Select the Project. +5. Enter the task prompt. +6. Choose Schedule or App. +7. Configure timing, event, or webhook values. +8. Save the trigger. + +## Manage triggers + +You can: + +- Edit a trigger +- Activate or deactivate it +- Delete it +- Sort by created time, last execution, or token cost +- Review execution history +- Retry a failed execution + +## Review execution logs + +Open the execution log panel to see: + +- Trigger lifecycle events +- Execution start and completion +- Errors and cancellations +- Webhook receipt +- Token or task metadata when available + +Use the logs to distinguish trigger-delivery failures from task-execution failures. + +## Control execution + +Set: + +- Maximum failure count +- Hourly execution limits +- Daily execution limits +- Expiration date for recurring schedules + +These controls help prevent a misconfigured trigger from generating unbounded work. + +> **Video placeholder:** Add a 90-second MP4 showing a scheduled trigger creation, automatic execution, log review, deactivation, and retry. Include captions. + +## Security + +- Treat webhook URLs as credentials. +- Restrict app-trigger permissions. +- Review task prompts before enabling recurring execution. +- Add rate limits to event-driven triggers. +- Disable triggers that are no longer monitored. + +## Related guides + +- [Scheduled triggers](/automation/scheduled-triggers) +- [Webhook and app triggers](/automation/webhook-triggers) +- [Dispatch and remote control](/automation/dispatch) diff --git a/docs/automation/scheduled-triggers.md b/docs/automation/scheduled-triggers.md new file mode 100644 index 000000000..219fd68e7 --- /dev/null +++ b/docs/automation/scheduled-triggers.md @@ -0,0 +1,103 @@ +--- +title: Scheduled triggers +description: Run project tasks once or on daily, weekly, and monthly schedules. +icon: clock +--- + +Scheduled triggers run a saved task prompt at a configured time. Use them for recurring reports, monitoring, content updates, reminders, and other predictable work. + +## Before you begin + +Prepare: + +- A Project in the active Space +- A model and required tools +- A prompt that can run without additional clarification +- A schedule and time zone + +Test the prompt manually before automating it. + +## Create a one-time schedule + +1. Open **Scheduled**. +2. Select **Create**. +3. Enter a trigger name and task prompt. +4. Select **Schedule**. +5. Choose **One time**. +6. Select the date, hour, and minute. +7. Set the maximum failure count. +8. Create the trigger. + +## Create a recurring schedule + +Choose one of: + +- **Daily:** Runs every day at the selected time. +- **Weekly:** Runs on selected weekdays. +- **Monthly:** Runs on a selected day of the month. +- **Custom cron:** Uses a five-part cron expression. + +Optional: Set an expiration date so the trigger stops creating new executions. + +> **Screenshot placeholder:** Add a screenshot of the schedule picker with weekly frequency, selected weekdays, execution time, expiration, and upcoming-run preview visible. + +## Understand time conversion + +The schedule picker displays local time and converts it to a UTC cron expression for storage. + +Review the upcoming execution preview before saving. Daylight-saving changes can affect local-time expectations for long-running schedules. + +## Use a custom cron expression + +A standard five-part expression contains: + +```text +minute hour day-of-month month day-of-week +``` + +Example: + +```text +0 9 * * 1-5 +``` + +This represents 09:00 on weekdays in the cron time zone used by the system. + +Use the visual schedule options when possible because they also provide validation and execution previews. + +## Activate or deactivate a schedule + +Use the trigger switch to stop or resume future executions without deleting the configuration. + +Deactivating a trigger does not cancel a task that already started. + +## Review executions + +Open execution logs to review: + +- Scheduled time +- Start and completion +- Failure details +- Retry status +- Token cost when available + +> **Video placeholder:** Add a 60-second MP4 showing weekly schedule creation, upcoming execution preview, activation, and execution-log review. Include captions. + +## Troubleshooting + +### The trigger ran at the wrong local time + +Review the time zone, UTC conversion, and daylight-saving changes. + +### No execution was created + +Confirm that the trigger is active, has not expired, and still belongs to an available Project. + +### Repeated failures stop the trigger + +Review the maximum failure count, task prompt, model, and connector availability. + +## Related guides + +- [Automation overview](/automation/overview) +- [Webhook and app triggers](/automation/webhook-triggers) diff --git a/docs/automation/webhook-triggers.md b/docs/automation/webhook-triggers.md new file mode 100644 index 000000000..df410d81f --- /dev/null +++ b/docs/automation/webhook-triggers.md @@ -0,0 +1,96 @@ +--- +title: Webhook and app triggers +description: Start Eigent tasks from HTTP requests or supported application events. +icon: webhook +--- + +Webhook and app triggers start tasks when an external event occurs. + +Use a webhook for a custom system. Use an application trigger when Eigent provides a guided integration for the event source. + +## Create a webhook trigger + +1. Open **Scheduled**. +2. Select **Create**. +3. Enter a trigger name and task prompt. +4. Select **App**. +5. Choose **Webhook**. +6. Select the HTTP method. +7. Configure optional execution settings. +8. Create the trigger. + +Eigent generates the webhook URL after creation. + +> **Screenshot placeholder:** Add a screenshot of the webhook configuration and the post-creation URL dialog. Obscure most of the URL token. + +## Call the webhook + +Use the generated URL from the external service. A simplified request can look like: + +```bash +curl -X POST "https://example.com/api/your-webhook-url" \ + -H "Content-Type: application/json" \ + -d '{"event":"new_record","id":"123"}' +``` + +The real URL and accepted payload depend on the deployment and trigger configuration. + +Treat the URL as a credential. Do not commit it to a public repository. + +## Configure execution limits + +For event-driven triggers, configure: + +- Maximum executions per hour +- Maximum executions per day +- Authentication or verification values +- Other dynamically loaded provider settings + +Rate limits reduce the impact of loops or high-volume events. + +## Create a Slack trigger + +1. Install and authenticate the Slack connector. +2. Create a new App trigger. +3. Select **Slack**. +4. Complete the dynamically loaded event configuration. +5. Enter the task prompt. +6. Save and activate the trigger. + +A pending-authentication state means additional verification is required. + +## Use event data in the task + +Write the trigger prompt so the agent understands: + +- What the event represents +- Which fields are relevant +- What output to create +- Where to send or store the result +- When to stop or request review + +## Review and retry executions + +Use execution logs to review event receipt, task creation, completion, and errors. Retry only after fixing the underlying model, credential, prompt, or connector issue. + +> **Video placeholder:** Add a 90-second MP4 showing webhook creation, a test `curl` request, execution logs, and a Slack trigger configuration. Include captions. + +## Troubleshooting + +### The webhook returns an error + +Confirm the method, URL, authentication, and deployment base address. + +### The event creates too many tasks + +Deactivate the trigger, add hourly and daily limits, and fix the external event rule. + +### Slack remains pending authentication + +Reconnect Slack and complete the required verification fields. + +## Related guides + +- [Automation overview](/automation/overview) +- [MCP Marketplace](/connectors/mcp-marketplace) +- [Privacy](/settings/privacy) diff --git a/docs/browser/connections.md b/docs/browser/connections.md new file mode 100644 index 000000000..313591744 --- /dev/null +++ b/docs/browser/connections.md @@ -0,0 +1,84 @@ +--- +title: Browser connections +description: Launch a managed browser or connect an existing CDP-enabled browser. +icon: globe +--- + +The browser pool contains Chrome DevTools Protocol sessions that Eigent agents can use. + +## Open a new browser + +1. Open **Browser > Connections**. +2. Select **Open new browser**. +3. Wait for Eigent to launch the browser and add it to the pool. + +The browser item displays its name and debugging port. + +> **Screenshot placeholder:** Add a screenshot of the browser pool with two active browsers and their ports. + +## Connect an existing browser + +Start Chrome or Chromium with remote debugging enabled. For example: + +```bash +google-chrome --remote-debugging-port=9222 +``` + +The executable name differs by operating system and installation. + +Then: + +1. Open **Browser > Connections**. +2. Select **Connect existing browser**. +3. Enter the remote-debugging port. +4. Select **Connect**. + +Eigent checks `http://localhost:/json/version` before adding the browser. + +## Choose a port + +Use a port from `1` to `65535`. The port must: + +- Belong to a running CDP-enabled browser +- Not already exist in the Eigent browser pool +- Be reachable from the Eigent application + +## Remove a browser + +1. Find the browser in the pool. +2. Select its delete action. +3. Confirm the removal. + +Removing a browser disconnects it from Eigent. It does not necessarily close an external browser process. + +## Use the browser in a task + +1. Ensure a Browser worker is available. +2. Start a task that requires web interaction. +3. Open the Browser agent workspace in the Session. +4. Use Take Control when the agent requests manual interaction. + +> **Video placeholder:** Add a 60-second MP4 showing Chrome started with remote debugging, connection through port `9222`, and a successful Browser-agent navigation. Include captions. + +## Troubleshooting + +### Invalid port + +Enter a whole number between `1` and `65535`. + +### Port already in use + +The port is already registered in the browser pool. Use the existing item or start another browser on a different port. + +### No browser found + +Confirm that the browser is running with remote debugging and that `/json/version` responds. + +### The browser disappears after restart + +External browser processes and ports can change. Start the browser again and reconnect it. + +## Related guides + +- [Browser overview](/browser/overview) +- [Browser cookies](/browser/cookies) diff --git a/docs/browser/cookies.md b/docs/browser/cookies.md new file mode 100644 index 000000000..cbf494108 --- /dev/null +++ b/docs/browser/cookies.md @@ -0,0 +1,81 @@ +--- +title: Browser cookies +description: Add authenticated browser sessions and manage cookies by domain. +icon: cookie +--- + +Browser cookies let agents use authenticated websites without entering credentials during every task. + +Cookies can grant account access. Use a dedicated profile and remove sessions that are no longer required. + +## Open Cookie management + +1. Open the Eigent dashboard. +2. Select **Browser**. +3. Select **Cookies**. + +The page groups cookie records by main domain and shows the total cookie count for each group. + +> **Screenshot placeholder:** Add a screenshot of the Cookies page with several sample domains and cookie counts. Do not show real customer or personal domains. + +## Add authenticated cookies + +1. Select **Open browser**. +2. Sign in to the required websites. +3. Close the login browser when finished. +4. Wait for Eigent to refresh the cookie list. +5. Restart Eigent when prompted. + +The restart makes the new cookie state available to browser automation. + +## Refresh the cookie list + +Select the refresh control to reload available domains and counts. + +Use refresh after completing another login or when the list appears stale. + +## Delete a domain + +1. Find the main domain. +2. Select its delete action. +3. Confirm the deletion. + +Eigent deletes cookies for the main domain and its listed subdomains. + +## Delete all cookies + +1. Select **Delete all**. +2. Confirm the action. +3. Restart Eigent when prompted. + +This removes all browser cookie records managed by this feature. + +> **Video placeholder:** Add a 60-second MP4 showing login, cookie import, domain deletion, and restart. Use a test account and include captions. + +## Security guidance + +- Prefer test or dedicated automation accounts. +- Do not import sessions with broad administrative access unless required. +- Remove cookies after temporary work. +- Protect local user data and backups containing browser state. +- Never include cookie values in screenshots or support requests. + +## Troubleshooting + +### No new cookies appear + +Confirm that login completed, the login browser was closed, and the target site actually stored cookies. + +### A website still asks for login + +Restart Eigent, confirm the domain appears in the list, and check whether the website uses another domain or additional authentication. + +### Deleting cookies does not sign out immediately + +Restart Eigent and close other browser sessions. The service can also maintain server-side sessions until they expire or are revoked. + +## Related guides + +- [Browser overview](/browser/overview) +- [Browser connections](/browser/connections) +- [Privacy](/settings/privacy) diff --git a/docs/browser/overview.md b/docs/browser/overview.md new file mode 100644 index 000000000..2a92819ac --- /dev/null +++ b/docs/browser/overview.md @@ -0,0 +1,70 @@ +--- +title: Browser overview +description: Give Eigent agents controlled access to browser sessions and authenticated websites. +icon: compass +--- + +Eigent can launch or connect to Chrome DevTools Protocol browsers for research and browser automation. Browser agents can navigate pages, interact with controls, capture screenshots, and maintain session state. + +## Open Browser settings + +1. Open the Eigent dashboard. +2. Select **Browser**. +3. Choose **Connections**, **Plugins**, or **Cookies**. + +> **Screenshot placeholder:** Add a screenshot of the Browser settings page with the three navigation items and an active browser pool. + +## Browser Connections + +Connections manage browsers available to agents. You can: + +- Open a new managed browser +- Connect an existing CDP-enabled browser +- Review browser names and ports +- Remove a browser from the pool + +See [Browser connections](/browser/connections). + +## Browser Cookies + +Cookies let agents use authenticated sessions. Open a dedicated login browser, sign in to required services, then let Eigent import the resulting cookie domains. + +See [Browser cookies](/browser/cookies). + +## Browser Plugins + +Browser Plugins are currently marked **Coming soon**. + + +Do not present browser extension or plugin features as available until the product page includes active installation controls. + + +## Use a browser in a Session + +When a Browser agent starts work, its workspace appears in the Project Session. + +The agent can: + +- Search and navigate +- Click and type +- Read page content +- Capture visual state +- Use available authenticated cookies + +When manual interaction is required, use **Take Control**, complete the action, and return control to the agent. + +## Security + +- Use a dedicated browser profile. +- Avoid storing unnecessary privileged sessions. +- Delete cookies when a task no longer needs them. +- Review actions before giving an agent access to sensitive services. +- Do not connect a remote-debugging port to an untrusted network. + +> **Video placeholder:** Add a 60-second MP4 showing a browser connection, a Browser-agent task, Take Control, and return of control. Include captions. + +## Related guides + +- [Browser connections](/browser/connections) +- [Browser cookies](/browser/cookies) +- [Google Search](/connectors/google-search) diff --git a/docs/connectors/custom-mcp.md b/docs/connectors/custom-mcp.md new file mode 100644 index 000000000..839882366 --- /dev/null +++ b/docs/connectors/custom-mcp.md @@ -0,0 +1,107 @@ +--- +title: Custom MCP servers +description: Add local command-based or remote URL-based Model Context Protocol servers. +icon: wrench +--- + +Use a custom Model Context Protocol server when you need a tool that is not included in Eigent's supported integration catalog. + +Eigent supports: + +- Local MCP servers started with a command +- Remote MCP servers reached through a URL + +## Review the server before installation + +An MCP server can read data, call APIs, or execute actions. Before adding one: + +- Read its source code or trusted documentation. +- Review requested permissions. +- Inspect commands and arguments. +- Confirm how credentials are stored. +- Test it with a restricted account. + +## Add a local MCP server + +1. Open **Connectors**. +2. Select **Add MCP**. +3. Choose **Local**. +4. Paste the MCP JSON configuration. +5. Review the command and arguments. +6. Select **Install**. + +Example: + +```json +{ + "mcpServers": { + "sequential-thinking": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"] + } + } +} +``` + +Use an absolute executable path when the Electron process cannot resolve the command through the normal shell environment. + +> **Screenshot placeholder:** Add a screenshot of the local MCP JSON dialog with a safe example server configuration. + +## Add a remote MCP server + +1. Open **Connectors**. +2. Select **Add MCP**. +3. Choose **Remote**. +4. Enter a display name. +5. Enter the remote server URL. +6. Save the configuration. + +Use HTTPS for remote servers outside a trusted local network. + +## Configure environment variables + +1. Select the MCP server. +2. Open its environment configuration. +3. Add the required keys and values. +4. Save. + +Never place production secrets directly in public configuration examples. + +## Enable and test the server + +1. Enable the MCP server. +2. Add it to a test worker. +3. Start a small task that uses one tool. +4. Review the task log for the tool call and result. + +## Edit or delete a server + +Use the server actions to update command arguments, URL, description, or environment values. Delete the server when it is no longer required. + +Deleting an MCP entry does not revoke credentials at the external service. + +> **Video placeholder:** Add a 90-second MP4 showing local and remote MCP setup, environment configuration, worker assignment, and a test call. Include captions. + +## Troubleshooting + +### The local command is not found + +Use an absolute path or ensure the executable is installed in the environment used by Eigent. + +### The process exits immediately + +Run the command in a terminal and inspect its output. Confirm arguments and required environment variables. + +### A remote URL cannot be reached + +Check DNS, TLS, authentication, proxy settings, and firewall rules. + +### Tools do not appear + +Enable the MCP server, restart it if required, and assign it to the worker. + +## Related guides + +- [Connectors overview](/connectors/overview) +- [MCP Marketplace](/connectors/mcp-marketplace) +- [Workers](/core/workers) diff --git a/docs/connectors/google-search.md b/docs/connectors/google-search.md new file mode 100644 index 000000000..ae355f2fb --- /dev/null +++ b/docs/connectors/google-search.md @@ -0,0 +1,78 @@ +--- +title: Google Search +description: Configure web search for managed and self-hosted Eigent deployments. +icon: magnifying-glass +--- + +Google Search provides current web results for Browser agents and research workflows. + +## Determine your setup + +### Managed Eigent mode + +Google Search can be enabled by default and does not require user credentials. + +### Self-hosted or custom mode + +Provide: + +- Google API key +- Google Custom Search Engine ID + +## Create Google credentials + +1. In Google Cloud, create or select a project. +2. Enable the Custom Search JSON API. +3. Create an API key with appropriate restrictions. +4. Create or select a Programmable Search Engine. +5. Copy its Search Engine ID. + +Use Google's current Custom Search documentation for account-specific steps and quota information. + +## Configure Search in Eigent + +1. Open **Connectors**. +2. Select **Google Search**. +3. Enter the Google API key. +4. Enter the Search Engine ID. +5. Save the configuration. + +> **Screenshot placeholder:** Add a screenshot of the Google Search configuration form. Blur the complete API key and Search Engine ID. + +## Test Search + +Start a small Browser-agent task, for example: + +> Find the three most recent official release notes for Eigent and return their publication dates and source links. + +Review the task log to confirm that the search tool returned results. + +## Control cost and quota + +Google Custom Search can enforce daily quotas or billing limits. Use a restricted key and monitor usage in Google Cloud. + +## Troubleshooting + +### Invalid API key + +Confirm that the key is active, the Custom Search JSON API is enabled, and API restrictions allow the service. + +### Invalid Search Engine ID + +Copy the identifier from the Programmable Search Engine control panel, not the display name. + +### Empty results + +Review the search engine's site scope and settings. A search engine restricted to selected sites will not return the full web. + +### Quota exceeded + +Review the current quota and billing settings in Google Cloud. + +> **Video placeholder:** Add a 45-second MP4 showing credential entry, saving, and a successful Browser-agent search. Include captions. + +## Related guides + +- [Connectors overview](/connectors/overview) +- [Browser overview](/browser/overview) +- [Self-hosting](/get_started/self-hosting) diff --git a/docs/connectors/mcp-marketplace.md b/docs/connectors/mcp-marketplace.md new file mode 100644 index 000000000..1178fb70b --- /dev/null +++ b/docs/connectors/mcp-marketplace.md @@ -0,0 +1,92 @@ +--- +title: MCP Marketplace +description: Install, configure, enable, and remove supported connector integrations. +icon: store +--- + +The MCP Marketplace provides guided installation for supported services. Use it before creating a custom MCP configuration because marketplace integrations include service-specific defaults and credential fields. + +## Browse integrations + +1. Open **Connectors**. +2. Expand the supported integration section. +3. Use search to find a service. +4. Select the integration. + +Connected services appear before unconnected services. Coming-soon services appear after available integrations. + +> **Screenshot placeholder:** Add a screenshot of the connector catalog with one connected integration, one available integration, and one Coming soon item. + +## Install an integration + +The exact flow depends on the service: + +1. Select the integration. +2. Select **Install** or **Connect**. +3. Complete OAuth or enter required environment variables. +4. Save the configuration. +5. Enable the connector. + +Notion and other services can open a dedicated authentication flow. Other integrations request keys or tokens directly. + +## Configure environment variables + +1. Select a connected integration. +2. Open its configuration. +3. Enter each required environment value. +4. Save the changes. + +Use credentials created specifically for Eigent. Avoid personal administrator tokens. + +## Enable or disable an integration + +Use the connector switch to control whether its tools are available. + +Disabling a connector preserves its configuration but prevents new agent use. Existing external sessions can remain active at the provider until revoked. + +## Edit an integration + +1. Open the integration actions. +2. Select **Edit** or **Configure**. +3. Update the required values. +4. Save. +5. Run a test task. + +## Uninstall an integration + +1. Open the integration actions. +2. Select **Uninstall**. +3. Confirm the action. + +Uninstalling removes the Eigent connector configuration. Revoke OAuth grants or provider tokens separately when required. + +## Assign the integration to a worker + +1. Open the Workspace. +2. Add or edit a worker. +3. Select the integration from **Agent Tool**. +4. Save the worker. + +The worker can use only the tools assigned to it. + +> **Video placeholder:** Add a 60-second MP4 showing installation, credential configuration, enable and disable, and worker assignment for one integration. Include captions. + +## Troubleshooting + +### Installation completes but the connector is not shown as connected + +Refresh the connector list and confirm that required credentials were saved. + +### A tool returns an authorization error + +Reconnect the integration or replace the expired credential. + +### The desired service is not listed + +Use [Custom MCP servers](/connectors/custom-mcp). + +## Related guides + +- [Connectors overview](/connectors/overview) +- [Workers](/core/workers) +- [Privacy](/settings/privacy) diff --git a/docs/connectors/overview.md b/docs/connectors/overview.md new file mode 100644 index 000000000..477d3867c --- /dev/null +++ b/docs/connectors/overview.md @@ -0,0 +1,103 @@ +--- +title: Connectors overview +description: Extend Eigent with hosted integrations, Google Search, and custom MCP servers. +icon: plug +--- + +Connectors give agents access to external services and tools. A connector can search data, read or write records, send messages, access calendars, or expose a custom capability through the Model Context Protocol (MCP). + +## Open Connectors + +1. Open the Eigent dashboard. +2. Select **Connectors**. + +The page combines supported integrations, Google Search, and user-managed MCP servers. + +> **Screenshot placeholder:** Add a screenshot of the Connectors page showing connected integrations, available integrations, and the Your MCP section. Hide account names and credentials. + +## Connector types + +### Supported integrations + +Supported integrations provide a guided setup for known services. The current interface can include: + +- Notion +- Slack +- Google Calendar +- Gmail +- LinkedIn +- Lark +- Telegram +- Cursor +- VS Code + +Catalog availability can vary by deployment. + +### Google Search + +Google Search provides current web results for Browser agents and research workflows. Managed mode can enable it by default; self-hosted mode requires Google Custom Search credentials. + +### Custom MCP servers + +Add a local command-based server or a remote MCP URL when the required service is not in the supported catalog. + +## Understand connector status + +Connected integrations appear before unconnected ones. A connector can provide: + +- Install or authentication action +- Enable or disable switch +- Configuration form +- Environment variables +- Edit and delete actions + +Some items remain visible as future integrations. + + +X, WhatsApp, Reddit, and GitHub are marked Coming soon in the current connector interface. Do not describe them as generally available until their controls are enabled. + + +## Assign tools to agents + +Installing a connector makes its tools available to Eigent. To use it in Workforce: + +1. Open the Workspace. +2. Add or edit a worker. +3. Open the tool selector. +4. Select the connector. +5. Save the worker. + +Describe the service and expected operation in the task prompt. + +## Security + +Connectors can perform actions in external systems. + +- Use accounts and credentials with minimum permissions. +- Review OAuth consent and environment variables. +- Audit custom MCP commands and remote URLs. +- Disable connectors that are not required. +- Remove credentials before sharing screenshots or logs. + +> **Video placeholder:** Add a 90-second MP4 showing a supported integration install, a custom MCP server, worker assignment, and a successful tool call. Include captions. + +## Troubleshooting + +### A connector is installed but unused + +Assign it to the relevant worker and make the intended action explicit in the task. + +### Authentication expires + +Open the connector configuration and repeat its authentication flow. + +### An MCP server does not start + +Run the configured command independently and review its environment variables and executable path. + +## Related guides + +- [MCP Marketplace](/connectors/mcp-marketplace) +- [Custom MCP servers](/connectors/custom-mcp) +- [Google Search](/connectors/google-search) +- [Workers](/core/workers) diff --git a/docs/core/agent-skills.md b/docs/core/agent-skills.md index dd0cc4ac5..abf4bc764 100644 --- a/docs/core/agent-skills.md +++ b/docs/core/agent-skills.md @@ -27,19 +27,19 @@ Eigent provides pre-built Agent Skills for common tasks, and you can create or u - **Example Skills:** These are pre-built Agent Skills available to all users on Eigent. They operate seamlessly behind the scenes, and Eigent utilizes them without requiring any manual setup. You have the option to manually enable or disable it. - + - **Custom Skills:** These allow you to package your specific domain expertise and organizational knowledge. They are available across your Eigent workforce, and you can assign them to specific agents. You can create them directly within the Skill interface or add them via Eigent's settings. - ![Screenshot 2026-02-24 at 21.58.11.png](/docs/images/agent_skills_settings_screenshot.png) + ![Screenshot 2026-02-24 at 21.58.11.png](/images/agent_skills_settings_screenshot.png) Upload your own Skills as zip files through Homepage > Agents > Skills. Custom Skills are individual to each user and saved locally. - + You can upload a standalone `SKILL.md` file or a complete `.zip` skill package. If uploading a package, it must contain a `SKILL.md` file in its root directory. In either case, the `SKILL.md` file must define the Skill's name and description using YAML formatting. - + Every Skill requires a `SKILL.md` file with YAML frontmatter: @@ -60,12 +60,12 @@ description: Brief description of what this Skill does and when to use it Eigent supports uploading multiple skills within one zip file, but please ensure the contents of each skill folder are complete. - + ## Using Skills To test your Skill file immediately, click the **Try in chat** button. - + Use Skills only from trusted sources. Malicious Skills can misuse tools or execute unintended actions, potentially causing data leaks or unauthorized access—so carefully audit any untrusted Skill before use. diff --git a/docs/core/brain-architecture.md b/docs/core/brain-architecture.md new file mode 100644 index 000000000..2f6139033 --- /dev/null +++ b/docs/core/brain-architecture.md @@ -0,0 +1,146 @@ +--- +title: Brain Architecture +description: Understand how Eigent separates clients, Brain, and runtime capabilities across desktop and web deployments. +icon: brain +--- + +## Overview + +Eigent is evolving toward a Brain-centered architecture. + +Instead of treating the desktop app as the system boundary, Eigent treats the Brain as the primary runtime. Clients such as Desktop and Web connect to the same Brain over HTTP and SSE, while runtime capabilities are determined by where the Brain is deployed. + +This shift makes it easier to: + +- support both Desktop and Web as first-class clients +- run Brain independently from Electron +- keep capability boundaries consistent across local and remote deployments +- prepare for future CLI, channel, and remote resource integrations + +## Core Building Blocks + +### Brain + +The Brain is the central runtime for Eigent. It is responsible for: + +- task and chat orchestration +- agent and workforce coordination +- file, tool, MCP, and skill APIs +- session-aware request handling +- runtime capability resolution + +In practice, the Brain is the part of the system that reasons, routes work, and executes actions through the available runtime capabilities. + +### Clients + +Eigent supports multiple client shapes around the same Brain: + +- Desktop +- Web +- future CLI and channel-based clients + +Clients are responsible for presentation and interaction. They do not define what the system is allowed to do. They only define how users connect to and interact with the Brain. + +### Hands + +Hands represent what the Brain can actually operate in its current environment. + +Examples include: + +- terminal execution +- browser control +- filesystem access +- MCP usage + +This is an important architectural choice: Hands are determined by the Brain deployment environment, not by the client type. + +That means a Web client connected to a full local or VM-hosted Brain can still access browser and terminal capabilities, while a client connected to a restricted Brain will see a reduced capability set. + +### Host + +For Desktop, Electron acts as a host layer. It provides native integrations such as: + +- window controls +- file picking +- CDP and webview-related integrations +- backend lifecycle support + +The host is intentionally kept separate from Brain logic so shared frontend code can work across Desktop and Web. + +## High-Level Architecture + +```text +Clients + ├─ Desktop + ├─ Web + └─ Future CLI / Channels + │ + │ HTTP / SSE + ▼ + Brain + ├─ Router layer + ├─ Chat / Task / Tool / File APIs + ├─ MCP / Skills services + └─ Hands + ├─ terminal + ├─ browser + ├─ filesystem + └─ MCP +``` + +## Request Flow + +### Desktop + +In Desktop mode, Electron starts and hosts the local Brain. The frontend resolves the local Brain endpoint through the host layer, then uses shared Brain HTTP and SSE APIs for most business flows. + +### Web + +In Web mode, the frontend connects directly to a Brain endpoint. Session metadata is carried through headers, file attachments are uploaded through Brain APIs, and task streaming uses shared SSE transport. + +This makes Web a first-class entry point instead of a limited fallback path. + +## Why Hands Are Environment-Driven + +A common pitfall in multi-client systems is tying capability boundaries to the client type. + +Eigent avoids that by separating: + +- **channel**: how a client connects and how responses should be adapted +- **hands**: what the Brain can actually do in its runtime environment + +This enables a cleaner model: + +- Desktop does not automatically mean full capability +- Web does not automatically mean restricted capability +- the Brain environment remains the source of truth for runtime power + +## Deployment Modes + +The architecture supports multiple deployment shapes: + +- **Desktop + Local Brain** + - best for local development and full machine access +- **Web + Local Brain** + - useful for frontend/backend separation and local web usage +- **Web + Cloud or VM Brain** + - allows browser-based access to a remotely hosted Brain +- **Brain + Remote resource pools** + - enables future remote browser or terminal acquisition patterns + +## What This Architecture Enables + +This architecture lays the foundation for: + +- stronger separation between UI and runtime +- better Web support without breaking Desktop +- clearer capability modeling +- future remote execution and multi-client expansion + +It also reduces the amount of client-specific branching required in the product by moving more system behavior into shared Brain-side abstractions. + +## Current Direction + +The architecture is being rolled out incrementally. Desktop remains supported while Web and standalone Brain flows are being strengthened around the same core abstractions. + +That incremental approach helps Eigent evolve toward a more portable and extensible system without requiring a full rewrite. diff --git a/docs/core/concepts.md b/docs/core/concepts.md index 666f17c22..49c9dc520 100644 --- a/docs/core/concepts.md +++ b/docs/core/concepts.md @@ -10,7 +10,7 @@ Autonomous agents tailored to specific roles that run tasks independently or tog Each Worker is designed with specific capabilities and can be customized to handle particular types of tasks efficiently. -![Workers concept illustration](/docs/images/concepts_worker.png) +> **Screenshot placeholder:** Add a current screenshot for “Workers concept illustration”. ## Workforce @@ -18,7 +18,7 @@ A coordinated team of Workers that collaborate to complete complex workflows. Th The Workforce orchestrates multiple Workers, ensuring they work together seamlessly to achieve your goals. -![Workforce collaboration illustration](/docs/images/concepts_workforce.gif) +> **Video placeholder:** Add a current video walkthrough for “Workforce collaboration illustration”. Include captions. ## Workspace @@ -26,7 +26,7 @@ A live window into a Worker's process where you can watch or take control. For e Workspaces provide real-time visibility into what your Workers are doing, allowing you to monitor progress and intervene when needed. -![Workspace interface illustration](/docs/images/concepts_workspace.gif) +> **Video placeholder:** Add a current video walkthrough for “Workspace interface illustration”. Include captions. ## Tasks & Subtasks @@ -34,7 +34,7 @@ You define a mission (task), the Workforce breaks it into components (subtasks), This hierarchical approach ensures complex projects are broken down into manageable pieces and executed efficiently. -![Tasks and subtasks breakdown illustration](/docs/images/concepts_tasks_subtasks.gif) +> **Video placeholder:** Add a current video walkthrough for “Tasks and subtasks breakdown illustration”. Include captions. ## Chat @@ -42,7 +42,7 @@ Your primary interface for communicating with your Workforce. You use it to defi The Chat interface serves as your command center, where you can give instructions, ask questions, and receive updates from your AI team. -![Chat interface illustration](/docs/images/concepts_chat.png) +> **Screenshot placeholder:** Add a current screenshot for “Chat interface illustration”. ## MCP @@ -50,7 +50,7 @@ Model Context Protocol that allows Workers to use external tools. It connects yo MCP extends your Workers' capabilities by providing access to real-world data and tools, making them more powerful and versatile. -![MCP protocol illustration](/docs/images/concepts_mcp.png) +> **Screenshot placeholder:** Add a current screenshot for “MCP protocol illustration”. ## Models @@ -58,4 +58,4 @@ Different AI "brains" that power your Workers. Eigent allows you to choose from Choose the right model for each task based on your specific needs for performance, accuracy, or cost efficiency. -![AI models illustration](/docs/images/concepts_models.png) +> **Screenshot placeholder:** Add a current screenshot for “AI models illustration”. diff --git a/docs/core/models/byok.md b/docs/core/models/byok.md index 8153da5a5..6110124ed 100644 --- a/docs/core/models/byok.md +++ b/docs/core/models/byok.md @@ -25,7 +25,7 @@ description: Configure your own API keys to use various LLM providers with Eigen 1. Find the **OpenAI** card in the Custom Model section -![byok_1](/docs/images/byok_1.png) +![byok_1](/images/byok_1.png) 1. Fill in the following fields: @@ -68,21 +68,28 @@ When saving your configuration, Eigent validates your API key and model. Here ar Eigent supports the following BYOK providers: -| Provider | Default API Host | Official Documentation | -| -------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| **OpenAI** | `https://api.openai.com/v1` | [OpenAI API Docs](https://platform.openai.com/docs/api-reference) | -| **Anthropic** | `https://api.anthropic.com/` | [Anthropic API Docs](https://docs.anthropic.com/en/api/getting-started) | -| **Google Gemini** | `https://generativelanguage.googleapis.com/v1beta/openai/` | [Gemini API Docs](https://ai.google.dev/gemini-api/docs) | -| **OpenRouter** | `https://openrouter.ai/api/v1` | [OpenRouter Docs](https://openrouter.ai/docs) | -| **OrcaRouter** | `https://api.orcarouter.ai/v1` | [OrcaRouter Docs](https://docs.orcarouter.ai/) | -| **Nebius Token Factory** | `https://api.tokenfactory.nebius.com/v1` | [Nebius Token Factory Docs](https://docs.tokenfactory.nebius.com/quickstart) | -| **Qwen (Alibaba)** | `https://dashscope.aliyuncs.com/compatible-mode/v1` | [Qwen API Docs](https://help.aliyun.com/zh/dashscope/developer-reference/api-details) | -| **DeepSeek** | `https://api.deepseek.com` | [DeepSeek API Docs](https://platform.deepseek.com/api-docs) | -| **Minimax** | `https://api.minimax.io/v1` | [Minimax API Docs](https://platform.minimaxi.com/document/Announcement) | -| **Z.ai** | `https://api.z.ai/api/coding/paas/v4/` | [Z.ai Platform](https://z.ai) | -| **Azure OpenAI** | _(user-provided)_ | [Azure OpenAI Docs](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference) | -| **AWS Bedrock** | _(user-provided)_ | [AWS Bedrock Docs](https://docs.aws.amazon.com/bedrock/latest/userguide/what-is-bedrock.html) | -| **OpenAI Compatible** | _(user-provided)_ | For custom endpoints (e.g., xAI, local servers) | +| Provider | Default API Host | Official Documentation | +| ------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| **Google Gemini** | `https://generativelanguage.googleapis.com/v1beta/openai/` | [Gemini API Docs](https://ai.google.dev/gemini-api/docs) | +| **OpenAI** | `https://api.openai.com/v1` | [OpenAI API Docs](https://platform.openai.com/docs/api-reference) | +| **Anthropic** | `https://api.anthropic.com` | [Anthropic API Docs](https://docs.anthropic.com/en/api/getting-started) | +| **OrcaRouter** | `https://api.orcarouter.ai/v1` | [OrcaRouter Docs](https://docs.orcarouter.ai/) | +| **OpenRouter** | `https://openrouter.ai/api/v1` | [OpenRouter Docs](https://openrouter.ai/docs) | +| **Nebius Token Factory** | `https://api.tokenfactory.nebius.com/v1` | [Nebius Token Factory Docs](https://docs.tokenfactory.nebius.com/quickstart) | +| **Qwen (Alibaba)** | `https://dashscope.aliyuncs.com/compatible-mode/v1` | [Qwen API Docs](https://help.aliyun.com/zh/dashscope/developer-reference/api-details) | +| **DeepSeek** | `https://api.deepseek.com` | [DeepSeek API Docs](https://platform.deepseek.com/api-docs) | +| **MiniMax** | `https://api.minimax.io/v1` | [MiniMax API Docs](https://platform.minimax.io/docs/api-reference/api-overview) | +| **Z.ai** | `https://api.z.ai/api/coding/paas/v4/` | [Z.ai Developer Docs](https://zhipu-32152247.mintlify.app/api-reference/introduction) | +| **Moonshot** | `https://api.moonshot.ai/v1` | [Moonshot AI Platform](https://platform.moonshot.ai/) | +| **ModelArk** | `https://ark.ap-southeast.bytepluses.com/api/v3` | [ModelArk Docs](https://docs.byteplus.com/en/docs/ModelArk/1298459) | +| **SambaNova** | `https://api.sambanova.ai/v1` | [SambaNova API Reference](https://docs.sambanova.ai/docs/en/api-reference/overview) | +| **Grok** | `https://api.x.ai/v1` | [xAI Docs](https://docs.x.ai/docs/introduction) | +| **Mistral** | `https://api.mistral.ai` | [Mistral API Docs](https://docs.mistral.ai/api) | +| **AWS Bedrock** | `https://bedrock-mantle.us-east-1.api.aws/v1` | [AWS Bedrock Docs](https://docs.aws.amazon.com/bedrock/latest/userguide/what-is-bedrock.html) | +| **AWS Bedrock Converse** | `https://bedrock-runtime.us-east-1.amazonaws.com` | [Bedrock Converse API](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html) | +| **Azure OpenAI** | _(user-provided)_ | [Azure OpenAI Docs](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference) | +| **Baidu ERNIE** | `https://qianfan.baidubce.com/v2` | [Baidu Qianfan Docs](https://intl.cloud.baidu.com/doc/qianfan/index.html) | +| **OpenAI Compatible** | _(user-provided)_ | For custom endpoints (e.g., xAI, local servers) | ## Tips diff --git a/docs/core/models/ernie.md b/docs/core/models/ernie.md index 1d90492cd..b6ab485ff 100644 --- a/docs/core/models/ernie.md +++ b/docs/core/models/ernie.md @@ -16,7 +16,7 @@ description: This guide walks you through setting up your Baidu ERNIE API key wi - Launch Eigent and navigate to the **Home Page**. - Click on the **Agent** tab, then click on the **Models** button. -![Ernie 1 Pn](/docs/images/model_setting.png) +![Ernie 1 Pn](/images/model_setting.png) #### 2. Locate Model Configuration @@ -34,7 +34,7 @@ Click on the Ernie card and fill in the following fields: - _Example:_ `ernie-5.0` - **Save:** Click the **Save** button to apply your changes. -![Ernie 2 Pn](/docs/images/ernie.png) +![Ernie 2 Pn](/images/ernie.png) #### 4. Set as Default & Verify diff --git a/docs/core/models/gemini.md b/docs/core/models/gemini.md index 726c1460b..a24001ee0 100644 --- a/docs/core/models/gemini.md +++ b/docs/core/models/gemini.md @@ -16,7 +16,7 @@ description: This guide walks you through setting up your Google Gemini API key - Launch Eigent and navigate to the **Home Page**. - Click on the **Agent** tab, then click on the **Models** button. -![Gemini 1 Pn](/docs/images/model_setting.png) +![Gemini 1 Pn](/images/model_setting.png) #### 2. Locate Model Configuration @@ -34,7 +34,7 @@ Click on the Gemini Config card and fill in the following fields: - _Example:_ `gemini-3-pro-preview` - **Save:** Click the **Save** button to apply your changes. -![Gemini 3 Pn](/docs/images/gemini.png) +![Gemini 3 Pn](/images/gemini.png) #### 4. Set as Default & Verify diff --git a/docs/core/models/kimi.md b/docs/core/models/kimi.md index 28806206f..764e6fe80 100644 --- a/docs/core/models/kimi.md +++ b/docs/core/models/kimi.md @@ -16,7 +16,7 @@ description: This guide walks you through setting up your Kimi (Moonshot AI) API - Launch Eigent and navigate to the **Home Page**. - Click on the **Agent** tab, then click on the **Models** button. -![Kimi 1 Pn](/docs/images/model_setting.png) +![Kimi 1 Pn](/images/model_setting.png) #### 2. Locate Model Configuration @@ -34,7 +34,7 @@ Click on the Moonshot card and fill in the following fields: - _Example:_ `kimi-k2.5` - **Save:** Click the **Save** button to apply your changes. -![Kimi 3 Pn](/docs/images/kimi.png) +![Kimi 3 Pn](/images/kimi.png) #### 4. Set as Default & Verify diff --git a/docs/core/models/local-model.md b/docs/core/models/local-model.md index 6d9288ffc..965601cc7 100644 --- a/docs/core/models/local-model.md +++ b/docs/core/models/local-model.md @@ -45,13 +45,13 @@ ollama pull qwen2.5:7b 2. Setting your model -![set_local_model](/docs/images/models_local_model.png) +**Screenshot placeholder:** Add a current screenshot for “set_local_model”. 3. Configure the Google Search toolkit -![configure_searchtools](/docs/images/models_configure_tools.png) +**Screenshot placeholder:** Add a current screenshot for “configure_searchtools”. -configure_searchtoolsapi You can refer to the following document for detailed information on how to configure **GOOGLE_API_KEY** and **SEARCH_ENGINE_ID :** https://developers.google.com/custom-search/v1/overview +**Screenshot placeholder:** Add a current screenshot for “configure_searchtoolsapi”. You can refer to the following document for detailed information on how to configure **GOOGLE_API_KEY** and **SEARCH_ENGINE_ID :** https://developers.google.com/custom-search/v1/overview ## **API KEY Reference** diff --git a/docs/core/models/minimax.md b/docs/core/models/minimax.md index c60a044ae..91df762f5 100644 --- a/docs/core/models/minimax.md +++ b/docs/core/models/minimax.md @@ -17,7 +17,7 @@ description: This guide walks you through setting up your MiniMax API key within - Click on the **Settings** tab (usually located in the sidebar or top navigation). -![Minimax 1 Pn](/docs/images/model_setting.png) +![Minimax 1 Pn](/images/model_setting.png) #### 2. Locate Model Configuration @@ -25,7 +25,7 @@ description: This guide walks you through setting up your MiniMax API key within - Scroll down to the **Custom Model** area. - Look for the **Minimax Config** card. -![Minimax 2 Pn](/docs/images/minimax_1.png) +> **Screenshot placeholder:** Add a current screenshot for “Minimax 2 Pn”. #### 3. Enter API Details @@ -37,7 +37,7 @@ Click on the Minimax Config card and fill in the following fields: - _Example:_ `MiniMax-M2.1` - **Save:** Click the **Save** button to apply your changes. -![Minimax 3 Pn](/docs/images/minimax_2.png) +> **Screenshot placeholder:** Add a current screenshot for “Minimax 3 Pn”. #### 4. Set as Default & Verify @@ -45,6 +45,6 @@ Click on the Minimax Config card and fill in the following fields: selected/active. - **You are ready to go.** Your Eigent agents can now utilize the Minimax model. -![Minimax 4 Pn](/docs/images/minimax_3.png) +> **Screenshot placeholder:** Add a current screenshot for “Minimax 4 Pn”. --- diff --git a/docs/core/models/sambanova.md b/docs/core/models/sambanova.md index 92d0b4f70..fc3df158a 100644 --- a/docs/core/models/sambanova.md +++ b/docs/core/models/sambanova.md @@ -16,7 +16,7 @@ description: This guide walks you through setting up your SambaNova API key with - Launch Eigent and navigate to the **Home Page**. - Click on the **Agent** tab,then click on the **Models** button. -![SambaNova 1 Pn](/docs/images/model_setting.png) +![SambaNova 1 Pn](/images/model_setting.png) #### 2. Locate Model Configuration @@ -34,7 +34,7 @@ Click on the SambaNova card and fill in the following fields: - _Example:_ `DeepSeek-V3.1` - **Save:** Click the **Save** button to apply your changes. -![SambaNova 2 Pn](/docs/images/sambanova.png) +![SambaNova 2 Pn](/images/sambanova.png) #### 4. Set as Default & Verify diff --git a/docs/core/project-runs.md b/docs/core/project-runs.md new file mode 100644 index 000000000..c679e022b --- /dev/null +++ b/docs/core/project-runs.md @@ -0,0 +1,121 @@ +--- +title: Project runs +description: Continue a project with follow-up requests and switch between each run's progress, context, and outputs. +icon: rotate +--- + +Use follow-up requests to continue working in the same Project without losing earlier history. Eigent saves the original task and each follow-up as a separate **Run**. + +The Run selector appears in the Session side panel after a Project contains at least two Runs. + +## Understand Runs + +The first request in a Project is **Run 1**. The first follow-up is **Run 2**, and later follow-ups continue in chronological order. + +Each Run can have its own: + +- Prompt and attachments +- Status +- Plan and subtasks +- Assigned agents +- Execution context +- Browser and terminal state +- Generated files + +> **Screenshot placeholder:** Add a screenshot of a Project with **Run 2** selected and the Run dropdown open. Show at least one completed Run and one active Run. + +## Create another Run + +1. Open a Project. +2. In the Session composer, enter a follow-up request. +3. Attach any new files. +4. Send the request. +5. In Workforce mode, review and start the new plan. + +Eigent adds the request to the same conversation and updates the Run selector. + +Use a follow-up when the new request depends on previous work. Create another Project when the goal, file boundary, or audience is unrelated. + +## Switch Runs + +1. Open the Session side panel. +2. Select **Run N** in the panel header. +3. Select a Run from the dropdown. + +The dropdown lists the newest Run first. Each item includes: + +- Run number +- Prompt preview +- Status indicator +- Checkmark for the selected Run + +Selecting a Run scrolls the conversation to that request and updates the Session side panel. + +## Understand status indicators + +| Indicator | Meaning | +| ----------------- | --------------------------- | +| Pulsing brand dot | Pending or running | +| Green dot | Finished | +| Red dot | Failed | +| Neutral dot | Inactive or no final status | + +## Review Run-specific content + +Selecting a Run updates the available: + +- Workforce or Single Agent progress +- Agents and subtasks +- Execution context +- Uploaded and generated files +- Browser workspace +- Terminal workspace +- Expanded Workforce view + +Opening a file or selecting a subtask acts on the selected Run instead of automatically using the latest Run. + +## Scroll through Run history + +The Run selector and conversation remain synchronized: + +- Selecting a Run scrolls the conversation to it. +- Manually scrolling to another Run updates the Run label. +- After a dropdown selection, Eigent keeps the selected Run while smooth scrolling reaches the requested position. + +> **Video placeholder:** Add a 45-60 second MP4 showing Run selection, automatic chat scrolling, manual scrolling, and the side panel changing between two Runs. Include captions. + +## Example workflow + +Suppose Run 1 asks Eigent to research a market and create a report. + +1. Add competitor pricing as Run 2. +2. Turn the updated findings into a presentation as Run 3. +3. Select Run 1 to review the original research. +4. Select Run 2 to inspect pricing outputs. +5. Select Run 3 to monitor presentation creation. + +All three Runs remain in one Project. + +## Troubleshooting + +### The Run selector is not visible + +The Project contains only one Run, or the follow-up has not been added to the conversation. + +### The side panel changes while you scroll + +The selected Run follows the Run most visible in the conversation. Select a Run from the dropdown to return to it. + +### An older Run has no workspace + +That Run might not have used the selected agent, browser, terminal, or output type. + +### A Run does not appear + +A Run needs a user prompt. Wait for the follow-up to appear in the conversation, then reopen the selector. + +## Related guides + +- [Projects overview](/projects/overview) +- [Sessions](/projects/sessions) +- [Context and files](/projects/context-and-files) diff --git a/docs/core/tools.md b/docs/core/tools.md index 640e6ad5b..cbdd326c7 100644 --- a/docs/core/tools.md +++ b/docs/core/tools.md @@ -10,20 +10,20 @@ icon: plug 1. Click Settings -![click_settings](/docs/images/models_settings.png) +> **Screenshot placeholder:** Add a current screenshot for “click_settings”. 2. Click Add MCP Server -![add_mcp](/docs/images/tools_add_mcp.png) +> **Screenshot placeholder:** Add a current screenshot for “add_mcp”. 3. Configure Your MCP Server and install -![configure_mcp](/docs/images/tools_configure_mcp.png) +> **Screenshot placeholder:** Add a current screenshot for “configure_mcp”. 4.Add external servers to your own Agent - You can check the installed mcp server in the Added external servers column -![check_mcp](/docs/images/tools_check.png) +> **Screenshot placeholder:** Add a current screenshot for “check_mcp”. - After configuring your mcp server, you can add it to a Custom Agent. diff --git a/docs/core/workers.md b/docs/core/workers.md index c2ed086d5..af997cd3a 100644 --- a/docs/core/workers.md +++ b/docs/core/workers.md @@ -26,7 +26,7 @@ Always treat your API keys and access tokens like passwords. Eigent stores them -![add mcp servers.gif](/docs/images/add_mcp_servers.gif) +> **Video placeholder:** Add a current video walkthrough for “add mcp servers.gif”. Include captions. ## Creating and Equipping a Custom Worker @@ -39,7 +39,7 @@ Once you've configured a new MCP server, you need to create a worker that knows - Select the custom MCP server you just configured (e.g., Github MCP). You can also add any other tools you want this worker to have. - Click **Save**. -![add worker.gif](/docs/images/add_worker.gif) +> **Video placeholder:** Add a current video walkthrough for “add worker.gif”. Include captions. ## What’s next? diff --git a/docs/core/workforce.md b/docs/core/workforce.md index 2b12b42e3..3376120a8 100644 --- a/docs/core/workforce.md +++ b/docs/core/workforce.md @@ -27,7 +27,8 @@ With Workforce, agents plan, solve, and verify work together—like a project te ### **Architecture: How Workforce Works** Workforce uses a **hierarchical, modular design** for real-world team problem-solving. -![Workforce](/docs/images/workforce.jpg) + +> **Screenshot placeholder:** Add a current screenshot for “Workforce”. See how the coordinator and task planner agents orchestrate a multi-agent workflow: @@ -77,6 +78,7 @@ _A skilled coding assistant that can write and execute code, run terminal comman - TerminalToolkit - NoteTakingToolkit - WebDeployToolkit +- SkillToolkit ### BrowserAgent @@ -89,6 +91,7 @@ _Can search the web, extract webpage content, simulate browser actions, and prov - HumanToolkit - NoteTakingToolkit - TerminalToolkit +- SkillToolkit ### DocumentAgent @@ -105,6 +108,7 @@ _A document processing assistant for creating, modifying, and managing various d - TerminalToolkit - GoogleDriveMCPToolkit - SearchToolkit +- SkillToolkit ### Multi-ModalAgent @@ -114,12 +118,12 @@ _A multi-modal processing assistant for analyzing and generating media content l - VideoDownloaderToolkit - AudioAnalysisToolkit -- ImageAnalysisToolkit - OpenAIImageToolkit - HumanToolkit - TerminalToolkit - NoteTakingToolkit - SearchToolkit +- SkillToolkit ## Toolkit Reference @@ -163,12 +167,6 @@ _Provides a powerful, stateful browser for web navigation and interaction._ This toolkit gives an agent a fully-featured web browser that it can control programmatically. Unlike simple web scraping, this toolkit maintains a session, allowing the agent to click, type, hover, screenshot, and live _Take Control_ from the UI. -### [ImageAnalysisToolkit](https://docs.camel-ai.org/reference/camel.toolkits.image_analysis_toolkit) - -_Provides tools for understanding the content of images._ - -This toolkit enables an agent to "see" and interpret images. It can generate a detailed text description of an image or answer specific questions about what an image contains. This is crucial for tasks that involve visual data, such as describing products, analyzing charts, or identifying objects in a photo. - ### [MarkItDownToolkit](https://docs.camel-ai.org/reference/camel.toolkits.markitdown_toolkit) _A specialized toolkit for converting content into clean Markdown._ @@ -199,6 +197,12 @@ _Provides access to various web search engines._ This toolkit is the primary tool for web research. It allows an agent to search information on engines like Google, Wikipedia, Bing, and Baidu. The agent can submit a query and receive a list of relevant URLs and snippets, which it can then use as a starting point for deeper investigation with the `HybridBrowserToolkit`. +### [SkillToolkit](https://docs.camel-ai.org/reference/camel.toolkits.skill_toolkit) + +_Loads custom Skills assigned to the current agent._ + +This toolkit gives agents access to enabled Skills from the user's or project's Skill configuration. Skills can add reusable instructions, domain knowledge, or procedures, and can be scoped globally or to selected agents. + ### [TerminalToolkit](https://docs.camel-ai.org/reference/camel.toolkits.terminal_toolkit) _A toolkit for terminal operations across multiple operating systems._ @@ -209,7 +213,7 @@ This toolkit gives an agent access to a command-line interface. It supports term _Allows an agent to download and process videos from popular platforms._ -This toolkit enables an agent to download video content from URLs (e.g., from YouTube) and optionally split them into chunks. The saved video can then be analyzed by other toolkits, such as the `AudioAnalysisToolkit` for transcription, or `ImageAnalysisToolkit` for object detection. +This toolkit enables an agent to download video content from URLs (e.g., from YouTube) and optionally split them into chunks. The saved video can then be analyzed by other tools, such as the `AudioAnalysisToolkit` for transcription. ### [WebDeployToolkit](https://docs.camel-ai.org/reference/camel.toolkits.web_deploy_toolkit) diff --git a/docs/dashboard/overview.md b/docs/dashboard/overview.md new file mode 100644 index 000000000..31680bae1 --- /dev/null +++ b/docs/dashboard/overview.md @@ -0,0 +1,93 @@ +--- +title: Dashboard overview +description: Navigate Eigent's Home dashboard and manage Spaces, Projects, Tasks, and Triggers. +icon: grid-2 +--- + +The Home dashboard is the management surface for work across Eigent. Use it to find active work, review history, organize projects, and manage automations without opening each project first. + +## Open the dashboard + +1. Open Eigent. +2. In the main navigation, select **Home**. +3. Select **Spaces**, **Projects**, **Tasks**, or **Triggers**. + +The active section appears in the URL, so returning to the same link restores that section. + +> **Screenshot placeholder:** Add a full-width screenshot of the Home dashboard with the four section tabs and toolbar visible. Use sample data that does not contain customer information. + +## Dashboard sections + +### Spaces + +Spaces are top-level work areas. A Space can use an Eigent-managed scratch folder or connect directly to a local folder. Each Space contains projects, tasks, files, and triggers. + +### Projects + +Projects group related tasks and follow-up runs. Open a project to continue its conversation, review files, or inspect agent activity. + +### Tasks + +Tasks provide a cross-project history of requests. Use this view when you know the prompt or task status but not the containing project. + +### Triggers + +Triggers run project prompts on a schedule, through a webhook, or from a supported application event. + +## Use the toolbar + +The toolbar changes the active dashboard section without changing how its items are managed. + +| Control | Purpose | +| ------- | ----------------------------------------------------------- | +| Search | Filters the active section by name or prompt | +| Sort | Sorts by created time, updated time, or name | +| Grid | Shows visual cards with key metadata | +| List | Shows compact rows for scanning many items | +| Board | Groups items into status columns | +| Create | Starts a blank Space or a Space connected to a local folder | + +Search and sort reset when you move to another section. The selected layout persists for future visits. + +## Understand board status + +Board view organizes work into three operational groups: + +- **Default:** Work that is not currently executing or waiting for review. +- **Running:** Work with an active task or execution. +- **Awaiting review:** Work that needs user input or a decision. + +The exact status of a card still appears in its metadata. + +## Start new work + +1. In the Home toolbar, open the create menu. +2. Choose **Start from scratch** for an Eigent-managed workspace, or **Use local folder** to connect existing files. +3. Eigent opens the new Space in the Workspace. +4. Enter a task, select Single Agent or Workforce mode, and send the request. + +> **Video placeholder:** Add a short MP4 showing a user creating a Space, starting a task, and returning to the dashboard to find the new Project and Task. Include captions. + +## Manage existing work + +Cards and rows expose actions appropriate to their type: + +- Rename or delete a project. +- Open, share, or delete a task. +- Pause or resume an ongoing task. +- Edit, enable, disable, or delete a trigger. +- Open a Space and continue work in its Workspace. + +## Next steps + + + + Learn how Spaces define file and project boundaries. + + + Manage persistent project workstreams. + + + Find work and choose the best dashboard layout. + + diff --git a/docs/dashboard/projects.md b/docs/dashboard/projects.md new file mode 100644 index 000000000..66db8beae --- /dev/null +++ b/docs/dashboard/projects.md @@ -0,0 +1,77 @@ +--- +title: Projects +description: Find, rename, review, and manage projects across your Spaces. +icon: folder-kanban +--- + +Projects are persistent workstreams inside a Space. A project can contain multiple task runs, file outputs, agent workspaces, and triggers. + +## Find a project + +1. In the Home dashboard, select **Projects**. +2. Use search when you know part of the project name. +3. Sort by created time, updated time, or name. +4. Select a project card or row to open it. + +The project opens in its Space and loads the available session history. + +> **Screenshot placeholder:** Add a screenshot of the Projects dashboard in board view with default, running, and awaiting-review columns. + +## Understand project metadata + +A project item can show: + +- Project name +- Containing Space +- Number of tasks +- Number of triggers +- Latest activity +- Current execution or review state + +Use the Space label to distinguish projects with similar names. + +## Rename a project + +1. Open the project actions. +2. Select **Rename**. +3. Enter the new name. +4. Save the change. + +Choose a name that describes the ongoing goal rather than only the first task. For example, use “Q3 competitor research” instead of “Search the web.” + +## Continue a project + +1. Open the project. +2. In the chat input, enter a follow-up request. +3. Send the request. +4. Review the generated plan when Workforce planning is enabled. +5. Start the task. + +Eigent adds the request as another run in the same project. See [Project runs](/core/project-runs). + +## End a project + +Ending, or achieving, a project marks the work as complete. + +1. In the project sidebar, open the project actions. +2. Select the end-project action. +3. Confirm the action. + +If a run is active, Eigent stops it before marking the project achieved. The project remains available in history. + +## Delete a project + +1. Open the project actions. +2. Select **Delete**. +3. Review the confirmation message. +4. Confirm the deletion. + +Deleting a project removes it from the active project list and can archive its server-backed record. Export or copy required outputs before deletion. + +> **Video placeholder:** Add a short MP4 showing project search, rename, follow-up run creation, and project completion. Include captions. + +## Related guides + +- [Projects overview](/projects/overview) +- [Sessions](/projects/sessions) +- [Tasks dashboard](/dashboard/tasks) diff --git a/docs/dashboard/spaces.md b/docs/dashboard/spaces.md new file mode 100644 index 000000000..553da3f73 --- /dev/null +++ b/docs/dashboard/spaces.md @@ -0,0 +1,105 @@ +--- +title: Spaces +description: Organize projects and local folders into persistent Eigent work areas. +icon: folder-tree +--- + +A Space is the top-level boundary for work in Eigent. It groups projects, tasks, triggers, and files so that related work stays together. + +## Choose a Space type + +### Blank Space + +A blank Space starts with an Eigent-managed scratch workspace. Use it for research, writing, planning, or tasks that should not modify an existing local folder. + +### Local-folder Space + +A local-folder Space binds Eigent to a folder on your computer. Use it when agents need to inspect or modify an existing codebase, document set, or project directory. + +### Legacy Space + +A legacy Space contains work created before the current Space system. Eigent keeps these projects available so that older task history remains accessible. + +## Create a blank Space + +1. In the Home dashboard, select **Spaces**. +2. Open the create menu. +3. Select **Start from scratch**. +4. Eigent creates a Space and opens its Workspace. +5. Optional: Rename the Space from the Space switcher. + +Blank Spaces use artifact-only storage. Agents create outputs in Eigent-managed project storage rather than writing directly to a user-selected folder. + +## Create a Space from a local folder + +1. In the Home dashboard, select **Spaces**. +2. Open the create menu. +3. Select **Use local folder**. +4. In the folder picker, select the directory that Eigent can access. +5. Confirm the selection. + +The Space name initially follows the selected folder name. The **Context** tab shows the folder binding. + +> **Screenshot placeholder:** Add a screenshot of the Space creation menu with **Start from scratch** and **Use local folder** visible. + +## Switch Spaces + +1. In the project sidebar, select the current Space name. +2. Select another Space. + +Eigent loads that Space's projects and restores its most recently visited project when possible. + +## Rename a Space + +1. Open the Space switcher. +2. Open the actions for the active Space. +3. Select **Rename**. +4. Enter a non-empty name and select **Save**. + +Legacy Spaces and some system-managed Spaces cannot be renamed. + +## Work with local changes + +Local-folder Spaces can show pending workspace changes. Depending on the current state, the Space menu can provide actions to: + +- Load or refresh the working directory. +- Apply pending changes. +- Discard pending changes. +- Resolve a stale workspace state. + +Review changed files before applying or discarding them. + +> **Video placeholder:** Add a short MP4 showing a local-folder Space, agent-generated file changes, review in Context, and the apply or discard workflow. Include captions. + +## Review Space status + +The Spaces dashboard shows: + +- Space name and source type +- Local folder name when applicable +- Project count +- Task count +- Trigger count +- Current operational status + +Use board view to group Spaces by default, running, and awaiting-review state. + +## Troubleshooting + +### Context is unavailable + +The active Space might not have a workspace binding. Create a Space from a local folder or wait for Eigent to finish preparing the scratch workspace. + +### A local folder does not appear + +Confirm that the folder still exists and that Eigent has operating-system permission to access it. + +### A blank Space disappeared + +Eigent can hide unused placeholder Spaces. Start a project or give the Space a meaningful name to keep it in the dashboard. + +## Related guides + +- [Create a project](/projects/create-project) +- [Context and files](/projects/context-and-files) +- [Dashboard projects](/dashboard/projects) diff --git a/docs/dashboard/tasks.md b/docs/dashboard/tasks.md new file mode 100644 index 000000000..eb7d682ac --- /dev/null +++ b/docs/dashboard/tasks.md @@ -0,0 +1,72 @@ +--- +title: Tasks +description: Review and manage individual tasks across all Eigent projects. +icon: list-check +--- + +The Tasks dashboard provides a cross-project view of every request submitted to Eigent. Use it to find work by prompt, status, or date when you do not need to browse the project hierarchy first. + +## Find a task + +1. In the Home dashboard, select **Tasks**. +2. Search for text from the original request. +3. Optional: Sort by created time, updated time, or name. +4. Select the task to open its containing project. + +Task search matches the request text. The project and Space labels identify where the task belongs. + +> **Screenshot placeholder:** Add a screenshot of the Tasks dashboard in list view with prompt, project, Space, date, and status visible. + +## Understand task status + +Tasks can move through the following states: + +- **Pending:** The request exists but execution has not started. +- **Planning:** Eigent is preparing or splitting the task. +- **Running:** One or more agents are working. +- **Paused:** Execution is temporarily stopped. +- **Awaiting review:** Eigent needs user input, confirmation, or plan approval. +- **Completed:** The task reached a successful final state. +- **Failed:** Execution ended with an error. + +Status names can vary slightly between the dashboard and live session, but they represent the same task lifecycle. + +## Pause an ongoing task + +1. Find the running task. +2. Open its task actions. +3. Select **Pause**. + +Eigent records elapsed time and sends a pause request to the active task. + +## Resume a paused task + +1. Find the paused task. +2. Open its task actions. +3. Select **Resume**. + +The task returns to a running state and continues from its preserved context when supported. + +## Share a task + +1. Open the actions for a completed task. +2. Select **Share**. +3. Copy the generated link. + +Review the shared content before distributing the link. Shared tasks can contain prompts, outputs, and project context. + +## Delete a task + +1. Open the task actions. +2. Select **Delete**. +3. Confirm the deletion. + +Deleting a task removes it from task history and its containing project. Save required outputs first. + +> **Video placeholder:** Add a short MP4 showing task search, pause, resume, and share actions. Include captions. + +## Related guides + +- [Sessions](/projects/sessions) +- [Project runs](/core/project-runs) +- [Views and search](/dashboard/views-and-search) diff --git a/docs/dashboard/views-and-search.md b/docs/dashboard/views-and-search.md new file mode 100644 index 000000000..b05a02434 --- /dev/null +++ b/docs/dashboard/views-and-search.md @@ -0,0 +1,80 @@ +--- +title: Views and search +description: Search, sort, and change layouts across the Eigent dashboard. +icon: table-columns +--- + +The Home dashboard provides the same search, sort, and layout controls for Spaces, Projects, Tasks, and Triggers. Use these controls to move from a visual overview to a compact operational list without changing the underlying data. + +## Search the active section + +1. Select **Spaces**, **Projects**, **Tasks**, or **Triggers**. +2. Select the search control. +3. Enter part of a name or task prompt. + +Search applies only to the active section: + +| Section | Search target | +| -------- | --------------------- | +| Spaces | Space name | +| Projects | Project name | +| Tasks | Original task request | +| Triggers | Trigger name | + +Changing sections clears the search query. + +## Sort dashboard items + +1. Select the sort control. +2. Choose **Created**, **Updated**, or **Name**. +3. To reverse the direction, select the same field again. + +Created and updated fields default to newest first. Name defaults to alphabetical order. + +## Choose a layout + +### Grid view + +Grid view uses cards and emphasizes names, status, counts, and primary actions. Use it for a visual overview of a smaller set of items. + +### List view + +List view uses compact rows. Use it to scan many items and compare metadata across the same columns. + +### Board view + +Board view groups items into: + +- Default +- Running +- Awaiting review + +Use board view as an operational queue for active work. + +> **Screenshot placeholder:** Add one composite image showing the same Projects data in grid, list, and board layouts. Label each layout in surrounding text rather than inside the image. + +## Choose the right view + +| Goal | Recommended view | +| ------------------------------- | -------------------- | +| Browse a few recent items | Grid | +| Scan a large history | List | +| Monitor active and blocked work | Board | +| Find one known item | Any view with search | + +## Current limitations + +The filter control appears in the toolbar but is currently disabled. Use section search, sort, and board status grouping until advanced filters are available. + + +Do not document advanced dashboard filters as available until the control is enabled in the product. + + +> **Video placeholder:** Add a 30-45 second MP4 showing search, sort-direction changes, and switching between all three layouts. Include captions. + +## Related guides + +- [Dashboard overview](/dashboard/overview) +- [Spaces](/dashboard/spaces) +- [Projects](/dashboard/projects) +- [Tasks](/dashboard/tasks) diff --git a/docs/docs.json b/docs/docs.json index d20352a07..1c9dd4b65 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -4,15 +4,15 @@ "theme": "aspen", "defaultPage": "/get_started/welcome", "logo": { - "light": "images/logo-light.png", - "dark": "images/logo-dark.png", + "light": "/images/logo-light.png", + "dark": "/images/logo-dark.png", "href": "https://www.eigent.ai" }, "favicon": "images/favicon.png", "colors": { "primary": "#1d1d1d", "light": "#F5F4F0", - "dark": "#363AF5" + "dark": "#5F63FF" }, "background": { "color": { @@ -20,12 +20,6 @@ "dark": "#1d1d1d" } }, - "styling": { - "logo": { - "width": "auto", - "height": "100%" - } - }, "navbar": { "links": [ { @@ -36,7 +30,7 @@ ], "primary": { "type": "button", - "label": "Get Started", + "label": "Download", "href": "https://www.eigent.ai/download" } }, @@ -51,34 +45,113 @@ "pages": [ "/get_started/welcome", "/get_started/installation", - "/get_started/quick_start" + "/get_started/quick_start", + "/get_started/self-hosting", + "/core/concepts" + ] + }, + { + "group": "Dashboard", + "icon": "grid-2", + "pages": [ + "/dashboard/overview", + "/dashboard/spaces", + "/dashboard/projects", + "/dashboard/tasks", + "/dashboard/views-and-search" + ] + }, + { + "group": "Projects", + "icon": "folder-kanban", + "pages": [ + "/projects/overview", + "/projects/create-project", + "/projects/workspace", + "/projects/context-and-files", + "/projects/sessions", + "/core/project-runs", + "/projects/single-agent", + "/core/workforce" ] }, { - "group": "Core", - "icon": "key", + "group": "Agents", + "icon": "bot", "pages": [ - "/core/concepts", - "/core/workforce", + "/agents/overview", + "/core/workers", + "/core/agent-skills", + "/agents/remote-sub-agents", + "/agents/memory" + ] + }, + { + "group": "Models", + "icon": "brain", + "pages": [ + "/models/overview", + "/models/eigent-cloud", + "/core/models/byok", + "/core/models/local-model", + "/models/provider-reference", { - "group": "Models", - "icon": "brain", - "expanded": true, + "group": "Provider Guides", + "expanded": false, "pages": [ - "/core/models/byok", - "/core/models/local-model", "/core/models/gemini", "/core/models/ernie", "/core/models/minimax", "/core/models/kimi", "/core/models/sambanova" ] - }, - "/core/tools", - "/core/workers", - "/core/agent-skills" + } ] }, + { + "group": "Connectors", + "icon": "plug", + "pages": [ + "/connectors/overview", + "/connectors/mcp-marketplace", + "/connectors/custom-mcp", + "/connectors/google-search", + "/core/tools" + ] + }, + { + "group": "Browser", + "icon": "compass", + "pages": [ + "/browser/overview", + "/browser/connections", + "/browser/cookies" + ] + }, + { + "group": "Automation", + "icon": "bolt", + "pages": [ + "/automation/overview", + "/automation/scheduled-triggers", + "/automation/webhook-triggers", + "/automation/dispatch" + ] + }, + { + "group": "Settings", + "icon": "gear", + "pages": [ + "/settings/general", + "/settings/appearance", + "/settings/privacy" + ] + }, + { + "group": "Open Source", + "icon": "code-branch", + "pages": ["/open-source/overview", "/core/brain-architecture"] + }, { "group": "Troubleshooting", "icon": "question-circle", @@ -90,9 +163,14 @@ "global": { "anchors": [ { - "anchor": "Download Here", - "href": "https://www.eigent.ai", - "icon": "gift" + "anchor": "Download Eigent", + "href": "https://www.eigent.ai/download", + "icon": "download" + }, + { + "anchor": "GitHub", + "href": "https://github.com/eigent-ai/eigent", + "icon": "github" } ] } diff --git a/docs/get_started/installation.md b/docs/get_started/installation.md index 6d1f5cbf2..1a1bd12d5 100644 --- a/docs/get_started/installation.md +++ b/docs/get_started/installation.md @@ -10,12 +10,11 @@ icon: wrench - Download for macOS - Download for Windows -``` **macOS Prerequisite** + Please ensure you are running macOS 11 (Big Sur) or a newer version to install Eigent. -``` diff --git a/docs/get_started/quick_start.md b/docs/get_started/quick_start.md index 2889d38c0..dc4495899 100644 --- a/docs/get_started/quick_start.md +++ b/docs/get_started/quick_start.md @@ -10,7 +10,7 @@ This guide will walk you through building your first multi-agent workforce using Once opened, you'll land on the **Task** page. It’s a clean space designed to turn your ideas into action. Let's break down what you see. -![Layout](/docs/images/quickstart_firsttask.png) +> **Screenshot placeholder:** Add a current screenshot for “Layout”. ### The Top Bar @@ -21,7 +21,7 @@ At the very top of the window is your main navigation bar. You'll access: - Ongoing Tasks - **Settings:** where you can configure the app to your liking. -![Task Hub](/docs/images/quickstart_thetopbar.png) +> **Screenshot placeholder:** Add a current screenshot for “Task Hub”. ### The Main View @@ -30,23 +30,24 @@ Your workspace is split into two panels: **Message Box (Left):** where you'll chat with your AI workforce to start a job. - Before running the task, you can Add, Edit, or Delete any subtask or Back to Edit your request, then resume. When tasks complete, you can use Replay to re-run the flow. - ![Message](/docs/images/quickstart_themainview.gif) -- You can pause anytime—hit **Pause**, edit via **Back to Edit**, then resume. When tasks complete, use **Replay** to re-run the flow.![Message 2](/docs/images/quickstart_pause.gif) + > **Video placeholder:** Add a current video walkthrough for “Message”. Include captions. + +- You can pause anytime—hit **Pause**, edit via **Back to Edit**, then resume. When tasks complete, use **Replay** to re-run the flow.> **Video placeholder:** Add a current video walkthrough for “Message 2”. Include captions. **Canvas (Right):** where your AI agents get to work. - **Before a Task:** You'll see your pre-built agents and and their tools. You can also click **+ New Worker** to add your own. These workers will always be on standby for your task. - **During a Task:** The Canvas shows the live status of all subtasks (`Done / In Progress / Unfinished`). Click any subtask to view detailed logs (reasoning steps, tool calls, results). More on this below. - ![Task in Progress](/docs/images/quickstart_canvas_inprogress.png) + > **Screenshot placeholder:** Add a current screenshot for “Task in Progress”. - **Canvas Toolbar:** At the bottom of the Canvas, you'll see a toolbar. This is where you manage your views of agents. You can switch between different task views, such as **Home**, **Agent Folder**, or a specific worker's **Workspace**. - ![Add Worker](/docs/images/quickstart_canvas_bottom.png) + > **Screenshot placeholder:** Add a current screenshot for “Add Worker”. ### Agent Folder This is the filing cabinet for your workforce. Any files your agents create or use (like documents, spreadsheets, code, pictures, or presentations) are automatically saved here. These files are also stored locally on your computer and/or in your cloud for easy access. -![Agent Folder](/docs/images/quickstart_agentfolder.png) +> **Screenshot placeholder:** Add a current screenshot for “Agent Folder”. #### 📌 Note on File Storage @@ -66,7 +67,7 @@ Eigent comes with four ready-to-work agents. Each is equipped with a specific se 1. **Multimodal Agent** – ideals with images, videos and more 1. **Document Agent** – reads, writes and manages files (Markdown, PDF, Word, etc.) -![Pre-build Agents](/docs/images/quickstart_prebuiltagents.gif) +> **Video placeholder:** Add a current video walkthrough for “Pre-build Agents”. Include captions. ### Add your own workers @@ -75,8 +76,9 @@ Click **“+ Add Workers”**, provide: - **Name** (required) - **Description** (optional): the role of your customized agent - **Agent Tool**: install any tool available from our MCP Servers to give your agent the exact skills it needs. +- **Custom Model Configuration**: use the "Use custom model configuration" option to assign a specific model to this new worker. -![Add Your Own Workers](/docs/images/quickstart_addworker.gif) +> **Video placeholder:** Add a current video walkthrough for “Add Your Own Workers”. Include captions. ## Start Your First Task @@ -97,7 +99,7 @@ Once you send your task, our **Coordinator Agent** and **Task Agent** kick in to Once you're happy with the plan, hit **Start Task.** Eigent will automatically assign each subtask to the best agent for the job based on the tools they have. -![Launch the Task](/docs/images/quickstart_lauchtask.gif) +> **Video placeholder:** Add a current video walkthrough for “Launch the Task”. Include captions. ## Watch Agents Work @@ -109,18 +111,18 @@ Once the task starts, your agents will run in parallel on the Canvas: - **Task Results:** The output or conclusion of the subtask. - Hover over tasks to see status details -![Watch Agents Work](/docs/images/quickstart_subtasklog.gif) +> **Video placeholder:** Add a current video walkthrough for “Watch Agents Work”. Include captions. Click on an agent icon to open its **Workspace**: - Example 1: open **Browser Agent**, launch embedded browser - Use **“Take Control”** to take over browsing (e.g., accept cookies), then return control to the agent -![Browser Agent](/docs/images/quickstart_takecontrol.gif) +> **Video placeholder:** Add a current video walkthrough for “Browser Agent”. Include captions. - Example 2: open **Developer Agent**, lauch **Terminal** -![Developer Agent](/docs/images/quickstart_terminal.gif) +> **Video placeholder:** Add a current video walkthrough for “Developer Agent”. Include captions. -![Models](/docs/images/quickstart_settings_localmodel.png) +> **Screenshot placeholder:** Add a current screenshot for “Models”. -- **Cloud Version:** We provide pre-configured, state-of-the-art models, including GPT-4.1, GPT-4.1 mini and Gemini 2.5 Pro. Using these models is the easiest way to get started and will be billed to your account based on usage (credits). +- **Cloud Version:** We provide pre-configured, state-of-the-art models from leading providers including OpenAI, Anthropic, Google, DeepSeek, and Minimax. Using these models is the easiest way to get started and will be billed to your account based on usage (credits). - **Self-hosted Version:** You can connect your own models. - **Cloud Models:** Connect your personal accounts from providers like OpenAI, Anthropic, Qwen, Deepseek, Nebius Token Factory and Azure by entering your own API key. - **Local Models:** For advanced users, you can run models locally using Ollama, vLLM, SGLang, LM Studio, or LLaMA.cpp server. @@ -173,11 +175,11 @@ Eigent can run in two modes. Your choice here affects how you are billed and wha MCPs are the **tools** that give your agents their skills. We've pre-configured popular tools like Slack, Notion, Google Calendar, GitHub, and more in **MCP Market**, which you can install for your agents with a single click. -![MCP Markets](/docs/images/quickstart_settings_mcp.png) +> **Screenshot placeholder:** Add a current screenshot for “MCP Markets”. For advanced users, you can click **Add MCP Server** to configure and install custom tools from third-party sources. -![MCP Servers](/docs/images/quickstart_settings_addmcp.png) +> **Screenshot placeholder:** Add a current screenshot for “MCP Servers”. ## Next Steps diff --git a/docs/get_started/self-hosting.md b/docs/get_started/self-hosting.md new file mode 100644 index 000000000..42c289486 --- /dev/null +++ b/docs/get_started/self-hosting.md @@ -0,0 +1,150 @@ +--- +title: Self-hosting +description: Run Eigent with your own backend, model providers, and local workspace. +icon: server +--- + +Self-host Eigent when you need control over model providers, credentials, project files, or the infrastructure that runs your agents. A self-hosted installation uses the open-source Eigent application and lets you connect cloud APIs, OpenAI-compatible endpoints, or local inference servers. + +This page describes the recommended setup path. Exact deployment requirements can change between releases, so use the repository files and release notes as the source of truth for version-specific values. + +## Before you begin + +Prepare the following: + +- A supported desktop operating system or Linux development environment +- Git +- Node.js `18` through `22` +- The Python version required by the repository backend +- Enough disk space for dependencies, generated files, and optional local models +- Credentials for at least one cloud provider, or a running local model server + + +Local model requirements depend on the model and runtime. Large models can require substantial memory or GPU capacity. + + +## Choose a deployment model + +Eigent supports three common arrangements: + +| Arrangement | Model execution | Best for | +| --------------------- | --------------------------------------------- | ------------------------------------------------ | +| Managed application | Eigent Cloud | Fast evaluation with minimal setup | +| Self-hosted with BYOK | External provider APIs | Infrastructure control with hosted model quality | +| Fully local | Ollama, vLLM, SGLang, LM Studio, or LLaMA.cpp | Private environments and local experimentation | + +You can configure more than one provider and change the preferred model later. + +## Set up the repository + +1. Clone the Eigent repository: + + ```bash + git clone https://github.com/eigent-ai/eigent.git + cd eigent + ``` + +2. Install the frontend dependencies: + + ```bash + npm install + ``` + +3. Review `.env.development`, `backend/README.md`, and the root `README.md` for the current backend and environment configuration. + +4. Start the development application: + + ```bash + npm run dev + ``` + +5. In Eigent, open **Agents > Models** and configure a model provider. + + +The first development start can take longer because Eigent prepares frontend and backend dependencies. + + +> **Screenshot placeholder:** Add a screenshot of Eigent running locally with **Agents > Models** open. Crop the image to the application window and hide credentials. + +## Configure a model + +For the fastest self-hosted setup, connect an existing provider: + +1. In Eigent, open **Agents > Models**. +2. Expand **Bring Your Own Key**. +3. Select a provider. +4. Enter the API key, endpoint, and model name required by that provider. +5. Select **Validate** or **Save**. +6. Enable the provider and mark it as preferred when you want it to be the default. + +To keep inference local, start one of the supported runtimes and follow [Local models](/core/models/local-model). + +## Configure tools and browser access + +A model can reason about a task, but tools let it act. + +- Add hosted integrations from [MCP Marketplace](/connectors/mcp-marketplace). +- Add your own local or remote server from [Custom MCP servers](/connectors/custom-mcp). +- Configure a CDP browser from [Browser connections](/browser/connections). +- Create a Space from a local folder when agents need direct access to project files. + +> **Video placeholder:** Add a 60-90 second MP4 showing a local installation, model configuration, local-folder Space creation, and the first successful task. Include captions and a short transcript. + +## Build a distributable application + +Use the build script for the target platform: + +```bash +npm run build +``` + +Platform-specific scripts include: + +```bash +npm run build:mac +npm run build:win +npm run build:linux +``` + +Review the Electron signing, packaging, and backend dependency requirements before distributing a build to other users. + +## Update a self-hosted installation + +1. Commit or back up local configuration changes. +2. Pull the target release or branch. +3. Review release notes and environment changes. +4. Reinstall dependencies when lockfiles changed. +5. Run the relevant tests and build command. +6. Start Eigent and validate models, connectors, browser sessions, and existing Spaces. + +## Troubleshooting + +### The application starts without a model + +Open **Agents > Models** and configure at least one cloud, BYOK, or local provider. Eigent blocks new tasks when no valid model is available. + +### A local model cannot be reached + +Confirm that the runtime is running, the endpoint includes the expected `/v1` path, and the port is accessible from the Eigent process. + +### MCP tools fail during startup + +Review the MCP command, arguments, environment variables, and executable path. Run the server independently to confirm that it starts before adding it to Eigent. + +### Browser connection fails + +Confirm that Chrome or Chromium was started with remote debugging and that the configured port exposes `/json/version`. + +## Next steps + + + + Compare every cloud and local model provider supported by Eigent. + + + Review the repository architecture and extension points. + + + Understand how the frontend, Brain backend, and services work together. + + diff --git a/docs/get_started/welcome.md b/docs/get_started/welcome.md index 57ff418f8..79b597431 100644 --- a/docs/get_started/welcome.md +++ b/docs/get_started/welcome.md @@ -4,16 +4,11 @@ description: Learn about Eigent and Unlock Your Exceptional Productivity now icon: wave --- -**Eigent** is the world’s first **Multi-agent Workforce** desktop application, empowering you to build, manage, and deploy a custom AI workforce that can turn your most complex workflows into automated tasks. +**Eigent** is the first open-source, general-agent desktop app. Easily build, manage, and deploy a custom AI workforce or orchestrate a master agent with specialized sub-agents to turn your most complex workflows into automated tasks. -Built on CAMEL-AI's acclaimed open-source project (CAMEL with 13k⭐ on GitHub, #1 on GitHub Daily Trending), our system introduces a **Multi-Agent Workforce** that **boosts productivity** through parallel execution, customization, and privacy protection. Previously #1 opensource project on GAIA. +Built on CAMEL-AI's acclaimed open-source project (CAMEL with 17k⭐ on GitHub, #1 on GitHub Daily Trending), our system introduces a **Multi-Agent Workforce** that **boosts productivity** through parallel execution, customization, and privacy protection. Previously #1 opensource project on GAIA. - +> **Screenshot placeholder:** Add a current screenshot for “Dynamic Workforce”. ## Core Features and Capabilities @@ -48,4 +43,14 @@ height="auto" icon="server"> Deploy Eigent locally with your preferred models. Your data stays on **your own device**, addressing privacy and security concerns. You can use personal API keys or local LLMs so that sensitive information never leaves your environment. + + To boost Eigent's efficiency and reliability, we provide a flexible Skills system for agents. Skills can be dynamically generated from tasks (so agents quickly gain the right tools for the job) or manually added by users for precise control and governance. + + + Eigent also supports powerful Triggers to automate when agents should run. This includes Schedule Triggers for recurring jobs, Webhook/Event Triggers for real-time actions from external systems, and Agentic Triggers where agents can trigger follow-up work based on context and outcomes. + diff --git a/docs/images/logo-dark.png b/docs/images/logo-dark.png index e575b582a..01e33152c 100644 Binary files a/docs/images/logo-dark.png and b/docs/images/logo-dark.png differ diff --git a/docs/images/logo-light.png b/docs/images/logo-light.png index 72106e0e4..5521ce5e0 100644 Binary files a/docs/images/logo-light.png and b/docs/images/logo-light.png differ diff --git a/docs/models/eigent-cloud.md b/docs/models/eigent-cloud.md new file mode 100644 index 000000000..7d260ca3b --- /dev/null +++ b/docs/models/eigent-cloud.md @@ -0,0 +1,92 @@ +--- +title: Eigent Cloud models +description: Use managed models and credits without configuring provider API keys. +icon: cloud +--- + +Eigent Cloud provides managed model access for users who do not want to configure provider credentials or local inference. + +## When to use Eigent Cloud + +Choose Eigent Cloud when you want: + +- The fastest setup path +- A curated model catalog +- No provider API-key management +- Usage tracked through Eigent credits +- A managed fallback while testing BYOK or local models + +## Enable a Cloud model + +1. Open **Agents > Models**. +2. Select **Cloud**. +3. Review the available models. +4. Select the model to use. +5. Enable Cloud and mark it as preferred when it should be the default. + +> **Screenshot placeholder:** Add a screenshot of the Cloud model catalog and preferred-model control. Use an account with non-sensitive sample credit data. + +## Available model families + +The current application catalog can include managed options from: + +- Google Gemini +- OpenAI +- Anthropic Claude +- DeepSeek +- MiniMax + +Exact models can change as providers release new versions. Treat the in-product catalog as the source of truth for current availability. + +## Understand credits + +Cloud usage consumes Eigent credits. Credit consumption depends on the selected model and task usage. + +The product can show: + +- Current plan +- Available credit balance +- Links for plan management +- Model availability based on the account + +Review the current pricing and plan page before running large or automated workloads. + +## Change the Cloud model + +1. Open the Cloud model selector. +2. Choose another available model. +3. Save or apply the selection. + +New tasks use the new preference. Running tasks are not migrated automatically. + +## Use Cloud with other providers + +You can keep Cloud enabled while also configuring BYOK and local providers. Mark one provider as preferred, then select another model for individual tasks when needed. + +This supports workflows such as: + +- Cloud as the default, local model for private files +- BYOK as the default, Cloud as a fallback +- Different model families for coding, research, and writing + +> **Video placeholder:** Add a 45-second MP4 showing Cloud model selection, preferred-provider changes, and selecting a different model for a new task. Include captions. + +## Troubleshooting + +### No Cloud models are available + +Confirm the account is signed in, the application can reach Eigent services, and the current plan includes model access. + +### Credits are unavailable + +Open account management to review the plan or add credits. + +### The selected model changed + +Model availability can change. Open the Cloud catalog and select another supported model. + +## Related guides + +- [Models overview](/models/overview) +- [Provider reference](/models/provider-reference) +- [Privacy](/settings/privacy) diff --git a/docs/models/overview.md b/docs/models/overview.md new file mode 100644 index 000000000..00e62c2d8 --- /dev/null +++ b/docs/models/overview.md @@ -0,0 +1,96 @@ +--- +title: Models overview +description: Choose between Eigent Cloud, your own provider keys, and local inference servers. +icon: brain +--- + +Eigent is model-flexible by design. You can use managed Eigent Cloud models, connect provider accounts with your own keys, or run open models on local infrastructure. + +At least one valid model is required before Eigent can start a task. + +## Open Models + +1. Open the Eigent dashboard. +2. Select **Agents**. +3. Select **Models**. + +The Models page separates managed cloud, bring-your-own-key, and local providers. + +> **Screenshot placeholder:** Add a screenshot of the Models page with the Cloud, BYOK, and Local sections visible. Hide all credential values. + +## Choose a model source + +### Eigent Cloud + +Use managed models without configuring provider credentials. This is the quickest way to evaluate Eigent and is billed through Eigent credits. + +### Bring Your Own Key + +Connect a supported cloud provider with your own API key and endpoint. Provider billing and data handling follow the provider account. + +### Local models + +Connect Eigent to Ollama, vLLM, SGLang, LM Studio, or LLaMA.cpp. Local models can keep inference on infrastructure you control. + +## Configure a provider + +1. Select the provider. +2. Enter the required key, endpoint, model name, and provider-specific fields. +3. Validate or save the configuration. +4. Enable the provider. +5. Optional: Mark it as preferred. + +Some providers can load their model catalog dynamically. Others require a model name. + +## Select a default model + +The preferred provider becomes the default choice for new tasks. You can also choose a model from the task composer when model selection is available there. + +Changing the default affects future tasks. It does not replace the model already used by an active Run. + +## Compare model options + +Consider: + +- Reasoning quality +- Tool-use reliability +- Context window +- Input and output modalities +- Latency +- Cost +- Data residency +- Local hardware requirements + +Use a representative task to validate a model before making it the default for all work. + +> **Video placeholder:** Add a 60-second MP4 showing one BYOK provider and one local runtime being configured, validated, enabled, and selected. Include captions. + +## Remove a provider + +1. Open the provider. +2. Disable it. +3. Select the delete or reset action. +4. Confirm the removal. + +Removing a provider deletes its stored Eigent configuration. It does not delete the provider account or local model. + +## Troubleshooting + +### Validation fails + +Check the key, endpoint, model name, provider region, API version, and network proxy. + +### No local models appear + +Confirm the local runtime is running and supports model listing. Some runtimes require entering the model name manually. + +### A task still uses another model + +Confirm the preferred provider and the model selected in the task composer. Existing active Runs keep their current configuration. + +## Related guides + +- [Eigent Cloud models](/models/eigent-cloud) +- [Bring Your Own Key](/core/models/byok) +- [Local models](/core/models/local-model) +- [Provider reference](/models/provider-reference) diff --git a/docs/models/provider-reference.md b/docs/models/provider-reference.md new file mode 100644 index 000000000..9d4506499 --- /dev/null +++ b/docs/models/provider-reference.md @@ -0,0 +1,110 @@ +--- +title: Provider reference +description: Review every cloud and local model provider supported by Eigent. +icon: list +--- + +Eigent supports multiple provider types so open-source deployments can choose models based on capability, cost, privacy, and infrastructure. + +Provider availability and required fields are defined by the current application. Use this page as an overview and the provider's official documentation for account, model, and billing details. + +## Cloud and BYOK providers + +| Provider | Typical required values | Notes | +| -------------------- | ------------------------------------------ | ------------------------------------- | +| Google Gemini | API key, endpoint, model | Google model family | +| OpenAI | API key, endpoint, model | OpenAI API | +| Anthropic | API key, endpoint, model | Claude model family | +| OrcaRouter | API key, endpoint | Can load a grouped model catalog | +| OpenRouter | API key, endpoint, model | Routes models from multiple providers | +| Qwen | API key, endpoint, model | Tongyi Qianwen provider | +| DeepSeek | API key, endpoint, model | DeepSeek model family | +| MiniMax | API key, endpoint, model | MiniMax model family | +| Z.ai | API key, endpoint, model | Z.ai model family | +| Moonshot | API key, endpoint, model | Moonshot model family | +| ModelArk | API key, endpoint, model | ModelArk service | +| SambaNova | API key, endpoint, model | SambaNova hosted inference | +| Grok | API key, endpoint, model | xAI model family | +| Mistral | API key, endpoint, model | Mistral model family | +| AWS Bedrock | Region, access key, secret, model | Optional session token | +| AWS Bedrock Converse | Region, access key, secret, model | Uses Bedrock Converse integration | +| Microsoft Azure | API key, endpoint, API version, deployment | Deployment name is required | +| Baidu ERNIE | API key, endpoint, model | ERNIE model family | +| OpenAI-compatible | Endpoint, optional key, model | For compatible third-party services | + + +Provider fields can change. Confirm required values in **Agents > Models** after updating Eigent. + + +## Local runtimes + +| Runtime | Default endpoint | Model discovery | +| --------- | --------------------------- | ------------------------------------------- | +| Ollama | `http://localhost:11434/v1` | Reads the Ollama tags API | +| vLLM | `http://localhost:8000/v1` | Enter the served model when not listed | +| SGLang | `http://localhost:30000/v1` | Enter the served model when not listed | +| LM Studio | `http://localhost:1234/v1` | Enter the loaded model when not listed | +| LLaMA.cpp | `http://localhost:8080/v1` | Reads the OpenAI-compatible models endpoint | + +## Configure a cloud provider + +1. Create an account with the provider. +2. Create a restricted API credential. +3. Confirm the provider endpoint and model identifier. +4. In Eigent, open **Agents > Models**. +5. Select the provider and enter the values. +6. Validate and save. +7. Run a small test task. + +## Configure an OpenAI-compatible endpoint + +Use the OpenAI-compatible provider for services that implement compatible chat APIs. + +Provide: + +- Base endpoint +- API key when required +- Exact model identifier exposed by the service + +Compatibility can vary. Test streaming, tool calls, and structured responses before using the provider for production work. + +## Configure a local runtime + +1. Install and start the runtime. +2. Load or serve a model. +3. Confirm the endpoint responds locally. +4. In Eigent, open **Agents > Models > Local**. +5. Select the runtime. +6. Enter the endpoint and model. +7. Validate and enable it. + +**Screenshot placeholder:** Add a composite screenshot showing one cloud provider form, Azure provider-specific fields, and one local runtime form. Blur credentials. + +**Video placeholder:** Add a 90-second MP4 showing an Ollama model and an OpenAI-compatible cloud endpoint being configured and tested. Include captions. + +## Provider page checklist + +Each dedicated provider guide should include: + +1. Account or runtime prerequisites +2. Credential creation +3. Endpoint format +4. Model identifier examples +5. Eigent configuration +6. Validation +7. Common errors +8. Billing and privacy notes + +## Security guidance + +- Do not include credentials in screenshots, logs, or issue reports. +- Use least-privilege cloud credentials. +- Restrict local endpoints to trusted networks. +- Rotate keys after accidental exposure. +- Review the provider's data-retention policy. + +## Related guides + +- [Bring Your Own Key](/core/models/byok) +- [Local models](/core/models/local-model) +- [Self-hosting](/get_started/self-hosting) diff --git a/docs/open-source/overview.md b/docs/open-source/overview.md new file mode 100644 index 000000000..f2673f304 --- /dev/null +++ b/docs/open-source/overview.md @@ -0,0 +1,135 @@ +--- +title: Open-source Eigent +description: Build, inspect, extend, and self-host Eigent from its public source code. +icon: code-branch +--- + +Eigent is an open-source agent workspace built around model choice, extensible tools, and local control. You can inspect the implementation, run the application yourself, connect private infrastructure, and contribute changes. + +## Why open source matters + +Open source lets teams: + +- Choose managed, BYOK, or local models +- Inspect agent and tool assembly +- Connect local files and browsers +- Add MCP servers and custom integrations +- Modify the React and Electron application +- Extend the Python Brain and server APIs +- Operate with their own security controls + +## Repository structure + +The repository contains several major areas: + +| Area | Purpose | +| ---------------- | ------------------------------------------------------------- | +| `src/` | React application, stores, pages, and components | +| `electron/` | Desktop host, IPC, windows, and local integrations | +| `backend/` | Brain backend, agents, tools, memory, and workspace services | +| `server/` | Server APIs, domains, persistence, remote control, and Spaces | +| `test/` | Frontend and Electron tests | +| `backend/tests/` | Brain backend tests | +| `server/tests/` | Server tests | +| `docs/` | Mintlify product documentation | + +> **Screenshot placeholder:** Add a repository tree diagram or screenshot that highlights the major directories without exposing local paths or unrelated files. + +## Set up a development environment + +1. Clone the repository. +2. Install the required Node.js and Python versions. +3. Install dependencies. +4. Review development environment files. +5. Start the application. +6. Configure a test model. + +See [Self-hosting](/get_started/self-hosting) for the setup workflow. + +## Run quality checks + +Common frontend checks include: + +```bash +npm run type-check +npm run lint +npm test +npm run format:check +``` + +Run focused backend and server tests for the modules you change. + +## Extend Eigent + +Common extension points include: + +- Add a model provider. +- Add a local inference runtime. +- Add an MCP integration. +- Create an Agent Skill. +- Add or modify a worker. +- Add a trigger type. +- Add a dashboard or Workspace surface. +- Extend Space, Project, or remote-control APIs. + +Follow existing module boundaries and add tests for shared behavior. + +## Contribute changes + +A useful contribution should include: + +- A clear problem statement +- Focused implementation +- Tests proportional to risk +- Documentation for user-facing behavior +- Screenshots or recordings for UI changes +- Migration notes for schema or configuration changes + +Before opening a pull request: + +1. Review the repository contribution guidance. +2. Rebase or update from the target branch. +3. Run relevant checks. +4. Remove secrets and local-only files. +5. Describe verification steps. + +> **Video placeholder:** Add a 90-second contributor onboarding MP4 showing repository setup, a small UI or documentation change, tests, and a pull request. Include captions. + +## Documentation contributions + +Product documentation lives in `docs/`. + +- Add pages to `docs/docs.json`. +- Include `title` and `description` frontmatter. +- Use task-based procedures. +- Add visible screenshot and video placeholders when assets are not ready. +- Keep provider docs synchronized with model configuration source files. +- Mark coming-soon functionality clearly. + +## Community and support + + + + Report reproducible bugs and feature requests. Please include Eigent version, operating system, deployment mode, model provider, reproduction steps, expected and actual behavior, and sanitized logs. + + + Join our Discord community to ask questions, share ideas, and connect with other Eigent users and contributors. + + + Reach out to us at info@eigent.ai for general inquiries and support. + + + +## Related guides + + + + Understand the main runtime and service boundaries. + + + Run Eigent with your own infrastructure. + + + Extend agents with new tools. + + diff --git a/docs/projects/context-and-files.md b/docs/projects/context-and-files.md new file mode 100644 index 000000000..0f4346622 --- /dev/null +++ b/docs/projects/context-and-files.md @@ -0,0 +1,94 @@ +--- +title: Context and files +description: Manage local workspace files, uploaded context, and generated project outputs. +icon: inbox +--- + +The Context tab is the file surface for the active Space and Project. It combines workspace files, user attachments, and outputs generated by agents. + +## Open Context + +1. Select a Space. +2. In the project sidebar, select **Context**. + +If Context is disabled, the Space does not yet have a usable workspace binding. + +> **Screenshot placeholder:** Add a screenshot of the Context tab with the file tree, preview area, and an unread-file notification visible. + +## Understand file sources + +Context can include: + +- Files from a local-folder Space +- Files stored in an Eigent scratch workspace +- Files attached to user requests +- Files generated by agents +- Agent-specific working folders +- Selected output files from a task run + +The project Session side panel can also list uploaded and generated files for the selected Run. + +## Open and preview a file + +1. In Context, browse or search the file tree. +2. Select a file. +3. Review the preview. + +Eigent supports previews for common text, source code, Markdown, image, HTML, document, and data formats. Unsupported formats remain available in the workspace even when they cannot be previewed. + +## Use a local-folder Space + +Local-folder Spaces use direct-write mode. Agents can read and modify files inside the selected folder, subject to available tools and permissions. + +Before starting a task: + +1. Confirm the folder shown by the Space binding. +2. Commit or back up important work. +3. Limit the request to the intended files. +4. Review generated changes before accepting them. + +## Use a blank Space + +Blank Spaces use artifact-only mode. Eigent stores generated outputs in managed project storage instead of modifying an existing user folder. + +Use this mode for research, reports, presentations, and tasks that should produce isolated deliverables. + +## Review pending changes + +When the Space reports pending changes: + +1. Open the Space switcher or Context. +2. Refresh the workspace when the displayed state is stale. +3. Review the changed files. +4. Apply the changes to keep them, or discard them to return to the previous state. + +> **Video placeholder:** Add a 60-90 second MP4 showing an agent creating a file, the unread Context indicator, file preview, and the apply or discard workflow. Include captions. + +## Open a run output + +1. Open the Project Session. +2. Select the required Run. +3. In the side panel, expand the file section. +4. Select an output. + +Eigent opens Context and selects the file belonging to that Run. + +## Troubleshooting + +### A generated file does not appear immediately + +Wait a few seconds and refresh Context. Some outputs are written after the task sends its finished event. + +### Context shows the wrong project file + +Confirm the selected Project and Run. File selection is scoped to the selected Run when possible. + +### A local file cannot be opened + +Confirm that the file still exists and that Eigent has permission to access the containing folder. + +## Related guides + +- [Spaces](/dashboard/spaces) +- [Sessions](/projects/sessions) +- [Project runs](/core/project-runs) diff --git a/docs/projects/create-project.md b/docs/projects/create-project.md new file mode 100644 index 000000000..04c8157eb --- /dev/null +++ b/docs/projects/create-project.md @@ -0,0 +1,101 @@ +--- +title: Create a project +description: Start a project, choose an execution mode, attach files, and submit the first task. +icon: plus +--- + +Create a Project when you want Eigent to keep related tasks, files, and follow-up requests together. + +## Before you begin + +Confirm the following: + +- A Space is selected. +- At least one model is configured. +- The task goal is clear enough to plan or execute. +- Required source files are available. + +If no model is configured, Eigent opens the Models page instead of starting the task. + +## Open the project composer + +Use one of these entry points: + +- In the project sidebar, select **New**. +- In the project sidebar, select **Workspace**. +- From the Home dashboard, create or open a Space. + +The composer displays the active Space and lets you select a project mode. + +> **Screenshot placeholder:** Add a screenshot of the new-project composer with the Space, project picker, Single Agent or Workforce toggle, attachment control, and send action visible. + +## Choose an execution mode + +### Single Agent + +Single Agent mode uses one CAMEL-based agent with the available tools. Choose it for focused tasks that benefit from one continuous context. + +### Workforce + +Workforce mode plans the request and distributes subtasks across specialized agents. Choose it for research, software, documents, or other work that benefits from parallel roles. + +You cannot change the execution mode of a run after it starts. Create another run or project when you need a different mode. + +## Attach source files + +1. In the composer, select the attachment control. +2. Choose one or more files. +3. Confirm that the file names appear in the composer. +4. Mention the files and their purpose in the request. + +Attachments become part of the first run's execution context. + +## Write the first request + +Describe: + +- The desired outcome +- Required source material +- Constraints or acceptance criteria +- Preferred output format +- Any service or tool the agents should use + +For example: + +> Analyze the attached customer interview notes. Group the findings into themes, identify the five highest-impact problems, and create a Markdown report with supporting quotes and recommended product actions. + +## Start the project + +1. Review the selected Space, mode, files, and request. +2. Select **Send**. + +Eigent creates a server-backed Project in the active Space, opens the Session, and starts the first Run. + +In Workforce mode, Eigent can show a task plan before execution. Review, edit, add, or remove subtasks before starting the plan. + +> **Video placeholder:** Add a 60-90 second MP4 showing project creation in both Single Agent and Workforce modes. Include captions and pause briefly on the Workforce plan review. + +## Troubleshooting + +### The send action does nothing + +Confirm that the request contains text and that project startup is not already in progress. + +### Eigent asks for a model + +Open **Agents > Models**, configure a provider, and return to the composer. + +### A file is missing + +Attach it again before sending, or add it from the Context tab in an existing project. + +### The wrong Space is selected + +Cancel the draft, select the correct Space, and start the project there. Space selection defines the project and file boundary. + +## Next steps + +- [Workspace](/projects/workspace) +- [Sessions](/projects/sessions) +- [Single Agent mode](/projects/single-agent) +- [Workforce](/core/workforce) diff --git a/docs/projects/overview.md b/docs/projects/overview.md new file mode 100644 index 000000000..5a9f78c22 --- /dev/null +++ b/docs/projects/overview.md @@ -0,0 +1,88 @@ +--- +title: Projects overview +description: Understand how Spaces, Projects, Sessions, and Runs organize work in Eigent. +icon: folder-kanban +--- + +Projects are persistent workstreams inside a Space. A project keeps related conversations, task runs, agents, files, browser activity, terminal state, and automations together. + +## Product hierarchy + +Eigent organizes work into four levels: + +| Level | Purpose | +| ------- | ------------------------------------------------- | +| Space | Defines the top-level workspace and file boundary | +| Project | Groups related work around an ongoing goal | +| Session | Presents the conversation and execution surfaces | +| Run | Represents one request or follow-up task | + +For example, a Space called “Marketing” can contain a “Product launch” project. The project session can include separate runs for research, copywriting, and presentation creation. + +## Use the project sidebar + +The project sidebar is available in the main Workspace. It provides access to: + +- The Space switcher +- Workspace +- Context +- Scheduled triggers +- Dispatch +- New project +- Projects in the active Space +- Project actions + +The sidebar can be resized or folded into an icon rail. + +> **Screenshot placeholder:** Add a screenshot of the expanded project sidebar. Annotate the Space switcher, Workspace, Context, Scheduled, Dispatch, New, and project list in surrounding text. + +## Start a project + +1. Select a Space from the sidebar. +2. Select **New** or open the Workspace. +3. Enter the first task. +4. Choose Single Agent or Workforce mode. +5. Attach required files. +6. Send the task. + +Eigent creates the Project and opens its live Session. + +## Continue a project + +Submit a follow-up request in the same conversation. Eigent adds a new Run while preserving earlier prompts, progress, and outputs. + +Use this approach when the new request contributes to the same goal. Create another Project when the work needs a separate history, file context, or lifecycle. + +## Manage project status + +### Active + +An active project can accept new runs, execute triggers, and appear in the project sidebar. + +### Achieved + +An achieved project is complete. If a run is still active when you end the project, Eigent stops that run before saving the achieved state. + +### Archived or deleted + +Archived projects leave the active workflow. Deleting a project can also remove its local project data. Save important output files before deletion. + +## Search across projects + +Use `Command/Ctrl + K` from the Workspace to open global search. Search can help locate existing projects and sessions without returning to the Home dashboard. + +> **Video placeholder:** Add a 60-second MP4 showing project creation, a follow-up run, global search, and project completion. Include captions. + +## Related guides + + + + Start the first run and choose an execution mode. + + + Follow agent execution and review outputs. + + + Continue work with follow-up requests. + + diff --git a/docs/projects/sessions.md b/docs/projects/sessions.md new file mode 100644 index 000000000..4a5b8a053 --- /dev/null +++ b/docs/projects/sessions.md @@ -0,0 +1,105 @@ +--- +title: Sessions +description: Follow task execution through chat, progress, agents, logs, files, browser, and terminal views. +icon: messages +--- + +A Session combines the project conversation with live execution details. It is the main surface for reviewing what Eigent understood, what its agents are doing, and what they produced. + +## Session layout + +The Session contains: + +- A chat and task timeline +- A task composer for follow-up requests +- A session side panel +- Workspace surfaces for files, browser, terminal, or workflow +- Controls for pausing, resuming, stopping, or expanding work + +> **Screenshot placeholder:** Add a screenshot of an active Workforce Session. Show the chat, task log, Run selector, progress, agent pool, and workspace surface. + +## Review the conversation + +The chat timeline can contain: + +- User prompts and attachments +- Planning status +- Workforce task plans +- Agent tool calls and logs +- Human-in-the-loop questions +- Completion summaries +- Follow-up runs + +Expand task logs when you need operational detail. Use summaries and generated files for the final result. + +## Review a Workforce plan + +Before execution, Workforce can split the request into subtasks. + +1. Review each subtask. +2. Edit unclear or overly broad items. +3. Add missing work. +4. Delete unnecessary work. +5. Start the task. + +The coordinator assigns each subtask to an appropriate worker. + +## Use the session side panel + +In Workforce mode, the side panel can show: + +- Agent pool +- Progress +- Execution context +- Uploaded files +- Generated files +- Skills and tools +- Expanded Workforce canvas + +In Single Agent mode, it shows the selected agent's progress, context, and outputs without the multi-agent canvas. + +## Open an agent workspace + +Select an agent or subtask to open its workspace: + +- Browser agents use an embedded browser. +- Developer agents use a terminal. +- Document and file work appears in Context. +- Workflow view shows the agent and task structure. + +When a browser needs manual help, use the available take-control flow, complete the action, and return control to the agent. + +## Pause, resume, or stop work + +- **Pause** preserves the task state and elapsed time. +- **Resume** continues a paused task. +- **Stop** ends the current execution while preserving the Project history. + +Use Stop when the task is no longer useful or is consuming resources without making progress. + +## Continue with a follow-up + +Enter another request in the composer. Eigent adds the request as a new Run. Use the Run selector to switch the side panel and workspaces between runs. + +> **Video placeholder:** Add a 90-second MP4 showing plan review, task execution, a browser or terminal workspace, pause and resume, and a follow-up Run. Include captions and a transcript. + +## Troubleshooting + +### Progress and chat show different runs + +Use the Run selector. The side panel follows the selected or visible Run. + +### An agent workspace is empty + +The selected Run might not have used that agent or workspace. Select another agent, return to workflow view, or choose the relevant Run. + +### A task is waiting + +Check the chat for a human-in-the-loop request, plan approval, authentication step, or unavailable tool. + +## Related guides + +- [Project runs](/core/project-runs) +- [Single Agent mode](/projects/single-agent) +- [Workforce](/core/workforce) +- [Context and files](/projects/context-and-files) diff --git a/docs/projects/single-agent.md b/docs/projects/single-agent.md new file mode 100644 index 000000000..4e656703b --- /dev/null +++ b/docs/projects/single-agent.md @@ -0,0 +1,88 @@ +--- +title: Single Agent mode +description: Run a task with one CAMEL agent and monitor its progress, context, and outputs. +icon: user-robot +--- + +Single Agent mode assigns a task to one CAMEL-based agent instead of creating a coordinated multi-agent Workforce. The agent can still use models, tools, Skills, files, browser sessions, and terminal access. + +## Choose Single Agent mode + +Use Single Agent mode for: + +- Focused tasks with one clear goal +- Work that benefits from a continuous context +- Short tool-driven workflows +- Tasks where coordination overhead would add little value +- Iterative follow-ups handled by the same execution pattern + +Use Workforce for broad tasks that benefit from specialized roles or parallel subtasks. + +## Start a Single Agent project + +1. Open the Workspace or select **New**. +2. Select **Single Agent**. +3. Enter the task. +4. Attach required files. +5. Send the request. + +Eigent opens a Session and starts the task without a Workforce planning stage. + +> **Screenshot placeholder:** Add a screenshot of the new-project composer with Single Agent mode selected. + +## Monitor the agent + +The Session side panel can show: + +- Task progress +- Execution context +- Uploaded files +- Generated output files +- Skills and tools + +The chat timeline shows tool calls, status updates, requests for user input, and the final response. + +## Use browser and terminal workspaces + +When the agent uses browser or terminal tools, the corresponding workspace becomes available in the Session. The workspace is scoped to the selected Run. + +## Continue with follow-ups + +1. Enter a follow-up request in the Session. +2. Send the request. +3. Use the Run selector to review earlier work. + +Each follow-up remains part of the same Project. + +## Compare execution modes + +| Capability | Single Agent | Workforce | +| ------------------------------------ | ------------ | ----------------------- | +| One continuous agent context | Yes | No, work is distributed | +| Task planning and splitting | Limited | Yes | +| Parallel specialized agents | No | Yes | +| Agent pool and workflow canvas | No | Yes | +| Browser, terminal, files, and Skills | Yes | Yes | +| Best for | Focused work | Complex multi-step work | + +> **Video placeholder:** Add a side-by-side MP4 comparison of the same focused request in Single Agent and Workforce mode. Keep the example short and include captions. + +## Troubleshooting + +### The task needs another specialty + +Add the required tool to the agent, install a connector, or start a Workforce Project with specialized workers. + +### The agent cannot access a file + +Attach the file, add it to the Space workspace, and confirm that the active Space has the correct folder binding. + +### The task is too broad + +Split the request into smaller follow-up Runs or use Workforce mode. + +## Related guides + +- [Create a project](/projects/create-project) +- [Agents overview](/agents/overview) +- [Workforce](/core/workforce) diff --git a/docs/projects/workspace.md b/docs/projects/workspace.md new file mode 100644 index 000000000..c6d782052 --- /dev/null +++ b/docs/projects/workspace.md @@ -0,0 +1,84 @@ +--- +title: Workspace +description: Use the Workspace landing page, project picker, instructions, agents, and recent sessions. +icon: desktop +--- + +The Workspace is the starting surface for the active Space. Use it to start work, select a project, review recent sessions, configure project behavior, and manage the agents available to Workforce mode. + +## Open the Workspace + +1. In the project sidebar, select **Workspace**. +2. Confirm the active Space in the sidebar. + +The main composer starts a new project unless you select an existing project. + +> **Screenshot placeholder:** Add a screenshot of the complete Workspace landing page with the composer, mode toggle, recent sessions, agent list, and instructions panel visible. + +## Select a project + +The project picker behaves differently depending on the current state: + +- In a fresh Workspace, select an existing project before submitting a task. +- After work has started, the selected project is displayed as context. +- Selecting a recent session opens that Project's live Session. + +Use **All sessions** to review more projects than the recent-session list shows. + +## Start a task + +1. Enter a request in the composer. +2. Select Single Agent or Workforce mode. +3. Attach required files. +4. Select **Send**. + +Eigent creates a Project when no existing Project is selected. + +## Use example prompts + +Example prompts appear for an empty Project that does not use a custom agent folder or replayed history. Select a prompt to understand the expected level of task detail, then adapt it to your goal. + +## Manage Workforce agents + +The Workspace shows built-in and custom workers available to Workforce mode. + +You can: + +- Review the built-in Developer, Browser, Document, and Multimodal roles. +- Add a worker. +- Edit a custom worker. +- Assign tools to a worker. +- Remove workers that are no longer required. + +See [Workers](/core/workers) for configuration details. + +## Configure instructions, rules, and tone + +Project instructions provide persistent guidance for work in the active Space or Project. Use them for: + +- Output conventions +- Brand or writing tone +- Coding standards +- Required files or workflows +- Safety and approval rules + +Keep instructions focused and actionable. Put step-by-step reusable expertise in an Agent Skill instead of one large instruction file. + +> **Screenshot placeholder:** Add a screenshot of the instructions panel with a short example instruction. Do not include private repository rules. + +## Toggle workspace memory + +The Workspace includes a memory toggle for instruction behavior. This local setting is separate from the Agent Memory page, which is currently marked coming soon. + +## Open recent sessions + +Recent sessions display project names and current state. Select one to open its Session. Use the project sidebar or Home dashboard for the complete project history. + +> **Video placeholder:** Add a 60-second MP4 showing project selection, instruction editing, worker management, and opening a recent session. Include captions. + +## Related guides + +- [Create a project](/projects/create-project) +- [Agents overview](/agents/overview) +- [Agent Skills](/core/agent-skills) +- [Sessions](/projects/sessions) diff --git a/docs/settings/appearance.md b/docs/settings/appearance.md new file mode 100644 index 000000000..943a4a56a --- /dev/null +++ b/docs/settings/appearance.md @@ -0,0 +1,85 @@ +--- +title: Appearance +description: Customize color mode, themes, contrast, and workspace backgrounds. +icon: palette +--- + +Appearance settings control Eigent's color mode, theme palette, contrast, and Workspace background. + +## Open Appearance + +1. Open the Eigent dashboard. +2. Select **Settings**. +3. Select **Appearance**. + +> **Screenshot placeholder:** Add a screenshot of Appearance settings with color mode, theme selection, color controls, contrast, and Workspace background visible. + +## Choose a color mode + +Select: + +- **Light** +- **Dark** +- **System** + +System mode follows the operating-system appearance. + +## Choose a theme + +Built-in theme families include: + +- Eigent +- CAMEL +- Claw +- Starfish + +Additional customizable slots include Whale and Custom. + +Light and dark mode can use different selected themes. + +## Edit theme colors + +Depending on the theme, configure: + +- Accent +- Background +- Ink + +Enter a six-digit hexadecimal color or use the color picker. Select **Apply** to keep the pending color. + +## Adjust contrast + +Use the contrast slider to increase or reduce separation between generated theme colors. + +Review text, controls, borders, status colors, and focus indicators after changing contrast. + +## Reset a theme + +Use Reset to restore the selected theme to its default values. Custom values for that theme are removed. + +## Choose a Workspace background + +Available backgrounds include: + +- Plain +- Grid +- Dot pattern +- Dashed lines +- Dotted lines +- Ruled lines + +The background changes the main Workspace canvas and does not affect project files or agent output. + +> **Video placeholder:** Add a 45-second MP4 showing light and dark mode, theme selection, custom accent color, contrast changes, and all Workspace backgrounds. Include captions. + +## Accessibility guidance + +- Keep text contrast high enough for comfortable reading. +- Check status colors in both light and dark mode. +- Avoid background patterns that reduce focus. +- Use System mode when the operating system manages accessibility preferences. + +## Related guides + +- [General settings](/settings/general) +- [Privacy](/settings/privacy) diff --git a/docs/settings/general.md b/docs/settings/general.md new file mode 100644 index 000000000..8e1aecc34 --- /dev/null +++ b/docs/settings/general.md @@ -0,0 +1,94 @@ +--- +title: General settings +description: Manage your account, language, application version, and network proxy. +icon: gear +--- + +General settings control the signed-in account, interface language, network proxy, and application version. + +## Open General settings + +1. Open the Eigent dashboard. +2. Select **Settings**. +3. Select **General**. + +> **Screenshot placeholder:** Add a screenshot of General settings with Profile, Language, and Network Proxy sections visible. Hide the full email address if required. + +## Manage the account + +The Profile section shows the current account. + +- Select **Manage** to open Eigent account management. +- Select **Log out** to clear active task state, reset local installation state, and return to login. + +Save or finish important work before logging out. + +## Change the language + +1. Open the Language selector. +2. Choose a language or **System default**. + +Current interface languages include: + +- English +- Simplified Chinese +- Traditional Chinese +- Japanese +- Arabic +- French +- German +- Russian +- Spanish +- Korean +- Italian + +Some third-party tool output and generated content can remain in another language. + +## Configure a network proxy + +1. Enter the proxy URL in **Network Proxy**. +2. Select **Save**. +3. Select **Restart to apply**. + +Supported schemes: + +- `http://` +- `https://` +- `socks4://` +- `socks5://` + +Example: + +```text +http://127.0.0.1:8080 +``` + +Clear the field and save to remove the proxy. + +## Review updates + +The Settings sidebar shows the installed version. When an update is available, select the update action to start the package download. + +Self-hosted development builds should update through Git and the repository build process instead. + +> **Video placeholder:** Add a 45-second MP4 showing language change, proxy save and restart, and the version or update control. Include captions. + +## Troubleshooting + +### Proxy URL is rejected + +Include a supported scheme and valid host. Credentials, when required, must use valid URL encoding. + +### Network changes do not apply + +Restart Eigent after saving or removing the proxy. + +### Language changes only part of the interface + +Restart the application and report missing translation keys with the current version and locale. + +## Related guides + +- [Appearance](/settings/appearance) +- [Privacy](/settings/privacy) +- [Self-hosting](/get_started/self-hosting) diff --git a/docs/settings/privacy.md b/docs/settings/privacy.md new file mode 100644 index 000000000..a62bc6dc6 --- /dev/null +++ b/docs/settings/privacy.md @@ -0,0 +1,104 @@ +--- +title: Privacy +description: Understand Eigent's privacy controls and data-handling boundaries. +icon: fingerprint +--- + +Eigent can process prompts, files, credentials, browser sessions, and external service data. Where that data goes depends on the selected model, deployment mode, Space type, and connectors. + +This page provides a practical privacy checklist. Review the current privacy policy and source code for deployment-specific guarantees. + +## Understand model data flow + +### Eigent Cloud + +Prompts and relevant context are sent through Eigent's managed model service. + +### Bring Your Own Key + +Prompts and relevant context are sent to the configured provider endpoint using your credentials. + +### Local models + +Model requests are sent to the configured local endpoint. Data can remain on infrastructure you control when all other tools and services are also local. + + +A local model does not make the entire workflow local if the task also uses cloud connectors, search, remote MCP servers, or external browser services. + + +## Understand file storage + +- Local-folder Spaces can let agents read and modify the selected directory. +- Blank Spaces store generated artifacts in Eigent-managed project storage. +- Uploaded files become task context. +- Generated outputs can remain in project or workspace folders. + +Limit each Space to the files required for its work. + +## Protect credentials + +Credentials can include: + +- Model API keys +- MCP environment variables +- OAuth grants +- Search credentials +- Browser cookies +- Remote-control links + +Use restricted credentials, rotate exposed values, and remove unused configurations. + +## Manage browser sessions + +Browser cookies can grant access to external accounts. + +- Use a dedicated automation profile. +- Delete unused cookie domains. +- Avoid privileged sessions. +- Restart after changing cookie state. + +## Share tasks and remote links safely + +Before sharing: + +- Review prompts and generated outputs. +- Remove personal or confidential data. +- Confirm the active Space and Project. +- Stop remote-control sessions after use. + +## Delete data + +Eigent provides deletion or removal actions for: + +- Tasks +- Projects +- Some Space records +- Model providers +- Connectors and MCP servers +- Browser cookies +- Remote-control sessions + +Deletion in Eigent does not automatically revoke data or credentials stored by an external provider. + +**Screenshot placeholder:** Add a privacy-oriented composite screenshot showing provider removal, connector removal, cookie deletion, and project deletion confirmations. Do not include real data. + +**Video placeholder:** Add a 60-second privacy walkthrough covering local versus cloud models, Space file boundaries, credential removal, and browser-cookie cleanup. Include captions. + +## Open-source deployments + +Self-hosting gives you control over the application and infrastructure, but you remain responsible for: + +- Network security +- Database access +- Secret storage +- Logs and backups +- User permissions +- Provider configuration +- Update and vulnerability management + +## Related guides + +- [Self-hosting](/get_started/self-hosting) +- [Provider reference](/models/provider-reference) +- [Custom MCP servers](/connectors/custom-mcp) +- [Browser cookies](/browser/cookies) diff --git a/docs/troubleshooting/bug.md b/docs/troubleshooting/bug.md index 17cac16e6..9930cdd5d 100644 --- a/docs/troubleshooting/bug.md +++ b/docs/troubleshooting/bug.md @@ -3,30 +3,25 @@ title: Bug description: 'Follow these simple steps to report bugs and help improve our product for everyone:' --- - +> **Video placeholder:** Add a current video walkthrough for “Bug Report”. Include captions. -### 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 +42,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/docs/troubleshooting/support.md b/docs/troubleshooting/support.md index 75aac84d5..b9b8e8876 100644 --- a/docs/troubleshooting/support.md +++ b/docs/troubleshooting/support.md @@ -55,7 +55,7 @@ For inquiries about our Scalable and Custom plans, please please refer to our [* - Click **Upgrade** to move to a higher tier with more monthly Credits. - Click **+ Add Credits** to purchase an Add-On Pack when you've used up your monthly allowance. -![Support Dashboard](/docs/images/support_dashboard.png) +> **Screenshot placeholder:** Add a current screenshot for “Support Dashboard”. ### Invitation Code diff --git a/electron/main/fileReader.ts b/electron/main/fileReader.ts index 40bc2b94c..556943c99 100644 --- a/electron/main/fileReader.ts +++ b/electron/main/fileReader.ts @@ -563,6 +563,7 @@ export class FileReader { // Folders to hide in the Agent Folder view private readonly hiddenFolders = [ 'browser_agent', + 'camel_logs', 'developer_agent', 'document_agent', 'multi_modal_agent', @@ -1040,41 +1041,21 @@ export class FileReader { return []; } - const allFiles: FileInfo[] = []; - const taskDirs = fs.readdirSync(projectPath); + const allFiles = this.getFilesRecursive(projectPath, projectPath).map( + (file) => { + const relativePath = path.relative(projectPath, file.path); + const taskMatch = relativePath.match(/^task_([^/\\]+)/); - for (const taskDir of taskDirs) { - if (!taskDir.startsWith('task_')) continue; - - const taskPath = path.join(projectPath, taskDir); - const stats = fs.statSync(taskPath); - - if (stats.isDirectory()) { - const taskId = taskDir.replace('task_', ''); - const taskFiles = this.getFilesRecursive(taskPath, taskPath); - - const enrichedFiles = taskFiles.map((file) => { - const fileDir = path.dirname(file.path); - const relativeParentPath = path.relative(projectPath, fileDir); - - return { - ...file, - task_id: taskId, - project_id: projectId, - relativePath: - relativeParentPath === '.' ? '' : relativeParentPath, - }; - }); - - allFiles.push(...enrichedFiles); + return { + ...file, + task_id: taskMatch?.[1], + project_id: projectId, + relativePath: relativePath === '.' ? '' : relativePath, + }; } - } + ); return allFiles.sort((a, b) => { - // Sort by task_id first, then by file path - if (a.task_id !== b.task_id) { - return a.task_id!.localeCompare(b.task_id!); - } return a.path.localeCompare(b.path); }); } catch (err) { diff --git a/electron/main/index.ts b/electron/main/index.ts index e6d3bc692..06b7a2690 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 ( @@ -1405,413 +1493,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); @@ -1820,15 +1505,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); return { @@ -2473,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 @@ -2728,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(); @@ -2754,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 @@ -2775,7 +2225,7 @@ async function createWindow() { : '#f5f5f580', // macOS-specific title bar styling titleBarStyle: isMac ? 'hidden' : undefined, - trafficLightPosition: isMac ? { x: 10, y: 10 } : undefined, + trafficLightPosition: isMac ? { x: 10, y: 12 } : undefined, icon: path.join(VITE_PUBLIC, 'favicon.ico'), // Rounded corners on macOS and Linux (as original) roundedCorners: !isWindows, @@ -3167,6 +2617,7 @@ const setupWindowEventListeners = () => { // ==================== devtools shortcuts ==================== const setupDevToolsShortcuts = () => { if (!win) return; + if (app.isPackaged) return; const toggleDevTools = () => win?.webContents.toggleDevTools(); @@ -3444,7 +2895,8 @@ app.whenReady().then(async () => { // Register protocol handler for both default session and main window session const protocolHandler = async (request: Request) => { const url = decodeURIComponent(request.url.replace('localfile://', '')); - const filePath = path.resolve(path.normalize(url)); + const normalizedUrl = url.replace(/^\/([A-Za-z]:[\\/])/, '$1'); + const filePath = path.resolve(path.normalize(normalizedUrl)); log.info(`[PROTOCOL] Handling localfile request: ${request.url}`); log.info(`[PROTOCOL] Resolved path: ${filePath}`); diff --git a/electron/main/init.ts b/electron/main/init.ts index b7b3c62e7..90d0ec5a7 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'); @@ -290,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; @@ -339,6 +336,7 @@ export async function startBackend( ...uvEnv, ...proxyEnv, SERVER_URL: serverUrl, + EIGENT_RUNTIME: 'electron', PYTHONIOENCODING: 'utf-8', PYTHONUNBUFFERED: '1', npm_config_cache: npmCacheDir, @@ -364,8 +362,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/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 d7ddb02e5..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; } @@ -45,11 +59,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 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 }; - const image = await webContents.view.webContents.capturePage(); - const jpegBuffer = image.toJPEG(10); + 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'); } @@ -204,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); @@ -257,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; @@ -333,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 }; @@ -354,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 ( @@ -374,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 910a670d7..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 @@ -175,32 +179,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..758d9fb58 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -58,9 +58,14 @@ export default [ '.vite/**', // Config files 'vite.config.ts', + 'vite.config.*.ts', + '**/vite.config.ts', 'vitest.config.ts', + '**/vitest.config.ts', 'tailwind.config.js', + '**/tailwind.config.js', 'postcss.config.cjs', + '**/postcss.config.cjs', // Generated files '**/*.d.ts', '**/*.map', @@ -70,6 +75,8 @@ export default [ '**/.venv/**', // Prebuilt resources 'resources/prebuilt/**', + // Archive (pre-refactor snapshots) + 'archive/**', ], }, @@ -140,6 +147,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 d916d4e75..1ae0ea879 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eigent", - "version": "0.0.91", + "version": "1.0.0", "main": "dist-electron/main/index.js", "description": "Eigent", "author": "Eigent.AI", @@ -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,13 +36,19 @@ "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", "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}\"", @@ -50,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", @@ -95,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 new file mode 100644 index 000000000..9c061ba20 --- /dev/null +++ b/scripts/check-electron-access.sh @@ -0,0 +1,23 @@ +#!/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\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 +)" + +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/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..6535a11e8 --- /dev/null +++ b/scripts/design-token-usage.allowlist @@ -0,0 +1,26 @@ +# 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 +# +# ColorPicker — HSV UI requires fixed gradients / hex placeholder. +src/components/ui/colorPicker.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/main/fileReader.ts +electron/preload/index.ts +# +# Animation assets — demo/preview components use hardcoded colors intentionally for visual fidelity. +src/assets/animation/project/ProjectWorkspaceDisplay.tsx +src/assets/animation/workspace/WorkspaceDisplay.tsx +# +# OnboardingSteps — theme accent swatches must reference exact brand hex values from the theme catalog. +src/components/InstallStep/OnboardingSteps.tsx 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/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/.env.example b/server/.env.example index c89d43f0c..e4629685b 100644 --- a/server/.env.example +++ b/server/.env.example @@ -5,6 +5,42 @@ secret_key=postgres CHAT_SHARE_SECRET_KEY=put-your-secret-key-here CHAT_SHARE_SALT=put-your-encode-salt-here +# Remote control public web origin. Set this to the HTTPS site users open +# from a phone or another computer, not the local desktop server. +# REMOTE_CONTROL_WEB_ORIGIN=https://www.eigent.ai +# If REMOTE_CONTROL_WEB_ORIGIN and SERVER_URL are different origins, list the +# web origins explicitly for browser API/WebSocket access. +# CORS_ALLOW_ORIGINS=https://www.eigent.ai +# Optional stricter per-WebSocket allowlists. If omitted, remote control derives +# from REMOTE_CONTROL_WEB_ORIGIN / CORS_ALLOW_ORIGINS and local dev origins. +# REMOTE_CONTROL_EVENTS_ALLOWED_ORIGINS=https://www.eigent.ai +# REMOTE_CONTROL_BRIDGE_ALLOWED_ORIGINS=https://www.eigent.ai +# Local dev browser origins accepted by remote-control WebSockets when explicit +# allowlists are not set. Comma-separated ports. +REMOTE_CONTROL_LOCAL_DEV_PORTS=3001,5173,5174 +# How often a bridge WebSocket re-checks token blacklist state during pings. +REMOTE_CONTROL_BRIDGE_BLACKLIST_CHECK_INTERVAL_SECONDS=60 +# Remote-control rate limits. These are per user/session/key limits, not global +# concurrency limits. Raise these for trusted beta traffic; use Traefik/WAF/CDN +# for global per-IP protection when exposing cloud relay publicly. +REMOTE_CONTROL_SESSION_CREATE_LIMIT=120 +REMOTE_CONTROL_SESSION_CREATE_WINDOW_SECONDS=3600 +REMOTE_CONTROL_COMMAND_BURST_LIMIT=10 +REMOTE_CONTROL_COMMAND_BURST_WINDOW_SECONDS=1 +REMOTE_CONTROL_COMMAND_MINUTE_LIMIT=120 +REMOTE_CONTROL_COMMAND_MINUTE_WINDOW_SECONDS=60 +REMOTE_CONTROL_STEPS_LIMIT=120 +REMOTE_CONTROL_STEPS_WINDOW_SECONDS=60 +REMOTE_CONTROL_WS_RECONNECT_LIMIT=30 +REMOTE_CONTROL_WS_RECONNECT_WINDOW_SECONDS=60 +# Comma-separated reverse proxy hosts/CIDRs whose X-Forwarded-For / X-Real-IP +# headers may be trusted for remote-control WS rate limits and audit logs. +# Set this to your Traefik / load balancer private IP or CIDR in production. +# Dokploy example: REMOTE_CONTROL_TRUSTED_PROXY_HOSTS=172.16.0.0/12 +REMOTE_CONTROL_TRUSTED_PROXY_HOSTS=127.0.0.1,::1 +# Only set true for local/dev diagnostics; it intentionally allows wildcard CORS/WS origins. +REMOTE_CONTROL_ALLOW_UNSAFE_ORIGINS=false + # Configuration if not running in docker # database_url=postgresql://postgres:123456@localhost:5432/eigent # redis_url=redis://localhost:6379/0 @@ -28,4 +64,4 @@ EXECUTION_TIMEOUT_CHECKER_INTERVAL=1 # EXECUTION_PENDING_TIMEOUT_SECONDS: Timeout for pending executions (default 60 seconds) EXECUTION_PENDING_TIMEOUT_SECONDS=60 # EXECUTION_RUNNING_TIMEOUT_SECONDS: Timeout for running executions (default 600 seconds / 10 minutes) -EXECUTION_RUNNING_TIMEOUT_SECONDS=600 \ No newline at end of file +EXECUTION_RUNNING_TIMEOUT_SECONDS=600 diff --git a/server/alembic/env.py b/server/alembic/env.py index 479a92356..84f7ae9d3 100644 --- a/server/alembic/env.py +++ b/server/alembic/env.py @@ -39,13 +39,17 @@ # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel -auto_import("app.model.mcp") -auto_import("app.model.user") -auto_import("app.model.config") -auto_import("app.model.chat") -auto_import("app.model.provider") +auto_import("app.model.mcp") +auto_import("app.model.user") +auto_import("app.model.config") +auto_import("app.model.chat") +auto_import("app.model.provider") auto_import("app.model.remote_sub_agent") -auto_import("app.model.trigger") +auto_import("app.model.remote_control") +auto_import("app.model.trigger") +auto_import("app.model.space") +auto_import("app.model.project") +auto_import("app.model.memory") # target_metadata = mymodel.Base.metadata target_metadata = SQLModel.metadata diff --git a/server/alembic/versions/2026_05_21_1200-add_remote_control_tables.py b/server/alembic/versions/2026_05_21_1200-add_remote_control_tables.py new file mode 100644 index 000000000..88caafc21 --- /dev/null +++ b/server/alembic/versions/2026_05_21_1200-add_remote_control_tables.py @@ -0,0 +1,293 @@ +# ========= 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. ========= +"""add remote control tables + +Revision ID: add_remote_control_tables +Revises: add_space_layer_foundation +Create Date: 2026-05-21 12:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "add_remote_control_tables" +down_revision: str | None = "add_space_layer_foundation" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _timestamps() -> list[sa.Column]: + return [ + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=True, + ), + ] + + +def upgrade() -> None: + op.create_table( + "remote_control_session", + *_timestamps(), + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("desktop_instance_id", sa.String(length=128), nullable=True), + sa.Column("project_id", sa.String(length=128), nullable=True), + sa.Column("active_task_id", sa.String(length=128), nullable=True), + sa.Column("brain_session_id", sa.String(length=128), nullable=True), + sa.Column("title", sa.String(length=256), nullable=True), + sa.Column("status", sa.String(length=32), nullable=True), + sa.Column("bridge_status", sa.String(length=32), nullable=True), + sa.Column("execution_mode", sa.String(length=32), nullable=True), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("last_bridge_seen_at", sa.DateTime(), nullable=True), + sa.Column("last_remote_seen_at", sa.DateTime(), nullable=True), + sa.Column("capabilities", sa.JSON(), nullable=True), + sa.Column("revoked_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_remote_control_session_active_task_id"), + "remote_control_session", + ["active_task_id"], + unique=False, + ) + op.create_index( + op.f("ix_remote_control_session_desktop_instance_id"), + "remote_control_session", + ["desktop_instance_id"], + unique=False, + ) + op.create_index( + op.f("ix_remote_control_session_expires_at"), + "remote_control_session", + ["expires_at"], + unique=False, + ) + op.create_index( + op.f("ix_remote_control_session_project_id"), + "remote_control_session", + ["project_id"], + unique=False, + ) + op.create_index( + op.f("ix_remote_control_session_status"), + "remote_control_session", + ["status"], + unique=False, + ) + op.create_index( + op.f("ix_remote_control_session_user_id"), + "remote_control_session", + ["user_id"], + unique=False, + ) + op.create_index( + "ix_remote_control_session_user_project_status", + "remote_control_session", + ["user_id", "project_id", "status"], + unique=False, + ) + + op.create_table( + "remote_control_link", + *_timestamps(), + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("session_id", sa.String(length=64), nullable=True), + sa.Column("token_hash", sa.String(length=128), nullable=True), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("first_used_at", sa.DateTime(), nullable=True), + sa.Column("use_count", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_remote_control_link_expires_at"), + "remote_control_link", + ["expires_at"], + unique=False, + ) + op.create_index( + op.f("ix_remote_control_link_session_id"), + "remote_control_link", + ["session_id"], + unique=False, + ) + op.create_index( + op.f("ix_remote_control_link_token_hash"), + "remote_control_link", + ["token_hash"], + unique=False, + ) + op.create_index( + "ix_remote_control_link_session_token", + "remote_control_link", + ["session_id", "token_hash"], + unique=False, + ) + + op.create_table( + "remote_control_command", + *_timestamps(), + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("session_id", sa.String(length=64), nullable=True), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("source_channel", sa.String(length=64), nullable=True), + sa.Column("type", sa.String(length=64), nullable=True), + sa.Column("payload", sa.JSON(), nullable=True), + sa.Column("next_task_id", sa.String(length=128), nullable=True), + sa.Column("status", sa.String(length=32), nullable=True), + sa.Column("error", sa.String(length=1024), nullable=True), + sa.Column("error_code", sa.String(length=128), nullable=True), + sa.Column("delivered_at", sa.DateTime(), nullable=True), + sa.Column("acknowledged_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_remote_control_command_next_task_id"), + "remote_control_command", + ["next_task_id"], + unique=False, + ) + op.create_index( + op.f("ix_remote_control_command_session_id"), + "remote_control_command", + ["session_id"], + unique=False, + ) + op.create_index( + op.f("ix_remote_control_command_status"), + "remote_control_command", + ["status"], + unique=False, + ) + op.create_index( + op.f("ix_remote_control_command_user_id"), + "remote_control_command", + ["user_id"], + unique=False, + ) + op.create_index( + "ix_remote_control_command_session_status_created", + "remote_control_command", + ["session_id", "status", "created_at"], + unique=False, + ) + + op.create_table( + "remote_control_event", + *_timestamps(), + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("session_id", sa.String(length=64), nullable=True), + sa.Column("type", sa.String(length=64), nullable=True), + sa.Column("payload", sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_remote_control_event_session_id"), + "remote_control_event", + ["session_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_remote_control_event_session_id"), + table_name="remote_control_event", + ) + op.drop_table("remote_control_event") + + op.drop_index( + "ix_remote_control_command_session_status_created", + table_name="remote_control_command", + ) + op.drop_index( + op.f("ix_remote_control_command_user_id"), + table_name="remote_control_command", + ) + op.drop_index( + op.f("ix_remote_control_command_status"), + table_name="remote_control_command", + ) + op.drop_index( + op.f("ix_remote_control_command_session_id"), + table_name="remote_control_command", + ) + op.drop_index( + op.f("ix_remote_control_command_next_task_id"), + table_name="remote_control_command", + ) + op.drop_table("remote_control_command") + + op.drop_index( + "ix_remote_control_link_session_token", + table_name="remote_control_link", + ) + op.drop_index( + op.f("ix_remote_control_link_token_hash"), + table_name="remote_control_link", + ) + op.drop_index( + op.f("ix_remote_control_link_session_id"), + table_name="remote_control_link", + ) + op.drop_index( + op.f("ix_remote_control_link_expires_at"), + table_name="remote_control_link", + ) + op.drop_table("remote_control_link") + + op.drop_index( + "ix_remote_control_session_user_project_status", + table_name="remote_control_session", + ) + op.drop_index( + op.f("ix_remote_control_session_user_id"), + table_name="remote_control_session", + ) + op.drop_index( + op.f("ix_remote_control_session_status"), + table_name="remote_control_session", + ) + op.drop_index( + op.f("ix_remote_control_session_project_id"), + table_name="remote_control_session", + ) + op.drop_index( + op.f("ix_remote_control_session_expires_at"), + table_name="remote_control_session", + ) + op.drop_index( + op.f("ix_remote_control_session_desktop_instance_id"), + table_name="remote_control_session", + ) + op.drop_index( + op.f("ix_remote_control_session_active_task_id"), + table_name="remote_control_session", + ) + op.drop_table("remote_control_session") diff --git a/server/alembic/versions/2026_05_22_1500-add_remote_control_desktop_targets.py b/server/alembic/versions/2026_05_22_1500-add_remote_control_desktop_targets.py new file mode 100644 index 000000000..fd2e325a0 --- /dev/null +++ b/server/alembic/versions/2026_05_22_1500-add_remote_control_desktop_targets.py @@ -0,0 +1,89 @@ +# ========= 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. ========= +"""add remote control desktop targets + +Revision ID: add_rc_desktop_targets +Revises: add_remote_control_tables +Create Date: 2026-05-22 15:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "add_rc_desktop_targets" +down_revision: str | None = "add_remote_control_tables" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("remote_control_session", sa.Column("current_project_id", sa.String(length=128), nullable=True)) + op.add_column("remote_control_session", sa.Column("current_task_id", sa.String(length=128), nullable=True)) + op.add_column("remote_control_session", sa.Column("current_history_id", sa.String(length=128), nullable=True)) + op.add_column("remote_control_session", sa.Column("current_brain_session_id", sa.String(length=128), nullable=True)) + op.create_index( + op.f("ix_remote_control_session_current_project_id"), + "remote_control_session", + ["current_project_id"], + unique=False, + ) + op.create_index( + op.f("ix_remote_control_session_current_task_id"), + "remote_control_session", + ["current_task_id"], + unique=False, + ) + + op.add_column("remote_control_command", sa.Column("target_project_id", sa.String(length=128), nullable=True)) + op.add_column("remote_control_command", sa.Column("target_task_id", sa.String(length=128), nullable=True)) + op.add_column("remote_control_command", sa.Column("target_brain_session_id", sa.String(length=128), nullable=True)) + op.create_index( + op.f("ix_remote_control_command_target_project_id"), + "remote_control_command", + ["target_project_id"], + unique=False, + ) + op.create_index( + op.f("ix_remote_control_command_target_task_id"), + "remote_control_command", + ["target_task_id"], + unique=False, + ) + op.create_index( + "ix_remote_control_command_target_project_created", + "remote_control_command", + ["target_project_id", "created_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_remote_control_command_target_project_created", table_name="remote_control_command") + op.drop_index(op.f("ix_remote_control_command_target_task_id"), table_name="remote_control_command") + op.drop_index(op.f("ix_remote_control_command_target_project_id"), table_name="remote_control_command") + op.drop_column("remote_control_command", "target_brain_session_id") + op.drop_column("remote_control_command", "target_task_id") + op.drop_column("remote_control_command", "target_project_id") + + op.drop_index(op.f("ix_remote_control_session_current_task_id"), table_name="remote_control_session") + op.drop_index(op.f("ix_remote_control_session_current_project_id"), table_name="remote_control_session") + op.drop_column("remote_control_session", "current_brain_session_id") + op.drop_column("remote_control_session", "current_history_id") + op.drop_column("remote_control_session", "current_task_id") + op.drop_column("remote_control_session", "current_project_id") diff --git a/server/alembic/versions/2026_05_25_1200-add_space_layer_foundation.py b/server/alembic/versions/2026_05_25_1200-add_space_layer_foundation.py new file mode 100644 index 000000000..0d6f0bf5d --- /dev/null +++ b/server/alembic/versions/2026_05_25_1200-add_space_layer_foundation.py @@ -0,0 +1,463 @@ +# ========= 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. ========= +"""add space layer foundation + +Revision ID: add_space_layer_foundation +Revises: add_remote_sub_agent_provider +Create Date: 2026-05-25 12:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "add_space_layer_foundation" +down_revision: str | None = "add_remote_sub_agent_provider" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _timestamps() -> list[sa.Column]: + return [ + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=True, + ), + ] + + +def upgrade() -> None: + op.create_table( + "space", + *_timestamps(), + sa.Column("id", sa.String(), nullable=False), + sa.Column("user_id", sa.String(length=128), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.String(length=1024), nullable=True), + sa.Column("source_type", sa.String(length=50), nullable=False), + sa.Column("root_path", sa.String(length=2048), nullable=True), + sa.Column("root_fingerprint", sa.JSON(), nullable=True), + sa.Column("status", sa.String(length=50), nullable=False), + sa.Column("schema_version", sa.Integer(), server_default="1", nullable=False), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.CheckConstraint("source_type IN ('blank', 'folder', 'legacy')", name="ck_space_source_type_valid"), + sa.CheckConstraint("status IN ('active', 'disconnected', 'archived')", name="ck_space_status_valid"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_space_source_type"), "space", ["source_type"], unique=False) + op.create_index(op.f("ix_space_status"), "space", ["status"], unique=False) + op.create_index(op.f("ix_space_user_id"), "space", ["user_id"], unique=False) + + op.create_table( + "project", + *_timestamps(), + sa.Column("id", sa.String(), nullable=False), + sa.Column("user_id", sa.String(length=128), nullable=False), + sa.Column("space_id", sa.String(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.String(length=1024), nullable=True), + sa.Column("mode", sa.String(length=50), nullable=True), + sa.Column("status", sa.String(length=50), nullable=False), + sa.Column("workdir_mode", sa.String(length=50), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.ForeignKeyConstraint(["space_id"], ["space.id"]), + sa.CheckConstraint("mode IS NULL OR mode IN ('single-agent', 'workforce')", name="ck_project_mode_valid"), + sa.CheckConstraint("status IN ('active', 'archived')", name="ck_project_status_valid"), + sa.CheckConstraint( + "workdir_mode IS NULL OR workdir_mode IN ('worktree', 'copy', 'direct-write', 'artifact-only')", + name="ck_project_workdir_mode_valid", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "id", name="uix_project_user_id_id"), + ) + op.create_index(op.f("ix_project_mode"), "project", ["mode"], unique=False) + op.create_index(op.f("ix_project_space_id"), "project", ["space_id"], unique=False) + op.create_index(op.f("ix_project_status"), "project", ["status"], unique=False) + op.create_index(op.f("ix_project_user_id"), "project", ["user_id"], unique=False) + op.create_index(op.f("ix_project_workdir_mode"), "project", ["workdir_mode"], unique=False) + + op.create_table( + "space_memory", + *_timestamps(), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.String(length=128), nullable=False), + sa.Column("space_id", sa.String(), nullable=False), + sa.Column("key", sa.String(length=255), nullable=False), + sa.Column("value", sa.JSON(), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.ForeignKeyConstraint(["space_id"], ["space.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("space_id", "key", name="uix_space_memory_space_key"), + ) + op.create_index(op.f("ix_space_memory_key"), "space_memory", ["key"], unique=False) + op.create_index(op.f("ix_space_memory_space_id"), "space_memory", ["space_id"], unique=False) + op.create_index(op.f("ix_space_memory_user_id"), "space_memory", ["user_id"], unique=False) + + op.create_table( + "project_memory", + *_timestamps(), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.String(length=128), nullable=False), + sa.Column("space_id", sa.String(), nullable=False), + sa.Column("project_id", sa.String(), nullable=False), + sa.Column("key", sa.String(length=255), nullable=False), + sa.Column("value", sa.JSON(), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.ForeignKeyConstraint(["project_id"], ["project.id"]), + sa.ForeignKeyConstraint(["space_id"], ["space.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("project_id", "key", name="uix_project_memory_project_key"), + ) + op.create_index(op.f("ix_project_memory_key"), "project_memory", ["key"], unique=False) + op.create_index(op.f("ix_project_memory_project_id"), "project_memory", ["project_id"], unique=False) + op.create_index(op.f("ix_project_memory_space_id"), "project_memory", ["space_id"], unique=False) + op.create_index(op.f("ix_project_memory_user_id"), "project_memory", ["user_id"], unique=False) + + op.create_table( + "space_file_index", + *_timestamps(), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("space_id", sa.String(), nullable=False), + sa.Column("path", sa.String(length=2048), nullable=False), + sa.Column("hash", sa.String(length=128), nullable=True), + sa.Column("size", sa.BigInteger(), nullable=True), + sa.Column("mode", sa.Integer(), nullable=True), + sa.Column("modified_at", sa.DateTime(), nullable=True), + sa.Column("indexed_at", sa.DateTime(), nullable=True), + sa.Column("row_version", sa.BigInteger(), server_default="1", nullable=False), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.ForeignKeyConstraint(["space_id"], ["space.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("space_id", "path", name="uix_space_file_index_space_path"), + ) + op.create_index(op.f("ix_space_file_index_hash"), "space_file_index", ["hash"], unique=False) + op.create_index(op.f("ix_space_file_index_path"), "space_file_index", ["path"], unique=False) + op.create_index(op.f("ix_space_file_index_space_id"), "space_file_index", ["space_id"], unique=False) + + op.create_table( + "space_file_index_overlay", + *_timestamps(), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("space_id", sa.String(), nullable=False), + sa.Column("project_id", sa.String(), nullable=False), + sa.Column("run_id", sa.String(length=128), nullable=False), + sa.Column("path", sa.String(length=2048), nullable=False), + sa.Column("status", sa.String(length=50), nullable=False), + sa.Column("hash", sa.String(length=128), nullable=True), + sa.Column("base_hash", sa.String(length=128), nullable=True), + sa.Column("base_snapshot_id", sa.String(length=128), nullable=True), + sa.Column("size", sa.BigInteger(), nullable=True), + sa.Column("mode", sa.Integer(), nullable=True), + sa.Column("modified_at", sa.DateTime(), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.ForeignKeyConstraint(["project_id"], ["project.id"]), + sa.ForeignKeyConstraint(["space_id"], ["space.id"]), + sa.CheckConstraint("status IN ('added', 'modified', 'deleted')", name="ck_space_file_index_overlay_status_valid"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "space_id", + "project_id", + "run_id", + "path", + name="uix_space_file_index_overlay_run_path", + ), + ) + op.create_index( + op.f("ix_space_file_index_overlay_base_hash"), + "space_file_index_overlay", + ["base_hash"], + unique=False, + ) + op.create_index( + op.f("ix_space_file_index_overlay_base_snapshot_id"), + "space_file_index_overlay", + ["base_snapshot_id"], + unique=False, + ) + op.create_index(op.f("ix_space_file_index_overlay_hash"), "space_file_index_overlay", ["hash"], unique=False) + op.create_index( + op.f("ix_space_file_index_overlay_modified_at"), + "space_file_index_overlay", + ["modified_at"], + unique=False, + ) + op.create_index(op.f("ix_space_file_index_overlay_path"), "space_file_index_overlay", ["path"], unique=False) + op.create_index( + op.f("ix_space_file_index_overlay_project_id"), + "space_file_index_overlay", + ["project_id"], + unique=False, + ) + op.create_index(op.f("ix_space_file_index_overlay_run_id"), "space_file_index_overlay", ["run_id"], unique=False) + op.create_index( + op.f("ix_space_file_index_overlay_space_id"), + "space_file_index_overlay", + ["space_id"], + unique=False, + ) + op.create_index( + op.f("ix_space_file_index_overlay_status"), + "space_file_index_overlay", + ["status"], + unique=False, + ) + + op.create_table( + "user_id_canonicalization", + *_timestamps(), + sa.Column("source", sa.String(length=64), nullable=False), + sa.Column("source_id", sa.String(length=128), nullable=False), + sa.Column("canonical_id", sa.String(length=128), nullable=False), + sa.PrimaryKeyConstraint("source", "source_id"), + ) + op.create_index( + op.f("ix_user_id_canonicalization_canonical_id"), + "user_id_canonicalization", + ["canonical_id"], + unique=False, + ) + + op.add_column("chat_history", sa.Column("space_id", sa.String(), nullable=True)) + op.add_column("chat_history", sa.Column("run_id", sa.String(), nullable=True)) + op.add_column("chat_history", sa.Column("skip_reason", sa.JSON(), nullable=True)) + op.create_index(op.f("ix_chat_history_space_id"), "chat_history", ["space_id"], unique=False) + op.create_index(op.f("ix_chat_history_run_id"), "chat_history", ["run_id"], unique=False) + + op.add_column("chat_step", sa.Column("run_id", sa.String(), nullable=True)) + op.create_index(op.f("ix_chat_step_run_id"), "chat_step", ["run_id"], unique=False) + + op.add_column("chat_snapshot", sa.Column("storage_key", sa.String(), nullable=True)) + op.create_index(op.f("ix_chat_snapshot_storage_key"), "chat_snapshot", ["storage_key"], unique=False) + + op.add_column("trigger", sa.Column("space_id", sa.String(), nullable=True)) + op.create_index(op.f("ix_trigger_space_id"), "trigger", ["space_id"], unique=False) + + op.add_column("trigger_execution", sa.Column("skip_reason", sa.JSON(), nullable=True)) + + op.execute("UPDATE chat_history SET project_id = task_id WHERE project_id IS NULL") + op.execute("UPDATE chat_history SET run_id = task_id WHERE run_id IS NULL") + # chat_history has no FK to Space; seed legacy ids before creating rows so + # later backfill CTEs can use a single shape for old and new records. + op.execute("UPDATE chat_history SET space_id = 'legacy_' || CAST(user_id AS TEXT) WHERE space_id IS NULL") + op.execute( + "UPDATE chat_step SET run_id = task_id WHERE run_id IS NULL" + ) + op.execute( + "UPDATE chat_snapshot SET storage_key = 'local://' || ltrim(image_path, '/') WHERE storage_key IS NULL" + ) + op.execute("UPDATE trigger SET space_id = 'legacy_' || CAST(user_id AS TEXT) WHERE space_id IS NULL") + + op.execute( + """ + INSERT INTO user_id_canonicalization (source, source_id, canonical_id, created_at, updated_at) + SELECT DISTINCT 'chat_history', CAST(user_id AS TEXT), CAST(user_id AS TEXT), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM chat_history + ON CONFLICT (source, source_id) DO NOTHING + """ + ) + op.execute( + """ + INSERT INTO user_id_canonicalization (source, source_id, canonical_id, created_at, updated_at) + SELECT DISTINCT 'trigger', CAST(user_id AS TEXT), CAST(user_id AS TEXT), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM trigger + ON CONFLICT (source, source_id) DO NOTHING + """ + ) + + op.execute( + """ + INSERT INTO space ( + id, user_id, name, description, source_type, status, schema_version, metadata, created_at, updated_at + ) + SELECT + 'legacy_' || canonical_id, + canonical_id, + 'Legacy Space', + 'Projects migrated from the pre-Space app model.', + 'legacy', + 'active', + 1, + json_build_object('legacy', true, 'schemaVersion', 1), + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + FROM ( + SELECT DISTINCT canonical_id FROM user_id_canonicalization + ) users + ON CONFLICT (id) DO NOTHING + """ + ) + + op.execute( + """ + WITH backfill_source AS ( + SELECT + CAST(user_id AS TEXT) AS user_id, + COALESCE(project_id, task_id) AS project_id, + NULLIF(project_name, '') AS project_name, + MAX(updated_at) AS updated_at + FROM chat_history + WHERE COALESCE(project_id, task_id) IS NOT NULL + GROUP BY CAST(user_id AS TEXT), COALESCE(project_id, task_id), NULLIF(project_name, '') + UNION + SELECT + CAST(user_id AS TEXT) AS user_id, + project_id, + NULLIF(name, '') AS project_name, + MAX(updated_at) AS updated_at + FROM trigger + WHERE project_id IS NOT NULL + GROUP BY CAST(user_id AS TEXT), project_id, NULLIF(name, '') + ), + normalized AS ( + SELECT DISTINCT + user_id, + project_id, + COALESCE(project_name, 'Project ' || project_id) AS project_name + FROM backfill_source + ), + collisions AS ( + SELECT project_id, COUNT(DISTINCT user_id) AS user_count + FROM normalized + GROUP BY project_id + ), + resolved AS ( + SELECT + CASE + WHEN collisions.user_count > 1 THEN normalized.user_id || ':' || normalized.project_id + ELSE normalized.project_id + END AS resolved_project_id, + normalized.user_id, + normalized.project_id AS legacy_project_id, + normalized.project_name, + collisions.user_count + FROM normalized + JOIN collisions ON collisions.project_id = normalized.project_id + ) + INSERT INTO project ( + id, user_id, space_id, name, status, workdir_mode, metadata, created_at, updated_at + ) + SELECT + resolved_project_id, + user_id, + 'legacy_' || user_id, + project_name, + 'active', + 'artifact-only', + CASE + WHEN user_count > 1 THEN json_build_object('legacy', true, 'legacyAlias', legacy_project_id) + ELSE json_build_object('legacy', true) + END, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + FROM resolved + ON CONFLICT (id) DO NOTHING + """ + ) + + op.execute( + """ + WITH collision_aliases AS ( + SELECT DISTINCT metadata ->> 'legacyAlias' AS project_id + FROM project + WHERE metadata ->> 'legacyAlias' IS NOT NULL + ) + UPDATE chat_history + SET project_id = CAST(chat_history.user_id AS TEXT) || ':' || COALESCE(chat_history.project_id, chat_history.task_id) + FROM collision_aliases + WHERE collision_aliases.project_id = COALESCE(chat_history.project_id, chat_history.task_id) + """ + ) + op.execute( + """ + WITH collision_aliases AS ( + SELECT DISTINCT metadata ->> 'legacyAlias' AS project_id + FROM project + WHERE metadata ->> 'legacyAlias' IS NOT NULL + ) + UPDATE trigger + SET project_id = CAST(trigger.user_id AS TEXT) || ':' || trigger.project_id + FROM collision_aliases + WHERE collision_aliases.project_id = trigger.project_id + """ + ) + + +def downgrade() -> None: + op.drop_column("trigger_execution", "skip_reason") + op.drop_index(op.f("ix_trigger_space_id"), table_name="trigger") + op.drop_column("trigger", "space_id") + op.drop_index(op.f("ix_chat_snapshot_storage_key"), table_name="chat_snapshot") + op.drop_column("chat_snapshot", "storage_key") + op.drop_index(op.f("ix_chat_step_run_id"), table_name="chat_step") + op.drop_column("chat_step", "run_id") + op.drop_index(op.f("ix_chat_history_run_id"), table_name="chat_history") + op.drop_index(op.f("ix_chat_history_space_id"), table_name="chat_history") + op.drop_column("chat_history", "skip_reason") + op.drop_column("chat_history", "run_id") + op.drop_column("chat_history", "space_id") + + op.drop_index(op.f("ix_user_id_canonicalization_canonical_id"), table_name="user_id_canonicalization") + op.drop_table("user_id_canonicalization") + + op.drop_index(op.f("ix_space_file_index_overlay_status"), table_name="space_file_index_overlay") + op.drop_index(op.f("ix_space_file_index_overlay_space_id"), table_name="space_file_index_overlay") + op.drop_index(op.f("ix_space_file_index_overlay_run_id"), table_name="space_file_index_overlay") + op.drop_index(op.f("ix_space_file_index_overlay_project_id"), table_name="space_file_index_overlay") + op.drop_index(op.f("ix_space_file_index_overlay_path"), table_name="space_file_index_overlay") + op.drop_index(op.f("ix_space_file_index_overlay_modified_at"), table_name="space_file_index_overlay") + op.drop_index(op.f("ix_space_file_index_overlay_hash"), table_name="space_file_index_overlay") + op.drop_index(op.f("ix_space_file_index_overlay_base_snapshot_id"), table_name="space_file_index_overlay") + op.drop_index(op.f("ix_space_file_index_overlay_base_hash"), table_name="space_file_index_overlay") + op.drop_table("space_file_index_overlay") + + op.drop_index(op.f("ix_space_file_index_space_id"), table_name="space_file_index") + op.drop_index(op.f("ix_space_file_index_path"), table_name="space_file_index") + op.drop_index(op.f("ix_space_file_index_hash"), table_name="space_file_index") + op.drop_table("space_file_index") + + op.drop_index(op.f("ix_project_memory_user_id"), table_name="project_memory") + op.drop_index(op.f("ix_project_memory_space_id"), table_name="project_memory") + op.drop_index(op.f("ix_project_memory_project_id"), table_name="project_memory") + op.drop_index(op.f("ix_project_memory_key"), table_name="project_memory") + op.drop_table("project_memory") + + op.drop_index(op.f("ix_space_memory_user_id"), table_name="space_memory") + op.drop_index(op.f("ix_space_memory_space_id"), table_name="space_memory") + op.drop_index(op.f("ix_space_memory_key"), table_name="space_memory") + op.drop_table("space_memory") + + op.drop_index(op.f("ix_project_workdir_mode"), table_name="project") + op.drop_index(op.f("ix_project_user_id"), table_name="project") + op.drop_index(op.f("ix_project_status"), table_name="project") + op.drop_index(op.f("ix_project_space_id"), table_name="project") + op.drop_index(op.f("ix_project_mode"), table_name="project") + op.drop_table("project") + + op.drop_index(op.f("ix_space_user_id"), table_name="space") + op.drop_index(op.f("ix_space_status"), table_name="space") + op.drop_index(op.f("ix_space_source_type"), table_name="space") + op.drop_table("space") diff --git a/server/alembic/versions/2026_06_01_1800-add_remote_control_space_scope.py b/server/alembic/versions/2026_06_01_1800-add_remote_control_space_scope.py new file mode 100644 index 000000000..543ebd657 --- /dev/null +++ b/server/alembic/versions/2026_06_01_1800-add_remote_control_space_scope.py @@ -0,0 +1,65 @@ +# ========= 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. ========= +"""add remote control space scope + +Revision ID: add_rc_space_scope +Revises: add_rc_desktop_targets +Create Date: 2026-06-01 18:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "add_rc_space_scope" +down_revision: str | None = "add_rc_desktop_targets" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("remote_control_session", sa.Column("space_id", sa.String(length=128), nullable=True)) + op.add_column("remote_control_session", sa.Column("space_name_snapshot", sa.String(length=255), nullable=True)) + op.add_column("remote_control_session", sa.Column("last_target_project_id", sa.String(length=128), nullable=True)) + op.add_column("remote_control_session", sa.Column("last_target_task_id", sa.String(length=128), nullable=True)) + op.add_column("remote_control_session", sa.Column("last_target_history_id", sa.String(length=128), nullable=True)) + op.add_column("remote_control_session", sa.Column("last_target_brain_session_id", sa.String(length=128), nullable=True)) + op.create_index(op.f("ix_remote_control_session_space_id"), "remote_control_session", ["space_id"], unique=False) + op.create_index( + op.f("ix_remote_control_session_last_target_project_id"), + "remote_control_session", + ["last_target_project_id"], + unique=False, + ) + + op.add_column("remote_control_command", sa.Column("space_id", sa.String(length=128), nullable=True)) + op.create_index(op.f("ix_remote_control_command_space_id"), "remote_control_command", ["space_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_remote_control_command_space_id"), table_name="remote_control_command") + op.drop_column("remote_control_command", "space_id") + + op.drop_index(op.f("ix_remote_control_session_last_target_project_id"), table_name="remote_control_session") + op.drop_index(op.f("ix_remote_control_session_space_id"), table_name="remote_control_session") + op.drop_column("remote_control_session", "last_target_brain_session_id") + op.drop_column("remote_control_session", "last_target_history_id") + op.drop_column("remote_control_session", "last_target_task_id") + op.drop_column("remote_control_session", "last_target_project_id") + op.drop_column("remote_control_session", "space_name_snapshot") + op.drop_column("remote_control_session", "space_id") diff --git a/server/app/domains/chat/api/history_controller.py b/server/app/domains/chat/api/history_controller.py index f2e0c0530..d717af24f 100644 --- a/server/app/domains/chat/api/history_controller.py +++ b/server/app/domains/chat/api/history_controller.py @@ -14,17 +14,21 @@ """Chat History controller. Uses ChatService for grouping.""" +from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi_pagination import Page from fastapi_pagination.ext.sqlmodel import paginate from loguru import logger +from sqlalchemy.exc import IntegrityError from sqlmodel import Session, case, delete, desc, func, select from fastapi_babel import _ from app.core.database import session +from app.domains.space.service import SpaceService from app.model.chat.chat_history import ChatHistory, ChatHistoryIn, ChatHistoryOut, ChatHistoryUpdate, ChatStatus +from app.model.project import Project from app.model.chat.chat_history_grouped import GroupedHistoryResponse, ProjectGroup from app.model.trigger.trigger import Trigger from app.model.trigger.trigger_execution import TriggerExecution @@ -36,12 +40,58 @@ router = APIRouter(prefix="/chat", tags=["Chat History"]) +def _sync_project_display_name( + db_session: Session, + *, + user_id: int | str, + project_id: str | None, + project_name: str | None, +) -> None: + name = (project_name or "").strip() + if not project_id or not name: + return + canonical_user_id = SpaceService.canonical_user_id(user_id) + project = db_session.exec( + select(Project).where( + Project.id == project_id, + Project.user_id == canonical_user_id, + ) + ).first() + if not project: + return + project.name = name[:255] + project.updated_at = datetime.now() + db_session.add(project) + + @router.post("/history", name="save chat history", response_model=ChatHistoryOut) def create_chat_history(data: ChatHistoryIn, db_session: Session = Depends(session), auth: V1UserAuth = Depends(auth_must)): data.user_id = auth.id - chat_history = ChatHistory(**data.model_dump()) + data.project_id = data.project_id or data.task_id + data.run_id = data.run_id or data.task_id + data.space_id = data.space_id or SpaceService.legacy_space_id(auth.id) + project_display_name = (data.project_name or data.question or "").strip() + try: + SpaceService.ensure_project( + auth.id, + data.project_id, + data.space_id, + project_display_name, + db_session, + mode=data.mode, + workdir_mode=data.workdir_mode, + metadata={"createdFrom": "chat_history"}, + commit=False, + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + chat_history = ChatHistory(**data.model_dump(exclude={"workdir_mode", "mode"})) db_session.add(chat_history) - db_session.commit() + try: + db_session.commit() + except IntegrityError as exc: + db_session.rollback() + raise HTTPException(status_code=409, detail="Chat history already exists") from exc db_session.refresh(chat_history) return chat_history @@ -63,10 +113,11 @@ def list_chat_history(db_session: Session = Depends(session), auth: V1UserAuth = @router.get("/histories/grouped", name="get grouped chat history") def list_grouped_chat_history( include_tasks: Optional[bool] = Query(True, description="Whether to include individual tasks in groups"), + space_id: Optional[str] = Query(None, description="Optional Space ID filter"), db_session: Session = Depends(session), auth: V1UserAuth = Depends(auth_must), ) -> GroupedHistoryResponse: - return ChatService.get_grouped_histories(auth.id, include_tasks, db_session) + return ChatService.get_grouped_histories(auth.id, include_tasks, db_session, space_id) @router.get("/histories/grouped/{project_id}", name="get single grouped project") @@ -129,6 +180,13 @@ async def update_chat_history( update_data = data.model_dump(exclude_unset=True) history.update_fields(update_data) + if "project_name" in update_data: + _sync_project_display_name( + db_session, + user_id=auth.id, + project_id=history.project_id or history.task_id, + project_name=history.project_name, + ) history.save(db_session) db_session.refresh(history) @@ -150,6 +208,12 @@ def update_project_name( for history in histories: history.project_name = new_name db_session.add(history) + _sync_project_display_name( + db_session, + user_id=user_id, + project_id=project_id, + project_name=new_name, + ) db_session.commit() return Response(status_code=200) except Exception as e: 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/snapshot_controller.py b/server/app/domains/chat/api/snapshot_controller.py index a4900ab60..a251b59b1 100644 --- a/server/app/domains/chat/api/snapshot_controller.py +++ b/server/app/domains/chat/api/snapshot_controller.py @@ -21,10 +21,11 @@ from fastapi import APIRouter, Depends, HTTPException, Response from fastapi_babel import _ +from sqlalchemy import or_ from sqlmodel import Session, select from app.core.database import session -from app.model.chat.chat_snpshot import ChatSnapshot, ChatSnapshotIn, ChatSnapshotUpdate +from app.model.chat.chat_snpshot import ChatSnapshot, ChatSnapshotIn, ChatSnapshotOut, ChatSnapshotUpdate from app.shared.auth import auth_must from app.shared.auth.ownership import require_owner @@ -33,6 +34,7 @@ # H19: api_task_id must be safe for path - only alphanumeric, dash, underscore API_TASK_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,128}$") +SNAPSHOT_COMPONENT_PATTERN = re.compile(r"^[a-zA-Z0-9_.:-]{1,200}$") def _validate_api_task_id(value: str) -> None: @@ -40,25 +42,79 @@ def _validate_api_task_id(value: str) -> None: if not value or not API_TASK_ID_PATTERN.match(value): raise HTTPException(status_code=400, detail=_("Invalid api_task_id: only letters, numbers, - and _ allowed")) -@router.get("/snapshots", name="list chat snapshots", response_model=List[ChatSnapshot]) + +def _validate_snapshot_component(value: str | None, field_name: str) -> None: + if value is not None and (value in {".", ".."} or not SNAPSHOT_COMPONENT_PATTERN.match(value)): + raise HTTPException(status_code=400, detail=_(f"Invalid {field_name}: unsafe snapshot path component")) + + +def _snapshot_storage_key(user_id: int, snapshot: ChatSnapshotIn, image_path: str) -> str: + if snapshot.storage_key: + return snapshot.storage_key + filename = image_path.rsplit("/", 1)[-1] + if snapshot.space_id and snapshot.project_id: + run_id = snapshot.run_id or snapshot.api_task_id + return ( + f"local://{user_id}/spaces/{snapshot.space_id}/projects/" + f"{snapshot.project_id}/runs/{run_id}/snapshot/{filename}" + ) + return f"local://{image_path.lstrip('/')}" + + +def _snapshot_image_url(snapshot: ChatSnapshot) -> str: + if snapshot.storage_key and snapshot.storage_key.startswith("local://"): + key = snapshot.storage_key.removeprefix("local://").lstrip("/") + if key.startswith("public/upload/"): + return f"/{key}" + match = re.match( + r"^[^/]+/spaces/([^/]+)/projects/([^/]+)/runs/([^/]+)/snapshot/([^/]+)$", + key, + ) + if match: + space_id, project_id, run_id, filename = match.groups() + return f"/public/upload/v2/{space_id}/{project_id}/{run_id}/{filename}" + # Unknown schemes such as s3://, or no storage_key at all, keep returning + # the legacy renderable image_path until a scheme-aware URL resolver lands. + return snapshot.image_path + + +def _snapshot_out(snapshot: ChatSnapshot) -> ChatSnapshotOut: + data = snapshot.model_dump() + data["image_url"] = _snapshot_image_url(snapshot) + return ChatSnapshotOut(**data) + + +@router.get("/snapshots", name="list chat snapshots", response_model=List[ChatSnapshotOut]) async def list_chat_snapshots( api_task_id: Optional[str] = None, + run_id: Optional[str] = None, camel_task_id: Optional[str] = None, browser_url: Optional[str] = None, db_session: Session = Depends(session), auth=Depends(auth_must), ): + if run_id is not None: + _validate_api_task_id(run_id) query = select(ChatSnapshot).where(ChatSnapshot.user_id == auth.user.id) if api_task_id is not None: + _validate_api_task_id(api_task_id) query = query.where(ChatSnapshot.api_task_id == api_task_id) + if run_id is not None: + _validate_api_task_id(run_id) + query = query.where( + or_( + ChatSnapshot.api_task_id == run_id, + ChatSnapshot.storage_key.like(f"%/runs/{run_id}/snapshot/%"), + ) + ) if camel_task_id is not None: query = query.where(ChatSnapshot.camel_task_id == camel_task_id) if browser_url is not None: query = query.where(ChatSnapshot.browser_url == browser_url) - return list(db_session.exec(query).all()) + return [_snapshot_out(snapshot) for snapshot in db_session.exec(query).all()] -@router.get("/snapshots/{snapshot_id}", name="get chat snapshot", response_model=ChatSnapshot) +@router.get("/snapshots/{snapshot_id}", name="get chat snapshot", response_model=ChatSnapshotOut) async def get_chat_snapshot( snapshot_id: int, db_session: Session = Depends(session), @@ -66,31 +122,45 @@ async def get_chat_snapshot( ): snapshot = db_session.get(ChatSnapshot, snapshot_id) require_owner(snapshot, auth.user.id) - return snapshot + return _snapshot_out(snapshot) -@router.post("/snapshots", name="create chat snapshot", response_model=ChatSnapshot) +@router.post("/snapshots", name="create chat snapshot", response_model=ChatSnapshotOut) async def create_chat_snapshot( snapshot: ChatSnapshotIn, db_session: Session = Depends(session), auth=Depends(auth_must), ): _validate_api_task_id(snapshot.api_task_id) - image_path = ChatSnapshotIn.save_image(auth.user.id, snapshot.api_task_id, snapshot.image_base64) + _validate_snapshot_component(snapshot.space_id, "space_id") + _validate_snapshot_component(snapshot.project_id, "project_id") + _validate_snapshot_component(snapshot.run_id, "run_id") + try: + image_path = ChatSnapshotIn.save_image( + auth.user.id, + snapshot.api_task_id, + snapshot.image_base64, + space_id=snapshot.space_id, + project_id=snapshot.project_id, + run_id=snapshot.run_id, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc chat_snapshot = ChatSnapshot( user_id=auth.user.id, api_task_id=snapshot.api_task_id, camel_task_id=snapshot.camel_task_id, browser_url=snapshot.browser_url, image_path=image_path, + storage_key=_snapshot_storage_key(auth.user.id, snapshot, image_path), ) db_session.add(chat_snapshot) db_session.commit() db_session.refresh(chat_snapshot) - return chat_snapshot + return _snapshot_out(chat_snapshot) -@router.put("/snapshots/{snapshot_id}", name="update chat snapshot", response_model=ChatSnapshot) +@router.put("/snapshots/{snapshot_id}", name="update chat snapshot", response_model=ChatSnapshotOut) async def update_chat_snapshot( snapshot_id: int, snapshot_update: ChatSnapshotUpdate, @@ -106,7 +176,7 @@ async def update_chat_snapshot( db_session.add(db_snapshot) db_session.commit() db_session.refresh(db_snapshot) - return db_snapshot + return _snapshot_out(db_snapshot) @router.delete("/snapshots/{snapshot_id}", name="delete chat snapshot") diff --git a/server/app/domains/chat/api/step_controller.py b/server/app/domains/chat/api/step_controller.py index d14c84ad0..b7f2341d4 100644 --- a/server/app/domains/chat/api/step_controller.py +++ b/server/app/domains/chat/api/step_controller.py @@ -22,8 +22,10 @@ from fastapi import APIRouter, Depends, HTTPException, Response from fastapi.responses import StreamingResponse +from loguru import logger +from sqlalchemy import or_ from sqlalchemy.sql.expression import case -from sqlmodel import Session, asc, select +from sqlmodel import Session, asc, desc, select from app.core.database import session from app.model.chat.chat_step import ChatStep, ChatStepOut, ChatStepIn, ChatStepUpdate @@ -32,6 +34,7 @@ from app.shared.auth import auth_must from app.domains.chat.service import ChatService from app.domains.chat.schema import TaskOwnershipCheckReq +from app.domains.remote_control.service.remote_control_service import RemoteControlService router = APIRouter(prefix="/chat", tags=["V1 Chat Step"]) @@ -40,9 +43,65 @@ def _task_owned_by_user(db: Session, task_id: str, user_id: int) -> bool: return ChatService.verify_task_ownership(TaskOwnershipCheckReq(task_id=task_id, user_id=user_id)) +def _history_for_run(db: Session, run_id: str, user_id: int) -> ChatHistory | None: + return db.exec( + select(ChatHistory) + .where(ChatHistory.user_id == user_id) + .where((ChatHistory.run_id == run_id) | (ChatHistory.task_id == run_id)) + .order_by( + desc(case((ChatHistory.run_id == run_id, 1), else_=0)), + desc(ChatHistory.created_at), + desc(ChatHistory.id), + ) + ).first() + + +def _steps_for_run_stmt(run_id: str, task_id: str): + return select(ChatStep).where(ChatStep.task_id == task_id).where( + or_( + ChatStep.run_id == run_id, + ChatStep.run_id.is_(None), + ChatStep.run_id == task_id, + ) + ) + + +def _ordered_steps_stmt(stmt): + return stmt.order_by( + asc(case((ChatStep.timestamp.is_(None), 1), else_=0)), + asc(ChatStep.timestamp), + asc(ChatStep.id), + ) + + +async def _playback_event_generator( + steps: list[ChatStep], + *, + delay_time: float, + empty_message: str, +): + if not steps: + yield f"data: {json.dumps({'error': empty_message})}\n\n" + return + for s in steps: + step_data = { + "id": s.id, + "task_id": s.task_id, + "run_id": s.run_id or 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" + if delay_time > 0: + await asyncio.sleep(delay_time) + + @router.get("/steps", name="list chat steps", response_model=List[ChatStepOut]) async def list_chat_steps( task_id: str, + run_id: Optional[str] = None, step: Optional[str] = None, db_session: Session = Depends(session), auth=Depends(auth_must), @@ -50,6 +109,24 @@ async def list_chat_steps( if not _task_owned_by_user(db_session, task_id, auth.user.id): return [] query = select(ChatStep).where(ChatStep.task_id == task_id) + if run_id is not None: + query = query.where((ChatStep.run_id == run_id) | ChatStep.run_id.is_(None)) + if step is not None: + query = query.where(ChatStep.step == step) + return list(db_session.exec(query).all()) + + +@router.get("/runs/{run_id}/steps", name="list chat steps by run", response_model=List[ChatStepOut]) +async def list_chat_steps_by_run( + run_id: str, + step: Optional[str] = None, + db_session: Session = Depends(session), + auth=Depends(auth_must), +): + history = _history_for_run(db_session, run_id, auth.user.id) + if history is None: + return [] + query = _steps_for_run_stmt(run_id, history.task_id) if step is not None: query = query.where(ChatStep.step == step) return list(db_session.exec(query).all()) @@ -68,26 +145,40 @@ async def share_playback( raise HTTPException(status_code=404, detail="Task not found") async def event_generator(): - stmt = select(ChatStep).where(ChatStep.task_id == task_id).order_by( - asc(case((ChatStep.timestamp.is_(None), 1), else_=0)), - asc(ChatStep.timestamp), - asc(ChatStep.id), - ) + stmt = _ordered_steps_stmt(select(ChatStep).where(ChatStep.task_id == task_id)) steps = db_session.exec(stmt).all() - if not steps: - yield f"data: {json.dumps({'error': 'No steps found for this task.'})}\n\n" - return - for s in steps: - step_data = { - "id": s.id, - "task_id": s.task_id, - "step": s.step, - "data": s.data, - "created_at": s.created_at.isoformat() if s.created_at else None, - } - yield f"data: {json.dumps(step_data)}\n\n" - if delay_time > 0: - await asyncio.sleep(delay_time) + async for event in _playback_event_generator( + list(steps), + delay_time=delay_time, + empty_message="No steps found for this task.", + ): + yield event + + return StreamingResponse(event_generator(), media_type="text/event-stream") + + +@router.get("/runs/{run_id}/steps/playback", name="Playback Chat Run Step via SSE") +async def run_playback( + run_id: str, + delay_time: float = 0, + db_session: Session = Depends(session), + auth=Depends(auth_must), +): + if delay_time > 5: + delay_time = 5 + history = _history_for_run(db_session, run_id, auth.user.id) + if history is None: + raise HTTPException(status_code=404, detail="Run not found") + + async def event_generator(): + stmt = _ordered_steps_stmt(_steps_for_run_stmt(run_id, history.task_id)) + steps = db_session.exec(stmt).all() + async for event in _playback_event_generator( + list(steps), + delay_time=delay_time, + empty_message="No steps found for this run.", + ): + yield event return StreamingResponse(event_generator(), media_type="text/event-stream") @@ -116,6 +207,7 @@ async def create_chat_step( raise HTTPException(status_code=403, detail="Task not found or access denied") chat_step = ChatStep( task_id=step.task_id, + run_id=step.run_id or step.task_id, step=step.step, data=step.data, timestamp=step.timestamp, @@ -123,6 +215,13 @@ async def create_chat_step( db_session.add(chat_step) db_session.commit() db_session.refresh(chat_step) + try: + RemoteControlService.publish_chat_step(chat_step, db_session) + except Exception as exc: + logger.warning( + "Remote-control step publish failed", + extra={"task_id": chat_step.task_id, "error": str(exc)}, + ) return {"code": 200, "msg": "success"} diff --git a/server/app/domains/chat/service/chat_service.py b/server/app/domains/chat/service/chat_service.py index e16c1819c..991821f03 100644 --- a/server/app/domains/chat/service/chat_service.py +++ b/server/app/domains/chat/service/chat_service.py @@ -28,11 +28,23 @@ from app.domains.chat.schema import TaskOwnershipCheckReq, FileValidationReq, FileValidationResult ALLOWED_EXTENSIONS = { - "jpg", "jpeg", "png", "gif", "webp", - "pdf", "txt", "md", "csv", - "json", "xml", "yaml", "yml", - "doc", "docx", "xls", "xlsx", - "zip", + # Images + "jpg", "jpeg", "png", "gif", "webp", "svg", "ico", + # Documents + "pdf", "txt", "md", "csv", "doc", "docx", "xls", "xlsx", + # Data + "json", "xml", "yaml", "yml", "toml", + # Web + "html", "htm", "css", "js", "jsx", "ts", "tsx", "vue", "svelte", + # Code + "py", "rb", "go", "rs", "java", "c", "cpp", "h", "hpp", "cs", + "sh", "bash", "zsh", "bat", "ps1", + "sql", "graphql", "proto", + # Config + "env", "ini", "cfg", "conf", "lock", + "dockerfile", "dockerignore", "gitignore", + # Archive + "zip", "tar", "gz", } MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB @@ -42,7 +54,7 @@ class ChatService: @staticmethod def verify_task_ownership(req: TaskOwnershipCheckReq) -> bool: - """Check if task_id belongs to user_id.""" + """Check if task_id belongs to user_id. Replaces _task_owned_by_user.""" with session_make() as s: h = s.exec( select(ChatHistory) @@ -138,6 +150,7 @@ def _build_project_data( project_map: Dict[str, Dict] = defaultdict( lambda: { "project_id": "", + "space_id": None, "project_name": None, "total_tokens": 0, "task_count": 0, @@ -157,6 +170,7 @@ def _build_project_data( if not project_data["project_id"]: project_data["project_id"] = project_id + project_data["space_id"] = history.space_id project_data["project_name"] = history.project_name or f"Project {project_id}" project_data["latest_task_date"] = history.created_at.isoformat() if history.created_at else "" project_data["last_prompt"] = history.question @@ -191,7 +205,12 @@ def _build_project_data( return projects @staticmethod - def get_grouped_histories(user_id: int, include_tasks: bool, s: Session) -> GroupedHistoryResponse: + def get_grouped_histories( + user_id: int, + include_tasks: bool, + s: Session, + space_id: str | None = None, + ) -> GroupedHistoryResponse: """Get all chat histories grouped by project for a user.""" stmt = ( select(ChatHistory) @@ -202,6 +221,8 @@ def get_grouped_histories(user_id: int, include_tasks: bool, s: Session) -> Grou desc(ChatHistory.id), ) ) + if space_id: + stmt = stmt.where(ChatHistory.space_id == space_id) histories = s.exec(stmt).all() trigger_count_stmt = ( diff --git a/server/app/domains/model_provider/service/provider_service.py b/server/app/domains/model_provider/service/provider_service.py index 599d3f728..cfab5186f 100644 --- a/server/app/domains/model_provider/service/provider_service.py +++ b/server/app/domains/model_provider/service/provider_service.py @@ -65,10 +65,21 @@ def update(provider_id: int, user_id: int, data: dict) -> dict: ).first() if not model: return {"success": False, "error_code": "PROVIDER_NOT_FOUND"} - # H10: only allow updating safe fields - _UPDATABLE_FIELDS = {"provider_name", "api_key", "api_base", "extra_config", "prefer", "is_vaild"} + # Only allow updating provider fields owned by this CRUD surface. + _UPDATABLE_FIELDS = { + "provider_name", + "api_key", + "endpoint_url", + "encrypted_config", + "model_type", + "prefer", + "is_valid", + "is_vaild", + } for key, value in data.items(): if key in _UPDATABLE_FIELDS: + if key == "is_vaild": + key = "is_valid" setattr(model, key, value) model.save(s) s.refresh(model) @@ -95,7 +106,7 @@ def invalidate(provider_id: int, user_id: int) -> dict: ).first() if not model: return {"success": False, "error_code": "PROVIDER_NOT_FOUND"} - model.is_vaild = VaildStatus.not_valid + model.is_valid = VaildStatus.not_valid model.save(s) s.refresh(model) logger.info("Provider invalidated", extra={"user_id": user_id, "provider_id": provider_id}) diff --git a/server/app/domains/remote_control/__init__.py b/server/app/domains/remote_control/__init__.py new file mode 100644 index 000000000..fa7455a0c --- /dev/null +++ b/server/app/domains/remote_control/__init__.py @@ -0,0 +1,13 @@ +# ========= 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. ========= diff --git a/server/app/domains/remote_control/api/__init__.py b/server/app/domains/remote_control/api/__init__.py new file mode 100644 index 000000000..fa7455a0c --- /dev/null +++ b/server/app/domains/remote_control/api/__init__.py @@ -0,0 +1,13 @@ +# ========= 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. ========= diff --git a/server/app/domains/remote_control/api/remote_control_controller.py b/server/app/domains/remote_control/api/remote_control_controller.py new file mode 100644 index 000000000..715debd0d --- /dev/null +++ b/server/app/domains/remote_control/api/remote_control_controller.py @@ -0,0 +1,1403 @@ +# ========= 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 asyncio +import hashlib +import ipaddress +import json +import os +import secrets +from datetime import datetime, timedelta +from functools import lru_cache +from typing import Any + +import redis +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, WebSocket, WebSocketDisconnect +from loguru import logger +from sqlmodel import Session, select + +from app.core.database import session, session_make +from app.core.environment import env +from app.core.redis_utils import get_redis_manager +from app.domains.remote_control.schema import ( + RemoteControlCommandIn, + RemoteControlCommandOut, + RemoteControlCreateProjectIn, + RemoteControlCreateSessionIn, + RemoteControlCreateSessionOut, + RemoteControlExtendIn, + RemoteControlExtendOut, + RemoteControlFolderApplyIn, + RemoteControlFolderDiscardIn, + RemoteControlFolderOperationOut, + RemoteControlFolderRefreshIn, + RemoteControlPatchTargetIn, + RemoteControlPatchTargetOut, + RemoteControlProjectListOut, + RemoteControlSessionOut, + RemoteControlStepsOut, +) +from app.model.project.project import ProjectOut +from app.model.space.apply import SpaceOverlayListResponse +from app.domains.remote_control.service.remote_control_service import ( + COMMAND_ACKNOWLEDGED, + COMMAND_FAILED, + COMMAND_PENDING, + RemoteControlRedis, + RemoteControlService, +) +from app.model.remote_control import RemoteControlCommand, RemoteControlSession +from app.model.user.user import User +from app.shared.auth import auth_must +from app.shared.auth.token_blacklist import BLACKLIST_PUBSUB_PREFIX, is_blacklisted +from app.shared.auth.user_auth import V1UserAuth, _get_jti +from app.shared.middleware.rate_limit import rate_limiter_factory +from app.shared.middleware.origins import ( + configured_remote_origins, + csv_values, + is_local_dev_origin, + truthy, +) + +router = APIRouter(prefix="/remote-control", tags=["Remote Control"]) + + +def _env_int(name: str, default: int, *, min_value: int = 1) -> int: + raw_value = os.getenv(name) + if raw_value is None or raw_value == "": + return default + try: + return max(min_value, int(raw_value)) + except ValueError: + logger.warning( + "Invalid integer environment value; using default", + extra={"name": name, "value": raw_value, "default": default}, + ) + return default + + +bridge_websockets: dict[str, WebSocket] = {} +bridge_users: dict[str, int] = {} +bridge_token_jtis: dict[str, str] = {} +remote_websockets: dict[str, set[WebSocket]] = {} +remote_projects: dict[int, str] = {} + +_pubsub_task: asyncio.Task | None = None +_scanner_task: asyncio.Task | None = None +_last_bridge_ttl_scan_at: datetime | None = None +WS_SUBSCRIBE_TIMEOUT_SECONDS = 5 +PUBSUB_RECONNECT_BASE_SECONDS = 1 +PUBSUB_RECONNECT_MAX_SECONDS = 30 +BRIDGE_BLACKLIST_CHECK_INTERVAL_SECONDS = _env_int( + "REMOTE_CONTROL_BRIDGE_BLACKLIST_CHECK_INTERVAL_SECONDS", + 60, + min_value=10, +) +WS_RECONNECT_RATE_LIMIT = _env_int("REMOTE_CONTROL_WS_RECONNECT_LIMIT", 30) +WS_RECONNECT_RATE_WINDOW_SECONDS = _env_int("REMOTE_CONTROL_WS_RECONNECT_WINDOW_SECONDS", 60) +SESSION_CREATE_RATE_LIMIT = _env_int("REMOTE_CONTROL_SESSION_CREATE_LIMIT", 120) +SESSION_CREATE_RATE_WINDOW_SECONDS = _env_int("REMOTE_CONTROL_SESSION_CREATE_WINDOW_SECONDS", 3600) +COMMAND_BURST_RATE_LIMIT = _env_int("REMOTE_CONTROL_COMMAND_BURST_LIMIT", 10) +COMMAND_BURST_RATE_WINDOW_SECONDS = _env_int("REMOTE_CONTROL_COMMAND_BURST_WINDOW_SECONDS", 1) +COMMAND_MINUTE_RATE_LIMIT = _env_int("REMOTE_CONTROL_COMMAND_MINUTE_LIMIT", 120) +COMMAND_MINUTE_RATE_WINDOW_SECONDS = _env_int("REMOTE_CONTROL_COMMAND_MINUTE_WINDOW_SECONDS", 60) +STEPS_RATE_LIMIT = _env_int("REMOTE_CONTROL_STEPS_LIMIT", 120) +STEPS_RATE_WINDOW_SECONDS = _env_int("REMOTE_CONTROL_STEPS_WINDOW_SECONDS", 60) +AUDIT_HASH_HEX_PREFIX_LEN = 16 +DEFAULT_TRUSTED_PROXY_HOSTS = ("127.0.0.1", "::1", "localhost") +RATE_LIMIT_HIT_LUA = """ +local current = redis.call('INCR', KEYS[1]) +if current == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) +end +return current +""" + + +def _is_debug_mode() -> bool: + return str(env("debug", "")).lower() == "on" + + +def _allow_unsafe_origins() -> bool: + return truthy(os.getenv("REMOTE_CONTROL_ALLOW_UNSAFE_ORIGINS")) + + +def _strip_bearer(token: str | None) -> str | None: + if not token: + return None + if token.lower().startswith("bearer "): + return token[7:].strip() + return token + + +def _reject_query_token_in_prod() -> bool: + value = str(os.getenv("REMOTE_CONTROL_REJECT_QUERY_TOKEN", "")).lower() + return value in {"1", "true", "yes", "on"} + + +def _remote_link_token(query_token: str | None, header_token: str | None) -> str | None: + if header_token: + return header_token + if query_token and _reject_query_token_in_prod(): + raise HTTPException( + status_code=400, + detail="Remote control link token must be sent via the X-Remote-Control-Token header", + ) + return query_token + + +@lru_cache(maxsize=1) +def _trusted_proxy_entries() -> tuple[Any, ...]: + explicit = csv_values(os.getenv("REMOTE_CONTROL_TRUSTED_PROXY_HOSTS")) + values = explicit or list(DEFAULT_TRUSTED_PROXY_HOSTS) + entries: list[Any] = [] + for value in values: + try: + entries.append(ipaddress.ip_network(value, strict=False)) + except ValueError: + entries.append(value) + return tuple(entries) + + +def _matches_trusted_proxy(host: str | None, trusted: Any) -> bool: + if not host: + return False + if isinstance(trusted, str): + return host == trusted + try: + return ipaddress.ip_address(host) in trusted + except ValueError: + return False + + +def _is_trusted_proxy_host(host: str | None) -> bool: + return any(_matches_trusted_proxy(host, trusted) for trusted in _trusted_proxy_entries()) + + +def _trusted_proxy_hosts() -> list[str]: + hosts: list[str] = [] + for entry in _trusted_proxy_entries(): + if isinstance(entry, str): + hosts.append(entry) + else: + hosts.append(str(entry)) + return hosts + + +def _first_forwarded_for(value: str | None) -> str | None: + if not value: + return None + return next((part.strip() for part in value.split(",") if part.strip()), None) + + +def _client_host_from_headers(headers: Any, peer_host: str | None) -> str: + if _is_trusted_proxy_host(peer_host): + forwarded_host = _first_forwarded_for(headers.get("x-forwarded-for")) + if forwarded_host: + return forwarded_host + real_ip = (headers.get("x-real-ip") or "").strip() + if real_ip: + return real_ip + return peer_host or "unknown" + + +def _request_client_host(request: Request) -> str: + peer_host = request.client.host if request.client else None + return _client_host_from_headers(request.headers, peer_host) + + +def _websocket_client_host(websocket: WebSocket) -> str: + peer_host = websocket.client.host if websocket.client else None + return _client_host_from_headers(websocket.headers, peer_host) + + +async def _remote_control_user_rate_key(request: Request) -> str: + raw = _strip_bearer(request.headers.get("authorization")) + if raw: + try: + auth = V1UserAuth.decode_token(raw) + return f"remote-control:user:{auth.id}" + except Exception: + pass + return f"remote-control:ip:{_request_client_host(request)}" + + +async def _remote_control_session_rate_key(request: Request) -> str: + try: + session_id = request.path_params.get("session_id") or "unknown" + return f"remote-control:session:{session_id}" + except Exception: + return f"remote-control:ip:{_request_client_host(request)}" + + +async def _enforce_ws_reconnect_rate_limit(scope: str, identifier: str | None) -> None: + if not identifier: + identifier = "unknown" + digest = hashlib.sha256(identifier.encode("utf-8")).hexdigest() + key = f"rc:rate:{scope}:{digest}:reconnect" + + def _hit() -> int: + client = get_redis_manager().client + return int(client.eval(RATE_LIMIT_HIT_LUA, 1, key, WS_RECONNECT_RATE_WINDOW_SECONDS)) + + try: + count = await asyncio.get_running_loop().run_in_executor(None, _hit) + except Exception as exc: + logger.warning( + "Remote-control websocket rate limiter failed open", + extra={"scope": scope, "error": str(exc)}, + exc_info=True, + ) + return + + if count > WS_RECONNECT_RATE_LIMIT: + raise HTTPException( + status_code=429, + detail="Remote control websocket reconnect rate limit exceeded", + ) + + +remote_session_create_rate_limiter = rate_limiter_factory( + times=SESSION_CREATE_RATE_LIMIT, + seconds=SESSION_CREATE_RATE_WINDOW_SECONDS, + identifier=_remote_control_user_rate_key, +) +remote_command_burst_rate_limiter = rate_limiter_factory( + times=COMMAND_BURST_RATE_LIMIT, + seconds=COMMAND_BURST_RATE_WINDOW_SECONDS, + identifier=_remote_control_session_rate_key, +) +remote_command_minute_rate_limiter = rate_limiter_factory( + times=COMMAND_MINUTE_RATE_LIMIT, + seconds=COMMAND_MINUTE_RATE_WINDOW_SECONDS, + identifier=_remote_control_session_rate_key, +) +remote_steps_rate_limiter = rate_limiter_factory( + times=STEPS_RATE_LIMIT, + seconds=STEPS_RATE_WINDOW_SECONDS, + identifier=_remote_control_session_rate_key, +) + + +def _bridge_allowed_origins() -> set[str]: + explicit = set(csv_values(os.getenv("REMOTE_CONTROL_BRIDGE_ALLOWED_ORIGINS"))) + return explicit or set(configured_remote_origins()) + + +def _events_allowed_origins() -> set[str]: + explicit = set(csv_values(os.getenv("REMOTE_CONTROL_EVENTS_ALLOWED_ORIGINS"))) + if explicit: + return explicit + # Fall back to the CORS allowlist so the events WS shares the HTTP policy. + return set(csv_values(os.getenv("CORS_ALLOW_ORIGINS"))) | set(configured_remote_origins()) + + +def _check_ws_origin(websocket: WebSocket, allowed: set[str], *, allow_missing: bool) -> bool: + """ + Return True iff the WebSocket Origin header is acceptable. + + - Empty allowlist only permits local development origins by default. + - "*" in allowlist, or REMOTE_CONTROL_ALLOW_UNSAFE_ORIGINS=true, is an + explicit permissive mode for development and test deployments. + - allow_missing controls whether non-browser clients (no Origin) may connect. + """ + if _allow_unsafe_origins() or "*" in allowed: + return True + if _is_debug_mode() and not allowed: + return True + origin = websocket.headers.get("origin") + if not origin: + return allow_missing + if allowed: + return origin in allowed + return is_local_dev_origin(origin) + + +def _bridge_blacklist_check_interval_seconds() -> int: + return BRIDGE_BLACKLIST_CHECK_INTERVAL_SECONDS + + +def _sha256_prefix(value: str | None) -> str | None: + if not value: + return None + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:AUDIT_HASH_HEX_PREFIX_LEN] + + +def _remote_audit_payload(websocket: WebSocket) -> dict[str, str | None]: + client_host = _websocket_client_host(websocket) + user_agent = websocket.headers.get("user-agent") + return { + "remote_ip_hash": _sha256_prefix(client_host), + "user_agent_hash": _sha256_prefix(user_agent), + } + + +def _remember_bridge_token_jti(desktop_instance_id: str, token_jti: str | None) -> None: + if token_jti: + bridge_token_jtis[desktop_instance_id] = token_jti + else: + bridge_token_jtis.pop(desktop_instance_id, None) + + +async def _auth_token_with_jti( + token: str | None, + db: Session, + *, + token_is_stripped: bool = False, +) -> tuple[V1UserAuth, str | None]: + raw = token if token_is_stripped else _strip_bearer(token) + if not raw: + raise HTTPException(status_code=401, detail="Authentication required") + auth = V1UserAuth.decode_token(raw) + jti = _get_jti(raw) + if jti and await is_blacklisted(jti): + raise HTTPException(status_code=401, detail="Token has been revoked") + user = db.get(User, auth.id) + if not user: + raise HTTPException(status_code=401, detail="User not found") + auth._user = user + return auth, jti + + +async def _auth_token(token: str | None, db: Session) -> V1UserAuth: + auth, _jti = await _auth_token_with_jti(token, db) + return auth + + +def _pending_bridge_commands( + desktop_instance_id: str, + user_id: int, + db: Session, + *, + limit: int = 50, +) -> list[tuple[RemoteControlSession, RemoteControlCommand]]: + sessions = db.exec( + select(RemoteControlSession).where( + RemoteControlSession.user_id == user_id, + RemoteControlSession.desktop_instance_id == desktop_instance_id, + RemoteControlSession.status == "active", + ) + ).all() + if not sessions: + return [] + + sessions_by_id = {rc_session.id: rc_session for rc_session in sessions} + commands = db.exec( + select(RemoteControlCommand) + .where( + RemoteControlCommand.session_id.in_(sessions_by_id.keys()), + RemoteControlCommand.status == COMMAND_PENDING, + ) + .order_by(RemoteControlCommand.created_at) + .limit(limit) + ).all() + return [ + (sessions_by_id[command.session_id], command) + for command in commands + if command.session_id in sessions_by_id + ] + + +async def _send_bridge_command( + websocket: WebSocket, + rc_session: RemoteControlSession, + command: RemoteControlCommand, +) -> bool: + try: + await websocket.send_json(RemoteControlService.command_payload(rc_session, command)) + return True + except Exception as exc: + logger.warning( + "Failed to send remote-control command to desktop bridge", + extra={ + "desktop_instance_id": rc_session.desktop_instance_id, + "command_id": command.id, + "error": str(exc), + }, + ) + return False + + +async def _flush_pending_bridge_commands( + desktop_instance_id: str, + user_id: int, + db: Session, + *, + websocket: WebSocket | None = None, +) -> int: + target_ws = websocket or bridge_websockets.get(desktop_instance_id) + if target_ws is None: + return 0 + if websocket is None and bridge_users.get(desktop_instance_id) != user_id: + return 0 + + delivered_count = 0 + for rc_session, command in _pending_bridge_commands(desktop_instance_id, user_id, db): + if await _send_bridge_command(target_ws, rc_session, command): + delivered_count += 1 + return delivered_count + + +async def _flush_pending_for_session(rc_session: RemoteControlSession, db: Session) -> None: + delivered_count = await _flush_pending_bridge_commands( + rc_session.desktop_instance_id, + rc_session.user_id, + db, + ) + if delivered_count: + logger.info( + "Flushed pending remote-control commands to local bridge", + extra={ + "session_id": rc_session.id, + "desktop_instance_id": rc_session.desktop_instance_id, + "delivered_count": delivered_count, + }, + ) + + +@router.post( + "/sessions", + response_model=RemoteControlCreateSessionOut, + dependencies=[remote_session_create_rate_limiter], +) +def create_session( + data: RemoteControlCreateSessionIn, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + return RemoteControlService.create_session(data, auth.id, db_session) + + +@router.get("/sessions/{session_id}", response_model=RemoteControlSessionOut) +def get_session( + session_id: str, + t: str | None = Query(None), + x_remote_control_token: str | None = Header( + None, + alias="X-Remote-Control-Token", + ), + db_session: Session = Depends(session), +): + rc_session = RemoteControlService.verify_link( + session_id, + _remote_link_token(t, x_remote_control_token), + None, + db_session, + ) + return RemoteControlService.to_session_out(rc_session) + + +@router.get("/sessions/{session_id}/projects", response_model=RemoteControlProjectListOut) +def list_session_projects( + session_id: str, + t: str | None = Query(None), + x_remote_control_token: str | None = Header( + None, + alias="X-Remote-Control-Token", + ), + db_session: Session = Depends(session), +): + rc_session = RemoteControlService.verify_link( + session_id, + _remote_link_token(t, x_remote_control_token), + None, + db_session, + ) + return RemoteControlService.list_projects(session_id, rc_session.user_id, db_session) + + +@router.post( + "/sessions/{session_id}/projects", + response_model=ProjectOut, + dependencies=[remote_command_burst_rate_limiter, remote_command_minute_rate_limiter], +) +def create_session_project( + session_id: str, + data: RemoteControlCreateProjectIn, + t: str | None = Query(None), + x_remote_control_token: str | None = Header( + None, + alias="X-Remote-Control-Token", + ), + db_session: Session = Depends(session), +): + rc_session = RemoteControlService.verify_link( + session_id, + _remote_link_token(t, x_remote_control_token), + None, + db_session, + ) + return RemoteControlService.create_project(session_id, rc_session.user_id, data, db_session) + + +@router.post("/sessions/{session_id}/extend", response_model=RemoteControlExtendOut) +def extend_session( + session_id: str, + data: RemoteControlExtendIn, + t: str | None = Query(None), + x_remote_control_token: str | None = Header( + None, + alias="X-Remote-Control-Token", + ), + db_session: Session = Depends(session), +): + rc_session = RemoteControlService.verify_link( + session_id, + _remote_link_token(t, x_remote_control_token), + None, + db_session, + ) + expires_at = RemoteControlService.extend_session( + session_id, + rc_session.user_id, + data.extend_seconds, + db_session, + ) + return RemoteControlExtendOut(expires_at=expires_at) + + +@router.patch("/sessions/{session_id}/target", response_model=RemoteControlPatchTargetOut) +async def patch_target( + session_id: str, + data: RemoteControlPatchTargetIn, + t: str | None = Query(None), + x_remote_control_token: str | None = Header( + None, + alias="X-Remote-Control-Token", + ), + db_session: Session = Depends(session), +): + rc_session = RemoteControlService.verify_link( + session_id, + _remote_link_token(t, x_remote_control_token), + None, + db_session, + ) + result = RemoteControlService.patch_target( + session_id, + rc_session.user_id, + data, + db_session, + ) + await _flush_pending_for_session(rc_session, db_session) + return result + + +@router.delete("/sessions/{session_id}") +def revoke_session( + session_id: str, + t: str | None = Query(None), + x_remote_control_token: str | None = Header( + None, + alias="X-Remote-Control-Token", + ), + db_session: Session = Depends(session), +): + rc_session = RemoteControlService.verify_link( + session_id, + _remote_link_token(t, x_remote_control_token), + None, + db_session, + ) + RemoteControlService.revoke_session(session_id, rc_session.user_id, db_session) + return Response(status_code=204) + + +@router.post( + "/sessions/{session_id}/commands", + response_model=RemoteControlCommandOut, + response_model_exclude_none=True, + dependencies=[remote_command_burst_rate_limiter, remote_command_minute_rate_limiter], +) +async def send_command( + session_id: str, + data: RemoteControlCommandIn, + t: str | None = Query(None), + x_remote_control_token: str | None = Header( + None, + alias="X-Remote-Control-Token", + ), + db_session: Session = Depends(session), +): + rc_session = RemoteControlService.verify_link( + session_id, + _remote_link_token(t, x_remote_control_token), + None, + db_session, + ) + result = RemoteControlService.send_command( + session_id, + rc_session.user_id, + data, + db_session, + ) + await _flush_pending_for_session(rc_session, db_session) + return result + + +@router.get( + "/sessions/{session_id}/steps", + response_model=RemoteControlStepsOut, + dependencies=[remote_steps_rate_limiter], +) +def list_steps( + session_id: str, + t: str | None = Query(None), + x_remote_control_token: str | None = Header( + None, + alias="X-Remote-Control-Token", + ), + project_id: str | None = None, + since: int = 0, + limit: int = 200, + order: str = "asc", + db_session: Session = Depends(session), +): + if order not in {"asc", "desc"}: + raise HTTPException(status_code=400, detail="order must be asc or desc") + rc_session = RemoteControlService.verify_link( + session_id, + _remote_link_token(t, x_remote_control_token), + None, + db_session, + ) + return RemoteControlService.list_steps( + session_id, + rc_session.user_id, + project_id, + since, + limit, + order, + db_session, + ) + + +@router.get( + "/sessions/{session_id}/projects/{project_id}/overlays", + response_model=SpaceOverlayListResponse, + dependencies=[remote_steps_rate_limiter], +) +def list_session_project_overlays( + session_id: str, + project_id: str, + t: str | None = Query(None), + x_remote_control_token: str | None = Header( + None, + alias="X-Remote-Control-Token", + ), + run_id: str | None = Query(None), + db_session: Session = Depends(session), +): + rc_session = RemoteControlService.verify_link( + session_id, + _remote_link_token(t, x_remote_control_token), + None, + db_session, + ) + return RemoteControlService.list_overlays( + session_id, + rc_session.user_id, + project_id, + run_id, + db_session, + ) + + +@router.post( + "/sessions/{session_id}/projects/{project_id}/apply", + response_model=RemoteControlFolderOperationOut, + dependencies=[remote_command_burst_rate_limiter, remote_command_minute_rate_limiter], +) +async def apply_session_project_run( + session_id: str, + project_id: str, + data: RemoteControlFolderApplyIn, + t: str | None = Query(None), + x_remote_control_token: str | None = Header( + None, + alias="X-Remote-Control-Token", + ), + db_session: Session = Depends(session), +): + rc_session = RemoteControlService.verify_link( + session_id, + _remote_link_token(t, x_remote_control_token), + None, + db_session, + ) + result = RemoteControlService.enqueue_apply_project( + session_id, + rc_session.user_id, + project_id, + data, + db_session, + ) + await _flush_pending_for_session(rc_session, db_session) + return result + + +@router.post( + "/sessions/{session_id}/projects/{project_id}/discard", + response_model=RemoteControlFolderOperationOut, + dependencies=[remote_command_burst_rate_limiter, remote_command_minute_rate_limiter], +) +async def discard_session_project_overlays( + session_id: str, + project_id: str, + data: RemoteControlFolderDiscardIn, + t: str | None = Query(None), + x_remote_control_token: str | None = Header( + None, + alias="X-Remote-Control-Token", + ), + db_session: Session = Depends(session), +): + rc_session = RemoteControlService.verify_link( + session_id, + _remote_link_token(t, x_remote_control_token), + None, + db_session, + ) + result = RemoteControlService.enqueue_discard_project_overlays( + session_id, + rc_session.user_id, + project_id, + data, + db_session, + ) + await _flush_pending_for_session(rc_session, db_session) + return result + + +@router.post( + "/sessions/{session_id}/projects/{project_id}/refresh", + response_model=RemoteControlFolderOperationOut, + dependencies=[remote_command_burst_rate_limiter, remote_command_minute_rate_limiter], +) +async def refresh_session_project( + session_id: str, + project_id: str, + data: RemoteControlFolderRefreshIn, + t: str | None = Query(None), + x_remote_control_token: str | None = Header( + None, + alias="X-Remote-Control-Token", + ), + db_session: Session = Depends(session), +): + rc_session = RemoteControlService.verify_link( + session_id, + _remote_link_token(t, x_remote_control_token), + None, + db_session, + ) + result = RemoteControlService.enqueue_refresh_project( + session_id, + rc_session.user_id, + project_id, + data, + db_session, + ) + await _flush_pending_for_session(rc_session, db_session) + return result + + +async def _validate_bridge_token( + token: str | None, + db: Session, + expected_user_id: int, + *, + token_is_stripped: bool = False, +) -> tuple[V1UserAuth, datetime, str | None]: + """ + Re-verify a bridge auth token and confirm it still belongs to the + same user. Returns (auth, exp, jti) or raises HTTPException. + """ + auth, jti = await _auth_token_with_jti(token, db, token_is_stripped=token_is_stripped) + if auth.id != expected_user_id: + raise HTTPException(status_code=401, detail="Token does not match bridge owner") + return auth, auth.expired_at, jti + + +@router.websocket("/bridge/subscribe") +async def bridge_subscribe(websocket: WebSocket): + if not _check_ws_origin(websocket, _bridge_allowed_origins(), allow_missing=True): + await websocket.close(code=1008) + return + await start_remote_control_workers() + await websocket.accept() + desktop_instance_id: str | None = None + user_id: int | None = None + db = session_make() + + try: + try: + data = await asyncio.wait_for( + websocket.receive_json(), + timeout=WS_SUBSCRIBE_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + await websocket.close(code=1008) + return + + if data.get("type") != "subscribe" or not data.get("desktop_instance_id"): + await websocket.send_json({"type": "error", "message": "Invalid bridge subscription"}) + await websocket.close() + return + + token_raw = _strip_bearer(data.get("auth_token")) + client_host = _websocket_client_host(websocket) + await _enforce_ws_reconnect_rate_limit( + "bridge", + client_host, + ) + auth, token_jti = await _auth_token_with_jti(token_raw, db, token_is_stripped=True) + user_id = auth.id + token_expires_at = auth.expired_at + desktop_instance_id = data["desktop_instance_id"] + worker_id = f"{os.getpid()}:{id(websocket)}" + blacklist_check_interval = _bridge_blacklist_check_interval_seconds() + next_blacklist_check_at = datetime.utcnow() + timedelta(seconds=blacklist_check_interval) + + existing_user = bridge_users.get(desktop_instance_id) + if existing_user is not None and str(existing_user) != str(user_id): + await websocket.send_json( + { + "type": "error", + "message": "Desktop instance is already registered to another user", + } + ) + await websocket.close(code=1008) + return + try: + RemoteControlRedis.register_bridge( + desktop_instance_id, + user_id, + worker_id, + app_version=data.get("app_version"), + capabilities=data.get("capabilities"), + ) + except ValueError as exc: + await websocket.send_json({"type": "error", "message": str(exc)}) + await websocket.close(code=1008) + return + + bridge_websockets[desktop_instance_id] = websocket + bridge_users[desktop_instance_id] = user_id + _remember_bridge_token_jti(desktop_instance_id, token_jti) + + now = datetime.utcnow() + sessions = db.exec( + select(RemoteControlSession).where( + RemoteControlSession.user_id == user_id, + RemoteControlSession.desktop_instance_id == desktop_instance_id, + RemoteControlSession.status == "active", + ) + ).all() + for rc_session in sessions: + rc_session.bridge_status = "online" + rc_session.last_bridge_seen_at = now + db.add(rc_session) + RemoteControlService.record_event( + rc_session.id, + "bridge_online", + { + "desktop_instance_id": desktop_instance_id, + "app_version": data.get("app_version"), + "capabilities": data.get("capabilities"), + "last_seen_at": now.isoformat(), + }, + db, + commit=False, + ) + RemoteControlService.publish_status( + rc_session.id, + "bridge_status", + {"status": "online", "last_seen_at": now.isoformat()}, + ) + db.commit() + + await websocket.send_json({"type": "connected", "desktop_instance_id": desktop_instance_id}) + flushed_at_connect = await _flush_pending_bridge_commands( + desktop_instance_id, + user_id, + db, + websocket=websocket, + ) + logger.info( + "[RC-TRACE] bridge registered", + extra={ + "desktop_instance_id": desktop_instance_id, + "user_id": user_id, + "worker_id": worker_id, + "flushed_pending_at_connect": flushed_at_connect, + }, + ) + + while True: + msg = await websocket.receive_json() + msg_type = msg.get("type") + if msg_type == "ping": + # Re-validate auth: the JWT has an exp claim that we cached at + # connect, and may be added to the blacklist mid-flight (e.g. + # the user logged out or changed their password). Either case + # must close the bridge. A ping may also carry a fresh token, so + # validate that first before checking the cached expiry. + now = datetime.utcnow() + next_token_raw = _strip_bearer(msg.get("auth_token")) + if ( + next_token_raw + and (not token_raw or not secrets.compare_digest(next_token_raw, token_raw)) + ): + try: + new_auth, new_exp, new_jti = await _validate_bridge_token( + next_token_raw, db, user_id, token_is_stripped=True + ) + token_expires_at = new_exp + token_jti = new_jti + token_raw = next_token_raw + _remember_bridge_token_jti(desktop_instance_id, token_jti) + next_blacklist_check_at = now + timedelta(seconds=blacklist_check_interval) + except HTTPException as exc: + await websocket.send_json( + {"type": "auth_expired", "message": exc.detail} + ) + await websocket.close(code=4401) + return + if token_expires_at <= now: + await websocket.send_json({"type": "auth_expired"}) + await websocket.close(code=4401) + return + if token_jti and now >= next_blacklist_check_at: + if await is_blacklisted(token_jti): + await websocket.send_json({"type": "revoke_bridge", "reason": "token_revoked"}) + await websocket.close(code=4401) + return + next_blacklist_check_at = now + timedelta(seconds=blacklist_check_interval) + RemoteControlRedis.refresh_bridge(desktop_instance_id, user_id, worker_id) + await _flush_pending_bridge_commands( + desktop_instance_id, + user_id, + db, + websocket=websocket, + ) + await websocket.send_json({"type": "pong", "timestamp": datetime.utcnow().isoformat()}) + elif msg_type == "reauth" and msg.get("auth_token"): + try: + next_token_raw = _strip_bearer(msg.get("auth_token")) + new_auth, new_exp, new_jti = await _validate_bridge_token( + next_token_raw, db, user_id, token_is_stripped=True + ) + token_expires_at = new_exp + token_jti = new_jti + token_raw = next_token_raw + _remember_bridge_token_jti(desktop_instance_id, token_jti) + next_blacklist_check_at = datetime.utcnow() + timedelta(seconds=blacklist_check_interval) + await websocket.send_json({"type": "reauth_ok"}) + except HTTPException as exc: + await websocket.send_json( + {"type": "auth_expired", "message": exc.detail} + ) + await websocket.close(code=4401) + return + elif msg_type == "command_delivered" and msg.get("command_id"): + logger.info( + "[RC-TRACE] command_delivered received", + extra={ + "command_id": msg["command_id"], + "desktop_instance_id": desktop_instance_id, + }, + ) + RemoteControlService.mark_delivered(msg["command_id"], db) + elif msg_type == "command_ack" and msg.get("command_id"): + status = msg.get("status") or COMMAND_FAILED + logger.info( + "[RC-TRACE] command_ack received", + extra={ + "command_id": msg["command_id"], + "status": status, + "error_code": msg.get("error_code"), + "error": msg.get("error"), + }, + ) + RemoteControlService.mark_ack( + msg["command_id"], + COMMAND_ACKNOWLEDGED if status == COMMAND_ACKNOWLEDGED else COMMAND_FAILED, + msg.get("error_code"), + msg.get("error"), + db, + msg.get("result"), + ) + + except WebSocketDisconnect: + logger.info("Remote-control bridge disconnected", extra={"desktop_instance_id": desktop_instance_id}) + except HTTPException as exc: + await websocket.send_json({"type": "error", "message": exc.detail}) + await websocket.close() + except Exception as exc: + logger.error("Remote-control bridge websocket failed", extra={"error": str(exc)}, exc_info=True) + finally: + if desktop_instance_id: + is_current_bridge = bridge_websockets.get(desktop_instance_id) is websocket + if is_current_bridge: + bridge_websockets.pop(desktop_instance_id, None) + bridge_users.pop(desktop_instance_id, None) + bridge_token_jtis.pop(desktop_instance_id, None) + RemoteControlRedis.unregister_bridge(desktop_instance_id, worker_id) + if is_current_bridge and user_id is not None: + sessions = db.exec( + select(RemoteControlSession).where( + RemoteControlSession.user_id == user_id, + RemoteControlSession.desktop_instance_id == desktop_instance_id, + RemoteControlSession.status == "active", + ) + ).all() + for rc_session in sessions: + rc_session.bridge_status = "offline" + db.add(rc_session) + RemoteControlService.record_event( + rc_session.id, + "bridge_offline", + { + "desktop_instance_id": desktop_instance_id, + "last_seen_at": rc_session.last_bridge_seen_at.isoformat() + if rc_session.last_bridge_seen_at + else None, + "reason": "websocket_disconnect", + }, + db, + commit=False, + ) + RemoteControlService.publish_status( + rc_session.id, + "bridge_status", + { + "status": "offline", + "last_seen_at": rc_session.last_bridge_seen_at.isoformat() + if rc_session.last_bridge_seen_at + else None, + "message": "Desktop is offline", + }, + ) + db.commit() + db.close() + + +@router.websocket("/sessions/{session_id}/events/subscribe") +async def events_subscribe(websocket: WebSocket, session_id: str): + # Browser clients always send Origin; reject anonymous connections in non-dev. + if not _check_ws_origin(websocket, _events_allowed_origins(), allow_missing=False): + await websocket.close(code=1008) + return + await start_remote_control_workers() + await websocket.accept() + db = session_make() + registered = False + + try: + try: + data = await asyncio.wait_for( + websocket.receive_json(), + timeout=WS_SUBSCRIBE_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + await websocket.close(code=1008) + return + + if data.get("type") != "subscribe": + await websocket.send_json({"type": "error", "message": "Invalid event subscription"}) + await websocket.close() + return + + client_host = _websocket_client_host(websocket) + link_token = data.get("link_token") + await _enforce_ws_reconnect_rate_limit( + "events", + f"{session_id}:{client_host}", + ) + rc_session = RemoteControlService.verify_link( + session_id, + link_token, + None, + db, + ) + requested_project_id = data.get("subscribed_project_id") + effective_project_id = requested_project_id or rc_session.current_project_id or rc_session.project_id + if requested_project_id: + RemoteControlService._ensure_project_in_session_space( + rc_session, + rc_session.user_id, + requested_project_id, + db, + ) + remote_audit = _remote_audit_payload(websocket) + remote_websockets.setdefault(session_id, set()).add(websocket) + if effective_project_id: + remote_projects[id(websocket)] = effective_project_id + RemoteControlService.record_event( + session_id, + "remote_joined", + { + "user_id": rc_session.user_id, + "project_id": effective_project_id, + **remote_audit, + }, + db, + ) + registered = True + + session_out = RemoteControlService.to_session_out(rc_session) + await websocket.send_json( + { + "type": "connected", + "session_id": session_id, + "project_id": effective_project_id, + "current_project_id": session_out.current_project_id, + "current_task_id": session_out.current_task_id, + "current_history_id": session_out.current_history_id, + "current_brain_session_id": session_out.current_brain_session_id, + "bridge_status": session_out.bridge_status, + } + ) + + while True: + msg = await websocket.receive_json() + if msg.get("type") == "ping": + await websocket.send_json({"type": "pong", "timestamp": datetime.utcnow().isoformat()}) + elif msg.get("type") == "subscribe_project": + next_project_id = msg.get("project_id") + if next_project_id: + RemoteControlService._ensure_project_in_session_space( + rc_session, + rc_session.user_id, + next_project_id, + db, + ) + remote_projects[id(websocket)] = next_project_id + else: + remote_projects.pop(id(websocket), None) + await websocket.send_json( + { + "type": "subscribed_project", + "session_id": session_id, + "project_id": next_project_id, + } + ) + + except WebSocketDisconnect: + logger.info("Remote-control event websocket disconnected", extra={"session_id": session_id}) + except HTTPException as exc: + await websocket.send_json({"type": "error", "message": exc.detail}) + await websocket.close() + except Exception as exc: + logger.error("Remote-control event websocket failed", extra={"error": str(exc)}, exc_info=True) + finally: + if registered: + RemoteControlService.record_event( + session_id, + "remote_left", + remote_audit, + db, + ) + sockets = remote_websockets.get(session_id) + if sockets and websocket in sockets: + sockets.remove(websocket) + if not sockets: + remote_websockets.pop(session_id, None) + remote_projects.pop(id(websocket), None) + db.close() + + +async def _send_to_remote_session(session_id: str, payload: dict[str, Any]) -> None: + sockets = list(remote_websockets.get(session_id, set())) + for ws in sockets: + try: + await ws.send_json(payload) + except Exception: + remote_websockets.get(session_id, set()).discard(ws) + + +async def _send_to_remote_socket(session_id: str, websocket: WebSocket, payload: dict[str, Any]) -> None: + try: + await websocket.send_json(payload) + except Exception: + remote_websockets.get(session_id, set()).discard(websocket) + remote_projects.pop(id(websocket), None) + + +async def _close_bridges_for_blacklisted_jti(jti: str | None) -> None: + if not jti: + return + + for desktop_instance_id, current_jti in list(bridge_token_jtis.items()): + if not secrets.compare_digest(current_jti, jti): + continue + ws = bridge_websockets.get(desktop_instance_id) + if not ws: + continue + try: + await ws.send_json({"type": "revoke_bridge", "reason": "token_revoked"}) + await ws.close(code=4401) + except Exception as exc: + logger.debug( + "Failed to close revoked remote-control bridge", + extra={"desktop_instance_id": desktop_instance_id, "error": str(exc)}, + ) + + +async def _handle_pubsub_message(channel: str, payload: dict[str, Any]) -> None: + if channel.startswith(BLACKLIST_PUBSUB_PREFIX): + await _close_bridges_for_blacklisted_jti(payload.get("jti")) + return + + if channel.startswith("rc:cmd:"): + desktop_instance_id = channel.removeprefix("rc:cmd:") + ws = bridge_websockets.get(desktop_instance_id) + if ws: + command_id = payload.get("command", {}).get("id") + try: + await ws.send_json(payload) + except Exception as exc: + logger.warning( + "[RC-TRACE] bridge ws send FAILED", + extra={ + "command_id": command_id, + "desktop_instance_id": desktop_instance_id, + "pid": os.getpid(), + "error": str(exc), + }, + ) + return + logger.info( + "[RC-TRACE] command sent to bridge ws", + extra={ + "command_id": command_id, + "desktop_instance_id": desktop_instance_id, + "pid": os.getpid(), + }, + ) + else: + logger.warning( + "Remote-control command pub/sub arrived without a local bridge websocket", + extra={ + "desktop_instance_id": desktop_instance_id, + "command_id": payload.get("command", {}).get("id"), + }, + ) + return + + if channel.startswith("rc:ack:"): + session_id = channel.removeprefix("rc:ack:") + await _send_to_remote_session(session_id, payload) + return + + if channel.startswith("project:") and channel.endswith(":step"): + project_id = channel[len("project:") : -len(":step")] + for session_id, sockets in list(remote_websockets.items()): + for ws in list(sockets): + if remote_projects.get(id(ws)) == project_id: + await _send_to_remote_socket(session_id, ws, payload) + + +async def start_remote_control_workers() -> None: + global _pubsub_task, _scanner_task + + if _pubsub_task is None or _pubsub_task.done(): + _pubsub_task = asyncio.create_task(_run_pubsub_listener()) + if _scanner_task is None or _scanner_task.done(): + _scanner_task = asyncio.create_task(_run_scanners()) + + +async def _run_pubsub_listener() -> None: + loop = asyncio.get_running_loop() + reconnect_delay = PUBSUB_RECONNECT_BASE_SECONDS + + while True: + redis_url = get_redis_manager().redis_url + pubsub_client = None + pubsub = None + try: + pubsub_client = redis.from_url( + redis_url, + decode_responses=True, + socket_connect_timeout=5, + socket_timeout=5, + ) + pubsub = pubsub_client.pubsub() + await loop.run_in_executor( + None, + pubsub.psubscribe, + "rc:cmd:*", + "rc:ack:*", + "project:*:step", + f"{BLACKLIST_PUBSUB_PREFIX}*", + ) + reconnect_delay = PUBSUB_RECONNECT_BASE_SECONDS + logger.info("Remote-control pub/sub listener started") + + while True: + message = await loop.run_in_executor( + None, + pubsub.get_message, + True, + 1.0, + ) + if message and message.get("type") == "pmessage": + try: + payload = json.loads(message["data"]) + await _handle_pubsub_message(message["channel"], payload) + except Exception as exc: + logger.error( + "Failed to handle remote-control pub/sub message", + extra={"error": str(exc)}, + exc_info=True, + ) + await asyncio.sleep(0.01) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "Remote-control pub/sub listener disconnected; reconnecting", + extra={"error": str(exc), "retry_in_seconds": reconnect_delay}, + exc_info=True, + ) + await asyncio.sleep(reconnect_delay) + reconnect_delay = min(reconnect_delay * 2, PUBSUB_RECONNECT_MAX_SECONDS) + finally: + if pubsub is not None: + pubsub.close() + if pubsub_client is not None: + pubsub_client.close() + + +async def _run_scanners() -> None: + global _last_bridge_ttl_scan_at + logger.info("Remote-control retry/TTL scanner started") + while True: + await asyncio.sleep(10) + db = session_make() + try: + RemoteControlService.retry_pending_commands(db) + RemoteControlService.expire_timed_out_commands(db) + now = datetime.utcnow() + if _last_bridge_ttl_scan_at is None or now - _last_bridge_ttl_scan_at >= timedelta(seconds=30): + _last_bridge_ttl_scan_at = now + RemoteControlService.expire_stale_bridges(db) + except Exception as exc: + db.rollback() + logger.warning("Remote-control scanner iteration failed", extra={"error": str(exc)}, exc_info=True) + finally: + db.close() diff --git a/server/app/domains/remote_control/schema/__init__.py b/server/app/domains/remote_control/schema/__init__.py new file mode 100644 index 000000000..198221955 --- /dev/null +++ b/server/app/domains/remote_control/schema/__init__.py @@ -0,0 +1,15 @@ +# ========= 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.domains.remote_control.schema.schemas import * diff --git a/server/app/domains/remote_control/schema/schemas.py b/server/app/domains/remote_control/schema/schemas.py new file mode 100644 index 000000000..cf4381375 --- /dev/null +++ b/server/app/domains/remote_control/schema/schemas.py @@ -0,0 +1,183 @@ +# ========= 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 datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from app.model.project.project import ProjectOut +from app.model.space.apply import ApplyResolutionIn +from app.model.space.space import SpaceOut + + +RemoteControlCommandType = Literal[ + "user_message", + "human_reply", + "stop", + "skip_task", + "add_task", + "remove_task", + "supplement", + "switch_project_view", + "space_project_upsert", + "space_overlay_list", + "space_apply_project_run", + "space_refresh_project", + "space_discard_project_overlays", +] + + +class RemoteControlCreateSessionIn(BaseModel): + desktop_instance_id: str + space_id: str | None = None + project_id: str | None = None + active_task_id: str | None = None + brain_session_id: str | None = None + initial_project_id: str | None = None + initial_task_id: str | None = None + initial_history_id: str | None = None + title: str = "" + expires_in_seconds: int = Field(default=86400, ge=60, le=604800) + + +class RemoteControlCreateSessionOut(BaseModel): + session_id: str + url: str + expires_at: datetime + bridge_status: str + space_id: str | None = None + space_name: str | None = None + current_project_id: str | None = None + current_task_id: str | None = None + current_history_id: str | None = None + current_brain_session_id: str | None = None + + +class RemoteControlSessionOut(BaseModel): + session_id: str + desktop_instance_id: str + space_id: str | None = None + space_name: str | None = None + space: SpaceOut | None = None + project_id: str | None = None + active_task_id: str | None = None + brain_session_id: str | None = None + current_project_id: str | None = None + current_task_id: str | None = None + current_history_id: str | None = None + current_brain_session_id: str | None = None + title: str + status: str + bridge_status: str + execution_mode: str + capabilities: dict[str, Any] + created_at: datetime | None + expires_at: datetime + + +class RemoteControlExtendIn(BaseModel): + extend_seconds: int + + +class RemoteControlExtendOut(BaseModel): + expires_at: datetime + + +class RemoteControlCommandIn(BaseModel): + source_channel: str = "remote_web" + type: RemoteControlCommandType + payload: dict[str, Any] = Field(default_factory=dict) + space_id: str | None = None + target_project_id: str | None = None + target_task_id: str | None = None + target_brain_session_id: str | None = None + + +class RemoteControlPatchTargetIn(BaseModel): + project_id: str + task_id: str | None = None + history_id: str | None = None + + +class RemoteControlPatchTargetOut(BaseModel): + space_id: str | None = None + current_project_id: str + current_task_id: str | None = None + current_history_id: str | None = None + current_brain_session_id: str + desktop_ready: Literal["pending", "ready", "failed"] = "pending" + + +class RemoteControlCommandOut(BaseModel): + model_config = ConfigDict(extra="ignore") + + command_id: str + status: str + next_task_id: str | None = None + + +class RemoteControlStepOut(BaseModel): + step_id: int + task_id: str + project_id: str | None = None + step: str + data: Any + timestamp: float | None = None + + +class RemoteControlStepsOut(BaseModel): + items: list[RemoteControlStepOut] + has_more: bool + next_since: int + + +class RemoteControlProjectListOut(BaseModel): + space: SpaceOut + items: list[ProjectOut] + + +class RemoteControlCreateProjectIn(BaseModel): + name: str + description: str | None = None + mode: Literal["single-agent", "workforce"] = "single-agent" + workdir_mode: str | None = None + metadata: dict[str, Any] | None = None + + +class RemoteControlFolderApplyIn(BaseModel): + run_id: str + paths: list[str] | None = None + force_resolutions: list[ApplyResolutionIn] | None = None + confirm: bool = False + + +class RemoteControlFolderDiscardIn(BaseModel): + run_id: str | None = None + paths: list[str] | None = None + confirm: bool = False + + +class RemoteControlFolderRefreshIn(BaseModel): + force: bool = False + + +class RemoteControlOverlayListOut(BaseModel): + command_id: str + status: str + + +class RemoteControlFolderOperationOut(BaseModel): + command_id: str + status: str diff --git a/server/app/domains/remote_control/service/__init__.py b/server/app/domains/remote_control/service/__init__.py new file mode 100644 index 000000000..fa7455a0c --- /dev/null +++ b/server/app/domains/remote_control/service/__init__.py @@ -0,0 +1,13 @@ +# ========= 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. ========= diff --git a/server/app/domains/remote_control/service/remote_control_service.py b/server/app/domains/remote_control/service/remote_control_service.py new file mode 100644 index 000000000..8fb54d9e7 --- /dev/null +++ b/server/app/domains/remote_control/service/remote_control_service.py @@ -0,0 +1,1747 @@ +# ========= 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 hashlib +import json +import secrets +from datetime import datetime, timedelta +from types import SimpleNamespace +from typing import Any +from urllib.parse import quote +from uuid import uuid4 + +from fastapi import HTTPException +from loguru import logger +from sqlalchemy import desc +from sqlmodel import Session, select + +from app.core.environment import env +from app.core.redis_utils import get_redis_manager +from app.domains.chat.service import ChatService +from app.domains.remote_control.schema import ( + RemoteControlCommandIn, + RemoteControlCommandOut, + RemoteControlCreateProjectIn, + RemoteControlCreateSessionIn, + RemoteControlCreateSessionOut, + RemoteControlFolderApplyIn, + RemoteControlFolderDiscardIn, + RemoteControlFolderOperationOut, + RemoteControlFolderRefreshIn, + RemoteControlPatchTargetIn, + RemoteControlPatchTargetOut, + RemoteControlProjectListOut, + RemoteControlSessionOut, + RemoteControlStepOut, + RemoteControlStepsOut, +) +from app.domains.space.service.overlay_service import SpaceOverlayService +from app.domains.space.service.space_service import SpaceService +from app.model.chat.chat_history import ChatHistory, ChatStatus +from app.model.chat.chat_step import ChatStep +from app.model.project.project import Project, ProjectIn, ProjectMode, ProjectOut +from app.model.provider.provider import Provider +from app.model.remote_control import ( + RemoteControlCommand, + RemoteControlEvent, + RemoteControlLink, + RemoteControlSession, +) +from app.model.space.space import Space, SpaceOut, SpaceSourceType + +SESSION_ACTIVE = "active" +SESSION_REVOKED = "revoked" +SESSION_EXPIRED = "expired" + +COMMAND_PENDING = "pending" +COMMAND_DELIVERED = "delivered" +COMMAND_ACKNOWLEDGED = "acknowledged" +COMMAND_FAILED = "failed" +COMMAND_EXPIRED = "expired" + +BRIDGE_ONLINE_TTL_SECONDS = 90 +COMMAND_ACK_TIMEOUT_SECONDS = 120 +SWITCH_PROJECT_VIEW_ACK_TIMEOUT_SECONDS = 45 +REMOTE_SESSION_TITLE_MAX_LENGTH = 256 +DEFAULT_CAPABILITIES = { + "bridge_version": 1, + "commands": [ + "user_message", + "human_reply", + "stop", + "skip_task", + "add_task", + "remove_task", + "supplement", + "switch_project_view", + "space_project_upsert", + "space_overlay_list", + "space_apply_project_run", + "space_refresh_project", + "space_discard_project_overlays", + ], +} +SWITCH_PROJECT_VIEW = "switch_project_view" +SPACE_PROJECT_UPSERT = "space_project_upsert" +SPACE_OVERLAY_LIST = "space_overlay_list" +SPACE_APPLY_PROJECT_RUN = "space_apply_project_run" +SPACE_REFRESH_PROJECT = "space_refresh_project" +SPACE_DISCARD_PROJECT_OVERLAYS = "space_discard_project_overlays" +SPACE_COMMANDS = { + SPACE_PROJECT_UPSERT, + SPACE_OVERLAY_LIST, + SPACE_APPLY_PROJECT_RUN, + SPACE_REFRESH_PROJECT, + SPACE_DISCARD_PROJECT_OVERLAYS, +} +NON_BRAIN_COMMANDS = {SWITCH_PROJECT_VIEW, *SPACE_COMMANDS} + + +def _now() -> datetime: + return datetime.utcnow() + + +def _id(prefix: str) -> str: + return f"{prefix}_{uuid4().hex}" + + +def _token_hash(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def _utc_iso(value: datetime | None) -> str | None: + return value.isoformat() if value else None + + +def _truncate(value: str | None, max_length: int) -> str: + text = (value or "").strip() + if len(text) <= max_length: + return text + return text[: max_length - 3].rstrip() + "..." + + +def _legacy_dual_write_enabled() -> bool: + value = str(env("REMOTE_CONTROL_LEGACY_DUAL_WRITE", "true")).lower() + return value not in {"0", "false", "no", "off"} + + +def _normalize_origin(value: str | None) -> str: + origin = (value or "").strip().rstrip("/") + if not origin: + return "" + lower = origin.lower() + for suffix in ("/api/v1", "/api/v2", "/api"): + if lower.endswith(suffix): + return origin[: -len(suffix)].rstrip("/") + return origin + + +class RemoteControlRedis: + @staticmethod + def bridge_key(desktop_instance_id: str) -> str: + return f"rc:bridge:{desktop_instance_id}" + + @staticmethod + def command_channel(desktop_instance_id: str) -> str: + return f"rc:cmd:{desktop_instance_id}" + + @staticmethod + def ack_channel(session_id: str) -> str: + return f"rc:ack:{session_id}" + + @staticmethod + def step_channel(project_id: str) -> str: + return f"project:{project_id}:step" + + @staticmethod + def register_bridge( + desktop_instance_id: str, + user_id: int, + worker_id: str, + app_version: str | None = None, + capabilities: dict[str, Any] | None = None, + ) -> None: + existing = RemoteControlRedis.get_bridge(desktop_instance_id) + if existing and str(existing.get("user_id")) != str(user_id): + raise ValueError("Desktop instance is already registered to another user") + payload = { + "user_id": user_id, + "desktop_instance_id": desktop_instance_id, + "worker_id": worker_id, + "app_version": app_version, + "capabilities": capabilities or DEFAULT_CAPABILITIES, + "connected_at": _now().isoformat(), + "last_heartbeat": _now().isoformat(), + } + get_redis_manager().client.setex( + RemoteControlRedis.bridge_key(desktop_instance_id), + BRIDGE_ONLINE_TTL_SECONDS, + json.dumps(payload), + ) + + @staticmethod + def refresh_bridge(desktop_instance_id: str, user_id: int, worker_id: str) -> None: + existing = RemoteControlRedis.get_bridge(desktop_instance_id) or {} + if existing.get("user_id") and str(existing.get("user_id")) != str(user_id): + return + if existing.get("worker_id") and existing.get("worker_id") != worker_id: + return + RemoteControlRedis.register_bridge( + desktop_instance_id, + user_id, + worker_id, + app_version=existing.get("app_version"), + capabilities=existing.get("capabilities"), + ) + + @staticmethod + def unregister_bridge(desktop_instance_id: str, worker_id: str | None = None) -> None: + if worker_id: + existing = RemoteControlRedis.get_bridge(desktop_instance_id) + if existing and existing.get("worker_id") != worker_id: + return + get_redis_manager().client.delete(RemoteControlRedis.bridge_key(desktop_instance_id)) + + @staticmethod + def get_bridge(desktop_instance_id: str) -> dict[str, Any] | None: + data = get_redis_manager().client.get(RemoteControlRedis.bridge_key(desktop_instance_id)) + if not data: + return None + try: + return json.loads(data) + except Exception: + return None + + @staticmethod + def is_bridge_online(desktop_instance_id: str, user_id: int) -> bool: + bridge = RemoteControlRedis.get_bridge(desktop_instance_id) + return bool(bridge and str(bridge.get("user_id")) == str(user_id)) + + @staticmethod + def publish(channel: str, payload: dict[str, Any]) -> bool: + try: + subscriber_count = get_redis_manager().client.publish(channel, json.dumps(payload)) + if subscriber_count == 0 and channel.startswith("rc:cmd:"): + logger.warning( + "Remote control command publish had no Redis subscribers", + extra={"channel": channel}, + ) + return subscriber_count > 0 + except Exception as exc: + logger.warning("Remote control Redis publish failed", extra={"channel": channel, "error": str(exc)}) + return False + + +class RemoteControlService: + @staticmethod + def web_origin() -> str: + return _normalize_origin( + env("REMOTE_CONTROL_WEB_ORIGIN") + or env("WEB_APP_ORIGIN") + or env("VITE_REMOTE_CONTROL_WEB_ORIGIN") + or env("web_app_origin") + or env("VITE_WEB_APP_ORIGIN") + or env("VITE_SITE_URL") + or env("SITE_URL") + ) + + @staticmethod + def remote_url(session_id: str, token: str) -> str: + origin = RemoteControlService.web_origin() + path = f"/remote-control/{session_id}#t={quote(token, safe='')}" + return f"{origin}{path}" if origin else path + + @staticmethod + def _ensure_active(session: RemoteControlSession, db: Session) -> None: + if session.status != SESSION_ACTIVE: + raise HTTPException(status_code=410, detail="Remote control session is not active") + if session.expires_at <= _now(): + session.status = SESSION_EXPIRED + db.add(session) + db.commit() + raise HTTPException(status_code=410, detail="Remote control session has expired") + + @staticmethod + def _owned_session(session_id: str, user_id: int, db: Session) -> RemoteControlSession: + session = db.get(RemoteControlSession, session_id) + if not session or session.user_id != user_id: + raise HTTPException(status_code=404, detail="Remote control session not found") + RemoteControlService._ensure_active(session, db) + return session + + @staticmethod + def _effective_target(session: RemoteControlSession) -> tuple[str | None, str | None, str | None, str | None]: + return ( + session.current_project_id or session.project_id, + session.current_task_id or session.active_task_id, + session.current_history_id, + session.current_brain_session_id or session.brain_session_id, + ) + + @staticmethod + def _effective_space_id(session: RemoteControlSession) -> str | None: + return session.space_id + + @staticmethod + def _get_owned_space(user_id: int, space_id: str, db: Session) -> Space: + try: + return SpaceService._get_owned_space(space_id, user_id, db) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @staticmethod + def _get_owned_project(user_id: int, project_id: str, db: Session) -> Project | None: + canonical_user_id = SpaceService.canonical_user_id(user_id) + return db.exec( + select(Project).where( + Project.id == project_id, + Project.user_id == canonical_user_id, + ) + ).first() + + @staticmethod + def _space_for_project(user_id: int, project_id: str, db: Session) -> Space | None: + project = RemoteControlService._get_owned_project(user_id, project_id, db) + if project: + return RemoteControlService._get_owned_space(user_id, project.space_id, db) + history = RemoteControlService._find_project_template(user_id, project_id, db) + if not history: + history = RemoteControlService._find_task_history(user_id, project_id, db) + if history and history.space_id: + return RemoteControlService._get_owned_space(user_id, history.space_id, db) + return None + + @staticmethod + def _session_space(session: RemoteControlSession, user_id: int, db: Session) -> Space: + if session.space_id: + return RemoteControlService._get_owned_space(user_id, session.space_id, db) + project_id, _, _, _ = RemoteControlService._effective_target(session) + if project_id: + space = RemoteControlService._space_for_project(user_id, project_id, db) + if space: + session.space_id = space.id + session.space_name_snapshot = space.name + db.add(session) + db.flush() + return space + space = SpaceService.ensure_legacy_space(user_id, db) + session.space_id = space.id + session.space_name_snapshot = space.name + db.add(session) + db.flush() + return space + + @staticmethod + def _ensure_folder_space(space: Space) -> None: + if space.source_type != SpaceSourceType.FOLDER: + raise HTTPException(status_code=400, detail={"code": "SPACE_NOT_FOLDER_BACKED"}) + + @staticmethod + def _ensure_project_in_session_space( + session: RemoteControlSession, + user_id: int, + project_id: str, + db: Session, + ) -> Project | None: + project = RemoteControlService._get_owned_project(user_id, project_id, db) + session_space_id = RemoteControlService._effective_space_id(session) + if project: + if session_space_id and project.space_id != session_space_id: + raise HTTPException(status_code=403, detail="Project is outside this remote Space") + return project + + history = RemoteControlService._find_project_template(user_id, project_id, db) + if not history: + history = RemoteControlService._find_task_history(user_id, project_id, db) + if not history: + raise HTTPException(status_code=403, detail="Project not found or access denied") + if session_space_id and history.space_id and history.space_id != session_space_id: + raise HTTPException(status_code=403, detail="Project is outside this remote Space") + return None + + @staticmethod + def _is_legacy_target_request(session: RemoteControlSession, normalized: dict[str, Any]) -> bool: + if session.current_brain_session_id is not None or not session.brain_session_id: + return False + if ( + normalized["target_project_id"] is None + and normalized["target_task_id"] is None + and normalized["target_brain_session_id"] is None + ): + return True + return ( + normalized["target_project_id"] == session.project_id + and normalized["target_brain_session_id"] == session.brain_session_id + and ( + normalized["target_task_id"] is None + or normalized["target_task_id"] == session.active_task_id + ) + ) + + @staticmethod + def _find_task_history(user_id: int, task_id: str, db: Session) -> ChatHistory | None: + return db.exec( + select(ChatHistory).where( + ChatHistory.user_id == user_id, + ChatHistory.task_id == task_id, + ) + ).first() + + @staticmethod + def _find_project_template(user_id: int, project_id: str, db: Session) -> ChatHistory | None: + return db.exec( + select(ChatHistory) + .where( + ChatHistory.user_id == user_id, + ChatHistory.project_id == project_id, + ) + .order_by(desc(ChatHistory.created_at), desc(ChatHistory.id)) + ).first() + + @staticmethod + def _ensure_project_owner(user_id: int, project_id: str, db: Session) -> ChatHistory: + project = RemoteControlService._get_owned_project(user_id, project_id, db) + history = RemoteControlService._find_project_template(user_id, project_id, db) + if not history: + history = RemoteControlService._find_task_history(user_id, project_id, db) + if not history: + if project: + return None # type: ignore[return-value] + raise HTTPException(status_code=403, detail="Project not found or access denied") + return history + + @staticmethod + def _ensure_target_owner( + user_id: int, + project_id: str, + task_id: str | None, + db: Session, + ) -> ChatHistory: + RemoteControlService._ensure_project_owner(user_id, project_id, db) + if not task_id: + template = RemoteControlService._find_project_template(user_id, project_id, db) + if not template: + template = RemoteControlService._find_task_history(user_id, project_id, db) + if not template: + if RemoteControlService._get_owned_project(user_id, project_id, db): + return None # type: ignore[return-value] + raise HTTPException(status_code=403, detail="Project not found or access denied") + return template + history = RemoteControlService._find_task_history(user_id, task_id, db) + if not history: + raise HTTPException(status_code=403, detail="Task not found or access denied") + history_project_id = history.project_id or history.task_id + if history_project_id != project_id: + raise HTTPException(status_code=403, detail="Task does not belong to target project") + return history + + @staticmethod + def verify_link( + session_id: str, + token: str | None, + user_id: int | None, + db: Session, + ) -> RemoteControlSession: + if not token: + raise HTTPException(status_code=400, detail="Remote control link token is required") + if user_id is None: + session = db.get(RemoteControlSession, session_id) + if not session: + raise HTTPException(status_code=404, detail="Remote control session not found") + RemoteControlService._ensure_active(session, db) + else: + session = RemoteControlService._owned_session(session_id, user_id, db) + link = db.exec( + select(RemoteControlLink) + .where( + RemoteControlLink.session_id == session_id, + RemoteControlLink.token_hash == _token_hash(token), + ) + ).first() + if not link or link.expires_at <= _now(): + raise HTTPException(status_code=403, detail="Remote control link is invalid or expired") + # Bump use_count only on first activation: every subsequent request + # (step polling, command, extend, ...) bumps last_remote_seen_at on + # the session instead. This keeps use_count meaningful as a "has-been- + # used" indicator for anomaly detection rather than a request counter. + if link.first_used_at is None: + link.first_used_at = _now() + link.use_count = (link.use_count or 0) + 1 + db.add(link) + session.last_remote_seen_at = _now() + db.add(session) + db.commit() + db.refresh(session) + return session + + @staticmethod + def create_session( + data: RemoteControlCreateSessionIn, + user_id: int, + db: Session, + ) -> RemoteControlCreateSessionOut: + if not RemoteControlRedis.is_bridge_online(data.desktop_instance_id, user_id): + raise HTTPException(status_code=409, detail={"code": "BRIDGE_OFFLINE"}) + + is_v2_request = any( + value is not None + for value in (data.initial_project_id, data.initial_task_id, data.initial_history_id) + ) or data.space_id is not None or not (data.project_id and data.active_task_id) + target_project_id = data.initial_project_id if data.initial_project_id is not None else data.project_id + target_task_id = data.initial_task_id if data.initial_task_id is not None else data.active_task_id + target_history_id = data.initial_history_id if is_v2_request else None + target_history: ChatHistory | None = None + if target_project_id: + target_history = RemoteControlService._ensure_target_owner(user_id, target_project_id, target_task_id, db) + if target_history and target_task_id is None: + target_task_id = target_history.task_id + if target_history and target_history_id is None and target_history.id is not None: + target_history_id = str(target_history.id) + if data.space_id: + space = RemoteControlService._get_owned_space(user_id, data.space_id, db) + elif target_project_id: + space = RemoteControlService._space_for_project(user_id, target_project_id, db) + if not space: + space = SpaceService.ensure_legacy_space(user_id, db) + else: + space = SpaceService.ensure_legacy_space(user_id, db) + if target_project_id: + project = RemoteControlService._get_owned_project(user_id, target_project_id, db) + if project and project.space_id != space.id: + raise HTTPException(status_code=403, detail="Project is outside this remote Space") + if target_history and target_history.space_id and target_history.space_id != space.id: + raise HTTPException(status_code=403, detail="Project is outside this remote Space") + + expires_at = _now() + timedelta(seconds=data.expires_in_seconds) + title = _truncate(data.title or space.name or "Eigent Desktop", REMOTE_SESSION_TITLE_MAX_LENGTH) + current_brain_session_id = _id("rc_brain") if is_v2_request and target_project_id else None + legacy_brain_session_id = data.brain_session_id or (_id("rc_brain") if target_project_id else None) + session = None + if not is_v2_request and target_project_id and target_task_id: + session = db.exec( + select(RemoteControlSession).where( + RemoteControlSession.user_id == user_id, + RemoteControlSession.desktop_instance_id == data.desktop_instance_id, + RemoteControlSession.project_id == target_project_id, + RemoteControlSession.active_task_id == target_task_id, + RemoteControlSession.status == SESSION_ACTIVE, + ) + ).first() + if session is None: + session = RemoteControlSession( + id=_id("rcs"), + user_id=user_id, + desktop_instance_id=data.desktop_instance_id, + space_id=space.id, + space_name_snapshot=space.name, + project_id=target_project_id if (not is_v2_request or _legacy_dual_write_enabled()) else None, + active_task_id=target_task_id if (not is_v2_request or _legacy_dual_write_enabled()) else None, + brain_session_id=( + current_brain_session_id + if is_v2_request and _legacy_dual_write_enabled() + else (legacy_brain_session_id if not is_v2_request else None) + ), + current_project_id=target_project_id if is_v2_request else None, + current_task_id=target_task_id if is_v2_request else None, + current_history_id=target_history_id if is_v2_request else None, + current_brain_session_id=current_brain_session_id, + last_target_project_id=target_project_id, + last_target_task_id=target_task_id, + last_target_history_id=target_history_id, + last_target_brain_session_id=current_brain_session_id or legacy_brain_session_id, + title=title, + status=SESSION_ACTIVE, + bridge_status="online", + execution_mode="desktop_ui", + expires_at=expires_at, + last_bridge_seen_at=_now(), + capabilities=DEFAULT_CAPABILITIES, + ) + else: + session.brain_session_id = data.brain_session_id or session.brain_session_id or legacy_brain_session_id + session.space_id = space.id + session.space_name_snapshot = space.name + session.title = title or session.title + session.expires_at = expires_at + session.bridge_status = "online" + session.last_bridge_seen_at = _now() + session.last_target_project_id = target_project_id or session.last_target_project_id + session.last_target_task_id = target_task_id or session.last_target_task_id + session.last_target_history_id = target_history_id or session.last_target_history_id + session.last_target_brain_session_id = ( + current_brain_session_id + or legacy_brain_session_id + or session.last_target_brain_session_id + ) + + token = secrets.token_urlsafe(32) + link = RemoteControlLink( + id=_id("rcl"), + session_id=session.id, + token_hash=_token_hash(token), + expires_at=expires_at, + ) + db.add(session) + db.add(link) + db.commit() + db.refresh(session) + RemoteControlService.record_event( + session.id, + "session_created", + { + "user_id": user_id, + "desktop_instance_id": session.desktop_instance_id, + "space_id": session.space_id, + "project_id": session.project_id, + "active_task_id": session.active_task_id, + "current_project_id": session.current_project_id, + "current_task_id": session.current_task_id, + "expires_at": _utc_iso(session.expires_at), + }, + db, + ) + return RemoteControlCreateSessionOut( + session_id=session.id, + url=RemoteControlService.remote_url(session.id, token), + expires_at=session.expires_at, + bridge_status=session.bridge_status, + space_id=session.space_id, + space_name=session.space_name_snapshot, + current_project_id=session.current_project_id, + current_task_id=session.current_task_id, + current_history_id=session.current_history_id, + current_brain_session_id=session.current_brain_session_id, + ) + + @staticmethod + def to_session_out(session: RemoteControlSession) -> RemoteControlSessionOut: + project_id, task_id, history_id, brain_session_id = RemoteControlService._effective_target(session) + return RemoteControlSessionOut( + session_id=session.id, + desktop_instance_id=session.desktop_instance_id, + space_id=session.space_id, + space_name=session.space_name_snapshot, + project_id=project_id, + active_task_id=task_id, + brain_session_id=brain_session_id, + current_project_id=project_id, + current_task_id=task_id, + current_history_id=history_id, + current_brain_session_id=brain_session_id, + title=session.title, + status=session.status, + bridge_status="online" + if RemoteControlRedis.is_bridge_online(session.desktop_instance_id, session.user_id) + else "offline", + execution_mode=session.execution_mode, + capabilities=session.capabilities or DEFAULT_CAPABILITIES, + created_at=session.created_at, + expires_at=session.expires_at, + ) + + @staticmethod + def extend_session(session_id: str, user_id: int, extend_seconds: int, db: Session) -> datetime: + if extend_seconds not in {86400, 259200, 604800}: + raise HTTPException(status_code=400, detail="Unsupported extension duration") + session = RemoteControlService._owned_session(session_id, user_id, db) + max_expires_at = (session.created_at or _now()) + timedelta(days=7) + requested = session.expires_at + timedelta(seconds=extend_seconds) + if requested > max_expires_at: + raise HTTPException(status_code=400, detail="Remote control session cannot exceed 7 days") + session.expires_at = requested + db.add(session) + links = db.exec(select(RemoteControlLink).where(RemoteControlLink.session_id == session.id)).all() + for link in links: + link.expires_at = min(link.expires_at + timedelta(seconds=extend_seconds), session.expires_at) + db.add(link) + db.commit() + db.refresh(session) + return session.expires_at + + @staticmethod + def patch_target( + session_id: str, + user_id: int, + data: RemoteControlPatchTargetIn, + db: Session, + ) -> RemoteControlPatchTargetOut: + session = RemoteControlService._owned_session(session_id, user_id, db) + if not RemoteControlRedis.is_bridge_online(session.desktop_instance_id, user_id): + raise HTTPException(status_code=409, detail={"code": "BRIDGE_OFFLINE"}) + RemoteControlService._ensure_project_in_session_space(session, user_id, data.project_id, db) + history = RemoteControlService._ensure_target_owner(user_id, data.project_id, data.task_id, db) + current_task_id = data.task_id or (history.task_id if history else None) + current_history_id = data.history_id or (str(history.id) if history and history.id is not None else None) + previous_project_id, previous_task_id, previous_history_id, previous_brain_session_id = ( + RemoteControlService._effective_target(session) + ) + current_brain_session_id = _id("rc_brain") + + session.current_project_id = data.project_id + session.current_task_id = current_task_id + session.current_history_id = current_history_id + session.current_brain_session_id = current_brain_session_id + session.last_target_project_id = data.project_id + session.last_target_task_id = current_task_id + session.last_target_history_id = current_history_id + session.last_target_brain_session_id = current_brain_session_id + if _legacy_dual_write_enabled(): + session.project_id = data.project_id + session.active_task_id = current_task_id + session.brain_session_id = current_brain_session_id + db.add(session) + switch_payload = RemoteControlService._enrich_switch_project_payload( + user_id, + data.project_id, + { + "target_project_id": data.project_id, + "target_task_id": current_task_id, + "target_history_id": current_history_id, + "previous_project_id": previous_project_id, + "previous_task_id": previous_task_id, + "previous_history_id": previous_history_id, + "previous_brain_session_id": previous_brain_session_id, + }, + db, + ) + command = RemoteControlCommand( + id=_id("rc_cmd"), + session_id=session.id, + user_id=user_id, + source_channel="remote_web", + type=SWITCH_PROJECT_VIEW, + payload=switch_payload, + space_id=session.space_id, + target_project_id=data.project_id, + target_task_id=current_task_id, + target_brain_session_id=current_brain_session_id, + status=COMMAND_PENDING, + ) + db.add(command) + RemoteControlService.record_event( + session.id, + "target_changed", + { + "from_project_id": previous_project_id, + "to_project_id": data.project_id, + "from_task_id": previous_task_id, + "to_task_id": current_task_id, + "new_brain_session_id": current_brain_session_id, + }, + db, + commit=False, + ) + db.commit() + db.refresh(session) + db.refresh(command) + RemoteControlService.publish_status( + session.id, + "target_changed", + { + "space_id": session.space_id, + "current_project_id": session.current_project_id or data.project_id, + "current_task_id": session.current_task_id, + "current_history_id": session.current_history_id, + "current_brain_session_id": ( + session.current_brain_session_id or current_brain_session_id + ), + "previous_project_id": previous_project_id, + "previous_task_id": previous_task_id, + }, + ) + RemoteControlService.publish_command(session, command) + return RemoteControlPatchTargetOut( + space_id=session.space_id, + current_project_id=session.current_project_id or data.project_id, + current_task_id=session.current_task_id, + current_history_id=session.current_history_id, + current_brain_session_id=session.current_brain_session_id or current_brain_session_id, + desktop_ready="pending", + ) + + @staticmethod + def revoke_session(session_id: str, user_id: int, db: Session) -> None: + session = RemoteControlService._owned_session(session_id, user_id, db) + now = _now() + session.status = SESSION_REVOKED + session.revoked_at = now + db.add(session) + links = db.exec( + select(RemoteControlLink).where( + RemoteControlLink.session_id == session.id, + RemoteControlLink.expires_at > now, + ) + ).all() + for link in links: + link.expires_at = now + db.add(link) + RemoteControlService.record_event( + session.id, + "session_revoked", + {"user_id": user_id, "reason": "owner_request"}, + db, + commit=False, + ) + db.commit() + RemoteControlService.publish_status(session.id, "session_revoked", {"session_id": session.id}) + + @staticmethod + def revoke_user_sessions(user_id: int, db: Session, reason: str) -> int: + sessions = db.exec( + select(RemoteControlSession).where( + RemoteControlSession.user_id == user_id, + RemoteControlSession.status == SESSION_ACTIVE, + ) + ).all() + now = _now() + session_ids = [session.id for session in sessions] + if session_ids: + links = db.exec( + select(RemoteControlLink).where( + RemoteControlLink.session_id.in_(session_ids), + RemoteControlLink.expires_at > now, + ) + ).all() + for link in links: + link.expires_at = now + db.add(link) + for session in sessions: + session.status = SESSION_REVOKED + session.revoked_at = now + db.add(session) + RemoteControlService.record_event( + session.id, + "session_revoked", + {"user_id": user_id, "reason": reason}, + db, + commit=False, + ) + db.commit() + for session in sessions: + RemoteControlService.publish_status( + session.id, + "session_revoked", + {"session_id": session.id, "reason": reason}, + ) + return len(sessions) + + @staticmethod + def _history_template( + user_id: int, + target_project_id: str, + target_task_id: str | None, + db: Session, + ) -> ChatHistory | SimpleNamespace: + if target_task_id: + active = RemoteControlService._find_task_history(user_id, target_task_id, db) + if active: + return active + fallback = RemoteControlService._find_project_template(user_id, target_project_id, db) + if not fallback: + fallback = db.exec( + select(ChatHistory) + .where(ChatHistory.user_id == user_id) + .order_by(desc(ChatHistory.created_at), desc(ChatHistory.id)) + ).first() + if fallback: + return fallback + + provider = db.exec( + select(Provider) + .where(Provider.user_id == user_id, Provider.prefer == True, Provider.no_delete()) # noqa: E712 + .order_by(desc(Provider.updated_at), desc(Provider.id)) + ).first() + if not provider: + raise HTTPException( + status_code=400, + detail={"code": "REMOTE_MODEL_PROVIDER_REQUIRED"}, + ) + return SimpleNamespace( + language="en", + model_platform=provider.provider_name, + model_type=provider.model_type or "", + api_key=provider.api_key or "", + api_url=provider.endpoint_url or "", + max_retries=3, + file_save_path=None, + installed_mcp={}, + project_name="", + ) + + @staticmethod + def _create_history_for_command( + user_id: int, + space_id: str | None, + target_project_id: str, + target_task_id: str | None, + command: RemoteControlCommandIn, + next_task_id: str, + db: Session, + ) -> ChatHistory: + template = RemoteControlService._history_template(user_id, target_project_id, target_task_id, db) + history = ChatHistory( + user_id=user_id, + task_id=next_task_id, + project_id=target_project_id, + space_id=space_id, + question=str(command.payload.get("content") or command.payload.get("question") or ""), + language=template.language, + model_platform=template.model_platform, + model_type=template.model_type, + api_key=template.api_key, + api_url=template.api_url, + max_retries=template.max_retries, + file_save_path=template.file_save_path, + installed_mcp=template.installed_mcp, + project_name=template.project_name, + summary="", + tokens=0, + spend=0, + status=ChatStatus.ongoing.value, + ) + db.add(history) + return history + + @staticmethod + def _enrich_switch_project_payload( + user_id: int, + target_project_id: str, + payload: dict[str, Any], + db: Session, + ) -> dict[str, Any]: + project = RemoteControlService._get_owned_project(user_id, target_project_id, db) + space = RemoteControlService._space_for_project(user_id, target_project_id, db) + project_group = None + try: + project_group = ChatService.get_grouped_project(user_id, target_project_id, True, db) + except Exception: + project_group = None + + tasks = project_group.tasks if project_group and project_group.tasks else [] + task_ids = [task.task_id for task in tasks] + representative = tasks[0] if tasks else None + if not project and not representative: + raise HTTPException(status_code=404, detail="Project not found") + project_name = ( + (project_group.project_name if project_group else None) + or (project.name if project else None) + or target_project_id + ) + enriched = dict(payload) + enriched.update( + { + "space_id": space.id if space else None, + "space": SpaceOut.from_model(space).model_dump(mode="json") if space else None, + "project": ProjectOut.from_model(project).model_dump(mode="json") if project else None, + "target_project_id": target_project_id, + "task_ids": task_ids, + "question": ( + (project_group.last_prompt if project_group else None) + or (representative.question if representative else None) + or (project.description if project else None) + or project_name + or "" + ), + "history_id": str(representative.id) if representative and representative.id is not None else None, + "project_name": project_name, + } + ) + return enriched + + @staticmethod + def _enqueue_bridge_command( + session: RemoteControlSession, + user_id: int, + command_type: str, + payload: dict[str, Any], + db: Session, + *, + target_project_id: str | None = None, + target_task_id: str | None = None, + target_brain_session_id: str | None = None, + source_channel: str = "remote_web", + ) -> RemoteControlCommandOut: + command = RemoteControlCommand( + id=_id("rc_cmd"), + session_id=session.id, + user_id=user_id, + source_channel=source_channel, + type=command_type, + payload=payload, + space_id=session.space_id, + target_project_id=target_project_id, + target_task_id=target_task_id, + target_brain_session_id=target_brain_session_id, + status=COMMAND_PENDING, + ) + db.add(command) + db.commit() + db.refresh(command) + RemoteControlService.record_event( + session.id, + "command_created", + { + "command_id": command.id, + "type": command.type, + "space_id": command.space_id, + "target_project_id": command.target_project_id, + }, + db, + ) + RemoteControlService.publish_command(session, command) + return RemoteControlCommandOut(command_id=command.id, status=command.status) + + @staticmethod + def _restore_switch_target_if_current(command: RemoteControlCommand, db: Session) -> dict[str, Any]: + payload = getattr(command, "payload", None) or {} + if "previous_project_id" not in payload or "previous_brain_session_id" not in payload: + return {} + + session = db.get(RemoteControlSession, command.session_id) + if not session: + return {} + # Only roll back when the session still points at the exact target + # produced by this failed switch. This protects a newer switch in the + # same project from being overwritten by an older failed command. + if ( + session.current_project_id != command.target_project_id + or session.current_task_id != command.target_task_id + or session.current_brain_session_id != command.target_brain_session_id + ): + return {} + + previous_project_id = payload.get("previous_project_id") + previous_task_id = payload.get("previous_task_id") + previous_history_id = payload.get("previous_history_id") + previous_brain_session_id = payload.get("previous_brain_session_id") + session.current_project_id = previous_project_id + session.current_task_id = previous_task_id + session.current_history_id = previous_history_id + session.current_brain_session_id = previous_brain_session_id + session.last_target_project_id = previous_project_id + session.last_target_task_id = previous_task_id + session.last_target_history_id = previous_history_id + session.last_target_brain_session_id = previous_brain_session_id + if _legacy_dual_write_enabled(): + session.project_id = previous_project_id + session.active_task_id = previous_task_id + session.brain_session_id = previous_brain_session_id + db.add(session) + return { + "restored_project_id": previous_project_id, + "restored_task_id": previous_task_id, + "restored_history_id": previous_history_id, + "restored_brain_session_id": previous_brain_session_id, + } + + @staticmethod + def list_projects( + session_id: str, + user_id: int, + db: Session, + ) -> RemoteControlProjectListOut: + session = RemoteControlService._owned_session(session_id, user_id, db) + space = RemoteControlService._session_space(session, user_id, db) + projects = SpaceService.list_projects(space.id, user_id, db) + return RemoteControlProjectListOut(space=SpaceOut.from_model(space), items=projects) + + @staticmethod + def create_project( + session_id: str, + user_id: int, + data: RemoteControlCreateProjectIn, + db: Session, + ) -> ProjectOut: + session = RemoteControlService._owned_session(session_id, user_id, db) + if not RemoteControlRedis.is_bridge_online(session.desktop_instance_id, user_id): + raise HTTPException(status_code=409, detail={"code": "BRIDGE_OFFLINE"}) + space = RemoteControlService._session_space(session, user_id, db) + project = SpaceService.create_project( + space.id, + ProjectIn( + name=data.name, + description=data.description, + mode=data.mode or ProjectMode.SINGLE_AGENT, + workdir_mode=data.workdir_mode, + metadata=data.metadata, + ), + user_id, + db, + ) + project_out = ProjectOut.from_model(project) + RemoteControlService._enqueue_bridge_command( + session, + user_id, + SPACE_PROJECT_UPSERT, + { + "space": SpaceOut.from_model(space).model_dump(mode="json"), + "project": project_out.model_dump(mode="json"), + }, + db, + target_project_id=project.id, + ) + RemoteControlService.publish_status( + session.id, + "space_project_upserted", + {"space_id": space.id, "project": project_out.model_dump(mode="json")}, + ) + return project_out + + @staticmethod + def list_overlays( + session_id: str, + user_id: int, + project_id: str, + run_id: str | None, + db: Session, + ): + session = RemoteControlService._owned_session(session_id, user_id, db) + space = RemoteControlService._session_space(session, user_id, db) + RemoteControlService._ensure_folder_space(space) + RemoteControlService._ensure_project_in_session_space(session, user_id, project_id, db) + try: + return SpaceOverlayService.list_overlays( + space.id, + project_id, + user_id, + db, + run_id=run_id, + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @staticmethod + def enqueue_apply_project( + session_id: str, + user_id: int, + project_id: str, + data: RemoteControlFolderApplyIn, + db: Session, + ) -> RemoteControlFolderOperationOut: + if not data.confirm: + raise HTTPException(status_code=400, detail={"code": "REMOTE_CONFIRM_REQUIRED"}) + session = RemoteControlService._owned_session(session_id, user_id, db) + if not RemoteControlRedis.is_bridge_online(session.desktop_instance_id, user_id): + raise HTTPException(status_code=409, detail={"code": "BRIDGE_OFFLINE"}) + space = RemoteControlService._session_space(session, user_id, db) + RemoteControlService._ensure_folder_space(space) + RemoteControlService._ensure_project_in_session_space(session, user_id, project_id, db) + out = RemoteControlService._enqueue_bridge_command( + session, + user_id, + SPACE_APPLY_PROJECT_RUN, + { + "space_id": space.id, + "project_id": project_id, + "run_id": data.run_id, + "paths": data.paths, + "force_resolutions": [ + item.model_dump(mode="json") for item in (data.force_resolutions or []) + ], + }, + db, + target_project_id=project_id, + ) + return RemoteControlFolderOperationOut(command_id=out.command_id, status=out.status) + + @staticmethod + def enqueue_discard_project_overlays( + session_id: str, + user_id: int, + project_id: str, + data: RemoteControlFolderDiscardIn, + db: Session, + ) -> RemoteControlFolderOperationOut: + if not data.confirm: + raise HTTPException(status_code=400, detail={"code": "REMOTE_CONFIRM_REQUIRED"}) + session = RemoteControlService._owned_session(session_id, user_id, db) + if not RemoteControlRedis.is_bridge_online(session.desktop_instance_id, user_id): + raise HTTPException(status_code=409, detail={"code": "BRIDGE_OFFLINE"}) + space = RemoteControlService._session_space(session, user_id, db) + RemoteControlService._ensure_folder_space(space) + RemoteControlService._ensure_project_in_session_space(session, user_id, project_id, db) + out = RemoteControlService._enqueue_bridge_command( + session, + user_id, + SPACE_DISCARD_PROJECT_OVERLAYS, + { + "space_id": space.id, + "project_id": project_id, + "run_id": data.run_id, + "paths": data.paths, + }, + db, + target_project_id=project_id, + ) + return RemoteControlFolderOperationOut(command_id=out.command_id, status=out.status) + + @staticmethod + def enqueue_refresh_project( + session_id: str, + user_id: int, + project_id: str, + data: RemoteControlFolderRefreshIn, + db: Session, + ) -> RemoteControlFolderOperationOut: + session = RemoteControlService._owned_session(session_id, user_id, db) + if not RemoteControlRedis.is_bridge_online(session.desktop_instance_id, user_id): + raise HTTPException(status_code=409, detail={"code": "BRIDGE_OFFLINE"}) + space = RemoteControlService._session_space(session, user_id, db) + RemoteControlService._ensure_folder_space(space) + RemoteControlService._ensure_project_in_session_space(session, user_id, project_id, db) + out = RemoteControlService._enqueue_bridge_command( + session, + user_id, + SPACE_REFRESH_PROJECT, + {"space_id": space.id, "project_id": project_id, "force": data.force}, + db, + target_project_id=project_id, + ) + return RemoteControlFolderOperationOut(command_id=out.command_id, status=out.status) + + @staticmethod + def send_command( + session_id: str, + user_id: int, + data: RemoteControlCommandIn, + db: Session, + ) -> RemoteControlCommandOut: + session = RemoteControlService._owned_session(session_id, user_id, db) + if not RemoteControlRedis.is_bridge_online(session.desktop_instance_id, user_id): + raise HTTPException(status_code=409, detail={"code": "BRIDGE_OFFLINE"}) + session_space = RemoteControlService._session_space(session, user_id, db) + + normalized = { + "space_id": data.space_id or session_space.id, + "target_project_id": data.target_project_id, + "target_task_id": data.target_task_id, + "target_brain_session_id": data.target_brain_session_id, + "type": data.type, + "payload": dict(data.payload or {}), + } + if data.space_id and data.space_id != session_space.id: + raise HTTPException(status_code=403, detail="Command is outside this remote Space") + is_legacy_request = ( + normalized["target_project_id"] is None + and normalized["target_task_id"] is None + and normalized["target_brain_session_id"] is None + ) + is_legacy_target = RemoteControlService._is_legacy_target_request(session, normalized) + if is_legacy_target and is_legacy_request: + normalized["target_project_id"] = session.project_id + normalized["target_task_id"] = session.active_task_id + normalized["target_brain_session_id"] = session.brain_session_id + + target_project_id = normalized["target_project_id"] + target_task_id = normalized["target_task_id"] + target_brain_session_id = normalized["target_brain_session_id"] + command_type = normalized["type"] + if not target_project_id: + raise HTTPException(status_code=400, detail={"code": "REMOTE_TARGET_REQUIRED"}) + if command_type not in NON_BRAIN_COMMANDS and not target_brain_session_id: + raise HTTPException(status_code=400, detail={"code": "REMOTE_BRAIN_SESSION_REQUIRED"}) + if ( + not is_legacy_target + and target_brain_session_id + and not str(target_brain_session_id).startswith("rc_brain_") + ): + raise HTTPException(status_code=400, detail={"code": "REMOTE_BRAIN_SESSION_FORMAT_INVALID"}) + if command_type == "remove_task" and not normalized["payload"].get("task_id"): + raise HTTPException(status_code=400, detail={"code": "REMOTE_TASK_ID_REQUIRED"}) + + RemoteControlService._ensure_project_in_session_space(session, user_id, target_project_id, db) + RemoteControlService._ensure_target_owner(user_id, target_project_id, target_task_id, db) + if command_type == SWITCH_PROJECT_VIEW: + normalized["payload"] = RemoteControlService._enrich_switch_project_payload( + user_id, + target_project_id, + normalized["payload"], + db, + ) + + next_task_id = _id("task") if data.type == "user_message" else None + command = RemoteControlCommand( + id=_id("rc_cmd"), + session_id=session.id, + user_id=user_id, + source_channel=data.source_channel, + type=command_type, + payload=normalized["payload"], + space_id=normalized["space_id"], + next_task_id=next_task_id, + target_project_id=target_project_id, + target_task_id=target_task_id, + target_brain_session_id=target_brain_session_id, + status=COMMAND_PENDING, + ) + + if next_task_id: + try: + history = RemoteControlService._create_history_for_command( + user_id, + normalized["space_id"], + target_project_id, + target_task_id, + data, + next_task_id, + db, + ) + db.flush() + normalized["payload"]["remote_history_id"] = history.id + command.payload = normalized["payload"] + except HTTPException: + raise + db.add(command) + db.commit() + db.refresh(command) + RemoteControlService.record_event( + session.id, + "command_created", + { + "command_id": command.id, + "type": command.type, + "source_channel": command.source_channel, + "next_task_id": command.next_task_id, + "target_project_id": command.target_project_id, + "target_task_id": command.target_task_id, + "is_legacy_target": is_legacy_target, + }, + db, + ) + + RemoteControlService.publish_command(session, command) + return RemoteControlCommandOut( + command_id=command.id, + status=command.status, + next_task_id=command.next_task_id, + ) + + @staticmethod + def command_payload(session: RemoteControlSession, command: RemoteControlCommand) -> dict[str, Any]: + project_id = command.target_project_id or session.current_project_id or session.project_id + task_id = command.target_task_id or session.current_task_id or session.active_task_id + brain_session_id = command.target_brain_session_id or session.current_brain_session_id or session.brain_session_id + payload: dict[str, Any] = { + "type": "remote_command", + "command": { + "id": command.id, + "session_id": session.id, + "user_id": session.user_id, + "desktop_instance_id": session.desktop_instance_id, + "space_id": command.space_id or session.space_id, + "project_id": project_id, + "active_task_id": task_id, + "brain_session_id": brain_session_id, + "target_project_id": command.target_project_id, + "target_task_id": command.target_task_id, + "target_brain_session_id": command.target_brain_session_id, + "source_channel": command.source_channel, + "type": command.type, + "payload": command.payload, + }, + } + if command.next_task_id: + payload["command"]["next_task_id"] = command.next_task_id + return payload + + @staticmethod + def publish_command(session: RemoteControlSession, command: RemoteControlCommand) -> bool: + had_subscriber = RemoteControlRedis.publish( + RemoteControlRedis.command_channel(session.desktop_instance_id), + RemoteControlService.command_payload(session, command), + ) + logger.info( + "[RC-TRACE] command published", + extra={ + "command_id": command.id, + "type": command.type, + "session_id": session.id, + "desktop_instance_id": session.desktop_instance_id, + "had_subscriber": had_subscriber, + }, + ) + return had_subscriber + + @staticmethod + def publish_status(session_id: str, event_type: str, payload: dict[str, Any]) -> bool: + return RemoteControlRedis.publish( + RemoteControlRedis.ack_channel(session_id), + {"type": event_type, "session_id": session_id, **payload}, + ) + + @staticmethod + def mark_delivered(command_id: str, db: Session) -> RemoteControlCommand | None: + command = db.get(RemoteControlCommand, command_id) + if not command: + return None + if command.status == COMMAND_PENDING: + command.status = COMMAND_DELIVERED + command.delivered_at = _now() + db.add(command) + RemoteControlService.record_event( + command.session_id, + "command_delivered", + {"command_id": command.id, "type": command.type}, + db, + commit=False, + ) + db.commit() + db.refresh(command) + RemoteControlService.publish_status( + command.session_id, + "command_status", + {"command_id": command.id, "status": command.status}, + ) + return command + + @staticmethod + def mark_ack( + command_id: str, + status: str, + error_code: str | None, + error: str | None, + db: Session, + result: dict[str, Any] | None = None, + ) -> RemoteControlCommand | None: + command = db.get(RemoteControlCommand, command_id) + if not command: + return None + if command.status in {COMMAND_ACKNOWLEDGED, COMMAND_FAILED, COMMAND_EXPIRED}: + RemoteControlService.publish_status( + command.session_id, + "command_status", + { + "command_id": command.id, + "status": command.status, + "error_code": command.error_code, + "error": command.error, + "result": result, + }, + ) + return command + command.status = COMMAND_ACKNOWLEDGED if status == COMMAND_ACKNOWLEDGED else COMMAND_FAILED + command.error_code = error_code + command.error = error + command.acknowledged_at = _now() + restored_payload: dict[str, Any] = {} + if command.type == SWITCH_PROJECT_VIEW and command.status == COMMAND_FAILED: + restored_payload = RemoteControlService._restore_switch_target_if_current(command, db) + db.add(command) + RemoteControlService.record_event( + command.session_id, + "command_ack" if command.status == COMMAND_ACKNOWLEDGED else "command_error", + { + "command_id": command.id, + "type": command.type, + "status": command.status, + "error_code": command.error_code, + "error": command.error, + "result": result, + }, + db, + commit=False, + ) + db.commit() + db.refresh(command) + status_payload = { + "command_id": command.id, + "status": command.status, + "error_code": command.error_code, + "error": command.error, + "result": result, + } + RemoteControlService.publish_status(command.session_id, "command_status", status_payload) + if command.type == SWITCH_PROJECT_VIEW: + target_payload = { + "space_id": command.space_id, + "current_project_id": command.target_project_id, + "current_task_id": command.target_task_id, + "current_brain_session_id": command.target_brain_session_id, + "command_id": command.id, + } + if command.status == COMMAND_ACKNOWLEDGED: + RemoteControlService.publish_status( + command.session_id, + "desktop_target_ready", + target_payload, + ) + else: + RemoteControlService.publish_status( + command.session_id, + "desktop_target_failed", + { + **target_payload, + "error_code": command.error_code, + "error": command.error, + **restored_payload, + }, + ) + return command + + @staticmethod + def record_event( + session_id: str, + event_type: str, + payload: dict[str, Any], + db: Session, + commit: bool = True, + ) -> None: + db.add( + RemoteControlEvent( + id=_id("rc_evt"), + session_id=session_id, + type=event_type, + payload=payload, + ) + ) + if commit: + db.commit() + + @staticmethod + def list_steps( + session_id: str, + user_id: int, + project_id: str | None, + since: int, + limit: int, + order: str, + db: Session, + ) -> RemoteControlStepsOut: + session = RemoteControlService._owned_session(session_id, user_id, db) + effective_project_id, _, _, _ = RemoteControlService._effective_target(session) + target_project_id = project_id or effective_project_id + if not target_project_id: + raise HTTPException(status_code=400, detail={"code": "REMOTE_TARGET_REQUIRED"}) + RemoteControlService._ensure_project_in_session_space(session, user_id, target_project_id, db) + limit = min(max(limit, 1), 1000) + histories = db.exec( + select(ChatHistory).where( + ChatHistory.user_id == user_id, + ChatHistory.project_id == target_project_id, + ) + .order_by(ChatHistory.created_at.asc(), ChatHistory.id.asc()) + ).all() + task_ids = [history.task_id for history in histories if history.task_id] + if not task_ids: + legacy_history = RemoteControlService._find_task_history(user_id, target_project_id, db) + if legacy_history: + task_ids = [legacy_history.task_id] + if not task_ids: + return RemoteControlStepsOut(items=[], has_more=False, next_since=since) + stmt = select(ChatStep).where(ChatStep.task_id.in_(task_ids), ChatStep.id > since) + stmt = stmt.order_by(ChatStep.id.desc() if order == "desc" else ChatStep.id.asc()).limit(limit + 1) + rows = list(db.exec(stmt).all()) + has_more = len(rows) > limit + rows = rows[:limit] + if order == "desc": + rows = list(reversed(rows)) + project_by_task = {task_id: target_project_id for task_id in task_ids} + items = [ + RemoteControlStepOut( + step_id=row.id, + task_id=row.task_id, + project_id=project_by_task.get(row.task_id), + step=row.step, + data=row.data, + timestamp=row.timestamp, + ) + for row in rows + ] + next_since = items[-1].step_id if items else since + return RemoteControlStepsOut(items=items, has_more=has_more, next_since=next_since) + + @staticmethod + def publish_chat_step(step: ChatStep, db: Session) -> None: + history = db.exec(select(ChatHistory).where(ChatHistory.task_id == step.task_id)).first() + if not history: + logger.warning("Skipping remote-control step publish for orphan step", extra={"task_id": step.task_id}) + return + project_id = history.project_id or history.task_id + RemoteControlRedis.publish( + RemoteControlRedis.step_channel(project_id), + { + "type": "step", + "project_id": project_id, + "task_id": step.task_id, + "step_id": step.id, + "step": step.step, + "data": step.data, + "timestamp": step.timestamp, + }, + ) + + @staticmethod + def retry_pending_commands(db: Session) -> None: + cutoff = _now() - timedelta(seconds=30) + commands = db.exec( + select(RemoteControlCommand).where( + RemoteControlCommand.status == COMMAND_PENDING, + RemoteControlCommand.created_at < cutoff, + ) + ).all() + for command in commands: + session = db.get(RemoteControlSession, command.session_id) + if not session or session.status != SESSION_ACTIVE: + continue + if not RemoteControlRedis.is_bridge_online(session.desktop_instance_id, session.user_id): + logger.warning( + "[RC-TRACE] pending command waiting, bridge offline", + extra={ + "command_id": command.id, + "session_id": command.session_id, + "age_seconds": (_now() - command.created_at).total_seconds(), + }, + ) + continue + logger.warning( + "[RC-TRACE] re-publishing stuck pending command", + extra={ + "command_id": command.id, + "session_id": command.session_id, + "age_seconds": (_now() - command.created_at).total_seconds(), + }, + ) + RemoteControlService.publish_command(session, command) + + @staticmethod + def expire_timed_out_commands(db: Session) -> None: + now = _now() + delivered_cutoff = now - timedelta( + seconds=min(COMMAND_ACK_TIMEOUT_SECONDS, SWITCH_PROJECT_VIEW_ACK_TIMEOUT_SECONDS) + ) + pending_cutoff = now - timedelta( + seconds=min(COMMAND_ACK_TIMEOUT_SECONDS, SWITCH_PROJECT_VIEW_ACK_TIMEOUT_SECONDS) + ) + commands = list( + db.exec( + select(RemoteControlCommand).where( + RemoteControlCommand.status == COMMAND_DELIVERED, + RemoteControlCommand.delivered_at < delivered_cutoff, + ) + ).all() + ) + commands.extend( + db.exec( + select(RemoteControlCommand).where( + RemoteControlCommand.status == COMMAND_PENDING, + RemoteControlCommand.created_at < pending_cutoff, + ) + ).all() + ) + + seen_command_ids: set[str] = set() + for command in commands: + if command.id in seen_command_ids: + continue + seen_command_ids.add(command.id) + + timeout_seconds = ( + SWITCH_PROJECT_VIEW_ACK_TIMEOUT_SECONDS + if command.type == SWITCH_PROJECT_VIEW + else COMMAND_ACK_TIMEOUT_SECONDS + ) + if command.status == COMMAND_PENDING: + if command.created_at >= now - timedelta(seconds=timeout_seconds): + continue + error_code = "PENDING_TIMEOUT" + error_message = "Remote command was not delivered before timeout" + else: + if not command.delivered_at or command.delivered_at >= now - timedelta(seconds=timeout_seconds): + continue + error_code = "BRIDGE_TIMEOUT" + error_message = "Remote command delivery timed out" + command.status = COMMAND_FAILED + command.error_code = error_code + command.error = error_message + command.acknowledged_at = _now() + restored_payload: dict[str, Any] = {} + if command.type == SWITCH_PROJECT_VIEW: + restored_payload = RemoteControlService._restore_switch_target_if_current(command, db) + db.add(command) + RemoteControlService.record_event( + command.session_id, + "command_error", + { + "command_id": command.id, + "type": command.type, + "status": command.status, + "error_code": command.error_code, + "error": command.error, + }, + db, + commit=False, + ) + RemoteControlService.publish_status( + command.session_id, + "command_status", + { + "command_id": command.id, + "status": command.status, + "error_code": command.error_code, + "error": command.error, + }, + ) + if command.type == SWITCH_PROJECT_VIEW: + RemoteControlService.publish_status( + command.session_id, + "desktop_target_failed", + { + "space_id": command.space_id, + "current_project_id": command.target_project_id, + "current_task_id": command.target_task_id, + "current_brain_session_id": command.target_brain_session_id, + "command_id": command.id, + "error_code": command.error_code, + "error": command.error, + **restored_payload, + }, + ) + db.commit() + + @staticmethod + def expire_stale_bridges(db: Session) -> None: + sessions = db.exec( + select(RemoteControlSession).where( + RemoteControlSession.status == SESSION_ACTIVE, + RemoteControlSession.bridge_status == "online", + ) + ).all() + for session in sessions: + if RemoteControlRedis.is_bridge_online(session.desktop_instance_id, session.user_id): + continue + session.bridge_status = "offline" + db.add(session) + RemoteControlService.record_event( + session.id, + "bridge_offline", + { + "desktop_instance_id": session.desktop_instance_id, + "last_seen_at": _utc_iso(session.last_bridge_seen_at), + "reason": "ttl_expired", + }, + db, + commit=False, + ) + RemoteControlService.publish_status( + session.id, + "bridge_status", + { + "status": "offline", + "last_seen_at": _utc_iso(session.last_bridge_seen_at), + "message": "Desktop is offline", + }, + ) + db.commit() diff --git a/server/app/domains/space/__init__.py b/server/app/domains/space/__init__.py new file mode 100644 index 000000000..fa7455a0c --- /dev/null +++ b/server/app/domains/space/__init__.py @@ -0,0 +1,13 @@ +# ========= 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. ========= diff --git a/server/app/domains/space/api/__init__.py b/server/app/domains/space/api/__init__.py new file mode 100644 index 000000000..fa7455a0c --- /dev/null +++ b/server/app/domains/space/api/__init__.py @@ -0,0 +1,13 @@ +# ========= 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. ========= diff --git a/server/app/domains/space/api/space_controller.py b/server/app/domains/space/api/space_controller.py new file mode 100644 index 000000000..99e644d0d --- /dev/null +++ b/server/app/domains/space/api/space_controller.py @@ -0,0 +1,368 @@ +# ========= 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 fastapi import APIRouter, Depends, HTTPException +from fastapi import Query +from sqlmodel import Session + +from app.core.database import session +from app.domains.space.service.apply_service import SpaceApplyService +from app.domains.space.service.overlay_service import ( + PendingOverlayError, + SpaceOverlayService, +) +from app.domains.space.service.space_service import SpaceHasProjectsError, SpaceService +from app.model.project import ProjectIn, ProjectOut, ProjectUpdate +from app.model.space import ( + SpaceIn, + SpaceOverlayDiscardIn, + SpaceOverlayDiscardResponse, + SpaceOverlayListResponse, + SpaceOverlayOut, + SpaceOverlayWriteIn, + SpaceOut, + SpaceProjectApplyIn, + SpaceProjectApplyResponse, + SpaceProjectRefreshIn, + SpaceProjectRefreshResponse, + SpaceRelocateIn, + SpaceUpdate, +) +from app.shared.auth import auth_must +from app.shared.auth.user_auth import V1UserAuth + +router = APIRouter(prefix="/spaces", tags=["Spaces"]) + + +@router.get("", name="list spaces", response_model=list[SpaceOut]) +def list_spaces( + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + return SpaceService.list_spaces(auth.id, db_session) + + +@router.post("", name="create space", response_model=SpaceOut) +def create_space( + data: SpaceIn, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + return SpaceOut.from_model(SpaceService.create_space(data, auth.id, db_session)) + except ValueError as exc: + detail = str(exc) + status_code = 409 if "already bound" in detail else 400 + raise HTTPException(status_code=status_code, detail=detail) from exc + + +@router.get("/{space_id}", name="get space", response_model=SpaceOut) +def get_space( + space_id: str, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + return SpaceOut.from_model(SpaceService.get_space(space_id, auth.id, db_session)) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.patch("/{space_id}", name="update space", response_model=SpaceOut) +def update_space( + space_id: str, + data: SpaceUpdate, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + return SpaceOut.from_model(SpaceService.update_space(space_id, data, auth.id, db_session)) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.delete("/{space_id}", name="delete space", status_code=204) +def delete_space( + space_id: str, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + SpaceService.delete_space(space_id, auth.id, db_session) + except SpaceHasProjectsError as exc: + raise HTTPException( + status_code=409, + detail={ + "code": "space_has_projects", + "message": str(exc), + "project_count": exc.project_count, + "projects": exc.projects, + }, + ) from exc + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/{space_id}/archive", name="archive space", response_model=SpaceOut) +def archive_space( + space_id: str, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + return SpaceOut.from_model(SpaceService.archive_space(space_id, auth.id, db_session)) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/{space_id}/unarchive", name="unarchive space", response_model=SpaceOut) +def unarchive_space( + space_id: str, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + return SpaceOut.from_model(SpaceService.unarchive_space(space_id, auth.id, db_session)) + except ValueError as exc: + status_code = 409 if "already bound" in str(exc) else 404 + raise HTTPException(status_code=status_code, detail=str(exc)) from exc + + +@router.post("/{space_id}/relocate", name="relocate space", response_model=SpaceOut) +def relocate_space( + space_id: str, + data: SpaceRelocateIn, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + return SpaceOut.from_model( + SpaceService.relocate_space( + space_id, + data.root_path, + auth.id, + db_session, + force=data.force, + root_fingerprint=data.root_fingerprint, + ) + ) + except ValueError as exc: + detail = str(exc) + if ( + "already bound" in detail + or "identity" in detail + or "cannot be verified" in detail + ): + raise HTTPException(status_code=409, detail=detail) from exc + raise HTTPException(status_code=404, detail=detail) from exc + + +@router.post("/legacy", name="ensure legacy space", response_model=SpaceOut) +def ensure_legacy_space( + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + return SpaceOut.from_model(SpaceService.ensure_legacy_space(auth.id, db_session)) + + +@router.get("/{space_id}/projects", name="list space projects", response_model=list[ProjectOut]) +def list_space_projects( + space_id: str, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + return SpaceService.list_projects(space_id, auth.id, db_session) + + +@router.post("/{space_id}/projects", name="create space project", response_model=ProjectOut) +def create_space_project( + space_id: str, + data: ProjectIn, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + return ProjectOut.from_model(SpaceService.create_project(space_id, data, auth.id, db_session)) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.patch("/{space_id}/projects/{project_id}", name="update space project", response_model=ProjectOut) +def update_space_project( + space_id: str, + project_id: str, + data: ProjectUpdate, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + return ProjectOut.from_model( + SpaceService.update_project(space_id, project_id, data, auth.id, db_session) + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/{space_id}/projects/{project_id}/promote", name="promote project to folder space", response_model=ProjectOut) +def promote_space_project( + space_id: str, + project_id: str, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + return ProjectOut.from_model( + SpaceService.promote_project(space_id, project_id, auth.id, db_session) + ) + except ValueError as exc: + detail = str(exc) + status_code = 409 if "Cannot promote" in detail or "target" in detail else 404 + raise HTTPException(status_code=status_code, detail=detail) from exc + + +@router.post( + "/{space_id}/projects/{project_id}/apply", + name="apply project run to space", + response_model=SpaceProjectApplyResponse, +) +def apply_space_project_run( + space_id: str, + project_id: str, + data: SpaceProjectApplyIn, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + return SpaceApplyService.apply_project_run( + space_id, + project_id, + data, + auth.id, + db_session, + ) + except ValueError as exc: + detail = str(exc) + if "disabled" in detail: + status_code = 403 + else: + status_code = 409 if "requires" in detail or "not available" in detail else 404 + raise HTTPException(status_code=status_code, detail=detail) from exc + + +@router.get( + "/{space_id}/projects/{project_id}/overlays", + name="list project overlays", + response_model=SpaceOverlayListResponse, +) +def list_space_project_overlays( + space_id: str, + project_id: str, + run_id: str | None = Query(None), + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + return SpaceOverlayService.list_overlays( + space_id, + project_id, + auth.id, + db_session, + run_id=run_id, + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post( + "/{space_id}/projects/{project_id}/overlays", + name="record project overlay write", + response_model=SpaceOverlayOut, +) +def record_space_project_overlay( + space_id: str, + project_id: str, + data: SpaceOverlayWriteIn, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + return SpaceOverlayService.record_overlay_write( + space_id, + project_id, + data, + auth.id, + db_session, + ) + except ValueError as exc: + detail = str(exc) + status_code = 403 if "disabled" in detail else 400 + raise HTTPException(status_code=status_code, detail=detail) from exc + + +@router.post( + "/{space_id}/projects/{project_id}/discard", + name="discard project overlays", + response_model=SpaceOverlayDiscardResponse, +) +def discard_space_project_overlays( + space_id: str, + project_id: str, + data: SpaceOverlayDiscardIn, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + return SpaceOverlayService.discard_overlays( + space_id, + project_id, + auth.id, + db_session, + run_id=data.run_id, + paths=data.paths, + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post( + "/{space_id}/projects/{project_id}/refresh", + name="refresh project workdir", + response_model=SpaceProjectRefreshResponse, +) +def refresh_space_project( + space_id: str, + project_id: str, + data: SpaceProjectRefreshIn, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + return SpaceOverlayService.refresh_project( + space_id, + project_id, + data, + auth.id, + db_session, + ) + except PendingOverlayError as exc: + raise HTTPException( + status_code=409, + detail={ + "code": "pending_overlays", + "message": str(exc), + "run_ids": exc.run_ids, + }, + ) from exc + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc diff --git a/server/app/domains/space/service/__init__.py b/server/app/domains/space/service/__init__.py new file mode 100644 index 000000000..e06b07d73 --- /dev/null +++ b/server/app/domains/space/service/__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.domains.space.service.space_service import SpaceService +from app.domains.space.service.apply_service import SpaceApplyService +from app.domains.space.service.overlay_service import SpaceOverlayService + +__all__ = ["SpaceApplyService", "SpaceOverlayService", "SpaceService"] diff --git a/server/app/domains/space/service/apply_service.py b/server/app/domains/space/service/apply_service.py new file mode 100644 index 000000000..756cff126 --- /dev/null +++ b/server/app/domains/space/service/apply_service.py @@ -0,0 +1,469 @@ +# ========= 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 hashlib +import logging +import os +import shutil +import threading +import weakref +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from uuid import uuid4 + +from sqlalchemy import text +from sqlmodel import Session, select + +from app.domains.space.service.file_ops_guard import assert_local_file_operations_enabled +from app.domains.space.service.space_service import SpaceService +from app.model.project import Project +from app.model.space import ( + AppliedPath, + ApplyConflict, + ApplyFailure, + ApplyResolutionIn, + ApplyWarning, + OVERLAY_SOURCE_PATH_METADATA_KEY, + OVERLAY_SOURCE_ROOT_METADATA_KEY, + Space, + SpaceFileIndex, + SpaceFileIndexOverlay, + SpaceProjectApplyIn, + SpaceProjectApplyResponse, + SpaceSourceType, +) + +try: + import fcntl +except ImportError: # pragma: no cover - Windows fallback + fcntl = None + +_HASH_CHUNK_SIZE = 1024 * 1024 +_APPLY_LOCKS: weakref.WeakValueDictionary[str, threading.Lock] = ( + weakref.WeakValueDictionary() +) +_APPLY_LOCKS_GUARD = threading.Lock() +_LOGGER = logging.getLogger(__name__) + + +def _thread_space_lock(space_id: str) -> threading.Lock: + with _APPLY_LOCKS_GUARD: + lock = _APPLY_LOCKS.get(space_id) + if lock is None: + lock = threading.Lock() + _APPLY_LOCKS[space_id] = lock + return lock + + +def _advisory_lock_key(space_id: str) -> int: + digest = hashlib.sha256(f"eigent-space:{space_id}".encode("utf-8")).digest() + return int.from_bytes(digest[:8], "big", signed=True) + + +class SpaceWriteLock: + """Canonical Space write lock shared by overlay writers and Apply. + + The thread lock protects single-process local Electron runs. When the + server uses Postgres, we also take a connection-scoped advisory lock so + multiple API workers cannot write the same Space concurrently. + """ + + def __init__(self, space_id: str) -> None: + self.space_id = space_id + self._thread_lock: threading.Lock | None = None + self._connection = None + self._lock_key = _advisory_lock_key(space_id) + + def __enter__(self) -> "SpaceWriteLock": + self._thread_lock = _thread_space_lock(self.space_id) + self._thread_lock.acquire() + try: + self._acquire_postgres_advisory_lock() + except Exception: + self._thread_lock.release() + self._thread_lock = None + raise + return self + + def __exit__(self, exc_type, exc, tb) -> None: + try: + self._release_postgres_advisory_lock() + finally: + if self._thread_lock is not None: + self._thread_lock.release() + self._thread_lock = None + + def _acquire_postgres_advisory_lock(self) -> None: + from app.core.database import engine + + if engine.url.get_backend_name() not in {"postgresql", "postgres"}: + return + self._connection = engine.connect() + self._connection.execute( + text("SELECT pg_advisory_lock(:lock_key)"), + {"lock_key": self._lock_key}, + ) + + def _release_postgres_advisory_lock(self) -> None: + if self._connection is None: + return + try: + self._connection.execute( + text("SELECT pg_advisory_unlock(:lock_key)"), + {"lock_key": self._lock_key}, + ) + except Exception as exc: # noqa: BLE001 - unlock failure should not mask caller errors. + _LOGGER.warning( + "Failed to release Space advisory lock", + extra={"space_id": self.space_id, "lock_key": self._lock_key, "error": str(exc)}, + ) + finally: + self._connection.close() + self._connection = None + + +def space_write_lock(space_id: str) -> SpaceWriteLock: + """Return the canonical Space write lock context manager.""" + + return SpaceWriteLock(space_id) + + +@contextmanager +def filesystem_space_lock(root: Path): + """Coordinate Space root filesystem reads/writes with Brain workdir copies.""" + + if fcntl is None: + yield + return + + fd: int | None = None + try: + fd = os.open(root, os.O_RDONLY) + fcntl.flock(fd, fcntl.LOCK_EX) + yield + finally: + if fd is not None: + try: + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) + + +def sha256_of_file(path: Path) -> str | None: + """Hash raw on-disk bytes. Missing files are NULL, distinct from empty files.""" + + if not path.exists(): + return None + if path.is_symlink() or not path.is_file(): + raise ValueError(f"Cannot hash non-regular file: {path}") + + digest = hashlib.sha256() + with path.open("rb") as handle: + while chunk := handle.read(_HASH_CHUNK_SIZE): + digest.update(chunk) + return digest.hexdigest() + + +def _normalize_relative_path(path: str) -> PurePosixPath: + normalized = PurePosixPath(path.replace("\\", "/")) + if not normalized.parts or normalized.is_absolute() or ".." in normalized.parts: + raise ValueError("Invalid relative path") + return normalized + + +def normalize_overlay_path(path: str) -> str: + return str(_normalize_relative_path(path)) + + +def _resolve_under(root: Path, rel_path: str) -> Path: + normalized = _normalize_relative_path(rel_path) + candidate = (root / Path(*normalized.parts)).resolve() + try: + candidate.relative_to(root) + except ValueError as exc: + raise ValueError("Path escapes Space root") from exc + return candidate + + +def _fsync_file(path: Path) -> None: + with path.open("rb") as handle: + os.fsync(handle.fileno()) + + +def _fsync_dir(path: Path) -> None: + fd = os.open(path, os.O_RDONLY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +class SpaceApplyService: + """Apply run-scoped Project overlays back to a folder-backed Space.""" + + @staticmethod + def apply_project_run( + space_id: str, + project_id: str, + data: SpaceProjectApplyIn, + user_id: int | str, + s: Session, + ) -> SpaceProjectApplyResponse: + canonical_user_id = SpaceService.canonical_user_id(user_id) + space = SpaceService.get_space(space_id, canonical_user_id, s) + project = s.exec( + select(Project).where( + Project.id == project_id, + Project.user_id == canonical_user_id, + ) + ).first() + if not project or project.space_id != space_id: + raise ValueError("Project not found") + if space.source_type != SpaceSourceType.FOLDER or not space.root_path: + raise ValueError("Apply requires a folder-backed Space") + assert_local_file_operations_enabled() + + root = Path(space.root_path).resolve() + if not root.exists() or not root.is_dir(): + raise ValueError("Space root is not available") + + requested_paths = set(data.paths or []) + query = select(SpaceFileIndexOverlay).where( + SpaceFileIndexOverlay.space_id == space_id, + SpaceFileIndexOverlay.project_id == project_id, + SpaceFileIndexOverlay.run_id == data.run_id, + ) + if requested_paths: + query = query.where(SpaceFileIndexOverlay.path.in_(requested_paths)) + rows = s.exec(query.order_by(SpaceFileIndexOverlay.id)).all() + + response = SpaceProjectApplyResponse( + kind="success", + space_id=space_id, + project_id=project_id, + run_id=data.run_id, + ) + if not rows: + return response + + resolutions = { + resolution.path: resolution + for resolution in data.force_resolutions or [] + } + + with space_write_lock(space_id), filesystem_space_lock(root): + actions: list[tuple[SpaceFileIndexOverlay, str, ApplyResolutionIn | None]] = [] + for row in rows: + resolution = resolutions.get(row.path) + try: + target = _resolve_under(root, row.path) + current_hash = sha256_of_file(target) + except ValueError as exc: + response.conflicts.append( + ApplyConflict( + path=row.path, + status=row.status, + base_hash=row.base_hash, + mine_hash=row.hash, + message=str(exc), + ) + ) + continue + + if current_hash != row.base_hash: + action = resolution.action if resolution else "" + if action not in {"apply_mine", "keep_theirs", "write_chosen"}: + response.conflicts.append( + ApplyConflict( + path=row.path, + status=row.status, + base_hash=row.base_hash, + current_hash=current_hash, + mine_hash=row.hash, + message="Space file changed since this run started", + ) + ) + continue + actions.append((row, resolution.action if resolution else "apply_mine", resolution)) + + if response.conflicts: + response.kind = "conflict" + return response + + for row, action, resolution in actions: + if action == "keep_theirs": + try: + live_hash = sha256_of_file(_resolve_under(root, row.path)) + SpaceApplyService._update_index(space, row.path, live_hash, s) + s.delete(row) + s.commit() + response.applied.append( + AppliedPath(path=row.path, status="kept_theirs", hash=live_hash) + ) + except Exception as exc: # noqa: BLE001 - per-path failure must keep overlay. + s.rollback() + response.failed.append( + ApplyFailure( + path=row.path, + reason="keep_theirs_failed", + message=str(exc), + ) + ) + continue + + try: + applied_hash, warnings = SpaceApplyService._apply_row_to_disk( + root=root, + row=row, + action=action, + resolution=resolution, + ) + response.warnings.extend(warnings) + SpaceApplyService._update_index(space, row.path, applied_hash, s) + s.delete(row) + s.commit() + response.applied.append( + AppliedPath(path=row.path, status=row.status, hash=applied_hash) + ) + except Exception as exc: # noqa: BLE001 - per-path failure keeps overlay retryable. + s.rollback() + response.failed.append( + ApplyFailure( + path=row.path, + reason="apply_path_failed", + message=str(exc), + ) + ) + + response.kind = "partial" if response.failed else "success" + return response + + @staticmethod + def _apply_row_to_disk( + *, + root: Path, + row: SpaceFileIndexOverlay, + action: str, + resolution: ApplyResolutionIn | None, + ) -> tuple[str | None, list[ApplyWarning]]: + target = _resolve_under(root, row.path) + warnings: list[ApplyWarning] = [] + + if row.status == "deleted": + if target.exists() and (target.is_symlink() or not target.is_file()): + raise ValueError("Cannot delete non-regular file") + if target.exists(): + target.unlink() + warnings.extend(SpaceApplyService._warn_parent_fsync(target.parent, row.path)) + return None, warnings + + source = SpaceApplyService._resolve_source_path(row, action, resolution) + target.parent.mkdir(parents=True, exist_ok=True) + tmp = target.parent / f".{target.name}.apply-{uuid4().hex}.tmp" + try: + shutil.copyfile(source, tmp) + if row.mode is not None: + os.chmod(tmp, row.mode) + _fsync_file(tmp) + staged_hash = sha256_of_file(tmp) + expected_hash = resolution.hash if action == "write_chosen" and resolution else row.hash + if expected_hash and staged_hash != expected_hash: + raise ValueError("hash_mismatch_before_swap") + os.replace(tmp, target) + warnings.extend(SpaceApplyService._warn_parent_fsync(target.parent, row.path)) + return staged_hash, warnings + finally: + if tmp.exists(): + tmp.unlink() + + @staticmethod + def _resolve_source_path( + row: SpaceFileIndexOverlay, + action: str, + resolution: ApplyResolutionIn | None, + ) -> Path: + metadata = row.metadata_json or {} + if action == "write_chosen": + raw_source = resolution.content_ref if resolution else None + else: + raw_source = ( + metadata.get(OVERLAY_SOURCE_PATH_METADATA_KEY) + or metadata.get("workdir_path") + or metadata.get("sourceFile") + ) + if not raw_source: + raise ValueError("Overlay row is missing source_path metadata") + raw_source_root = ( + metadata.get(OVERLAY_SOURCE_ROOT_METADATA_KEY) + or metadata.get("workdir_root") + or metadata.get("project_workdir") + ) + if not raw_source_root: + raise ValueError("Overlay row is missing source_root metadata") + source_root = Path(str(raw_source_root)).expanduser().resolve() + source = Path(str(raw_source)).expanduser().resolve() + try: + source.relative_to(source_root) + except ValueError as exc: + raise ValueError("Overlay source escapes source_root") from exc + if source.is_symlink() or not source.is_file(): + raise ValueError("Overlay source is not a regular file") + return source + + @staticmethod + def _warn_parent_fsync(parent: Path, path: str) -> list[ApplyWarning]: + try: + _fsync_dir(parent) + return [] + except OSError as exc: + return [ + ApplyWarning( + code="durability_warning", + path=path, + message=f"Parent directory fsync failed after commit: {exc}", + ) + ] + + @staticmethod + def _update_index( + space: Space, + rel_path: str, + file_hash: str | None, + s: Session, + ) -> None: + existing = s.exec( + select(SpaceFileIndex).where( + SpaceFileIndex.space_id == space.id, + SpaceFileIndex.path == rel_path, + ) + ).first() + if file_hash is None: + if existing: + s.delete(existing) + return + + target = _resolve_under(Path(space.root_path).resolve(), rel_path) + stat_result = target.stat() + if existing is None: + existing = SpaceFileIndex(space_id=space.id, path=rel_path) + existing.hash = file_hash + existing.size = stat_result.st_size + existing.mode = stat_result.st_mode + existing.modified_at = datetime.fromtimestamp(stat_result.st_mtime, timezone.utc) + existing.indexed_at = datetime.now(timezone.utc) + existing.row_version = (existing.row_version or 0) + 1 + s.add(existing) diff --git a/server/app/domains/space/service/file_ops_guard.py b/server/app/domains/space/service/file_ops_guard.py new file mode 100644 index 000000000..41a1cf297 --- /dev/null +++ b/server/app/domains/space/service/file_ops_guard.py @@ -0,0 +1,32 @@ +# ========= 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.core.environment import env + +LOCAL_FILE_OPS_DISABLED_MESSAGE = "Folder Space local file operations are disabled on this server" + + +def _truthy(value: str | None) -> bool: + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def local_file_operations_enabled() -> bool: + """Whether this server may touch host paths for folder-backed Spaces.""" + + return _truthy(env("SPACE_LOCAL_FILE_OPERATIONS_ENABLED", "false")) + + +def assert_local_file_operations_enabled() -> None: + if not local_file_operations_enabled(): + raise ValueError(LOCAL_FILE_OPS_DISABLED_MESSAGE) diff --git a/server/app/domains/space/service/folder_binding.py b/server/app/domains/space/service/folder_binding.py new file mode 100644 index 000000000..1ecef69d3 --- /dev/null +++ b/server/app/domains/space/service/folder_binding.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. ========= + +import os +from pathlib import Path +from typing import Any + + +def normalize_folder_root_reference(root_path: str) -> str: + """Normalize a folder reference without touching the local filesystem. + + Space is a cloud-owned logical layer. A folder root may live on the user's + desktop Brain, a Docker host, or a future cloud workspace, so the server + must not require the path to exist in its own filesystem namespace during + Space creation. Local readability is validated by the environment-specific + Brain binding path. + + Normalization is intentionally string-only: collapse "./" / "//" / trailing + separators via os.path.normpath, but never expand "~", resolve symlinks, or + stat the path. That keeps "/Users/alice/./repo" and "/Users/alice/repo/" + deduplicated without leaking the server's filesystem state. + """ + + value = str(root_path or "").strip() + if not value: + raise ValueError("Folder Space requires root_path") + if "\x00" in value: + raise ValueError("Space root_path contains an invalid character") + normalized = os.path.normpath(value) + if normalized in {"", "."}: + raise ValueError("Folder Space requires root_path") + return normalized.rstrip("/\\") or normalized + + +def resolve_folder_root(root_path: str) -> Path: + try: + resolved = Path(root_path).expanduser().resolve() + except (OSError, RuntimeError) as exc: + raise ValueError("Space root_path cannot be resolved") from exc + if not resolved.exists() or not resolved.is_dir(): + raise ValueError("Space root_path must be an existing directory") + return resolved + + +def folder_fingerprint(root: Path) -> dict[str, Any]: + stat = root.stat() + return { + "kind": "local_folder", + "path": str(root), + "device": stat.st_dev, + "inode": stat.st_ino, + "mtime_ns": stat.st_mtime_ns, + "ctime_ns": stat.st_ctime_ns, + } + + +def same_folder_reference(left: str, right: str) -> bool: + try: + return normalize_folder_root_reference(left) == normalize_folder_root_reference( + right + ) + except ValueError: + return False + + +def same_folder_path(left: str, right: str) -> bool: + try: + return ( + Path(left).expanduser().resolve() + == Path(right).expanduser().resolve() + ) + except (OSError, RuntimeError): + return False diff --git a/server/app/domains/space/service/overlay_service.py b/server/app/domains/space/service/overlay_service.py new file mode 100644 index 000000000..be270504d --- /dev/null +++ b/server/app/domains/space/service/overlay_service.py @@ -0,0 +1,274 @@ +# ========= 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 + +from datetime import datetime, timezone +from uuid import uuid4 + +from sqlmodel import Session, select + +from app.domains.space.service.apply_service import ( + normalize_overlay_path, + space_write_lock, +) +from app.domains.space.service.file_ops_guard import assert_local_file_operations_enabled +from app.domains.space.service.space_service import SpaceService +from app.model.project import Project +from app.model.space import ( + OVERLAY_SOURCE_PATH_METADATA_KEY, + OVERLAY_SOURCE_ROOT_METADATA_KEY, + SpaceFileIndexOverlay, + SpaceOverlayDiscardResponse, + SpaceOverlayListResponse, + SpaceOverlayOut, + SpaceOverlayWriteIn, + SpaceProjectRefreshIn, + SpaceProjectRefreshResponse, +) + + +class PendingOverlayError(ValueError): + def __init__(self, run_ids: list[str]) -> None: + self.run_ids = run_ids + super().__init__("Project has pending overlay changes") + + +class SpaceOverlayService: + @staticmethod + def _get_owned_project( + space_id: str, + project_id: str, + user_id: int | str, + s: Session, + ) -> Project: + canonical_user_id = SpaceService.canonical_user_id(user_id) + SpaceService.get_space(space_id, canonical_user_id, s) + project = s.exec( + select(Project).where( + Project.id == project_id, + Project.user_id == canonical_user_id, + Project.space_id == space_id, + ) + ).first() + if not project: + raise ValueError("Project not found") + return project + + @staticmethod + def list_overlays( + space_id: str, + project_id: str, + user_id: int | str, + s: Session, + *, + run_id: str | None = None, + ) -> SpaceOverlayListResponse: + SpaceOverlayService._get_owned_project(space_id, project_id, user_id, s) + query = select(SpaceFileIndexOverlay).where( + SpaceFileIndexOverlay.space_id == space_id, + SpaceFileIndexOverlay.project_id == project_id, + ) + if run_id: + query = query.where(SpaceFileIndexOverlay.run_id == run_id) + rows = s.exec(query.order_by(SpaceFileIndexOverlay.id)).all() + return SpaceOverlayListResponse( + space_id=space_id, + project_id=project_id, + overlays=[ + SpaceOverlayOut( + id=row.id, + space_id=row.space_id, + project_id=row.project_id, + run_id=row.run_id, + path=row.path, + status=row.status, + hash=row.hash, + base_hash=row.base_hash, + base_snapshot_id=row.base_snapshot_id, + size=row.size, + mode=row.mode, + metadata=row.metadata_json, + ) + for row in rows + ], + ) + + @staticmethod + def record_overlay_write( + space_id: str, + project_id: str, + data: SpaceOverlayWriteIn, + user_id: int | str, + s: Session, + ) -> SpaceOverlayOut: + SpaceOverlayService._get_owned_project(space_id, project_id, user_id, s) + assert_local_file_operations_enabled() + path = normalize_overlay_path(data.path) + metadata = dict(data.metadata or {}) + source_path = data.source_path or metadata.get(OVERLAY_SOURCE_PATH_METADATA_KEY) + source_root = data.source_root or metadata.get(OVERLAY_SOURCE_ROOT_METADATA_KEY) + if data.status != "deleted": + if not source_path: + raise ValueError("Overlay source_path is required") + if not source_root: + raise ValueError("Overlay source_root is required") + if source_path: + metadata[OVERLAY_SOURCE_PATH_METADATA_KEY] = source_path + if source_root: + # Same-FS bridge assumption: Apply currently resolves this Brain-local + # absolute path on the same machine. Cloud Brain must replace it with + # an opaque file handle or a server-readable content reference. + metadata[OVERLAY_SOURCE_ROOT_METADATA_KEY] = source_root + + with space_write_lock(space_id): + existing = s.exec( + select(SpaceFileIndexOverlay).where( + SpaceFileIndexOverlay.space_id == space_id, + SpaceFileIndexOverlay.project_id == project_id, + SpaceFileIndexOverlay.run_id == data.run_id, + SpaceFileIndexOverlay.path == path, + ) + ).first() + row = existing or SpaceFileIndexOverlay( + space_id=space_id, + project_id=project_id, + run_id=data.run_id, + path=path, + status=data.status, + base_hash=data.base_hash, + base_snapshot_id=data.base_snapshot_id, + ) + if existing is None: + row.base_hash = data.base_hash + row.base_snapshot_id = data.base_snapshot_id + row.status = data.status + else: + row.status = SpaceOverlayService._next_overlay_status( + existing.status, + data.status, + existing.base_hash, + ) + row.hash = None if data.status == "deleted" else data.hash + row.size = data.size + row.mode = data.mode + row.modified_at = ( + datetime.fromisoformat(data.modified_at) + if data.modified_at + else datetime.now(timezone.utc) + ) + row.metadata_json = { + **(row.metadata_json or {}), + **metadata, + } + s.add(row) + s.commit() + s.refresh(row) + return SpaceOverlayOut( + id=row.id, + space_id=row.space_id, + project_id=row.project_id, + run_id=row.run_id, + path=row.path, + status=row.status, + hash=row.hash, + base_hash=row.base_hash, + base_snapshot_id=row.base_snapshot_id, + size=row.size, + mode=row.mode, + metadata=row.metadata_json, + ) + + @staticmethod + def _next_overlay_status( + existing_status: str, + incoming_status: str, + base_hash: str | None, + ) -> str: + if incoming_status == "deleted": + return "deleted" + if existing_status == "added": + return "added" + if existing_status == "deleted": + return "added" if base_hash is None else "modified" + if existing_status == "modified" and incoming_status == "added": + return "modified" + return incoming_status + + @staticmethod + def discard_overlays( + space_id: str, + project_id: str, + user_id: int | str, + s: Session, + *, + run_id: str | None = None, + paths: list[str] | None = None, + ) -> SpaceOverlayDiscardResponse: + SpaceOverlayService._get_owned_project(space_id, project_id, user_id, s) + normalized_paths = [normalize_overlay_path(path) for path in paths or []] + query = select(SpaceFileIndexOverlay).where( + SpaceFileIndexOverlay.space_id == space_id, + SpaceFileIndexOverlay.project_id == project_id, + ) + if run_id: + query = query.where(SpaceFileIndexOverlay.run_id == run_id) + if normalized_paths: + query = query.where(SpaceFileIndexOverlay.path.in_(normalized_paths)) + rows = s.exec(query).all() + run_ids = sorted({row.run_id for row in rows}) + with space_write_lock(space_id): + for row in rows: + s.delete(row) + s.commit() + return SpaceOverlayDiscardResponse( + space_id=space_id, + project_id=project_id, + discarded=len(rows), + run_ids=run_ids, + ) + + @staticmethod + def refresh_project( + space_id: str, + project_id: str, + data: SpaceProjectRefreshIn, + user_id: int | str, + s: Session, + ) -> SpaceProjectRefreshResponse: + project = SpaceOverlayService._get_owned_project( + space_id, project_id, user_id, s + ) + pending_rows = s.exec( + select(SpaceFileIndexOverlay).where( + SpaceFileIndexOverlay.space_id == space_id, + SpaceFileIndexOverlay.project_id == project_id, + ) + ).all() + if pending_rows and not data.force: + raise PendingOverlayError(sorted({row.run_id for row in pending_rows})) + base_snapshot_id = f"snapshot_{uuid4().hex}" + project.metadata_json = { + **(project.metadata_json or {}), + "baseSnapshotId": base_snapshot_id, + "refreshedAt": datetime.now(timezone.utc).isoformat(), + } + s.add(project) + s.commit() + return SpaceProjectRefreshResponse( + kind="refreshed", + space_id=space_id, + project_id=project_id, + base_snapshot_id=base_snapshot_id, + ) diff --git a/server/app/domains/space/service/space_service.py b/server/app/domains/space/service/space_service.py new file mode 100644 index 000000000..e6aac4a14 --- /dev/null +++ b/server/app/domains/space/service/space_service.py @@ -0,0 +1,663 @@ +# ========= 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 datetime import datetime +from uuid import uuid4 + +from sqlalchemy import or_ +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session, select + +from app.domains.space.service.folder_binding import ( + normalize_folder_root_reference, + same_folder_reference, +) +from app.model.chat.chat_history import ChatHistory +from app.model.project import ( + Project, + ProjectIn, + ProjectOut, + ProjectUpdate, + ProjectWorkdirMode, +) +from app.model.space import Space, SpaceIn, SpaceOut, SpaceSourceType, SpaceStatus, SpaceUpdate + +_LOGGER = logging.getLogger(__name__) + + +class SpaceHasProjectsError(ValueError): + def __init__(self, project_count: int, projects: list[dict[str, str]]) -> None: + self.project_count = project_count + self.projects = projects + super().__init__( + f"Space has {project_count} project(s); archive or delete them first" + ) + + +class SpaceService: + PROJECT_DISPLAY_NAME_MAX = 255 + + @staticmethod + def _project_name_is_placeholder(name: str | None, project_id: str) -> bool: + normalized = (name or "").strip().lower() + return ( + not normalized + or normalized in {"new project", "new space"} + or normalized == f"project {project_id}".lower() + ) + + @staticmethod + def _history_project_display_name(history: ChatHistory) -> str | None: + for value in (history.project_name, history.question): + candidate = (value or "").strip() + if candidate and not SpaceService._project_name_is_placeholder( + candidate, + history.project_id or history.task_id, + ): + return candidate[: SpaceService.PROJECT_DISPLAY_NAME_MAX] + return None + + @staticmethod + def _backfill_project_names_from_history( + projects: list[Project], + *, + user_id: str, + s: Session, + ) -> None: + placeholder_projects = [ + project + for project in projects + if SpaceService._project_name_is_placeholder( + project.name, + project.id, + ) + ] + if not placeholder_projects: + return + + project_ids = [project.id for project in placeholder_projects] + history_user_id = int(user_id) if str(user_id).isdigit() else user_id + histories = s.exec( + select(ChatHistory) + .where( + ChatHistory.user_id == history_user_id, + or_( + ChatHistory.project_id.in_(project_ids), + ChatHistory.task_id.in_(project_ids), + ), + ) + .order_by(ChatHistory.created_at.desc(), ChatHistory.id.desc()) + ).all() + name_by_project_id: dict[str, str] = {} + for history in histories: + if history.project_id in project_ids: + project_id = history.project_id + elif history.task_id in project_ids: + project_id = history.task_id + else: + continue + if project_id in name_by_project_id: + continue + display_name = SpaceService._history_project_display_name(history) + if display_name: + name_by_project_id[project_id] = display_name + + changed = False + now = datetime.now() + for project in placeholder_projects: + display_name = name_by_project_id.get(project.id) + if not display_name: + continue + project.name = display_name + project.updated_at = now + s.add(project) + changed = True + + if changed: + s.commit() + + SPACE_SOURCE_TYPES = {"blank", "folder", "legacy"} + SPACE_STATUSES = {"active", "disconnected", "archived"} + PROJECT_MODES = {"single-agent", "workforce"} + PROJECT_STATUSES = {"active", "archived"} + PROJECT_WORKDIR_MODES = {"worktree", "copy", "direct-write", "artifact-only"} + + @staticmethod + def canonical_user_id(user_id: int | str) -> str: + return str(user_id) + + @staticmethod + def _default_project_workdir_mode(space: Space) -> str: + if space.source_type == SpaceSourceType.FOLDER: + return ProjectWorkdirMode.DIRECT_WRITE + return ProjectWorkdirMode.ARTIFACT_ONLY + + @staticmethod + def legacy_space_id(user_id: int | str) -> str: + return f"legacy_{SpaceService.canonical_user_id(user_id)}" + + @staticmethod + def ensure_legacy_space(user_id: int | str, s: Session, *, commit: bool = True) -> Space: + canonical_user_id = SpaceService.canonical_user_id(user_id) + space_id = SpaceService.legacy_space_id(canonical_user_id) + space = s.get(Space, space_id) + if space: + return space + + space = Space( + id=space_id, + user_id=canonical_user_id, + name="Legacy Space", + description="Projects migrated from the pre-Space app model.", + source_type=SpaceSourceType.LEGACY, + metadata_json={"legacy": True, "schemaVersion": 1}, + ) + try: + with s.begin_nested(): + s.add(space) + s.flush() + except IntegrityError: + existing = s.get(Space, space_id) + if existing and existing.user_id == canonical_user_id: + return existing + raise + if commit: + s.commit() + s.refresh(space) + return space + + @staticmethod + def _get_owned_space(space_id: str, user_id: int | str, s: Session) -> Space: + canonical_user_id = SpaceService.canonical_user_id(user_id) + space = s.get(Space, space_id) + if not space or space.user_id != canonical_user_id: + raise ValueError("Space not found") + return space + + @staticmethod + def _validate_space_payload(source_type: str | None = None, status: str | None = None) -> None: + if source_type is not None and source_type not in SpaceService.SPACE_SOURCE_TYPES: + raise ValueError("Invalid Space source_type") + if status is not None and status not in SpaceService.SPACE_STATUSES: + raise ValueError("Invalid Space status") + + @staticmethod + def _prepare_space_root( + data: SpaceIn, user_id: str, s: Session + ) -> tuple[str | None, dict | None]: + if data.source_type == SpaceSourceType.FOLDER: + root_ref = normalize_folder_root_reference(data.root_path or "") + existing_spaces = s.exec( + select(Space).where( + Space.user_id == user_id, + Space.source_type == SpaceSourceType.FOLDER, + Space.status != SpaceStatus.ARCHIVED, + ) + ).all() + for existing in existing_spaces: + if existing.root_path and same_folder_reference(existing.root_path, root_ref): + raise ValueError("Folder is already bound to another Space") + if ( + existing.root_fingerprint + and data.root_fingerprint + and existing.root_fingerprint == data.root_fingerprint + ): + raise ValueError("Folder is already bound to another Space") + return root_ref, data.root_fingerprint + + if data.root_path or data.root_fingerprint: + raise ValueError("root_path is only valid for folder Spaces") + return None, None + + @staticmethod + def _assert_folder_not_bound_to_other_space( + *, + user_id: str, + root_path: str, + space_id: str, + s: Session, + ) -> None: + existing_spaces = s.exec( + select(Space).where( + Space.user_id == user_id, + Space.source_type == SpaceSourceType.FOLDER, + Space.status != SpaceStatus.ARCHIVED, + Space.id != space_id, + ) + ).all() + for existing in existing_spaces: + if existing.root_path and same_folder_reference(existing.root_path, root_path): + raise ValueError("Folder is already bound to another Space") + + @staticmethod + def _folder_identity_matches( + previous: dict | None, current: dict + ) -> bool: + if not previous: + return True + if previous.get("kind") != current.get("kind"): + return False + for key in ("device", "inode"): + if previous.get(key) is not None and previous.get(key) != current.get(key): + return False + return True + + @staticmethod + def _validate_project_payload( + mode: str | None = None, + status: str | None = None, + workdir_mode: str | None = None, + ) -> None: + if mode is not None and mode not in SpaceService.PROJECT_MODES: + raise ValueError("Invalid Project mode") + if status is not None and status not in SpaceService.PROJECT_STATUSES: + raise ValueError("Invalid Project status") + if workdir_mode is not None and workdir_mode not in SpaceService.PROJECT_WORKDIR_MODES: + raise ValueError("Invalid Project workdir_mode") + + @staticmethod + def create_space(data: SpaceIn, user_id: int | str, s: Session) -> Space: + canonical_user_id = SpaceService.canonical_user_id(user_id) + SpaceService._validate_space_payload(data.source_type, data.status) + root_path, root_fingerprint = SpaceService._prepare_space_root(data, canonical_user_id, s) + space = Space( + id=data.id or f"space_{uuid4().hex}", + user_id=canonical_user_id, + name=data.name, + description=data.description, + source_type=data.source_type, + root_path=root_path, + root_fingerprint=root_fingerprint, + status=data.status, + schema_version=data.schema_version, + metadata_json=data.metadata, + ) + s.add(space) + s.commit() + s.refresh(space) + return space + + @staticmethod + def get_space(space_id: str, user_id: int | str, s: Session) -> Space: + return SpaceService._get_owned_space(space_id, user_id, s) + + @staticmethod + def delete_space(space_id: str, user_id: int | str, s: Session) -> None: + canonical_user_id = SpaceService.canonical_user_id(user_id) + space = SpaceService._get_owned_space(space_id, canonical_user_id, s) + projects = s.exec( + select(Project).where( + Project.user_id == canonical_user_id, + Project.space_id == space_id, + ) + ).all() + if projects: + raise SpaceHasProjectsError( + len(projects), + [ + {"id": project.id, "name": project.name} + for project in projects[:10] + ], + ) + s.delete(space) + s.commit() + + @staticmethod + def archive_space(space_id: str, user_id: int | str, s: Session) -> Space: + space = SpaceService._get_owned_space(space_id, user_id, s) + space.status = SpaceStatus.ARCHIVED + s.add(space) + s.commit() + s.refresh(space) + return space + + @staticmethod + def unarchive_space(space_id: str, user_id: int | str, s: Session) -> Space: + canonical_user_id = SpaceService.canonical_user_id(user_id) + space = SpaceService._get_owned_space(space_id, canonical_user_id, s) + if space.source_type == SpaceSourceType.FOLDER and space.root_path: + SpaceService._assert_folder_not_bound_to_other_space( + user_id=canonical_user_id, + root_path=space.root_path, + space_id=space.id, + s=s, + ) + space.status = SpaceStatus.ACTIVE + s.add(space) + s.commit() + s.refresh(space) + return space + + @staticmethod + def relocate_space( + space_id: str, + root_path: str, + user_id: int | str, + s: Session, + *, + force: bool = False, + root_fingerprint: dict | None = None, + ) -> Space: + canonical_user_id = SpaceService.canonical_user_id(user_id) + space = SpaceService._get_owned_space(space_id, canonical_user_id, s) + if space.source_type != SpaceSourceType.FOLDER: + raise ValueError("Only folder Spaces can be relocated") + + # Reference-only mode: the server never stats the new root path. + # Identity comparison uses the client-supplied fingerprint when + # available; without one we fall back to a string-equality check so + # the operation still degrades gracefully on cloud Brain deployments + # where fingerprints are unavailable. + root_ref = normalize_folder_root_reference(root_path) + if not force: + if root_fingerprint and space.root_fingerprint: + if not SpaceService._folder_identity_matches( + space.root_fingerprint, root_fingerprint + ): + raise ValueError( + "Relocated folder identity does not match this Space" + ) + elif space.root_path and not same_folder_reference( + space.root_path, root_ref + ): + # No fingerprint available on either side: require the caller + # to acknowledge the change with force=True. + raise ValueError( + "Relocated folder identity cannot be verified; " + "supply root_fingerprint or use force=True" + ) + SpaceService._assert_folder_not_bound_to_other_space( + user_id=canonical_user_id, + root_path=root_ref, + space_id=space.id, + s=s, + ) + space.root_path = root_ref + if root_fingerprint: + space.root_fingerprint = root_fingerprint + space.status = SpaceStatus.ACTIVE + s.add(space) + s.commit() + s.refresh(space) + return space + + @staticmethod + def ensure_project( + user_id: int | str, + project_id: str, + space_id: str | None, + project_name: str | None, + s: Session, + *, + description: str | None = None, + mode: str | None = None, + workdir_mode: str | None = None, + metadata: dict | None = None, + commit: bool = True, + ) -> Project: + canonical_user_id = SpaceService.canonical_user_id(user_id) + SpaceService._validate_project_payload(mode=mode, workdir_mode=workdir_mode) + target_space_id = space_id or SpaceService.legacy_space_id(canonical_user_id) + legacy_space_id = SpaceService.legacy_space_id(canonical_user_id) + space = s.get(Space, target_space_id) + if not space and target_space_id == legacy_space_id: + space = SpaceService.ensure_legacy_space(canonical_user_id, s, commit=commit) + elif not space: + raise ValueError("Space not found") + if space.user_id != canonical_user_id: + raise ValueError("Space not found") + + project = s.exec( + select(Project).where( + Project.id == project_id, + Project.user_id == canonical_user_id, + ) + ).first() + display_name = (project_name or "").strip() + if project: + changed = False + if display_name and SpaceService._project_name_is_placeholder( + project.name, + project.id, + ): + project.name = display_name[:255] + changed = True + # Persist mode the first time we learn it. We do NOT silently + # overwrite an existing mode mid-Project -- mode switches must be + # explicit (via update_project) so reload state matches what the + # user last set in the UI. + if mode and not project.mode: + project.mode = mode + changed = True + if workdir_mode and project.workdir_mode != workdir_mode: + project.workdir_mode = workdir_mode + changed = True + if metadata: + next_metadata = { + **(project.metadata_json or {}), + **metadata, + } + if next_metadata != (project.metadata_json or {}): + project.metadata_json = next_metadata + changed = True + if changed: + project.updated_at = datetime.now() + s.add(project) + if commit: + s.commit() + s.refresh(project) + else: + s.flush() + return project + + project = Project( + id=project_id, + user_id=canonical_user_id, + space_id=target_space_id, + name=display_name[:255] or f"Project {project_id}", + description=description, + mode=mode, + workdir_mode=workdir_mode + or SpaceService._default_project_workdir_mode(space), + metadata_json=metadata, + ) + try: + with s.begin_nested(): + s.add(project) + s.flush() + except IntegrityError: + existing_project = s.exec( + select(Project).where( + Project.id == project_id, + Project.user_id == canonical_user_id, + ) + ).first() + if existing_project: + return SpaceService.ensure_project( + user_id, + project_id, + target_space_id, + project_name, + s, + description=description, + mode=mode, + workdir_mode=workdir_mode, + metadata=metadata, + commit=commit, + ) + raise + if commit: + s.commit() + s.refresh(project) + return project + + @staticmethod + def list_spaces(user_id: int | str, s: Session) -> list[SpaceOut]: + canonical_user_id = SpaceService.canonical_user_id(user_id) + SpaceService.ensure_legacy_space(canonical_user_id, s) + spaces = s.exec( + select(Space) + .where(Space.user_id == canonical_user_id) + .order_by(Space.updated_at.desc()) + ).all() + return [SpaceOut.from_model(space) for space in spaces] + + @staticmethod + def list_projects(space_id: str, user_id: int | str, s: Session) -> list[ProjectOut]: + canonical_user_id = SpaceService.canonical_user_id(user_id) + SpaceService._get_owned_space(space_id, canonical_user_id, s) + projects = s.exec( + select(Project) + .where(Project.user_id == canonical_user_id, Project.space_id == space_id) + .order_by(Project.created_at.desc(), Project.id.desc()) + ).all() + try: + SpaceService._backfill_project_names_from_history( + projects, + user_id=canonical_user_id, + s=s, + ) + except Exception: + s.rollback() + _LOGGER.warning( + "Failed to backfill project display names while listing projects", + exc_info=True, + extra={ + "space_id": space_id, + "user_id": canonical_user_id, + }, + ) + return [ProjectOut.from_model(project) for project in projects] + + @staticmethod + def create_project( + space_id: str, + data: ProjectIn, + user_id: int | str, + s: Session, + ) -> Project: + canonical_user_id = SpaceService.canonical_user_id(user_id) + space = SpaceService._get_owned_space(space_id, canonical_user_id, s) + SpaceService._validate_project_payload(data.mode, data.status, data.workdir_mode) + + project_id = data.id or f"project_{uuid4().hex}" + project = Project( + id=project_id, + user_id=canonical_user_id, + space_id=space_id, + name=(data.name or "").strip() or f"Project {project_id}", + description=data.description, + mode=data.mode, + status=data.status, + workdir_mode=data.workdir_mode + or SpaceService._default_project_workdir_mode(space), + metadata_json=data.metadata, + ) + s.add(project) + s.commit() + s.refresh(project) + return project + + @staticmethod + def update_space(space_id: str, data: SpaceUpdate, user_id: int | str, s: Session) -> Space: + space = SpaceService._get_owned_space(space_id, user_id, s) + SpaceService._validate_space_payload(status=data.status) + update_data = data.model_dump(exclude_unset=True) + metadata = update_data.pop("metadata", None) + for key, value in update_data.items(): + setattr(space, key, value) + if metadata is not None: + space.metadata_json = { + **(space.metadata_json or {}), + **metadata, + } + s.add(space) + s.commit() + s.refresh(space) + return space + + @staticmethod + def update_project( + space_id: str, + project_id: str, + data: ProjectUpdate, + user_id: int | str, + s: Session, + ) -> Project: + canonical_user_id = SpaceService.canonical_user_id(user_id) + SpaceService._get_owned_space(space_id, canonical_user_id, s) + SpaceService._validate_project_payload(status=data.status, workdir_mode=data.workdir_mode) + project = s.exec( + select(Project).where( + Project.id == project_id, + Project.user_id == canonical_user_id, + Project.space_id == space_id, + ) + ).first() + if not project: + raise ValueError("Project not found") + + update_data = data.model_dump(exclude_unset=True) + metadata = update_data.pop("metadata", None) + for key, value in update_data.items(): + if key == "name" and isinstance(value, str): + value = value.strip() or project.name + setattr(project, key, value) + if metadata is not None: + project.metadata_json = { + **(project.metadata_json or {}), + **metadata, + } + s.add(project) + s.commit() + s.refresh(project) + return project + + @staticmethod + def promote_project( + space_id: str, + project_id: str, + user_id: int | str, + s: Session, + ) -> Project: + canonical_user_id = SpaceService.canonical_user_id(user_id) + target_space = SpaceService._get_owned_space(space_id, canonical_user_id, s) + if target_space.status != SpaceStatus.ACTIVE: + raise ValueError("Cannot promote into an inactive Space") + if target_space.source_type != SpaceSourceType.FOLDER: + raise ValueError("Promote target must be a folder Space") + + project = s.exec( + select(Project).where( + Project.id == project_id, + Project.user_id == canonical_user_id, + ) + ).first() + if not project: + raise ValueError("Project not found") + + previous_metadata = project.metadata_json or {} + previous_space_id = project.space_id + project.space_id = space_id + project.workdir_mode = ProjectWorkdirMode.COPY + project.metadata_json = { + **previous_metadata, + "promotedFromSpaceId": previous_metadata.get("promotedFromSpaceId") + or previous_space_id, + } + s.add(project) + s.commit() + s.refresh(project) + return project diff --git a/server/app/domains/trigger/api/trigger_execution_controller.py b/server/app/domains/trigger/api/trigger_execution_controller.py index 1802aa773..4303a8b67 100644 --- a/server/app/domains/trigger/api/trigger_execution_controller.py +++ b/server/app/domains/trigger/api/trigger_execution_controller.py @@ -35,7 +35,7 @@ from app.shared.auth import auth_must from app.shared.auth.user_auth import V1UserAuth from app.core.database import session -from app.core.redis_utils import get_redis_manager +from app.core.redis_utils import RedisSessionManager, get_redis_manager from app.domains.trigger.service.trigger_crud_service import TriggerCrudService # Store active WebSocket connections per session (WebSocket objects only, metadata in Redis) @@ -494,7 +494,11 @@ async def handle_pubsub_message(event_data: Dict[str, Any]): async def start_pubsub_listener(): - """Start the Redis pub/sub listener for this worker.""" + """Start the Redis pub/sub listener for this worker. + + Each uvicorn worker needs its own Redis pub/sub connection. A dedicated + manager avoids sharing sockets across forked worker processes. + """ global _pubsub_task if _pubsub_task is not None: @@ -502,12 +506,12 @@ async def start_pubsub_listener(): import os logger.info(f"[PID {os.getpid()}] Starting Redis pub/sub listener for execution events") - redis_manager = get_redis_manager() + pubsub_manager = RedisSessionManager() async def run_subscriber(): try: - await redis_manager.subscribe_to_execution_events(handle_pubsub_message) + await pubsub_manager.subscribe_to_execution_events(handle_pubsub_message) except Exception as e: logger.error("Pub/sub listener crashed", extra={"error": str(e)}, exc_info=True) - _pubsub_task = asyncio.create_task(run_subscriber()) \ No newline at end of file + _pubsub_task = asyncio.create_task(run_subscriber()) diff --git a/server/app/domains/trigger/service/trigger_crud_service.py b/server/app/domains/trigger/service/trigger_crud_service.py index 20a672510..337ae5a3b 100644 --- a/server/app/domains/trigger/service/trigger_crud_service.py +++ b/server/app/domains/trigger/service/trigger_crud_service.py @@ -37,6 +37,7 @@ from app.model.chat.chat_history import ChatHistory from app.shared.types.trigger_types import TriggerType, TriggerStatus from app.core.redis_utils import get_redis_manager +from app.domains.space.service import SpaceService from app.domains.trigger.service.trigger_schedule_service import TriggerScheduleService from app.domains.trigger.service.trigger_service import TriggerService @@ -93,6 +94,7 @@ def trigger_to_out(trigger: Trigger, execution_count: int = 0) -> TriggerOut: return TriggerOut( id=trigger.id, user_id=trigger.user_id, + space_id=trigger.space_id, project_id=trigger.project_id, name=trigger.name, description=trigger.description, @@ -122,6 +124,17 @@ def _ensure_project_chat_history(data: TriggerIn, user_id: int, s: Session) -> N if not data.project_id: return + target_space_id = data.space_id or SpaceService.legacy_space_id(user_id) + SpaceService.ensure_project( + user_id, + data.project_id, + target_space_id, + data.name, + s, + description=data.description, + metadata={"createdFrom": "trigger"}, + ) + existing_chat = s.exec( select(ChatHistory).where(ChatHistory.project_id == data.project_id) ).first() @@ -132,6 +145,8 @@ def _ensure_project_chat_history(data: TriggerIn, user_id: int, s: Session) -> N user_id=user_id, task_id=data.project_id, project_id=data.project_id, + space_id=target_space_id, + run_id=data.project_id, question=f"Project created via trigger: {data.name}", language="en", model_platform=data.agent_model or "none", @@ -222,7 +237,10 @@ def create(data: TriggerIn, user_id: int, s: Session) -> dict: Returns {"success": True, "trigger_out": TriggerOut} or {"success": False, "error": str, "status_code": int}. """ # 1. Ensure project ChatHistory exists - TriggerCrudService._ensure_project_chat_history(data, user_id, s) + try: + TriggerCrudService._ensure_project_chat_history(data, user_id, s) + except ValueError as e: + return {"success": False, "error": str(e), "status_code": 404} # 2. Generate webhook URL webhook_url = None @@ -241,6 +259,7 @@ def create(data: TriggerIn, user_id: int, s: Session) -> dict: # 5. Create trigger trigger_data = data.model_dump() trigger_data["user_id"] = str(user_id) + trigger_data["space_id"] = data.space_id or SpaceService.legacy_space_id(user_id) trigger_data["webhook_url"] = webhook_url trigger_data["status"] = initial_status diff --git a/server/app/domains/user/api/logout_controller.py b/server/app/domains/user/api/logout_controller.py index b602dde3d..324ba9085 100644 --- a/server/app/domains/user/api/logout_controller.py +++ b/server/app/domains/user/api/logout_controller.py @@ -19,8 +19,11 @@ import jwt from fastapi import APIRouter, Depends from loguru import logger +from sqlmodel import Session +from app.core.database import session from app.core.environment import env_not_empty +from app.domains.remote_control.service.remote_control_service import RemoteControlService from app.shared.auth.token_blacklist import blacklist_token from app.shared.auth.user_auth import _get_jti, oauth2_scheme @@ -28,7 +31,7 @@ @router.post("/logout", name="logout") -async def logout(token: str = Depends(oauth2_scheme)): +async def logout(token: str = Depends(oauth2_scheme), db_session: Session = Depends(session)): """Revoke current token. Requires Bearer token.""" if not token: logger.info("logout: no token provided") @@ -46,6 +49,11 @@ async def logout(token: str = Depends(oauth2_scheme)): await blacklist_token(jti, ttl) except Exception as e: logger.warning("logout: token decode/blacklist failed", extra={"error": str(e)}) + if user_id is not None: + try: + RemoteControlService.revoke_user_sessions(int(user_id), db_session, "logout") + except Exception as e: + logger.warning("logout: remote-control revoke failed", extra={"user_id": user_id, "error": str(e)}) jti_safe = (jti[:8] + "...") if jti and len(jti) >= 8 else (jti or None) logger.info("logout", extra={"user_id": user_id, "jti_preview": jti_safe}) return {"success": True, "message": "Logged out"} diff --git a/server/app/domains/user/api/password_controller.py b/server/app/domains/user/api/password_controller.py index f71be47cf..f85169fcb 100644 --- a/server/app/domains/user/api/password_controller.py +++ b/server/app/domains/user/api/password_controller.py @@ -19,6 +19,7 @@ from app.core import code from app.core.database import session from app.core.encrypt import password_hash, password_verify +from app.domains.remote_control.service.remote_control_service import RemoteControlService from app.model.user.user import UpdatePassword, UserOut from app.shared.auth import auth_must from app.shared.auth.user_auth import V1UserAuth @@ -38,4 +39,5 @@ def update_password( raise UserException(code.error, _("The two passwords do not match")) model.password = password_hash(data.new_password) model.save(db_session) + RemoteControlService.revoke_user_sessions(auth.id, db_session, "password_change") return model diff --git a/server/app/domains/user/service/user_auth_service.py b/server/app/domains/user/service/user_auth_service.py index f82be5487..5e7d79463 100644 --- a/server/app/domains/user/service/user_auth_service.py +++ b/server/app/domains/user/service/user_auth_service.py @@ -23,6 +23,7 @@ from app.core.database import session_make from app.core.encrypt import password_verify from app.core.environment import env_not_empty +from app.domains.remote_control.service.remote_control_service import RemoteControlService from app.model.user.user import Status, User from app.shared.auth import create_access_token, create_refresh_token @@ -113,6 +114,9 @@ async def logout(token: str) -> bool: ttl = max(0, int(exp) - int(datetime.utcnow().timestamp())) if exp else 3600 await blacklist_token(jti, ttl) logger.info("logout", extra={"user_id": user_id}) + if user_id is not None: + with session_make() as s: + RemoteControlService.revoke_user_sessions(int(user_id), s, "logout") except Exception as e: logger.warning("logout: token decode/blacklist failed", extra={"error": str(e)}) return True diff --git a/server/app/model/chat/chat_history.py b/server/app/model/chat/chat_history.py index 726f8101d..76b2430ab 100644 --- a/server/app/model/chat/chat_history.py +++ b/server/app/model/chat/chat_history.py @@ -14,6 +14,7 @@ from datetime import datetime from enum import IntEnum +from typing import Literal from pydantic import BaseModel, model_validator from sqlalchemy import Float, Integer @@ -21,6 +22,7 @@ from sqlmodel import JSON, Column, Field, SmallInteger, String from app.model.abstract.model import AbstractModel, DefaultTimes +from app.shared.types.space_types import SkipReason class ChatStatus(IntEnum): @@ -44,6 +46,8 @@ class ChatHistory(AbstractModel, DefaultTimes, table=True): user_id: int = Field(index=True) task_id: str = Field(index=True, unique=True) project_id: str = Field(index=True, unique=False, nullable=True) + space_id: str | None = Field(default=None, index=True) + run_id: str | None = Field(default=None, index=True) question: str language: str model_platform: str @@ -58,11 +62,14 @@ class ChatHistory(AbstractModel, DefaultTimes, table=True): tokens: int = Field(default=0, sa_column=(Column(Integer, server_default="0"))) spend: float = Field(default=0, sa_column=(Column(Float, server_default="0"))) status: int = Field(default=1, sa_column=Column(ChoiceType(ChatStatus, SmallInteger()))) + skip_reason: SkipReason | None = Field(default=None, sa_column=Column(JSON)) class ChatHistoryIn(BaseModel): task_id: str project_id: str | None = None + space_id: str | None = None + run_id: str | None = None user_id: int | None = None question: str language: str @@ -78,12 +85,24 @@ class ChatHistoryIn(BaseModel): tokens: int = 0 spend: float = 0 status: int = ChatStatus.ongoing.value + skip_reason: SkipReason | None = None + workdir_mode: str | None = None + # Project execution mode reported by the client when starting the chat. + # Persisted on the Project row so reload reflects the user's last choice + # (single-agent vs workforce) — without this, Project.mode stays NULL and + # reload defaults the UI back to single-agent. + # Constrained to a Literal so a bad value gets a 422 from pydantic instead + # of bubbling up as "Space not found" through ensure_project's ValueError + # → 404 mapping in history_controller. + mode: Literal["single-agent", "workforce"] | None = None class ChatHistoryOut(BaseModel): id: int task_id: str project_id: str | None = None + space_id: str | None = None + run_id: str | None = None question: str language: str model_platform: str @@ -97,6 +116,7 @@ class ChatHistoryOut(BaseModel): summary: str | None = None tokens: int status: int + skip_reason: SkipReason | None = None created_at: datetime | None = None updated_at: datetime | None = None @@ -105,6 +125,8 @@ def fill_project_id_from_task_id(self): """Fill project_id from task_id when project_id is None""" if self.project_id is None: self.project_id = self.task_id + if self.run_id is None: + self.run_id = self.task_id return self @model_validator(mode="after") @@ -121,3 +143,6 @@ class ChatHistoryUpdate(BaseModel): tokens: int | None = None status: int | None = None project_id: str | None = None + space_id: str | None = None + run_id: str | None = None + skip_reason: SkipReason | None = None diff --git a/server/app/model/chat/chat_history_grouped.py b/server/app/model/chat/chat_history_grouped.py index c5f68c95a..80c5e097c 100644 --- a/server/app/model/chat/chat_history_grouped.py +++ b/server/app/model/chat/chat_history_grouped.py @@ -22,6 +22,7 @@ class ProjectGroup(BaseModel): """Project group response model for grouped history""" project_id: str + space_id: str | None = None project_name: str | None = None total_tokens: int = 0 task_count: int = 0 diff --git a/server/app/model/chat/chat_snpshot.py b/server/app/model/chat/chat_snpshot.py index de8031b5c..00a7fb2bd 100644 --- a/server/app/model/chat/chat_snpshot.py +++ b/server/app/model/chat/chat_snpshot.py @@ -14,7 +14,9 @@ import base64 import os +import re import time +from datetime import datetime from pydantic import BaseModel from sqlalchemy import Column, Integer, text @@ -23,6 +25,14 @@ from app.core.sqids import encode_user_id from app.model.abstract.model import AbstractModel, DefaultTimes +SNAPSHOT_PATH_COMPONENT = re.compile(r"^[a-zA-Z0-9_.:-]{1,200}$") + + +def _safe_snapshot_component(value: str, field_name: str) -> str: + if not value or value in {".", ".."} or not SNAPSHOT_PATH_COMPONENT.match(value): + raise ValueError(f"Invalid {field_name}: unsafe snapshot path component") + return value + class ChatSnapshot(AbstractModel, DefaultTimes, table=True): id: int = Field(default=None, primary_key=True) @@ -31,6 +41,7 @@ class ChatSnapshot(AbstractModel, DefaultTimes, table=True): camel_task_id: str = Field(index=True) browser_url: str image_path: str + storage_key: str | None = Field(default=None, index=True) @classmethod def get_user_dir(cls, user_id: int) -> str: @@ -52,22 +63,65 @@ def caclDir(cls, path: str) -> float: class ChatSnapshotIn(BaseModel): api_task_id: str user_id: int | None = None + space_id: str | None = None + project_id: str | None = None + run_id: str | None = None camel_task_id: str browser_url: str image_base64: str + storage_key: str | None = None @staticmethod - def save_image(user_id: int, api_task_id: str, image_base64: str) -> str: + def save_image( + user_id: int, + api_task_id: str, + image_base64: str, + *, + space_id: str | None = None, + project_id: str | None = None, + run_id: str | None = None, + ) -> str: if "," in image_base64: image_base64 = image_base64.split(",", 1)[1] - user_dir = encode_user_id(user_id) - folder = os.path.join("app", "public", "upload", user_dir, api_task_id) + if space_id and project_id: + safe_space_id = _safe_snapshot_component(space_id, "space_id") + safe_project_id = _safe_snapshot_component(project_id, "project_id") + safe_run_id = _safe_snapshot_component(run_id or api_task_id, "run_id") + folder = os.path.join( + "app", + "public", + "upload", + "v2", + safe_space_id, + safe_project_id, + safe_run_id, + ) + public_prefix = f"/public/upload/v2/{safe_space_id}/{safe_project_id}/{safe_run_id}" + else: + user_dir = encode_user_id(user_id) + safe_api_task_id = _safe_snapshot_component(api_task_id, "api_task_id") + folder = os.path.join("app", "public", "upload", user_dir, safe_api_task_id) + public_prefix = f"/public/upload/{user_dir}/{safe_api_task_id}" os.makedirs(folder, exist_ok=True) filename = f"{int(time.time() * 1000)}.jpg" file_path = os.path.join(folder, filename) with open(file_path, "wb") as f: f.write(base64.b64decode(image_base64)) - return f"/public/upload/{user_dir}/{api_task_id}/{filename}" + return f"{public_prefix}/{filename}" + + +class ChatSnapshotOut(BaseModel): + id: int + user_id: int + api_task_id: str + camel_task_id: str + browser_url: str + image_path: str + image_url: str + storage_key: str | None = None + deleted_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None class ChatSnapshotUpdate(BaseModel): @@ -76,3 +130,4 @@ class ChatSnapshotUpdate(BaseModel): camel_task_id: str | None = None browser_url: str | None = None image_path: str | None = None + storage_key: str | None = None diff --git a/server/app/model/chat/chat_step.py b/server/app/model/chat/chat_step.py index 5755b611f..faed91459 100644 --- a/server/app/model/chat/chat_step.py +++ b/server/app/model/chat/chat_step.py @@ -13,7 +13,8 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import json -from typing import Any +from datetime import datetime +from typing import Any, Optional from pydantic import BaseModel, field_validator from sqlmodel import JSON, Field @@ -24,6 +25,7 @@ class ChatStep(AbstractModel, DefaultTimes, table=True): id: int = Field(default=None, primary_key=True) task_id: str = Field(index=True) + run_id: str | None = Field(default=None, index=True) step: str data: str = Field(sa_type=JSON) timestamp: float | None = Field(default=None, nullable=True) @@ -48,6 +50,7 @@ def deserialize_data(cls, v): class ChatStepIn(BaseModel): task_id: str + run_id: str | None = None step: str data: Any timestamp: float | None = None @@ -56,12 +59,20 @@ class ChatStepIn(BaseModel): class ChatStepOut(BaseModel): id: int task_id: str + run_id: str | None = None step: str data: Any - timestamp: float | None = None + timestamp: Optional[float] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + deleted_at: Optional[datetime] = None + + class Config: + from_attributes = True class ChatStepUpdate(BaseModel): - step: str | None = None - data: Any | None = None - timestamp: float | None = None + step: Optional[str] = None + data: Optional[Any] = None + timestamp: Optional[float] = None + run_id: str | None = None diff --git a/server/app/model/config/config.py b/server/app/model/config/config.py index 18141b62f..6f5949d7a 100644 --- a/server/app/model/config/config.py +++ b/server/app/model/config/config.py @@ -55,8 +55,9 @@ class ConfigInfo: # "toolkit": "", # }, ConfigGroup.SLACK.value: { - "env_vars": ["SLACK_BOT_TOKEN"], + "env_vars": ["SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET", "SLACK_APP_TOKEN"], "toolkit": "slack_toolkit", + "trigger": "slack_trigger", }, ConfigGroup.LARK.value: { "env_vars": ["LARK_APP_ID", "LARK_APP_SECRET"], @@ -149,9 +150,20 @@ class ConfigInfo: "toolkit": "google_drive_mcp_toolkit", }, ConfigGroup.GOOGLE_GMAIL_MCP.value: { - "env_vars": ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GOOGLE_REFRESH_TOKEN"], + "env_vars": [ + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + "GOOGLE_REFRESH_TOKEN", + "GMAIL_GOOGLE_CLIENT_ID", + "GMAIL_GOOGLE_CLIENT_SECRET", + "GMAIL_GOOGLE_REFRESH_TOKEN", + ], "toolkit": "google_gmail_native_toolkit", }, + ConfigGroup.IMAGE_ANALYSIS.value: { + "env_vars": [], + "toolkit": "image_analysis_toolkit", + }, ConfigGroup.MCP_SEARCH.value: { "env_vars": [], "toolkit": "mcp_search_toolkit", @@ -164,14 +176,6 @@ class ConfigInfo: "env_vars": ["OPENAI_API_KEY"], "toolkit": "rag_toolkit", }, - ConfigGroup.REDDIT.value: { - "env_vars": [ - "REDDIT_CLIENT_ID", - "REDDIT_CLIENT_SECRET", - "REDDIT_USER_AGENT", - ], - "toolkit": "reddit_toolkit", - }, } @classmethod diff --git a/server/app/model/memory/__init__.py b/server/app/model/memory/__init__.py new file mode 100644 index 000000000..5262c9781 --- /dev/null +++ b/server/app/model/memory/__init__.py @@ -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. ========= + +from app.model.memory.memory import ProjectMemory, SpaceMemory + +__all__ = [ + "ProjectMemory", + "SpaceMemory", +] diff --git a/server/app/model/memory/memory.py b/server/app/model/memory/memory.py new file mode 100644 index 000000000..a50d7e7b0 --- /dev/null +++ b/server/app/model/memory/memory.py @@ -0,0 +1,50 @@ +# ========= 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 sqlmodel import JSON, Column, Field, String, UniqueConstraint + +from app.model.abstract.model import AbstractModel, DefaultTimes + + +class SpaceMemory(AbstractModel, DefaultTimes, table=True): + """Future shared knowledge scoped to a Space.""" + + __table_args__ = ( + UniqueConstraint("space_id", "key", name="uix_space_memory_space_key"), + ) + + id: int = Field(default=None, primary_key=True) + user_id: str = Field(sa_column=Column(String(128), nullable=False, index=True)) + space_id: str = Field(foreign_key="space.id", index=True) + key: str = Field(sa_column=Column(String(255), nullable=False, index=True)) + value: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + metadata_json: dict[str, Any] | None = Field(default=None, sa_column=Column("metadata", JSON)) + + +class ProjectMemory(AbstractModel, DefaultTimes, table=True): + """Future knowledge scoped to a Project/session.""" + + __table_args__ = ( + UniqueConstraint("project_id", "key", name="uix_project_memory_project_key"), + ) + + id: int = Field(default=None, primary_key=True) + user_id: str = Field(sa_column=Column(String(128), nullable=False, index=True)) + space_id: str = Field(foreign_key="space.id", index=True) + project_id: str = Field(foreign_key="project.id", index=True) + key: str = Field(sa_column=Column(String(255), nullable=False, index=True)) + value: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + metadata_json: dict[str, Any] | None = Field(default=None, sa_column=Column("metadata", JSON)) diff --git a/server/app/model/project/__init__.py b/server/app/model/project/__init__.py new file mode 100644 index 000000000..3fd4a5c1d --- /dev/null +++ b/server/app/model/project/__init__.py @@ -0,0 +1,33 @@ +# ========= 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.model.project.project import ( + Project, + ProjectIn, + ProjectMode, + ProjectOut, + ProjectStatus, + ProjectUpdate, + ProjectWorkdirMode, +) + +__all__ = [ + "Project", + "ProjectIn", + "ProjectMode", + "ProjectOut", + "ProjectStatus", + "ProjectUpdate", + "ProjectWorkdirMode", +] diff --git a/server/app/model/project/project.py b/server/app/model/project/project.py new file mode 100644 index 000000000..4b8e585c6 --- /dev/null +++ b/server/app/model/project/project.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. ========= + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel +from sqlalchemy import CheckConstraint +from sqlmodel import JSON, Column, Field, String, UniqueConstraint + +from app.model.abstract.model import AbstractModel, DefaultTimes + + +class ProjectMode: + SINGLE_AGENT = "single-agent" + WORKFORCE = "workforce" + + +class ProjectStatus: + ACTIVE = "active" + ARCHIVED = "archived" + + +class ProjectWorkdirMode: + WORKTREE = "worktree" + COPY = "copy" + DIRECT_WRITE = "direct-write" + ARTIFACT_ONLY = "artifact-only" + + +class Project(AbstractModel, DefaultTimes, table=True): + """Session-like execution container scoped under a Space.""" + + __table_args__ = ( + UniqueConstraint("user_id", "id", name="uix_project_user_id_id"), + CheckConstraint( + "mode IS NULL OR mode IN ('single-agent', 'workforce')", + name="ck_project_mode_valid", + ), + CheckConstraint( + "status IN ('active', 'archived')", + name="ck_project_status_valid", + ), + CheckConstraint( + "workdir_mode IS NULL OR workdir_mode IN ('worktree', 'copy', 'direct-write', 'artifact-only')", + name="ck_project_workdir_mode_valid", + ), + ) + + id: str = Field(primary_key=True) + user_id: str = Field(sa_column=Column(String(128), nullable=False, index=True)) + space_id: str = Field(foreign_key="space.id", index=True) + name: str = Field(sa_column=Column(String(255), nullable=False)) + description: str | None = Field(default=None, sa_column=Column(String(1024))) + mode: str | None = Field(default=None, sa_column=Column(String(50), index=True)) + status: str = Field( + default=ProjectStatus.ACTIVE, + sa_column=Column(String(50), nullable=False, index=True), + ) + workdir_mode: str | None = Field(default=None, sa_column=Column(String(50), index=True)) + metadata_json: dict[str, Any] | None = Field(default=None, sa_column=Column("metadata", JSON)) + + +class ProjectIn(BaseModel): + id: str | None = None + space_id: str | None = None + name: str + description: str | None = None + mode: str | None = None + status: str = ProjectStatus.ACTIVE + workdir_mode: str | None = None + metadata: dict[str, Any] | None = None + + +class ProjectUpdate(BaseModel): + name: str | None = None + description: str | None = None + status: str | None = None + workdir_mode: str | None = None + metadata: dict[str, Any] | None = None + + +class ProjectOut(BaseModel): + id: str + user_id: str + space_id: str + name: str + description: str | None = None + mode: str | None = None + status: str + workdir_mode: str | None = None + metadata: dict[str, Any] | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + @classmethod + def from_model(cls, project: Project) -> "ProjectOut": + metadata = project.metadata_json if isinstance(project.metadata_json, dict) else None + return cls( + id=project.id, + user_id=project.user_id, + space_id=project.space_id, + name=project.name or f"Project {project.id}", + description=project.description, + mode=project.mode, + status=project.status or ProjectStatus.ACTIVE, + workdir_mode=project.workdir_mode, + metadata=metadata, + created_at=project.created_at, + updated_at=project.updated_at, + ) diff --git a/server/app/model/provider/provider.py b/server/app/model/provider/provider.py index c8545cdeb..e0270849b 100644 --- a/server/app/model/provider/provider.py +++ b/server/app/model/provider/provider.py @@ -1,20 +1,21 @@ -# ========= 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. ========= +# 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 enum import IntEnum +from typing import Optional -from pydantic import BaseModel +from pydantic import AliasChoices, BaseModel, Field as PydanticField, field_validator from sqlalchemy import Boolean, Column, SmallInteger, text from sqlalchemy_utils import ChoiceType from sqlmodel import JSON, Field @@ -36,9 +37,9 @@ class Provider(AbstractModel, DefaultTimes, table=True): endpoint_url: str = "" encrypted_config: dict | None = Field(default=None, sa_column=Column(JSON)) prefer: bool = Field(default=False, sa_column=Column(Boolean, server_default=text("false"))) - is_vaild: VaildStatus = Field( + is_valid: VaildStatus = Field( default=VaildStatus.not_valid, - sa_column=Column(ChoiceType(VaildStatus, SmallInteger()), server_default=text("1")), + sa_column=Column("is_vaild", ChoiceType(VaildStatus, SmallInteger()), server_default=text("1")), ) @@ -48,9 +49,19 @@ class ProviderIn(BaseModel): api_key: str endpoint_url: str encrypted_config: dict | None = None - is_vaild: VaildStatus = VaildStatus.not_valid + is_valid: VaildStatus = PydanticField( + default=VaildStatus.not_valid, + validation_alias=AliasChoices("is_valid", "is_vaild"), + ) prefer: bool = False + @field_validator("is_valid", mode="before") + @classmethod + def normalize_is_valid(cls, value): + if isinstance(value, bool): + return VaildStatus.is_valid if value else VaildStatus.not_valid + return value + class ProviderPreferIn(BaseModel): provider_id: int @@ -60,4 +71,4 @@ class ProviderOut(ProviderIn): id: int user_id: int prefer: bool - model_type: str | None = None + model_type: Optional[str] = None diff --git a/server/app/model/remote_control/__init__.py b/server/app/model/remote_control/__init__.py new file mode 100644 index 000000000..740af6ad7 --- /dev/null +++ b/server/app/model/remote_control/__init__.py @@ -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. ========= + +from app.model.remote_control.remote_control import ( + RemoteControlCommand, + RemoteControlEvent, + RemoteControlLink, + RemoteControlSession, +) + +__all__ = [ + "RemoteControlCommand", + "RemoteControlEvent", + "RemoteControlLink", + "RemoteControlSession", +] diff --git a/server/app/model/remote_control/remote_control.py b/server/app/model/remote_control/remote_control.py new file mode 100644 index 000000000..f29cc7cd0 --- /dev/null +++ b/server/app/model/remote_control/remote_control.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 datetime import datetime +from typing import Any + +from sqlalchemy import Column, String +from sqlmodel import JSON, Field + +from app.model.abstract.model import AbstractModel, DefaultTimes + + +class RemoteControlSession(AbstractModel, DefaultTimes, table=True): + id: str = Field(sa_column=Column(String(64), primary_key=True)) + user_id: int = Field(index=True) + desktop_instance_id: str = Field(sa_column=Column(String(128), index=True)) + space_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True, index=True)) + space_name_snapshot: str | None = Field(default=None, sa_column=Column(String(255), nullable=True)) + project_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True, index=True)) + active_task_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True, index=True)) + brain_session_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True)) + current_project_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True, index=True)) + current_task_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True, index=True)) + current_history_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True)) + current_brain_session_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True)) + last_target_project_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True, index=True)) + last_target_task_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True)) + last_target_history_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True)) + last_target_brain_session_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True)) + title: str = Field(default="", sa_column=Column(String(256))) + status: str = Field(default="active", sa_column=Column(String(32), index=True)) + bridge_status: str = Field(default="offline", sa_column=Column(String(32))) + execution_mode: str = Field(default="desktop_ui", sa_column=Column(String(32))) + expires_at: datetime = Field(index=True) + last_bridge_seen_at: datetime | None = None + last_remote_seen_at: datetime | None = None + capabilities: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + revoked_at: datetime | None = None + + +class RemoteControlLink(AbstractModel, DefaultTimes, table=True): + id: str = Field(sa_column=Column(String(64), primary_key=True)) + session_id: str = Field(sa_column=Column(String(64), index=True)) + token_hash: str = Field(sa_column=Column(String(128), index=True)) + expires_at: datetime = Field(index=True) + first_used_at: datetime | None = None + use_count: int = Field(default=0) + + +class RemoteControlCommand(AbstractModel, DefaultTimes, table=True): + id: str = Field(sa_column=Column(String(64), primary_key=True)) + session_id: str = Field(sa_column=Column(String(64), index=True)) + user_id: int = Field(index=True) + source_channel: str = Field(default="remote_web", sa_column=Column(String(64))) + type: str = Field(sa_column=Column(String(64))) + payload: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + space_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True, index=True)) + next_task_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True, index=True)) + target_project_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True, index=True)) + target_task_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True, index=True)) + target_brain_session_id: str | None = Field(default=None, sa_column=Column(String(128), nullable=True)) + status: str = Field(default="pending", sa_column=Column(String(32), index=True)) + error: str | None = Field(default=None, sa_column=Column(String(1024), nullable=True)) + error_code: str | None = Field(default=None, sa_column=Column(String(128), nullable=True)) + delivered_at: datetime | None = None + acknowledged_at: datetime | None = None + + +class RemoteControlEvent(AbstractModel, DefaultTimes, table=True): + id: str = Field(sa_column=Column(String(64), primary_key=True)) + session_id: str = Field(sa_column=Column(String(64), index=True)) + type: str = Field(sa_column=Column(String(64))) + payload: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) diff --git a/server/app/model/remote_sub_agent/provider.py b/server/app/model/remote_sub_agent/provider.py index cd1b5365b..77e00c670 100644 --- a/server/app/model/remote_sub_agent/provider.py +++ b/server/app/model/remote_sub_agent/provider.py @@ -44,18 +44,18 @@ class RemoteSubAgentProviderIn(BaseModel): config: dict | None = None @model_validator(mode="after") - def validate_provider_config(self): + def validate_enabled_config(self): if not self.provider_name.strip(): raise ValueError("Remote sub agent provider requires provider_name.") - if ( + if self.enabled and ( not self.api_key.strip() or not self.endpoint_url.strip() or not self.agent_name.strip() ): raise ValueError( - "Remote sub agent provider requires api_key, endpoint_url, " - "and agent_name." + "Enabled remote sub agent provider requires api_key, " + "endpoint_url, and agent_name." ) return self diff --git a/server/app/model/space/__init__.py b/server/app/model/space/__init__.py new file mode 100644 index 000000000..f2b2c7790 --- /dev/null +++ b/server/app/model/space/__init__.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 app.model.space.file_index import SpaceFileIndex, SpaceFileIndexOverlay +from app.model.space.apply import ( + AppliedPath, + ApplyConflict, + ApplyFailure, + ApplyResolutionIn, + ApplyWarning, + OVERLAY_SOURCE_PATH_METADATA_KEY, + OVERLAY_SOURCE_ROOT_METADATA_KEY, + SpaceOverlayDiscardIn, + SpaceOverlayDiscardResponse, + SpaceOverlayListResponse, + SpaceOverlayOut, + SpaceOverlayWriteIn, + SpaceProjectRefreshIn, + SpaceProjectRefreshResponse, + SpaceProjectApplyIn, + SpaceProjectApplyResponse, +) +from app.model.space.space import ( + Space, + SpaceIn, + SpaceOut, + SpaceRelocateIn, + SpaceSourceType, + SpaceStatus, + SpaceUpdate, +) +from app.model.space.user_id_canonicalization import UserIdCanonicalization + +__all__ = [ + "Space", + "SpaceIn", + "SpaceOut", + "SpaceRelocateIn", + "SpaceSourceType", + "SpaceStatus", + "SpaceUpdate", + "SpaceFileIndex", + "SpaceFileIndexOverlay", + "AppliedPath", + "ApplyConflict", + "ApplyFailure", + "ApplyResolutionIn", + "ApplyWarning", + "OVERLAY_SOURCE_PATH_METADATA_KEY", + "OVERLAY_SOURCE_ROOT_METADATA_KEY", + "SpaceOverlayDiscardIn", + "SpaceOverlayDiscardResponse", + "SpaceOverlayListResponse", + "SpaceOverlayOut", + "SpaceOverlayWriteIn", + "SpaceProjectRefreshIn", + "SpaceProjectRefreshResponse", + "SpaceProjectApplyIn", + "SpaceProjectApplyResponse", + "UserIdCanonicalization", +] diff --git a/server/app/model/space/apply.py b/server/app/model/space/apply.py new file mode 100644 index 000000000..5efe890e8 --- /dev/null +++ b/server/app/model/space/apply.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. ========= + +from typing import Literal + +from pydantic import BaseModel, Field + +OVERLAY_SOURCE_PATH_METADATA_KEY = "source_path" +OVERLAY_SOURCE_ROOT_METADATA_KEY = "source_root" + + +class ApplyResolutionIn(BaseModel): + path: str + action: Literal["apply_mine", "keep_theirs", "write_chosen"] = "apply_mine" + content_ref: str | None = None + hash: str | None = None + + +class SpaceProjectApplyIn(BaseModel): + run_id: str + paths: list[str] | None = None + force_resolutions: list[ApplyResolutionIn] | None = None + + +class SpaceOverlayWriteIn(BaseModel): + run_id: str + path: str + status: Literal["added", "modified", "deleted"] + hash: str | None = None + base_hash: str | None = None + base_snapshot_id: str | None = None + size: int | None = None + mode: int | None = None + modified_at: str | None = None + source_path: str | None = Field( + default=None, + description="Server-side Apply source file reference for this overlay row.", + ) + source_root: str | None = Field( + default=None, + description="Root that source_path must stay under; same-machine bridge until opaque FS handles land.", + ) + metadata: dict[str, str | int | float | bool | None] | None = None + + +class SpaceOverlayOut(BaseModel): + id: int + space_id: str + project_id: str + run_id: str + path: str + status: str + hash: str | None = None + base_hash: str | None = None + base_snapshot_id: str | None = None + size: int | None = None + mode: int | None = None + metadata: dict | None = None + + +class SpaceOverlayListResponse(BaseModel): + space_id: str + project_id: str + overlays: list[SpaceOverlayOut] = Field(default_factory=list) + + +class SpaceOverlayDiscardIn(BaseModel): + run_id: str | None = None + paths: list[str] | None = None + + +class SpaceOverlayDiscardResponse(BaseModel): + space_id: str + project_id: str + discarded: int + run_ids: list[str] = Field(default_factory=list) + + +class SpaceProjectRefreshIn(BaseModel): + force: bool = False + + +class SpaceProjectRefreshResponse(BaseModel): + kind: Literal["refreshed"] + space_id: str + project_id: str + base_snapshot_id: str + + +class ApplyWarning(BaseModel): + code: str + message: str + path: str | None = None + + +class AppliedPath(BaseModel): + path: str + status: str + hash: str | None = None + + +class ApplyFailure(BaseModel): + path: str + reason: str + message: str + + +class ApplyConflict(BaseModel): + path: str + status: str + base_hash: str | None = None + current_hash: str | None = None + mine_hash: str | None = None + message: str + + +class SpaceProjectApplyResponse(BaseModel): + kind: str + space_id: str + project_id: str + run_id: str + applied: list[AppliedPath] = Field(default_factory=list) + failed: list[ApplyFailure] = Field(default_factory=list) + conflicts: list[ApplyConflict] = Field(default_factory=list) + warnings: list[ApplyWarning] = Field(default_factory=list) diff --git a/server/app/model/space/file_index.py b/server/app/model/space/file_index.py new file mode 100644 index 000000000..db985694f --- /dev/null +++ b/server/app/model/space/file_index.py @@ -0,0 +1,78 @@ +# ========= 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 datetime import datetime +from typing import Any + +from sqlalchemy import BigInteger, CheckConstraint, Integer +from sqlmodel import JSON, Column, Field, String, UniqueConstraint + +from app.model.abstract.model import AbstractModel, DefaultTimes + + +class OverlayStatus: + ADDED = "added" + MODIFIED = "modified" + DELETED = "deleted" + + +class SpaceFileIndex(AbstractModel, DefaultTimes, table=True): + """Lazy cache of Space source files. Apply must still verify live disk.""" + + __table_args__ = ( + UniqueConstraint("space_id", "path", name="uix_space_file_index_space_path"), + ) + + id: int = Field(default=None, primary_key=True) + space_id: str = Field(foreign_key="space.id", index=True) + path: str = Field(sa_column=Column(String(2048), nullable=False, index=True)) + hash: str | None = Field(default=None, sa_column=Column(String(128), index=True)) + size: int | None = Field(default=None, sa_column=Column(BigInteger)) + mode: int | None = Field(default=None, sa_column=Column(Integer)) + modified_at: datetime | None = None + indexed_at: datetime | None = None + row_version: int = Field(default=1, sa_column=Column(BigInteger, nullable=False, server_default="1")) + metadata_json: dict[str, Any] | None = Field(default=None, sa_column=Column("metadata", JSON)) + + +class SpaceFileIndexOverlay(AbstractModel, DefaultTimes, table=True): + """Run-scoped pending diff row for a Project workdir.""" + + __table_args__ = ( + CheckConstraint( + "status IN ('added', 'modified', 'deleted')", + name="ck_space_file_index_overlay_status_valid", + ), + UniqueConstraint( + "space_id", + "project_id", + "run_id", + "path", + name="uix_space_file_index_overlay_run_path", + ), + ) + + id: int = Field(default=None, primary_key=True) + space_id: str = Field(foreign_key="space.id", index=True) + project_id: str = Field(foreign_key="project.id", index=True) + run_id: str = Field(sa_column=Column(String(128), nullable=False, index=True)) + path: str = Field(sa_column=Column(String(2048), nullable=False, index=True)) + status: str = Field(sa_column=Column(String(50), nullable=False, index=True)) + hash: str | None = Field(default=None, sa_column=Column(String(128), index=True)) + base_hash: str | None = Field(default=None, sa_column=Column(String(128), index=True)) + base_snapshot_id: str | None = Field(default=None, sa_column=Column(String(128), index=True)) + size: int | None = Field(default=None, sa_column=Column(BigInteger)) + mode: int | None = Field(default=None, sa_column=Column(Integer)) + modified_at: datetime | None = Field(default=None, index=True) + metadata_json: dict[str, Any] | None = Field(default=None, sa_column=Column("metadata", JSON)) diff --git a/server/app/model/space/space.py b/server/app/model/space/space.py new file mode 100644 index 000000000..68fcb53f6 --- /dev/null +++ b/server/app/model/space/space.py @@ -0,0 +1,130 @@ +# ========= 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 datetime import datetime +from typing import Any + +from pydantic import BaseModel +from sqlalchemy import CheckConstraint, Integer +from sqlmodel import JSON, Column, Field, String + +from app.model.abstract.model import AbstractModel, DefaultTimes + + +class SpaceSourceType: + BLANK = "blank" + FOLDER = "folder" + LEGACY = "legacy" + + +class SpaceStatus: + ACTIVE = "active" + DISCONNECTED = "disconnected" + ARCHIVED = "archived" + + +class Space(AbstractModel, DefaultTimes, table=True): + """Top-level source container shared by many projects/sessions.""" + + __table_args__ = ( + CheckConstraint( + "source_type IN ('blank', 'folder', 'legacy')", + name="ck_space_source_type_valid", + ), + CheckConstraint( + "status IN ('active', 'disconnected', 'archived')", + name="ck_space_status_valid", + ), + ) + + id: str = Field(primary_key=True) + user_id: str = Field(sa_column=Column(String(128), nullable=False, index=True)) + name: str = Field(sa_column=Column(String(255), nullable=False)) + description: str | None = Field(default=None, sa_column=Column(String(1024))) + source_type: str = Field( + default=SpaceSourceType.BLANK, + sa_column=Column(String(50), nullable=False, index=True), + ) + root_path: str | None = Field(default=None, sa_column=Column(String(2048))) + root_fingerprint: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + status: str = Field( + default=SpaceStatus.ACTIVE, + sa_column=Column(String(50), nullable=False, index=True), + ) + schema_version: int = Field( + default=1, + sa_column=Column(Integer, nullable=False, server_default="1"), + ) + metadata_json: dict[str, Any] | None = Field(default=None, sa_column=Column("metadata", JSON)) + + +class SpaceIn(BaseModel): + id: str | None = None + name: str + description: str | None = None + source_type: str = SpaceSourceType.BLANK + root_path: str | None = None + root_fingerprint: dict[str, Any] | None = None + status: str = SpaceStatus.ACTIVE + schema_version: int = 1 + metadata: dict[str, Any] | None = None + + +class SpaceUpdate(BaseModel): + name: str | None = None + description: str | None = None + status: str | None = None + metadata: dict[str, Any] | None = None + + +class SpaceRelocateIn(BaseModel): + root_path: str + force: bool = False + # Optional client-supplied fingerprint of the new root. When present, the + # server uses it for identity comparison instead of stat-ing root_path + # locally — required for cloud / multi-host Brain deployments where the + # server cannot resolve the user's filesystem path. + root_fingerprint: dict[str, Any] | None = None + + +class SpaceOut(BaseModel): + id: str + user_id: str + name: str + description: str | None = None + source_type: str + root_path: str | None = None + root_fingerprint: dict[str, Any] | None = None + status: str + schema_version: int + metadata: dict[str, Any] | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + @classmethod + def from_model(cls, space: Space) -> "SpaceOut": + return cls( + id=space.id, + user_id=space.user_id, + name=space.name, + description=space.description, + source_type=space.source_type, + root_path=space.root_path, + root_fingerprint=space.root_fingerprint, + status=space.status, + schema_version=space.schema_version, + metadata=space.metadata_json, + created_at=space.created_at, + updated_at=space.updated_at, + ) diff --git a/server/app/model/space/user_id_canonicalization.py b/server/app/model/space/user_id_canonicalization.py new file mode 100644 index 000000000..07737eb4f --- /dev/null +++ b/server/app/model/space/user_id_canonicalization.py @@ -0,0 +1,25 @@ +# ========= 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 sqlmodel import Column, Field, String + +from app.model.abstract.model import AbstractModel, DefaultTimes + + +class UserIdCanonicalization(AbstractModel, DefaultTimes, table=True): + """Maps legacy per-surface user ids into the Space-layer TEXT user id.""" + + source: str = Field(sa_column=Column(String(64), primary_key=True)) + source_id: str = Field(sa_column=Column(String(128), primary_key=True)) + canonical_id: str = Field(sa_column=Column(String(128), nullable=False, index=True)) diff --git a/server/app/model/trigger/trigger.py b/server/app/model/trigger/trigger.py index 98790b32a..7ac7eb266 100644 --- a/server/app/model/trigger/trigger.py +++ b/server/app/model/trigger/trigger.py @@ -26,6 +26,7 @@ class Trigger(AbstractModel, DefaultTimes, table=True): id: int = Field(default=None, primary_key=True) user_id: str = Field(index=True, description="User ID who owns this trigger") + space_id: Optional[str] = Field(default=None, index=True, description="Space ID this trigger belongs to") project_id: str = Field(index=True, description="Project ID this trigger belongs to") name: str = Field(max_length=100, description="Human readable name for the trigger") description: str = Field(default="", max_length=1000, description="Description of what this trigger does") @@ -130,6 +131,7 @@ class TriggerIn(BaseModel): """Input model for creating triggers""" name: str = Field(max_length=100) description: str = Field(default="", max_length=1000) + space_id: Optional[str] = None project_id: str trigger_type: TriggerType custom_cron_expression: Optional[str] = None @@ -147,6 +149,7 @@ class TriggerUpdate(BaseModel): """Model for updating triggers""" name: Optional[str] = Field(default=None, max_length=100) description: Optional[str] = Field(default=None, max_length=1000) + space_id: Optional[str] = None status: Optional[TriggerStatus] = None custom_cron_expression: Optional[str] = None listener_type: Optional[ListenerType] = None @@ -163,6 +166,7 @@ class TriggerOut(BaseModel): """Output model for trigger responses""" id: int user_id: str + space_id: Optional[str] = None project_id: str name: str description: str diff --git a/server/app/model/trigger/trigger_execution.py b/server/app/model/trigger/trigger_execution.py index c337a98ff..e9bc5d6a3 100644 --- a/server/app/model/trigger/trigger_execution.py +++ b/server/app/model/trigger/trigger_execution.py @@ -18,6 +18,7 @@ from sqlalchemy_utils import ChoiceType from pydantic import BaseModel from app.model.abstract.model import AbstractModel, DefaultTimes +from app.shared.types.space_types import SkipReason from app.shared.types.trigger_types import ExecutionType, ExecutionStatus @@ -68,6 +69,11 @@ class TriggerExecution(AbstractModel, DefaultTimes, table=True): default=None, description="Error message if execution failed" ) + skip_reason: Optional[SkipReason] = Field( + default=None, + sa_column=Column(JSON), + description="Structured reason for skipped or guarded executions" + ) # Retry configuration attempts: int = Field( @@ -108,6 +114,7 @@ class TriggerExecutionUpdate(BaseModel): duration_seconds: Optional[float] = None output_data: Optional[dict] = None error_message: Optional[str] = None + skip_reason: Optional[SkipReason] = None attempts: Optional[int] = None tokens_used: Optional[int] = None tools_executed: Optional[dict] = None @@ -126,9 +133,10 @@ class TriggerExecutionOut(BaseModel): input_data: Optional[dict] = None output_data: Optional[dict] = None error_message: Optional[str] = None + skip_reason: Optional[SkipReason] = None attempts: int max_retries: int tokens_used: Optional[int] = None tools_executed: Optional[dict] = None created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None \ No newline at end of file + updated_at: Optional[datetime] = None diff --git a/server/app/shared/auth/token_blacklist.py b/server/app/shared/auth/token_blacklist.py index b1084ce7f..ced9f82ec 100644 --- a/server/app/shared/auth/token_blacklist.py +++ b/server/app/shared/auth/token_blacklist.py @@ -19,11 +19,16 @@ TTL matches remaining token lifetime. """ +import json +import os + from app.core.environment import env_or_fail from redis import asyncio as aioredis _redis: aioredis.Redis | None = None BLACKLIST_PREFIX = "token:blacklist:" +BLACKLIST_PUBSUB_PREFIX = "token_blacklist:" +BLACKLIST_PUBSUB_CHANNEL = f"{BLACKLIST_PUBSUB_PREFIX}revoked" def _get_redis() -> aioredis.Redis: @@ -57,7 +62,23 @@ async def blacklist_token(jti: str, ttl_seconds: int) -> None: try: r = _get_redis() key = f"{BLACKLIST_PREFIX}{jti}" + payload = json.dumps({"type": "token_blacklisted", "jti": jti}) await r.set(key, "1", ex=ttl_seconds) + await r.publish(BLACKLIST_PUBSUB_CHANNEL, payload) + await _publish_to_session_redis_if_needed(payload) except Exception as e: from loguru import logger logger.error(f"Redis blacklist_token failed: {e}") + + +async def _publish_to_session_redis_if_needed(payload: str) -> None: + auth_redis_url = os.getenv("redis_url") + session_redis_url = os.getenv("SESSION_REDIS_URL") + if not session_redis_url or not auth_redis_url or session_redis_url == auth_redis_url: + return + + session_redis = aioredis.from_url(session_redis_url, encoding="utf-8", decode_responses=True) + try: + await session_redis.publish(BLACKLIST_PUBSUB_CHANNEL, payload) + finally: + await session_redis.aclose() diff --git a/server/app/shared/middleware/cors.py b/server/app/shared/middleware/cors.py index 63a3d84f7..fd0f075d4 100644 --- a/server/app/shared/middleware/cors.py +++ b/server/app/shared/middleware/cors.py @@ -16,37 +16,71 @@ CORS middleware with configurable origins from env. Reads CORS_ALLOW_ORIGINS from environment. Comma-separated list. -Defaults to ["*"] for development when not set. +When CORS_ALLOW_ORIGINS is missing, derives remote-control origins from +REMOTE_CONTROL_WEB_ORIGIN / VITE_REMOTE_CONTROL_WEB_ORIGIN / VITE_SITE_URL, +then falls back to localhost dev origins. When origins is ["*"], allow_credentials is False (CORS spec forbids * + credentials). -P2: Production warning when CORS_ALLOW_ORIGINS not set. """ import os from loguru import logger +from app.core.environment import env +from app.shared.middleware.origins import ( + configured_remote_origins, + csv_values, + local_dev_cors_origins, + truthy, +) + + +DEFAULT_ALLOW_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] +DEFAULT_ALLOW_HEADERS = [ + "Accept", + "Authorization", + "Content-Type", + "X-Remote-Control-Token", + "X-Trace-Id", + "X-Requested-With", +] + def get_cors_middleware(): """ Return kwargs for CORSMiddleware. Use: add_middleware(CORSMiddleware, **get_cors_middleware()) - Env: CORS_ALLOW_ORIGINS (comma-separated, e.g. "https://app.example.com,https://admin.example.com") - Default: ["*"] when not set (development). Production should set explicit origins. + Env: + CORS_ALLOW_ORIGINS: comma-separated origins. + REMOTE_CONTROL_ALLOW_UNSAFE_ORIGINS: set true to intentionally allow "*". + CORS_ALLOW_METHODS: comma-separated methods. Defaults to standard REST verbs. + CORS_ALLOW_HEADERS: comma-separated headers. Defaults include X-Remote-Control-Token. """ - raw = os.getenv("CORS_ALLOW_ORIGINS", "").strip() - if raw: - origins = [o.strip() for o in raw.split(",") if o.strip()] - else: + origins_raw = os.getenv("CORS_ALLOW_ORIGINS", "").strip() + if origins_raw: + origins = csv_values(origins_raw) + elif truthy(os.getenv("REMOTE_CONTROL_ALLOW_UNSAFE_ORIGINS")): origins = ["*"] - from app.core.environment import env - + logger.warning('REMOTE_CONTROL_ALLOW_UNSAFE_ORIGINS=true, using CORS allow_origins ["*"].') + else: + origins = configured_remote_origins() or local_dev_cors_origins() if env("debug", "") != "on": - logger.warning('CORS_ALLOW_ORIGINS not set, using ["*"]. Production should set explicit origins.') + logger.warning( + "CORS_ALLOW_ORIGINS not set. Using configured remote web/local dev origins only; " + "production should set explicit origins." + ) + + methods_raw = os.getenv("CORS_ALLOW_METHODS", "").strip() + methods = csv_values(methods_raw) if methods_raw else DEFAULT_ALLOW_METHODS + + headers_raw = os.getenv("CORS_ALLOW_HEADERS", "").strip() + headers = csv_values(headers_raw) if headers_raw else DEFAULT_ALLOW_HEADERS + # CORS spec: Access-Control-Allow-Origin: * cannot be used with credentials allow_credentials = origins != ["*"] return { "allow_origins": origins, "allow_credentials": allow_credentials, - "allow_methods": ["*"], - "allow_headers": ["*"], + "allow_methods": methods, + "allow_headers": headers, } diff --git a/server/app/shared/middleware/origins.py b/server/app/shared/middleware/origins.py new file mode 100644 index 000000000..45d0b3d26 --- /dev/null +++ b/server/app/shared/middleware/origins.py @@ -0,0 +1,101 @@ +# ========= 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 ipaddress +import os +from urllib.parse import urlparse + +REMOTE_ORIGIN_KEYS = ( + "REMOTE_CONTROL_WEB_ORIGIN", + "VITE_REMOTE_CONTROL_WEB_ORIGIN", + "VITE_SITE_URL", +) +DEFAULT_LOCAL_DEV_HOSTS = ("localhost", "127.0.0.1") +DEFAULT_LOCAL_DEV_WS_HOSTS = {"localhost", "127.0.0.1", "::1"} +DEFAULT_LOCAL_DEV_PORTS = (3001, 5173, 5174) + + +def csv_values(value: str | None) -> list[str]: + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + +def truthy(value: str | None) -> bool: + return str(value or "").lower() in {"1", "true", "yes", "on"} + + +def configured_remote_origins() -> list[str]: + seen: set[str] = set() + origins: list[str] = [] + for key in REMOTE_ORIGIN_KEYS: + for origin in csv_values(os.getenv(key)): + if origin not in seen: + seen.add(origin) + origins.append(origin) + return origins + + +def local_dev_ports() -> set[int]: + raw_ports = csv_values(os.getenv("REMOTE_CONTROL_LOCAL_DEV_PORTS")) + if not raw_ports: + return set(DEFAULT_LOCAL_DEV_PORTS) + + ports: set[int] = set() + for raw_port in raw_ports: + try: + port = int(raw_port) + except ValueError: + continue + if 0 < port <= 65535: + ports.add(port) + return ports or set(DEFAULT_LOCAL_DEV_PORTS) + + +def local_dev_cors_origins() -> list[str]: + origins: list[str] = [] + for host in DEFAULT_LOCAL_DEV_HOSTS: + for port in sorted(local_dev_ports()): + origins.append(f"http://{host}:{port}") + return origins + + +def is_local_dev_origin(origin: str | None) -> bool: + if not origin: + return False + try: + parsed = urlparse(origin) + except ValueError: + return False + if parsed.scheme not in {"http", "https"}: + return False + + host = parsed.hostname + if not host: + return False + + port = parsed.port or (443 if parsed.scheme == "https" else 80) + if port not in local_dev_ports(): + return False + + if host in DEFAULT_LOCAL_DEV_WS_HOSTS: + return True + + try: + address = ipaddress.ip_address(host) + except ValueError: + return False + return address.is_loopback or address.is_private diff --git a/server/app/shared/middleware/rate_limit.py b/server/app/shared/middleware/rate_limit.py index 671448c49..462e1703b 100644 --- a/server/app/shared/middleware/rate_limit.py +++ b/server/app/shared/middleware/rate_limit.py @@ -19,13 +19,17 @@ Provides convenient factory for common limits: login, register, webhook. """ -from collections.abc import Callable +from collections.abc import Awaitable, Callable from fastapi import Depends from fastapi_limiter.depends import RateLimiter -def rate_limiter_factory(times: int = 10, seconds: int = 60) -> Callable: +def rate_limiter_factory( + times: int = 10, + seconds: int = 60, + identifier: Callable[..., Awaitable[str]] | None = None, +) -> Callable: """ Create a RateLimiter dependency with given limits. @@ -33,7 +37,7 @@ def rate_limiter_factory(times: int = 10, seconds: int = 60) -> Callable: :param seconds: Window size in seconds :return: FastAPI Depends-compatible callable """ - return Depends(RateLimiter(times=times, seconds=seconds)) + return Depends(RateLimiter(times=times, seconds=seconds, identifier=identifier)) # Predefined limiters for common use cases diff --git a/server/app/shared/types/config_group.py b/server/app/shared/types/config_group.py index c0190ee1c..a61be61b0 100644 --- a/server/app/shared/types/config_group.py +++ b/server/app/shared/types/config_group.py @@ -36,11 +36,14 @@ class ConfigGroup(str, Enum): GITHUB = "Github" GOOGLE_CALENDAR = "Google Calendar" GOOGLE_DRIVE_MCP = "Google Drive MCP" + GOOGLE_GMAIL = "Google Gmail" GOOGLE_GMAIL_MCP = "Google Gmail" + IMAGE_ANALYSIS = "Image Analysis" MCP_SEARCH = "MCP Search" PPTX = "PPTX" RAG = "RAG" TERMINAL = "Terminal" + CODEX = "Codex" @classmethod def get_all_values(cls) -> list[str]: diff --git a/server/app/shared/types/space_types.py b/server/app/shared/types/space_types.py new file mode 100644 index 000000000..b9024c5b5 --- /dev/null +++ b/server/app/shared/types/space_types.py @@ -0,0 +1,36 @@ +# ========= 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, Literal, TypeAlias, TypedDict + +SkipReasonCode: TypeAlias = Literal[ + "space_disconnected", + "space_archived", + "project_archived", + "resource_cap", + "direct_write_conflict", + "workdir_mode_conflict", + "memory_pressure", + "apply_conflict", + "artifact_only_source_edit_attempt", + "apply_partial_fail", + "manual_cancelled", + "unknown", +] + + +class SkipReason(TypedDict, total=False): + code: SkipReasonCode + message: str + detail: dict[str, Any] diff --git a/server/lang/zh_CN/LC_MESSAGES/messages.po b/server/lang/zh_CN/LC_MESSAGES/messages.po index 4b78de2bf..a9322523a 100644 --- a/server/lang/zh_CN/LC_MESSAGES/messages.po +++ b/server/lang/zh_CN/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2025-08-06 09:56+0800\n" +"POT-Creation-Date: 2026-06-01 20:31+0800\n" "PO-Revision-Date: 2025-08-06 09:56+0800\n" "Last-Translator: FULL NAME \n" "Language: zh_Hans_CN\n" @@ -16,200 +16,234 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.17.0\n" +"Generated-By: Babel 2.18.0\n" -#: app/component/auth.py:41 -msgid "Validate credentials expired" +#: app/api/demo_controller.py:27 +msgid "no auth" msgstr "" -#: app/component/auth.py:43 -msgid "Could not validate credentials" +#: app/api/demo_controller.py:27 +msgid "hello" msgstr "" -#: app/component/permission.py:12 -msgid "User" +#: app/domains/chat/api/snapshot_controller.py:42 +msgid "Invalid api_task_id: only letters, numbers, - and _ allowed" msgstr "" -#: app/component/permission.py:13 -msgid "User manager" +#: app/domains/config/api/config_controller.py:40 +#: app/domains/config/api/config_controller.py:73 +#: app/domains/config/api/config_controller.py:85 +msgid "Configuration not found" msgstr "" -#: app/component/permission.py:17 -msgid "User Manage" +#: app/domains/config/api/config_controller.py:54 +msgid "Config Name is invalid" msgstr "" -#: app/component/permission.py:18 -msgid "View users" +#: app/domains/config/api/config_controller.py:55 +#: app/domains/config/api/config_controller.py:75 +msgid "Configuration already exists for this user" msgstr "" -#: app/component/permission.py:22 -msgid "User Edit" +#: app/domains/config/api/config_controller.py:74 +msgid "Invalid configuration group" msgstr "" -#: app/component/permission.py:23 -msgid "Manage users" +#: app/domains/mcp/api/mcp_controller.py:80 +msgid "Mcp not found" msgstr "" -#: app/component/permission.py:28 -msgid "Admin" +#: app/domains/mcp/api/user_controller.py:65 +msgid "mcp is installed" msgstr "" -#: app/component/permission.py:29 -msgid "Admin manager" +#: app/domains/model_provider/api/provider_controller.py:53 +#: app/domains/model_provider/api/provider_controller.py:67 +#: app/domains/model_provider/api/provider_controller.py:74 +#: app/domains/model_provider/api/provider_controller.py:82 +msgid "Provider not found" msgstr "" -#: app/component/permission.py:33 -msgid "Admin View" +#: app/domains/remote_sub_agent/api/provider_controller.py:63 +#: app/domains/remote_sub_agent/api/provider_controller.py:102 +#: app/domains/remote_sub_agent/api/provider_controller.py:118 +msgid "Remote sub agent provider not found" msgstr "" -#: app/component/permission.py:34 -msgid "View admins" +#: app/domains/user/api/login_controller.py:75 +msgid "Failed to create default user" msgstr "" -#: app/component/permission.py:38 -msgid "Admin Edit" +#: app/domains/user/api/login_controller.py:90 +msgid "Account or password error" msgstr "" -#: app/component/permission.py:39 -msgid "Edit admins" +#: app/domains/user/api/login_controller.py:92 +#: app/domains/user/api/login_controller.py:116 +msgid "Your account has been blocked." msgstr "" -#: app/component/permission.py:44 -msgid "Role" +#: app/domains/user/api/login_controller.py:94 +#: app/domains/user/api/login_controller.py:118 +msgid "Please activate your account via the email link." msgstr "" -#: app/component/permission.py:45 -msgid "Role manager" +#: app/domains/user/api/login_controller.py:110 +msgid "Refresh token required" msgstr "" -#: app/component/permission.py:49 -msgid "Role View" +#: app/domains/user/api/login_controller.py:114 +#: app/shared/auth/user_auth.py:144 +msgid "User not found" msgstr "" -#: app/component/permission.py:50 -msgid "View roles" +#: app/domains/user/api/password_controller.py:37 +msgid "Password is incorrect" msgstr "" -#: app/component/permission.py:54 -msgid "Role Edit" +#: app/domains/user/api/password_controller.py:39 +msgid "The two passwords do not match" msgstr "" -#: app/component/permission.py:55 -msgid "Edit roles" +#: app/model/abstract/model.py:109 +msgid "There is no data that meets the conditions" msgstr "" -#: app/component/permission.py:60 -msgid "Mcp" +#: app/shared/auth/admin_auth.py:56 +msgid "Admin user not found" msgstr "" -#: app/component/permission.py:61 -msgid "Mcp manager" +#: app/shared/auth/admin_auth.py:69 +msgid "Your are not authorized to access this function" msgstr "" -#: app/component/permission.py:65 -msgid "Mcp Edit" +#: app/shared/auth/admin_auth.py:77 +msgid "Invalid token type - admin required" msgstr "" -#: app/component/permission.py:66 -msgid "Edit mcp service" +#: app/shared/auth/admin_auth.py:80 app/shared/auth/user_auth.py:73 +msgid "Validate credentials expired" msgstr "" -#: app/component/permission.py:70 -msgid "Mcp Category Edit" +#: app/shared/auth/admin_auth.py:83 app/shared/auth/user_auth.py:76 +#: app/shared/auth/user_auth.py:128 +msgid "Could not validate credentials" msgstr "" -#: app/component/permission.py:71 -msgid "Edit mcp category" +#: app/shared/auth/admin_auth.py:114 app/shared/auth/user_auth.py:137 +msgid "Token required" msgstr "" -#: app/controller/chat/snapshot_controller.py:34 -#: app/controller/chat/snapshot_controller.py:68 -#: app/controller/chat/snapshot_controller.py:81 -msgid "Chat snapshot not found" +#: app/shared/auth/admin_auth.py:118 app/shared/auth/user_auth.py:125 +#: app/shared/auth/user_auth.py:141 +msgid "Token has been revoked" msgstr "" -#: app/controller/chat/step_controller.py:65 -#: app/controller/chat/step_controller.py:89 -#: app/controller/chat/step_controller.py:102 -msgid "Chat step not found" +#: app/shared/auth/admin_auth.py:121 +msgid "Admin not found" msgstr "" -#: app/controller/config/config_controller.py:40 -#: app/controller/config/config_controller.py:76 -#: app/controller/config/config_controller.py:108 -msgid "Configuration not found" +#: app/shared/auth/user_auth.py:70 +msgid "Invalid token type" msgstr "" -#: app/controller/config/config_controller.py:47 -msgid "Config Name is valid" +#: app/shared/auth/user_auth.py:120 +msgid "Invalid token type - refresh required" msgstr "" -#: app/controller/config/config_controller.py:55 -#: app/controller/config/config_controller.py:92 -msgid "Configuration already exists for this user" -msgstr "" +#~ msgid "User" +#~ msgstr "" -#: app/controller/config/config_controller.py:80 -msgid "Invalid configuration group" -msgstr "" +#~ msgid "User manager" +#~ msgstr "" -#: app/controller/mcp/mcp_controller.py:70 -msgid "Mcp not found" -msgstr "" +#~ msgid "User Manage" +#~ msgstr "" -#: app/controller/mcp/mcp_controller.py:73 -#: app/controller/mcp/user_controller.py:44 -msgid "mcp is installed" -msgstr "" +#~ msgid "View users" +#~ msgstr "" -#: app/controller/mcp/user_controller.py:34 -msgid "McpUser not found" -msgstr "" +#~ msgid "User Edit" +#~ msgstr "" -#: app/controller/mcp/user_controller.py:61 -#: app/controller/mcp/user_controller.py:75 -msgid "Mcp Info not found" -msgstr "" +#~ msgid "Manage users" +#~ msgstr "" -#: app/controller/mcp/user_controller.py:63 -msgid "current user have no permission to modify" -msgstr "" +#~ msgid "Admin" +#~ msgstr "" -#: app/controller/provider/provider_controller.py:41 -#: app/controller/provider/provider_controller.py:60 -#: app/controller/provider/provider_controller.py:79 -msgid "Provider not found" -msgstr "" +#~ msgid "Admin manager" +#~ msgstr "" -#: app/controller/user/login_controller.py:25 -msgid "Account or password error" -msgstr "" +#~ msgid "Admin View" +#~ msgstr "" -#: app/controller/user/login_controller.py:47 -msgid "User not found" -msgstr "" +#~ msgid "View admins" +#~ msgstr "" -#: app/controller/user/login_controller.py:64 -#: app/controller/user/login_controller.py:89 -msgid "Failed to register" -msgstr "" +#~ msgid "Admin Edit" +#~ msgstr "" -#: app/controller/user/login_controller.py:67 -msgid "Your account has been blocked." -msgstr "" +#~ msgid "Edit admins" +#~ msgstr "" -#: app/controller/user/login_controller.py:75 -msgid "Email already registered" -msgstr "" +#~ msgid "Role" +#~ msgstr "" -#: app/controller/user/user_password_controller.py:19 -msgid "Password is incorrect" -msgstr "" +#~ msgid "Role manager" +#~ msgstr "" -#: app/controller/user/user_password_controller.py:21 -msgid "The two passwords do not match" -msgstr "" +#~ msgid "Role View" +#~ msgstr "" + +#~ msgid "View roles" +#~ msgstr "" + +#~ msgid "Role Edit" +#~ msgstr "" + +#~ msgid "Edit roles" +#~ msgstr "" + +#~ msgid "Mcp" +#~ msgstr "" + +#~ msgid "Mcp manager" +#~ msgstr "" + +#~ msgid "Mcp Edit" +#~ msgstr "" + +#~ msgid "Edit mcp service" +#~ msgstr "" + +#~ msgid "Mcp Category Edit" +#~ msgstr "" + +#~ msgid "Edit mcp category" +#~ msgstr "" + +#~ msgid "Chat snapshot not found" +#~ msgstr "" + +#~ msgid "Chat step not found" +#~ msgstr "" + +#~ msgid "Config Name is valid" +#~ msgstr "" + +#~ msgid "McpUser not found" +#~ msgstr "" + +#~ msgid "Mcp Info not found" +#~ msgstr "" + +#~ msgid "current user have no permission to modify" +#~ msgstr "" + +#~ msgid "Failed to register" +#~ msgstr "" + +#~ msgid "Email already registered" +#~ msgstr "" -#: app/model/abstract/model.py:66 -msgid "There is no data that meets the conditions" -msgstr "" diff --git a/server/main.py b/server/main.py index 9798067ca..9c21a1265 100644 --- a/server/main.py +++ b/server/main.py @@ -25,6 +25,7 @@ import subprocess from importlib.metadata import version as pkg_version +from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi_pagination import add_pagination from loguru import logger as loguru_logger @@ -35,12 +36,13 @@ from app.core.babel import babel_configs from app.core.environment import auto_include_routers, env from app.shared.exception.handlers import register_exception_handlers -from app.shared.middleware import TraceIDMiddleware +from app.shared.middleware import TraceIDMiddleware, get_cors_middleware from app.shared.logging import trace_filter # Register exception handlers and i18n middleware register_exception_handlers(api) api.add_middleware(BabelMiddleware, babel_configs=babel_configs) +api.add_middleware(CORSMiddleware, **get_cors_middleware()) std_logger = logging.getLogger("server_main") diff --git a/server/messages.pot b/server/messages.pot index 04c9844e1..be3090146 100644 --- a/server/messages.pot +++ b/server/messages.pot @@ -1,214 +1,152 @@ # Translations template for PROJECT. -# Copyright (C) 2025 ORGANIZATION +# Copyright (C) 2026 ORGANIZATION # This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2025. +# FIRST AUTHOR , 2026. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2025-08-06 09:56+0800\n" +"POT-Creation-Date: 2026-06-01 20:31+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.17.0\n" +"Generated-By: Babel 2.18.0\n" -#: app/component/auth.py:41 -msgid "Validate credentials expired" -msgstr "" - -#: app/component/auth.py:43 -msgid "Could not validate credentials" -msgstr "" - -#: app/component/permission.py:12 -msgid "User" -msgstr "" - -#: app/component/permission.py:13 -msgid "User manager" -msgstr "" - -#: app/component/permission.py:17 -msgid "User Manage" -msgstr "" - -#: app/component/permission.py:18 -msgid "View users" -msgstr "" - -#: app/component/permission.py:22 -msgid "User Edit" -msgstr "" - -#: app/component/permission.py:23 -msgid "Manage users" -msgstr "" - -#: app/component/permission.py:28 -msgid "Admin" -msgstr "" - -#: app/component/permission.py:29 -msgid "Admin manager" -msgstr "" - -#: app/component/permission.py:33 -msgid "Admin View" -msgstr "" - -#: app/component/permission.py:34 -msgid "View admins" -msgstr "" - -#: app/component/permission.py:38 -msgid "Admin Edit" -msgstr "" - -#: app/component/permission.py:39 -msgid "Edit admins" -msgstr "" - -#: app/component/permission.py:44 -msgid "Role" +#: app/api/demo_controller.py:27 +msgid "no auth" msgstr "" -#: app/component/permission.py:45 -msgid "Role manager" +#: app/api/demo_controller.py:27 +msgid "hello" msgstr "" -#: app/component/permission.py:49 -msgid "Role View" +#: app/domains/chat/api/snapshot_controller.py:42 +msgid "Invalid api_task_id: only letters, numbers, - and _ allowed" msgstr "" -#: app/component/permission.py:50 -msgid "View roles" -msgstr "" - -#: app/component/permission.py:54 -msgid "Role Edit" +#: app/domains/config/api/config_controller.py:40 +#: app/domains/config/api/config_controller.py:73 +#: app/domains/config/api/config_controller.py:85 +msgid "Configuration not found" msgstr "" -#: app/component/permission.py:55 -msgid "Edit roles" +#: app/domains/config/api/config_controller.py:54 +msgid "Config Name is invalid" msgstr "" -#: app/component/permission.py:60 -msgid "Mcp" +#: app/domains/config/api/config_controller.py:55 +#: app/domains/config/api/config_controller.py:75 +msgid "Configuration already exists for this user" msgstr "" -#: app/component/permission.py:61 -msgid "Mcp manager" +#: app/domains/config/api/config_controller.py:74 +msgid "Invalid configuration group" msgstr "" -#: app/component/permission.py:65 -msgid "Mcp Edit" +#: app/domains/mcp/api/mcp_controller.py:80 +msgid "Mcp not found" msgstr "" -#: app/component/permission.py:66 -msgid "Edit mcp service" +#: app/domains/mcp/api/user_controller.py:65 +msgid "mcp is installed" msgstr "" -#: app/component/permission.py:70 -msgid "Mcp Category Edit" +#: app/domains/model_provider/api/provider_controller.py:53 +#: app/domains/model_provider/api/provider_controller.py:67 +#: app/domains/model_provider/api/provider_controller.py:74 +#: app/domains/model_provider/api/provider_controller.py:82 +msgid "Provider not found" msgstr "" -#: app/component/permission.py:71 -msgid "Edit mcp category" +#: app/domains/remote_sub_agent/api/provider_controller.py:63 +#: app/domains/remote_sub_agent/api/provider_controller.py:102 +#: app/domains/remote_sub_agent/api/provider_controller.py:118 +msgid "Remote sub agent provider not found" msgstr "" -#: app/controller/chat/snapshot_controller.py:34 -#: app/controller/chat/snapshot_controller.py:68 -#: app/controller/chat/snapshot_controller.py:81 -msgid "Chat snapshot not found" +#: app/domains/user/api/login_controller.py:75 +msgid "Failed to create default user" msgstr "" -#: app/controller/chat/step_controller.py:65 -#: app/controller/chat/step_controller.py:89 -#: app/controller/chat/step_controller.py:102 -msgid "Chat step not found" +#: app/domains/user/api/login_controller.py:90 +msgid "Account or password error" msgstr "" -#: app/controller/config/config_controller.py:40 -#: app/controller/config/config_controller.py:76 -#: app/controller/config/config_controller.py:108 -msgid "Configuration not found" +#: app/domains/user/api/login_controller.py:92 +#: app/domains/user/api/login_controller.py:116 +msgid "Your account has been blocked." msgstr "" -#: app/controller/config/config_controller.py:47 -msgid "Config Name is valid" +#: app/domains/user/api/login_controller.py:94 +#: app/domains/user/api/login_controller.py:118 +msgid "Please activate your account via the email link." msgstr "" -#: app/controller/config/config_controller.py:55 -#: app/controller/config/config_controller.py:92 -msgid "Configuration already exists for this user" +#: app/domains/user/api/login_controller.py:110 +msgid "Refresh token required" msgstr "" -#: app/controller/config/config_controller.py:80 -msgid "Invalid configuration group" +#: app/domains/user/api/login_controller.py:114 +#: app/shared/auth/user_auth.py:144 +msgid "User not found" msgstr "" -#: app/controller/mcp/mcp_controller.py:70 -msgid "Mcp not found" +#: app/domains/user/api/password_controller.py:37 +msgid "Password is incorrect" msgstr "" -#: app/controller/mcp/mcp_controller.py:73 -#: app/controller/mcp/user_controller.py:44 -msgid "mcp is installed" +#: app/domains/user/api/password_controller.py:39 +msgid "The two passwords do not match" msgstr "" -#: app/controller/mcp/user_controller.py:34 -msgid "McpUser not found" +#: app/model/abstract/model.py:109 +msgid "There is no data that meets the conditions" msgstr "" -#: app/controller/mcp/user_controller.py:61 -#: app/controller/mcp/user_controller.py:75 -msgid "Mcp Info not found" +#: app/shared/auth/admin_auth.py:56 +msgid "Admin user not found" msgstr "" -#: app/controller/mcp/user_controller.py:63 -msgid "current user have no permission to modify" +#: app/shared/auth/admin_auth.py:69 +msgid "Your are not authorized to access this function" msgstr "" -#: app/controller/provider/provider_controller.py:41 -#: app/controller/provider/provider_controller.py:60 -#: app/controller/provider/provider_controller.py:79 -msgid "Provider not found" +#: app/shared/auth/admin_auth.py:77 +msgid "Invalid token type - admin required" msgstr "" -#: app/controller/user/login_controller.py:25 -msgid "Account or password error" +#: app/shared/auth/admin_auth.py:80 app/shared/auth/user_auth.py:73 +msgid "Validate credentials expired" msgstr "" -#: app/controller/user/login_controller.py:47 -msgid "User not found" +#: app/shared/auth/admin_auth.py:83 app/shared/auth/user_auth.py:76 +#: app/shared/auth/user_auth.py:128 +msgid "Could not validate credentials" msgstr "" -#: app/controller/user/login_controller.py:64 -#: app/controller/user/login_controller.py:89 -msgid "Failed to register" +#: app/shared/auth/admin_auth.py:114 app/shared/auth/user_auth.py:137 +msgid "Token required" msgstr "" -#: app/controller/user/login_controller.py:67 -msgid "Your account has been blocked." +#: app/shared/auth/admin_auth.py:118 app/shared/auth/user_auth.py:125 +#: app/shared/auth/user_auth.py:141 +msgid "Token has been revoked" msgstr "" -#: app/controller/user/login_controller.py:75 -msgid "Email already registered" +#: app/shared/auth/admin_auth.py:121 +msgid "Admin not found" msgstr "" -#: app/controller/user/user_password_controller.py:19 -msgid "Password is incorrect" +#: app/shared/auth/user_auth.py:70 +msgid "Invalid token type" msgstr "" -#: app/controller/user/user_password_controller.py:21 -msgid "The two passwords do not match" +#: app/shared/auth/user_auth.py:120 +msgid "Invalid token type - refresh required" msgstr "" -#: app/model/abstract/model.py:66 -msgid "There is no data that meets the conditions" -msgstr "" diff --git a/server/scripts/space_migration_preflight.py b/server/scripts/space_migration_preflight.py new file mode 100644 index 000000000..7181afd67 --- /dev/null +++ b/server/scripts/space_migration_preflight.py @@ -0,0 +1,145 @@ +# ========= 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. ========= + +"""Preflight checks for the Space layer migration. + +This script is intentionally read-only. It surfaces user id mappings and +cross-user project id collisions before the Alembic migration materializes +Legacy Space and Project rows. +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import sys + +from sqlalchemy import create_engine, text + +_server_root = pathlib.Path(__file__).resolve().parents[1] +if str(_server_root) not in sys.path: + sys.path.insert(0, str(_server_root)) + +from app.core.environment import env_not_empty # noqa: E402 + + +def fetch_rows(engine, statement: str) -> list[dict]: + with engine.connect() as connection: + return [dict(row._mapping) for row in connection.execute(text(statement))] + + +def table_exists(engine, name: str) -> bool: + with engine.connect() as connection: + return bool( + connection.execute( + text( + "SELECT 1 FROM information_schema.tables " + "WHERE table_schema = current_schema() AND table_name = :n" + ), + {"n": name}, + ).first() + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Space migration preflight") + parser.add_argument( + "--check-mappings-only", + action="store_true", + help="Only report user id mapping blockers.", + ) + parser.add_argument( + "--canonicalize-only", + action="store_true", + help="Alias for --check-mappings-only kept for the V2.6 rollout docs.", + ) + args = parser.parse_args() + + engine = create_engine(env_not_empty("database_url")) + + has_chat_history = table_exists(engine, "chat_history") + has_trigger = table_exists(engine, "trigger") + missing_tables = [ + name + for name, present in (("chat_history", has_chat_history), ("trigger", has_trigger)) + if not present + ] + + user_source_sql_parts: list[str] = [] + if has_chat_history: + user_source_sql_parts.append( + "SELECT 'chat_history' AS source, CAST(user_id AS TEXT) AS source_id " + "FROM chat_history WHERE user_id IS NOT NULL" + ) + if has_trigger: + user_source_sql_parts.append( + "SELECT 'trigger' AS source, CAST(user_id AS TEXT) AS source_id " + "FROM trigger WHERE user_id IS NOT NULL" + ) + + user_sources: list[dict] = ( + fetch_rows(engine, " UNION ".join(user_source_sql_parts)) + if user_source_sql_parts + else [] + ) + mapping_blockers = [ + row + for row in user_sources + if row["source"] == "trigger" and not str(row["source_id"]).isdigit() + ] + + report: dict[str, object] = { + "missing_tables": missing_tables, + "user_sources": user_sources, + "mapping_blockers": mapping_blockers, + } + + if not (args.check_mappings_only or args.canonicalize_only): + collision_sql_parts: list[str] = [] + if has_chat_history: + collision_sql_parts.append( + "SELECT CAST(user_id AS TEXT) AS user_id, " + "COALESCE(project_id, task_id) AS project_id " + "FROM chat_history WHERE COALESCE(project_id, task_id) IS NOT NULL" + ) + if has_trigger: + collision_sql_parts.append( + "SELECT CAST(user_id AS TEXT) AS user_id, project_id " + "FROM trigger WHERE project_id IS NOT NULL" + ) + + if collision_sql_parts: + report["project_id_collisions"] = fetch_rows( + engine, + f""" + WITH source_projects AS ( + {" UNION ".join(collision_sql_parts)} + ) + SELECT project_id, COUNT(DISTINCT user_id) AS user_count + FROM source_projects + GROUP BY project_id + HAVING COUNT(DISTINCT user_id) > 1 + ORDER BY user_count DESC, project_id ASC + """, + ) + else: + report["project_id_collisions"] = [] + + print(json.dumps(report, indent=2, sort_keys=True)) + return 1 if mapping_blockers else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/tests/test_remote_control_security.py b/server/tests/test_remote_control_security.py new file mode 100644 index 000000000..37c129337 --- /dev/null +++ b/server/tests/test_remote_control_security.py @@ -0,0 +1,651 @@ +# ========= 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 types import SimpleNamespace + +import pytest +from fastapi import HTTPException + + +class FakeRedisClient: + def __init__(self): + self.counts: dict[str, int] = {} + self.expiries: dict[str, int] = {} + self.set_calls: list[tuple[str, str, int]] = [] + self.publish_calls: list[tuple[str, str]] = [] + + def incr(self, key: str) -> int: + self.counts[key] = self.counts.get(key, 0) + 1 + return self.counts[key] + + def expire(self, key: str, seconds: int) -> None: + self.expiries[key] = seconds + + def eval(self, _script: str, _numkeys: int, key: str, seconds: int) -> int: + count = self.incr(key) + if count == 1: + self.expire(key, seconds) + return count + + async def set(self, key: str, value: str, ex: int) -> None: + self.set_calls.append((key, value, ex)) + + async def publish(self, channel: str, payload: str) -> None: + self.publish_calls.append((channel, payload)) + + async def aclose(self) -> None: + return None + + +class FakeWebSocket: + def __init__(self): + self.sent: list[dict] = [] + self.close_code: int | None = None + + async def send_json(self, payload: dict) -> None: + self.sent.append(payload) + + async def close(self, code: int) -> None: + self.close_code = code + + +@pytest.fixture(autouse=True) +def clear_remote_control_proxy_cache(): + from app.domains.remote_control.api import remote_control_controller as controller + + controller._trusted_proxy_entries.cache_clear() + yield + controller._trusted_proxy_entries.cache_clear() + + +def test_remote_control_env_int_defaults_and_bounds(monkeypatch): + from app.domains.remote_control.api import remote_control_controller as controller + + monkeypatch.delenv("REMOTE_CONTROL_TEST_INT", raising=False) + assert controller._env_int("REMOTE_CONTROL_TEST_INT", 12, min_value=5) == 12 + + monkeypatch.setenv("REMOTE_CONTROL_TEST_INT", "3") + assert controller._env_int("REMOTE_CONTROL_TEST_INT", 12, min_value=5) == 5 + + monkeypatch.setenv("REMOTE_CONTROL_TEST_INT", "bad") + assert controller._env_int("REMOTE_CONTROL_TEST_INT", 12, min_value=5) == 12 + + +def test_legacy_remote_target_accepts_server_echoed_sess_id(): + from app.domains.remote_control.service.remote_control_service import RemoteControlService + + session = SimpleNamespace( + current_brain_session_id=None, + brain_session_id="sess_legacy", + project_id="project_1", + active_task_id="task_1", + ) + + assert RemoteControlService._is_legacy_target_request( + session, + { + "target_project_id": "project_1", + "target_task_id": "task_1", + "target_brain_session_id": "sess_legacy", + }, + ) + + +def test_partial_legacy_remote_target_does_not_bypass_v2_validation(): + from app.domains.remote_control.service.remote_control_service import RemoteControlService + + session = SimpleNamespace( + current_brain_session_id=None, + brain_session_id="sess_legacy", + project_id="project_1", + active_task_id="task_1", + ) + + assert not RemoteControlService._is_legacy_target_request( + session, + { + "target_project_id": None, + "target_task_id": "task_1", + "target_brain_session_id": None, + }, + ) + + +def test_switch_project_ack_publishes_desktop_target_ready(monkeypatch): + from app.domains.remote_control.service.remote_control_service import ( + COMMAND_ACKNOWLEDGED, + COMMAND_DELIVERED, + SWITCH_PROJECT_VIEW, + RemoteControlService, + ) + + command = SimpleNamespace( + id="rc_cmd_1", + session_id="rcs_1", + type=SWITCH_PROJECT_VIEW, + status=COMMAND_DELIVERED, + error_code=None, + error=None, + result=None, + space_id="space_1", + target_project_id="project_1", + target_task_id="task_1", + target_brain_session_id="rc_brain_1", + ) + published: list[tuple[str, str, dict]] = [] + + class FakeDb: + def get(self, _model, command_id): + assert command_id == command.id + return command + + def add(self, _obj): + return None + + def commit(self): + return None + + def refresh(self, _obj): + return None + + monkeypatch.setattr( + RemoteControlService, + "publish_status", + lambda session_id, event_type, payload: published.append( + (session_id, event_type, payload) + ) + or True, + ) + + RemoteControlService.mark_ack(command.id, COMMAND_ACKNOWLEDGED, None, None, FakeDb()) + + assert command.status == COMMAND_ACKNOWLEDGED + assert ( + "rcs_1", + "desktop_target_ready", + { + "space_id": "space_1", + "current_project_id": "project_1", + "current_task_id": "task_1", + "current_brain_session_id": "rc_brain_1", + "command_id": "rc_cmd_1", + }, + ) in published + + +def test_switch_project_failed_ack_publishes_desktop_target_failed(monkeypatch): + from app.domains.remote_control.service.remote_control_service import ( + COMMAND_DELIVERED, + COMMAND_FAILED, + SWITCH_PROJECT_VIEW, + RemoteControlService, + ) + + command = SimpleNamespace( + id="rc_cmd_1", + session_id="rcs_1", + type=SWITCH_PROJECT_VIEW, + status=COMMAND_DELIVERED, + error_code=None, + error=None, + result=None, + space_id="space_1", + target_project_id="project_1", + target_task_id="task_1", + target_brain_session_id="rc_brain_1", + ) + published: list[tuple[str, str, dict]] = [] + + class FakeDb: + def get(self, _model, command_id): + assert command_id == command.id + return command + + def add(self, _obj): + return None + + def commit(self): + return None + + def refresh(self, _obj): + return None + + monkeypatch.setattr( + RemoteControlService, + "publish_status", + lambda session_id, event_type, payload: published.append( + (session_id, event_type, payload) + ) + or True, + ) + + RemoteControlService.mark_ack( + command.id, + COMMAND_FAILED, + "BRIDGE_TARGET_NOT_ACTIVE", + "Desktop is not on the target project yet", + FakeDb(), + ) + + assert command.status == COMMAND_FAILED + assert any( + event_type == "desktop_target_failed" + and payload["error_code"] == "BRIDGE_TARGET_NOT_ACTIVE" + and payload["current_project_id"] == "project_1" + for _, event_type, payload in published + ) + + +def test_switch_project_failed_ack_restores_previous_target(monkeypatch): + from app.domains.remote_control.service.remote_control_service import ( + COMMAND_DELIVERED, + COMMAND_FAILED, + SWITCH_PROJECT_VIEW, + RemoteControlService, + ) + + command = SimpleNamespace( + id="rc_cmd_1", + session_id="rcs_1", + type=SWITCH_PROJECT_VIEW, + status=COMMAND_DELIVERED, + error_code=None, + error=None, + result=None, + space_id="space_1", + target_project_id="project_b", + target_task_id="task_b", + target_brain_session_id="rc_brain_b", + payload={ + "previous_project_id": "project_a", + "previous_task_id": "task_a", + "previous_history_id": "history_a", + "previous_brain_session_id": "rc_brain_a", + }, + ) + session = SimpleNamespace( + id="rcs_1", + current_project_id="project_b", + current_task_id="task_b", + current_history_id="history_b", + current_brain_session_id="rc_brain_b", + last_target_project_id="project_b", + last_target_task_id="task_b", + last_target_history_id="history_b", + last_target_brain_session_id="rc_brain_b", + project_id="project_b", + active_task_id="task_b", + brain_session_id="rc_brain_b", + ) + published: list[tuple[str, str, dict]] = [] + + class FakeDb: + def get(self, _model, object_id): + if object_id == command.id: + return command + if object_id == session.id: + return session + return None + + def add(self, _obj): + return None + + def commit(self): + return None + + def refresh(self, _obj): + return None + + monkeypatch.setattr( + RemoteControlService, + "publish_status", + lambda session_id, event_type, payload: published.append( + (session_id, event_type, payload) + ) + or True, + ) + + RemoteControlService.mark_ack( + command.id, + COMMAND_FAILED, + "BRIDGE_TARGET_NOT_ACTIVE", + "Desktop is not on the target project yet", + FakeDb(), + ) + + assert session.current_project_id == "project_a" + assert session.current_task_id == "task_a" + assert session.current_history_id == "history_a" + assert session.current_brain_session_id == "rc_brain_a" + assert any( + event_type == "desktop_target_failed" + and payload["restored_project_id"] == "project_a" + for _, event_type, payload in published + ) + + +def test_switch_project_failed_ack_does_not_restore_newer_task_target(monkeypatch): + from app.domains.remote_control.service.remote_control_service import ( + COMMAND_DELIVERED, + COMMAND_FAILED, + SWITCH_PROJECT_VIEW, + RemoteControlService, + ) + + command = SimpleNamespace( + id="rc_cmd_1", + session_id="rcs_1", + type=SWITCH_PROJECT_VIEW, + status=COMMAND_DELIVERED, + error_code=None, + error=None, + result=None, + space_id="space_1", + target_project_id="project_b", + target_task_id="task_b", + target_brain_session_id="rc_brain_b", + payload={ + "previous_project_id": "project_a", + "previous_task_id": "task_a", + "previous_history_id": "history_a", + "previous_brain_session_id": "rc_brain_a", + }, + ) + session = SimpleNamespace( + id="rcs_1", + current_project_id="project_b", + current_task_id="task_c", + current_history_id="history_c", + current_brain_session_id="rc_brain_b", + last_target_project_id="project_b", + last_target_task_id="task_c", + last_target_history_id="history_c", + last_target_brain_session_id="rc_brain_b", + project_id="project_b", + active_task_id="task_c", + brain_session_id="rc_brain_b", + ) + + class FakeDb: + def get(self, _model, object_id): + if object_id == command.id: + return command + if object_id == session.id: + return session + return None + + def add(self, _obj): + return None + + def commit(self): + return None + + def refresh(self, _obj): + return None + + monkeypatch.setattr( + RemoteControlService, + "publish_status", + lambda _session_id, _event_type, _payload: True, + ) + + RemoteControlService.mark_ack( + command.id, + COMMAND_FAILED, + "BRIDGE_TARGET_NOT_ACTIVE", + "Desktop is not on the target project yet", + FakeDb(), + ) + + assert session.current_project_id == "project_b" + assert session.current_task_id == "task_c" + assert session.current_history_id == "history_c" + assert session.current_brain_session_id == "rc_brain_b" + + +def test_late_switch_ack_after_timeout_does_not_publish_ready(monkeypatch): + from app.domains.remote_control.service.remote_control_service import ( + COMMAND_ACKNOWLEDGED, + COMMAND_FAILED, + SWITCH_PROJECT_VIEW, + RemoteControlService, + ) + + command = SimpleNamespace( + id="rc_cmd_1", + session_id="rcs_1", + type=SWITCH_PROJECT_VIEW, + status=COMMAND_FAILED, + error_code="BRIDGE_TIMEOUT", + error="Remote command delivery timed out", + result=None, + space_id="space_1", + target_project_id="project_b", + target_task_id="task_b", + target_brain_session_id="rc_brain_b", + ) + published: list[tuple[str, str, dict]] = [] + + class FakeDb: + def get(self, _model, command_id): + assert command_id == command.id + return command + + monkeypatch.setattr( + RemoteControlService, + "publish_status", + lambda session_id, event_type, payload: published.append( + (session_id, event_type, payload) + ) + or True, + ) + + RemoteControlService.mark_ack(command.id, COMMAND_ACKNOWLEDGED, None, None, FakeDb()) + + assert command.status == COMMAND_FAILED + assert not any(event_type == "desktop_target_ready" for _, event_type, _ in published) + + +def test_get_session_controller_returns_session_response(monkeypatch): + from app.domains.remote_control.api import remote_control_controller as controller + + db = SimpleNamespace() + session = SimpleNamespace(user_id=123) + expected = SimpleNamespace(session_id="rcs_test") + + def verify_link(session_id, token, user_id, db_session): + assert session_id == "rcs_test" + assert token == "link-token" + assert user_id is None + assert db_session is db + return session + + def to_session_out(rc_session): + assert rc_session is session + return expected + + monkeypatch.setattr(controller.RemoteControlService, "verify_link", verify_link) + monkeypatch.setattr(controller.RemoteControlService, "to_session_out", to_session_out) + + result = controller.get_session( + "rcs_test", + x_remote_control_token="link-token", + db_session=db, + ) + + assert result is expected + + +def test_list_steps_controller_returns_service_response(monkeypatch): + from app.domains.remote_control.api import remote_control_controller as controller + from app.domains.remote_control.schema import RemoteControlStepsOut + + db = SimpleNamespace() + session = SimpleNamespace(user_id=123) + expected = RemoteControlStepsOut(items=[], has_more=False, next_since=5) + + def verify_link(session_id, token, user_id, db_session): + assert session_id == "rcs_test" + assert token == "link-token" + assert user_id is None + assert db_session is db + return session + + def list_steps(session_id, user_id, project_id, since, limit, order, db_session): + assert session_id == "rcs_test" + assert user_id == 123 + assert project_id == "project_1" + assert since == 5 + assert limit == 10 + assert order == "asc" + assert db_session is db + return expected + + monkeypatch.setattr(controller.RemoteControlService, "verify_link", verify_link) + monkeypatch.setattr(controller.RemoteControlService, "list_steps", list_steps) + + result = controller.list_steps( + "rcs_test", + x_remote_control_token="link-token", + project_id="project_1", + since=5, + limit=10, + order="asc", + db_session=db, + ) + + assert result is expected + + +@pytest.mark.asyncio +async def test_ws_reconnect_rate_limit_is_per_identifier(monkeypatch): + from app.domains.remote_control.api import remote_control_controller as controller + + fake_client = FakeRedisClient() + monkeypatch.setattr( + controller, + "get_redis_manager", + lambda: SimpleNamespace(client=fake_client), + ) + + for _ in range(controller.WS_RECONNECT_RATE_LIMIT): + await controller._enforce_ws_reconnect_rate_limit("events", "token-a") + + with pytest.raises(HTTPException) as exc_info: + await controller._enforce_ws_reconnect_rate_limit("events", "token-a") + + assert exc_info.value.status_code == 429 + assert fake_client.expiries + + await controller._enforce_ws_reconnect_rate_limit("events", "token-b") + + +def test_remote_client_host_uses_forwarded_for_from_trusted_proxy(monkeypatch): + from app.domains.remote_control.api import remote_control_controller as controller + + monkeypatch.setenv("REMOTE_CONTROL_TRUSTED_PROXY_HOSTS", "127.0.0.1") + + assert ( + controller._client_host_from_headers( + {"x-forwarded-for": "203.0.113.10, 127.0.0.1"}, + "127.0.0.1", + ) + == "203.0.113.10" + ) + + +def test_remote_client_host_ignores_forwarded_for_from_untrusted_peer(monkeypatch): + from app.domains.remote_control.api import remote_control_controller as controller + + monkeypatch.setenv("REMOTE_CONTROL_TRUSTED_PROXY_HOSTS", "127.0.0.1") + + assert ( + controller._client_host_from_headers( + {"x-forwarded-for": "203.0.113.10"}, + "198.51.100.2", + ) + == "198.51.100.2" + ) + + +def test_remote_client_host_supports_trusted_proxy_cidr(monkeypatch): + from app.domains.remote_control.api import remote_control_controller as controller + + monkeypatch.setenv("REMOTE_CONTROL_TRUSTED_PROXY_HOSTS", "10.0.0.0/8") + + assert ( + controller._client_host_from_headers( + {"x-real-ip": "203.0.113.20"}, + "10.2.3.4", + ) + == "203.0.113.20" + ) + + +@pytest.mark.asyncio +async def test_blacklist_pubsub_closes_matching_bridge(): + from app.domains.remote_control.api import remote_control_controller as controller + from app.shared.auth.token_blacklist import BLACKLIST_PUBSUB_CHANNEL + + ws = FakeWebSocket() + controller.bridge_websockets["desk_test"] = ws + controller.bridge_token_jtis["desk_test"] = "jti_test" + controller.bridge_websockets["desk_other"] = FakeWebSocket() + controller.bridge_token_jtis["desk_other"] = "jti_other" + + try: + await controller._handle_pubsub_message( + BLACKLIST_PUBSUB_CHANNEL, + {"type": "token_blacklisted", "jti": "jti_test"}, + ) + finally: + controller.bridge_websockets.clear() + controller.bridge_token_jtis.clear() + + assert ws.sent == [{"type": "revoke_bridge", "reason": "token_revoked"}] + assert ws.close_code == 4401 + + +@pytest.mark.asyncio +async def test_blacklist_token_publishes_revocation(monkeypatch): + from app.shared.auth import token_blacklist + + fake_client = FakeRedisClient() + monkeypatch.setattr(token_blacklist, "_get_redis", lambda: fake_client) + + await token_blacklist.blacklist_token("jti_test", 60) + + assert fake_client.set_calls == [("token:blacklist:jti_test", "1", 60)] + assert fake_client.publish_calls + channel, payload = fake_client.publish_calls[0] + assert channel == token_blacklist.BLACKLIST_PUBSUB_CHANNEL + assert '"jti": "jti_test"' in payload + + +@pytest.mark.asyncio +async def test_blacklist_token_publishes_to_session_redis_when_distinct(monkeypatch): + from app.shared.auth import token_blacklist + + auth_client = FakeRedisClient() + session_client = FakeRedisClient() + monkeypatch.setattr(token_blacklist, "_get_redis", lambda: auth_client) + monkeypatch.setattr(token_blacklist.aioredis, "from_url", lambda *args, **kwargs: session_client) + monkeypatch.setenv("redis_url", "redis://localhost:6379/0") + monkeypatch.setenv("SESSION_REDIS_URL", "redis://localhost:6379/1") + + await token_blacklist.blacklist_token("jti_test", 60) + + assert auth_client.publish_calls + assert session_client.publish_calls + assert session_client.publish_calls[0][0] == token_blacklist.BLACKLIST_PUBSUB_CHANNEL diff --git a/server/tests/test_remote_sub_agent_provider.py b/server/tests/test_remote_sub_agent_provider.py index 47957ac47..227f1b341 100644 --- a/server/tests/test_remote_sub_agent_provider.py +++ b/server/tests/test_remote_sub_agent_provider.py @@ -21,24 +21,10 @@ class TestRemoteSubAgentProviderSchema: - def test_disabled_provider_requires_credentials(self): - with pytest.raises(ValidationError): - RemoteSubAgentProviderIn( - provider_name="gemini_agents", - enabled=False, - ) - - def test_disabled_provider_accepts_agent_configuration(self): + def test_disabled_provider_allows_incomplete_credentials(self): config = RemoteSubAgentProviderIn( provider_name="gemini_agents", enabled=False, - api_key="test-key", - endpoint_url="https://generativelanguage.googleapis.com/v1beta", - agent_name="antigravity-preview-05-2026", - config={ - "max_wall_time_seconds": 900, - "poll_interval_seconds": 5, - }, ) assert config.provider_name == "gemini_agents" diff --git a/server/tests/test_space_default_workdir_mode.py b/server/tests/test_space_default_workdir_mode.py new file mode 100644 index 000000000..c85e646df --- /dev/null +++ b/server/tests/test_space_default_workdir_mode.py @@ -0,0 +1,70 @@ +# ========= 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. ========= + +"""SpaceService default workdir_mode regression tests. + +Locks in the contract after the copy → direct-write default switch +(see docs/core/space-folder-output-copy-bug-analysis.md): +- folder-backed Space defaults newly-created Projects to direct-write so + the agent writes into the user's selected folder by default +- non-folder Spaces continue to default to artifact-only +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace + +os.environ.setdefault( + "database_url", + "sqlite:////private/tmp/eigent_default_workdir_mode_test.db", +) + +from app.domains.space.service.space_service import SpaceService +from app.model.project.project import ProjectWorkdirMode +from app.model.space.space import SpaceSourceType + + +def test_folder_space_defaults_to_direct_write(): + folder_space = SimpleNamespace(source_type=SpaceSourceType.FOLDER) + + assert ( + SpaceService._default_project_workdir_mode(folder_space) + == ProjectWorkdirMode.DIRECT_WRITE + ) + + +def test_blank_space_defaults_to_artifact_only(): + blank_space = SimpleNamespace(source_type=SpaceSourceType.BLANK) + + assert ( + SpaceService._default_project_workdir_mode(blank_space) + == ProjectWorkdirMode.ARTIFACT_ONLY + ) + + +def test_default_is_a_member_of_validation_set(): + """If the default ever drifts to a value the validator rejects, + SpaceService.create_project + ensure_project would raise on every + Project creation. Lock the relationship explicitly.""" + + folder_default = SpaceService._default_project_workdir_mode( + SimpleNamespace(source_type=SpaceSourceType.FOLDER) + ) + blank_default = SpaceService._default_project_workdir_mode( + SimpleNamespace(source_type=SpaceSourceType.BLANK) + ) + + assert folder_default in SpaceService.PROJECT_WORKDIR_MODES + assert blank_default in SpaceService.PROJECT_WORKDIR_MODES diff --git a/server/tests/test_space_folder_reference.py b/server/tests/test_space_folder_reference.py new file mode 100644 index 000000000..0fcb737f7 --- /dev/null +++ b/server/tests/test_space_folder_reference.py @@ -0,0 +1,139 @@ +# ========= 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 os +from types import SimpleNamespace + +import pytest + +os.environ.setdefault( + "database_url", "sqlite:////private/tmp/eigent_space_folder_ref_test.db" +) + +from app.domains.space.service.folder_binding import ( + normalize_folder_root_reference, + same_folder_reference, +) +from app.domains.space.service.space_service import SpaceService +from app.model.space import SpaceIn, SpaceSourceType + + +class _ExecResult: + def __init__(self, rows): + self._rows = rows + + def all(self): + return self._rows + + +class _FakeSession: + def __init__(self, rows=None): + self._rows = rows or [] + + def exec(self, _statement): + return _ExecResult(self._rows) + + +def test_prepare_folder_space_keeps_unmounted_root_reference(): + root_path = "/Users/alice/projects/not-mounted-inside-server-container" + + root_ref, fingerprint = SpaceService._prepare_space_root( + SpaceIn( + name="Repo", + source_type=SpaceSourceType.FOLDER, + root_path=root_path, + ), + "user_1", + _FakeSession(), + ) + + assert root_ref == root_path + assert fingerprint is None + + +def test_prepare_folder_space_rejects_duplicate_root_reference(): + existing = SimpleNamespace( + root_path="/Users/alice/projects/repo", + root_fingerprint=None, + ) + + with pytest.raises(ValueError, match="already bound"): + SpaceService._prepare_space_root( + SpaceIn( + name="Repo", + source_type=SpaceSourceType.FOLDER, + root_path="/Users/alice/projects/repo/", + ), + "user_1", + _FakeSession([existing]), + ) + + +def test_normalize_folder_root_reference_collapses_redundant_segments(): + """./, //, and trailing separators are deduped without touching the fs.""" + + assert ( + normalize_folder_root_reference("/Users/alice/./repo") + == "/Users/alice/repo" + ) + assert ( + normalize_folder_root_reference("/Users/alice//repo/") + == "/Users/alice/repo" + ) + assert same_folder_reference( + "/Users/alice/./repo/", + "/Users/alice/repo", + ) + + +def test_normalize_folder_root_reference_rejects_empty_after_normalization(): + with pytest.raises(ValueError, match="Folder Space requires root_path"): + normalize_folder_root_reference("./") + + +def test_relocate_rejects_unverified_change_without_force(): + """Reference-only relocate requires either a client fingerprint or force.""" + + space = SimpleNamespace( + id="space_1", + user_id="user_1", + source_type=SpaceSourceType.FOLDER, + root_path="/Users/alice/projects/repo", + root_fingerprint=None, + status="active", + ) + + session_calls: list[tuple[str, str]] = [] + + class _RelocateSession(_FakeSession): + def get(self, _model, _id): + return space + + def add(self, _obj): + session_calls.append(("add", "ok")) + + def commit(self): + session_calls.append(("commit", "ok")) + + def refresh(self, _obj): + session_calls.append(("refresh", "ok")) + + with pytest.raises(ValueError, match="cannot be verified"): + SpaceService.relocate_space( + "space_1", + "/Users/alice/projects/repo-renamed", + "user_1", + _RelocateSession(), + ) + assert session_calls == [] # rejected before any write diff --git a/server/tests/test_space_write_lock.py b/server/tests/test_space_write_lock.py new file mode 100644 index 000000000..935ffe647 --- /dev/null +++ b/server/tests/test_space_write_lock.py @@ -0,0 +1,69 @@ +# ========= 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 os +import sys +from types import SimpleNamespace + +os.environ.setdefault("database_url", "sqlite:////private/tmp/eigent_space_lock_test.db") + +from app.domains.space.service import apply_service + + +class _FakeUrl: + def __init__(self, backend_name: str) -> None: + self.backend_name = backend_name + + def get_backend_name(self) -> str: + return self.backend_name + + +def test_space_write_lock_uses_thread_lock_for_non_postgres(monkeypatch): + class FakeEngine: + url = _FakeUrl("sqlite") + + def connect(self): # pragma: no cover - should not be called. + raise AssertionError("non-Postgres lock should not open a DB connection") + + monkeypatch.setitem(sys.modules, "app.core.database", SimpleNamespace(engine=FakeEngine())) + + with apply_service.space_write_lock("space_a"): + pass + + +def test_space_write_lock_uses_postgres_advisory_lock(monkeypatch): + calls: list[tuple[str, dict[str, int]]] = [] + + class FakeConnection: + def execute(self, statement, params): + calls.append((str(statement), dict(params))) + + def close(self): + calls.append(("close", {})) + + class FakeEngine: + url = _FakeUrl("postgresql") + + def connect(self): + return FakeConnection() + + monkeypatch.setitem(sys.modules, "app.core.database", SimpleNamespace(engine=FakeEngine())) + + with apply_service.space_write_lock("space_a"): + pass + + assert calls[0][0] == "SELECT pg_advisory_lock(:lock_key)" + assert calls[1][0] == "SELECT pg_advisory_unlock(:lock_key)" + assert calls[0][1]["lock_key"] == calls[1][1]["lock_key"] + assert calls[2] == ("close", {}) diff --git a/src/App.tsx b/src/App.tsx index ebfca2717..b467a8453 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'; @@ -22,6 +23,7 @@ import { useNavigate } from 'react-router-dom'; import { Toaster } from 'sonner'; import { useBackgroundTaskProcessor } from './hooks/useBackgroundTaskProcessor'; import { useExecutionSubscription } from './hooks/useExecutionSubscription'; +import { useRemoteControlBridge } from './hooks/useRemoteControlBridge'; import { useTriggerTaskExecutor } from './hooks/useTriggerTaskExecutor'; import { hasStackKeys } from './lib'; import { useAuthStore } from './store/authStore'; @@ -29,6 +31,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(); @@ -41,6 +44,7 @@ function App() { // Execute triggered tasks automatically when WebSocket events are received useTriggerTaskExecutor(); + useRemoteControlBridge(token); useEffect(() => { const handleShareCode = (event: any, share_token: string) => { @@ -69,14 +73,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..1bc6f1415 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -15,45 +15,120 @@ 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, + 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 { + // This runs before getBaseURL() prefixes Brain-relative paths. Relative + // routes are our Brain calls and should carry auth; absolute URLs may point + // at third-party targets and must not receive the user's token. + return !url.includes('http://') && !url.includes('https://'); +} + +function buildBrainHeaders( + url: string, + customHeaders: Record = {}, + includeContentType = true +): Record { + const { token, user_id } = 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}`; + } + if (user_id != null) { + headers['X-User-ID'] = String(user_id); + } + 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(/\/$/, ''); } - const port = await window.ipcRenderer.invoke('get-backend-port'); - baseUrl = `http://localhost:${port}`; - return baseUrl; + // Electron: get port from IPC + const port = await createHost().ipcRenderer?.invoke('get-backend-port'); + if (port && port > 0) { + const resolved = `http://localhost:${port}`; + setConnectionConfig({ brainEndpoint: resolved }); + return resolved; + } + // 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 ''; } +type FetchRequestOptions = { + signal?: AbortSignal; +}; + async function fetchRequest( - method: 'GET' | 'POST' | 'PUT' | 'DELETE', + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', url: string, data?: Record, - customHeaders: Record = {} + customHeaders: Record = {}, + requestOptions: FetchRequestOptions = {} ): 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, headers, + signal: requestOptions.signal, }; if (method === 'GET') { @@ -82,23 +157,37 @@ async function handleResponse( ): Promise { try { const res = await responsePromise; + persistSessionIdFromResponse(res); if (res.status === 204) { return { code: 0, text: '' }; } + if (res.status === 304) { + return { code: 0, not_modified: true }; + } 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,15 +212,35 @@ async function handleResponse( } if (!res.ok) { + const detail = resData?.detail; + const detailMessage = Array.isArray(detail) + ? detail[0] + : typeof detail === 'string' + ? detail + : null; + const objectMessage = + detail && typeof detail === 'object' + ? detail.message || detail.code || JSON.stringify(detail) + : null; + const msg = + detailMessage || + objectMessage || + resData?.message || + `HTTP ${res.status}`; const err: any = new Error( - resData?.detail || resData?.message || `HTTP error ${res.status}` + typeof msg === 'string' ? msg : JSON.stringify(msg) ); + err.status = res.status; err.response = { data: resData, status: res.status }; throw err; } return resData; } catch (err: any) { + if (err?.name === 'AbortError') { + throw err; + } + // Only show traffic toast for cloud model requests const isCloudRequest = requestData?.api_url === 'cloud'; if (isCloudRequest) { @@ -151,8 +260,12 @@ async function handleResponse( } // Encapsulate common methods -export const fetchGet = (url: string, params?: any, headers?: any) => - fetchRequest('GET', url, params, headers); +export const fetchGet = ( + url: string, + params?: any, + headers?: any, + options?: FetchRequestOptions +) => fetchRequest('GET', url, params, headers, options); export const fetchPost = (url: string, data?: any, headers?: any) => fetchRequest('POST', url, data, headers); @@ -160,32 +273,110 @@ export const fetchPost = (url: string, data?: any, headers?: any) => export const fetchPut = (url: string, data?: any, headers?: any) => fetchRequest('PUT', url, data, headers); +export const fetchPatch = (url: string, data?: any, headers?: any) => + fetchRequest('PATCH', url, data, headers); + 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 -async function getProxyBaseURL() { +export 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; + const useLocalProxy = import.meta.env.VITE_USE_LOCAL_PROXY === 'true'; + const proxyUrl = import.meta.env.VITE_PROXY_URL; + const baseUrl = + !useLocalProxy && proxyUrl + ? proxyUrl + : import.meta.env.VITE_BASE_URL || proxyUrl; if (!baseUrl) { - throw new Error('VITE_BASE_URL not configured'); + throw new Error('VITE_BASE_URL or VITE_PROXY_URL not configured'); } - return baseUrl; + return String(baseUrl).replace(/\/$/, ''); } } async function proxyFetchRequest( - method: 'GET' | 'POST' | 'PUT' | 'DELETE', + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', url: string, data?: Record, customHeaders: Record = {} @@ -244,6 +435,9 @@ export const proxyFetchPost = (url: string, data?: any, headers?: any) => export const proxyFetchPut = (url: string, data?: any, headers?: any) => proxyFetchRequest('PUT', url, data, headers); +export const proxyFetchPatch = (url: string, data?: any, headers?: any) => + proxyFetchRequest('PATCH', url, data, headers); + export const proxyFetchDelete = (url: string, data?: any, headers?: any) => proxyFetchRequest('DELETE', url, data, headers); diff --git a/src/assets/Chrome.svg b/src/assets/Chrome.svg deleted file mode 100644 index 4983d8833..000000000 --- a/src/assets/Chrome.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/assets/Edge.svg b/src/assets/Edge.svg deleted file mode 100644 index 687f99303..000000000 --- a/src/assets/Edge.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/src/assets/Folder-1.svg b/src/assets/Folder-1.svg deleted file mode 100644 index f8d4794fa..000000000 --- a/src/assets/Folder-1.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/assets/NoIcon.svg b/src/assets/NoIcon.svg deleted file mode 100644 index da1d35fe1..000000000 --- a/src/assets/NoIcon.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/assets/add_worker.mp4 b/src/assets/add_worker.mp4 deleted file mode 100644 index f3608b229..000000000 Binary files a/src/assets/add_worker.mp4 and /dev/null differ diff --git a/src/assets/animation/onboarding_success.json b/src/assets/animation/onboarding_success.json deleted file mode 100644 index 076756a7b..000000000 --- a/src/assets/animation/onboarding_success.json +++ /dev/null @@ -1,2143 +0,0 @@ -{ - "assets": [ - { - "id": "8", - "layers": [ - { - "ind": 7, - "ty": 4, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "rc", - "p": { "a": 0, "k": [11, 11] }, - "r": { "a": 0, "k": 0 }, - "s": { "a": 0, "k": [22, 22] } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0, 0] }, - "o": { "a": 0, "k": 0 } - } - ] - }, - { - "ind": 0, - "ty": 4, - "ks": { "s": { "a": 0, "k": [133.33, 133.33] } }, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "gr", - "it": [ - { - "ty": "gr", - "it": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [5.52, 0] - ], - "o": [ - [0, 5.52], - [0, 0] - ], - "v": [ - [21, 11], - [11, 21] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [5.52, 0] - ], - "o": [ - [0, -5.52], - [0, 0] - ], - "v": [ - [21, 11], - [11, 1] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0] - ], - "v": [ - [21, 11], - [1, 11] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 5.52] - ], - "o": [ - [-5.52, 0], - [0, 0] - ], - "v": [ - [11, 21], - [1, 11] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 3.72], - [-2.57, 2.7] - ], - "o": [ - [-2.57, -2.7], - [0, -3.72], - [0, 0] - ], - "v": [ - [11, 21], - [7, 11], - [11, 1] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 3.72], - [2.57, 2.7] - ], - "o": [ - [2.57, -2.7], - [0, -3.72], - [0, 0] - ], - "v": [ - [11, 21], - [15, 11], - [11, 1] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [-5.52, 0] - ], - "o": [ - [0, -5.52], - [0, 0] - ], - "v": [ - [1, 11], - [11, 1] - ] - } - } - }, - { - "ty": "st", - "c": { "a": 0, "k": [0, 0.52, 0.82, 1] }, - "lc": 2, - "lj": 2, - "ml": 4, - "o": { "a": 0, "k": 100 }, - "w": { "a": 0, "k": 2 } - }, - { - "ty": "tr", - "o": { "a": 0, "k": 100 }, - "s": { "a": 0, "k": [75, 75] } - } - ] - }, - { "ty": "tr", "o": { "a": 0, "k": 100 } } - ] - } - ] - } - ] - }, - { - "id": "11", - "layers": [ - { - "ind": 10, - "ty": 0, - "parent": 6, - "ks": {}, - "w": 22, - "h": 22, - "ip": 0, - "op": 241, - "st": 0, - "refId": "8" - }, - { - "ind": 6, - "ty": 3, - "parent": 5, - "ks": { "s": { "a": 0, "k": [416.67, 416.67] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 5, - "ty": 3, - "ks": { "p": { "a": 0, "k": [4.167, 4.167] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "14", - "layers": [ - { - "ind": 13, - "ty": 0, - "parent": 4, - "ks": {}, - "w": 100, - "h": 100, - "ip": 0, - "op": 241, - "st": 0, - "refId": "11" - }, - { - "ind": 4, - "ty": 3, - "ks": { - "a": { "a": 0, "k": [50, 50] }, - "p": { "a": 0, "k": [50, 50] }, - "r": { - "a": 1, - "k": [ - { - "t": 0, - "s": [-45], - "i": { "x": 0, "y": 1 }, - "o": { "x": 0.5, "y": 0 } - }, - { "t": 24, "s": [0], "h": 1 }, - { "t": 240, "s": [0], "h": 1 } - ] - }, - "s": { - "a": 1, - "k": [ - { - "t": 0, - "s": [0, 0], - "i": { "x": [0, 0], "y": [1, 1] }, - "o": { "x": [0.5, 0.5], "y": [0, 0] } - }, - { - "t": 24, - "s": [100, 100], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { "t": 240, "s": [100, 100], "h": 1 } - ] - } - }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "18", - "layers": [ - { - "ind": 16, - "ty": 0, - "ks": { "p": { "a": 0, "k": [20, 20] } }, - "w": 96, - "h": 96, - "ip": 0, - "op": 241, - "st": 0, - "refId": "14" - }, - { - "ind": 17, - "ty": 4, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "rc", - "p": { "a": 0, "k": [67.917, 67.917] }, - "r": { "a": 0, "k": 0 }, - "s": { "a": 0, "k": [135.833, 135.833] } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0, 0] }, - "o": { "a": 0, "k": 0 } - } - ] - } - ] - }, - { - "id": "21", - "layers": [ - { - "ind": 20, - "ty": 0, - "parent": 3, - "ks": { "p": { "a": 0, "k": [-20, -20] } }, - "ef": [ - { - "ty": 29, - "ef": [ - { - "ty": 0, - "nm": "", - "v": { - "a": 1, - "k": [ - { "t": 0, "s": [0], "h": 1 }, - { - "t": 24, - "s": [0], - "i": { "x": 0.65, "y": 0.172 }, - "o": { "x": 0.278, "y": 0 } - }, - { - "t": 30, - "s": [3.67], - "i": { "x": 0.989, "y": 0.773 }, - "o": { "x": 0.826, "y": 0.241 } - }, - { "t": 36, "s": [33.33], "h": 1 }, - { "t": 240, "s": [33.33], "h": 1 } - ] - } - }, - { "ty": 7, "nm": "", "v": { "a": 0, "k": 1 } }, - { "ty": 7, "nm": "", "v": { "a": 0, "k": 0 } } - ] - } - ], - "w": 135.8334, - "h": 135.8333, - "ip": 0, - "op": 241, - "st": 0, - "refId": "18" - }, - { - "ind": 3, - "ty": 3, - "ks": { "p": { "a": 0, "k": [21, 21] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "30", - "layers": [ - { - "ind": 29, - "ty": 4, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "rc", - "p": { "a": 0, "k": [9, 11] }, - "r": { "a": 0, "k": 0 }, - "s": { "a": 0, "k": [18, 22] } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0, 0] }, - "o": { "a": 0, "k": 0 } - } - ] - }, - { - "ind": 0, - "ty": 4, - "ks": { "s": { "a": 0, "k": [133.33, 133.33] } }, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "gr", - "it": [ - { - "ty": "gr", - "it": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0], - [-0.37, -0.37], - [-0.53, 0], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0.53], - [0.38, 0.38], - [0, 0], - [0, 0] - ], - "v": [ - [11, 1], - [11, 5], - [11.58, 6.42], - [13, 7], - [17, 7] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0] - ], - "v": [ - [7, 8], - [5, 8] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0] - ], - "v": [ - [13, 12], - [5, 12] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0] - ], - "v": [ - [13, 16], - [5, 16] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0.38, -0.37], - [0, -0.53], - [0, 0], - [-0.37, -0.37], - [-0.53, 0], - [0, 0], - [-0.37, 0.38], - [0, 0.53], - [0, 0] - ], - "o": [ - [0, 0], - [-0.53, 0], - [-0.37, 0.38], - [0, 0], - [0, 0.53], - [0.38, 0.38], - [0, 0], - [0.53, 0], - [0.38, -0.37], - [0, 0], - [0, 0] - ], - "v": [ - [12, 1], - [3, 1], - [1.58, 1.58], - [1, 3], - [1, 19], - [1.58, 20.42], - [3, 21], - [15, 21], - [16.42, 20.42], - [17, 19], - [17, 6] - ] - } - } - }, - { - "ty": "st", - "c": { "a": 0, "k": [0.88, 0.44, 0, 1] }, - "lc": 2, - "lj": 2, - "ml": 4, - "o": { "a": 0, "k": 100 }, - "w": { "a": 0, "k": 2 } - }, - { - "ty": "tr", - "o": { "a": 0, "k": 100 }, - "s": { "a": 0, "k": [75, 75] } - } - ] - }, - { "ty": "tr", "o": { "a": 0, "k": 100 } } - ] - } - ] - } - ] - }, - { - "id": "33", - "layers": [ - { - "ind": 32, - "ty": 0, - "parent": 28, - "ks": {}, - "w": 18, - "h": 22, - "ip": 0, - "op": 241, - "st": 0, - "refId": "30" - }, - { - "ind": 28, - "ty": 3, - "parent": 27, - "ks": { "s": { "a": 0, "k": [416.67, 416.67] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 27, - "ty": 3, - "ks": { "p": { "a": 0, "k": [12.5, 4.167] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "36", - "layers": [ - { - "ind": 35, - "ty": 0, - "parent": 26, - "ks": {}, - "w": 100, - "h": 100, - "ip": 0, - "op": 241, - "st": 0, - "refId": "33" - }, - { - "ind": 26, - "ty": 3, - "ks": { - "a": { "a": 0, "k": [50, 50] }, - "p": { "a": 0, "k": [50, 50] }, - "r": { - "a": 1, - "k": [ - { "t": 0, "s": [-45], "h": 1 }, - { - "t": 36, - "s": [-45], - "i": { "x": 0, "y": 1 }, - "o": { "x": 0.5, "y": 0 } - }, - { "t": 60, "s": [0], "h": 1 }, - { "t": 240, "s": [0], "h": 1 } - ] - }, - "s": { - "a": 1, - "k": [ - { - "t": 0, - "s": [0, 0], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { - "t": 36, - "s": [0, 0], - "i": { "x": [0, 0], "y": [1, 1] }, - "o": { "x": [0.5, 0.5], "y": [0, 0] } - }, - { - "t": 60, - "s": [100, 100], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { "t": 240, "s": [100, 100], "h": 1 } - ] - } - }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "40", - "layers": [ - { - "ind": 38, - "ty": 0, - "ks": { "p": { "a": 0, "k": [20, 20] } }, - "w": 88, - "h": 96, - "ip": 0, - "op": 241, - "st": 0, - "refId": "36" - }, - { - "ind": 39, - "ty": 4, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "rc", - "p": { "a": 0, "k": [63.758, 67.917] }, - "r": { "a": 0, "k": 0 }, - "s": { "a": 0, "k": [127.515, 135.833] } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0, 0] }, - "o": { "a": 0, "k": 0 } - } - ] - } - ] - }, - { - "id": "43", - "layers": [ - { - "ind": 42, - "ty": 0, - "parent": 25, - "ks": { "p": { "a": 0, "k": [-20, -20] } }, - "ef": [ - { - "ty": 29, - "ef": [ - { - "ty": 0, - "nm": "", - "v": { - "a": 1, - "k": [ - { "t": 0, "s": [0], "h": 1 }, - { - "t": 60, - "s": [0], - "i": { "x": 0.65, "y": 0.172 }, - "o": { "x": 0.278, "y": 0 } - }, - { - "t": 66, - "s": [3.67], - "i": { "x": 0.989, "y": 0.773 }, - "o": { "x": 0.826, "y": 0.241 } - }, - { "t": 72, "s": [33.33], "h": 1 }, - { "t": 240, "s": [33.33], "h": 1 } - ] - } - }, - { "ty": 7, "nm": "", "v": { "a": 0, "k": 1 } }, - { "ty": 7, "nm": "", "v": { "a": 0, "k": 0 } } - ] - } - ], - "w": 127.5154, - "h": 135.8333, - "ip": 0, - "op": 241, - "st": 0, - "refId": "40" - }, - { - "ind": 25, - "ty": 3, - "ks": { "p": { "a": 0, "k": [21, 21] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "52", - "layers": [ - { - "ind": 51, - "ty": 4, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "rc", - "p": { "a": 0, "k": [11, 7] }, - "r": { "a": 0, "k": 0 }, - "s": { "a": 0, "k": [22, 14] } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0, 0] }, - "o": { "a": 0, "k": 0 } - } - ] - }, - { - "ind": 0, - "ty": 4, - "ks": { "s": { "a": 0, "k": [133.33, 133.33] } }, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "gr", - "it": [ - { - "ty": "gr", - "it": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0], - [0, 0] - ], - "v": [ - [15, 13], - [21, 7], - [15, 1] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0], - [0, 0] - ], - "v": [ - [7, 1], - [1, 7], - [7, 13] - ] - } - } - }, - { - "ty": "st", - "c": { "a": 0, "k": [0, 0.6, 0.4, 1] }, - "lc": 2, - "lj": 2, - "ml": 4, - "o": { "a": 0, "k": 100 }, - "w": { "a": 0, "k": 2 } - }, - { - "ty": "tr", - "o": { "a": 0, "k": 100 }, - "s": { "a": 0, "k": [75, 75] } - } - ] - }, - { "ty": "tr", "o": { "a": 0, "k": 100 } } - ] - } - ] - } - ] - }, - { - "id": "55", - "layers": [ - { - "ind": 54, - "ty": 0, - "parent": 50, - "ks": {}, - "w": 22, - "h": 14, - "ip": 0, - "op": 241, - "st": 0, - "refId": "52" - }, - { - "ind": 50, - "ty": 3, - "parent": 49, - "ks": { "s": { "a": 0, "k": [416.67, 416.67] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 49, - "ty": 3, - "ks": { "p": { "a": 0, "k": [4.167, 20.833] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "58", - "layers": [ - { - "ind": 57, - "ty": 0, - "parent": 48, - "ks": {}, - "w": 100, - "h": 100, - "ip": 0, - "op": 241, - "st": 0, - "refId": "55" - }, - { - "ind": 48, - "ty": 3, - "ks": { - "a": { "a": 0, "k": [50, 50] }, - "p": { "a": 0, "k": [50, 50] }, - "r": { - "a": 1, - "k": [ - { "t": 0, "s": [-45], "h": 1 }, - { - "t": 72, - "s": [-45], - "i": { "x": 0, "y": 1 }, - "o": { "x": 0.5, "y": 0 } - }, - { "t": 96, "s": [0], "h": 1 }, - { "t": 240, "s": [0], "h": 1 } - ] - }, - "s": { - "a": 1, - "k": [ - { - "t": 0, - "s": [0, 0], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { - "t": 72, - "s": [0, 0], - "i": { "x": [0, 0], "y": [1, 1] }, - "o": { "x": [0.5, 0.5], "y": [0, 0] } - }, - { - "t": 96, - "s": [100, 100], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { "t": 240, "s": [100, 100], "h": 1 } - ] - } - }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "62", - "layers": [ - { - "ind": 60, - "ty": 0, - "ks": { "p": { "a": 0, "k": [20, 20] } }, - "w": 96, - "h": 80, - "ip": 0, - "op": 241, - "st": 0, - "refId": "58" - }, - { - "ind": 61, - "ty": 4, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "rc", - "p": { "a": 0, "k": [67.917, 59.714] }, - "r": { "a": 0, "k": 0 }, - "s": { "a": 0, "k": [135.833, 119.428] } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0, 0] }, - "o": { "a": 0, "k": 0 } - } - ] - } - ] - }, - { - "id": "65", - "layers": [ - { - "ind": 64, - "ty": 0, - "parent": 47, - "ks": { "p": { "a": 0, "k": [-20, -20] } }, - "ef": [ - { - "ty": 29, - "ef": [ - { - "ty": 0, - "nm": "", - "v": { - "a": 1, - "k": [ - { "t": 0, "s": [0], "h": 1 }, - { - "t": 96, - "s": [0], - "i": { "x": 0.65, "y": 0.172 }, - "o": { "x": 0.278, "y": 0 } - }, - { - "t": 102, - "s": [3.67], - "i": { "x": 0.989, "y": 0.773 }, - "o": { "x": 0.826, "y": 0.241 } - }, - { "t": 108, "s": [33.33], "h": 1 }, - { "t": 240, "s": [33.33], "h": 1 } - ] - } - }, - { "ty": 7, "nm": "", "v": { "a": 0, "k": 1 } }, - { "ty": 7, "nm": "", "v": { "a": 0, "k": 0 } } - ] - } - ], - "w": 135.8333, - "h": 119.4284, - "ip": 0, - "op": 241, - "st": 0, - "refId": "62" - }, - { - "ind": 47, - "ty": 3, - "ks": { "p": { "a": 0, "k": [21, 21] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "74", - "layers": [ - { - "ind": 73, - "ty": 4, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "rc", - "p": { "a": 0, "k": [11, 10.5] }, - "r": { "a": 0, "k": 0 }, - "s": { "a": 0, "k": [22, 21] } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0, 0] }, - "o": { "a": 0, "k": 0 } - } - ] - }, - { - "ind": 0, - "ty": 4, - "ks": { "s": { "a": 0, "k": [133.33, 133.33] } }, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "gr", - "it": [ - { - "ty": "gr", - "it": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0] - ], - "v": [ - [15, 6], - [15.01, 6] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0], - [-1.5, 1.5], - [0, 2.12], - [0, 0] - ], - "o": [ - [0, 0], - [2.12, 0], - [1.5, -1.5], - [0, 0], - [0, 0] - ], - "v": [ - [2.4, 17], - [11, 17], - [16.66, 14.66], - [19, 9], - [19, 6] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0.51, 0.69], - [0.81, 0.26], - [0.81, -0.28], - [0.49, -0.7], - [0, 0] - ], - "o": [ - [0, -0.85], - [-0.5, -0.69], - [-0.81, -0.26], - [-0.81, 0.27], - [0, 0], - [0, 0] - ], - "v": [ - [19, 6], - [18.23, 3.63], - [16.2, 2.18], - [13.71, 2.2], - [11.72, 3.7], - [1, 19] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0], - [0, 0] - ], - "v": [ - [19, 6], - [21, 6.5], - [19, 7] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0] - ], - "v": [ - [9, 17], - [9, 20] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0] - ], - "v": [ - [13, 16.75], - [13, 20] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [-1.01, 0.71], - [-0.42, 1.16], - [0.32, 1.19], - [0.95, 0.79] - ], - "o": [ - [1.23, 0], - [1.01, -0.71], - [0.42, -1.16], - [-0.32, -1.19], - [0, 0] - ], - "v": [ - [6, 17], - [9.45, 15.91], - [11.64, 13.04], - [11.79, 9.43], - [9.84, 6.39] - ] - } - } - }, - { - "ty": "st", - "c": { "a": 0, "k": [0.58, 0.2, 0.92, 1] }, - "lc": 2, - "lj": 2, - "ml": 4, - "o": { "a": 0, "k": 100 }, - "w": { "a": 0, "k": 2 } - }, - { - "ty": "tr", - "o": { "a": 0, "k": 100 }, - "s": { "a": 0, "k": [75, 75] } - } - ] - }, - { "ty": "tr", "o": { "a": 0, "k": 100 } } - ] - } - ] - } - ] - }, - { - "id": "77", - "layers": [ - { - "ind": 76, - "ty": 0, - "parent": 72, - "ks": {}, - "w": 22, - "h": 21, - "ip": 0, - "op": 241, - "st": 0, - "refId": "74" - }, - { - "ind": 72, - "ty": 3, - "parent": 71, - "ks": { "s": { "a": 0, "k": [416.67, 416.67] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 71, - "ty": 3, - "ks": { "p": { "a": 0, "k": [4.167, 4.167] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "80", - "layers": [ - { - "ind": 79, - "ty": 0, - "parent": 70, - "ks": {}, - "w": 100, - "h": 100, - "ip": 0, - "op": 241, - "st": 0, - "refId": "77" - }, - { - "ind": 70, - "ty": 3, - "ks": { - "a": { "a": 0, "k": [50, 50] }, - "p": { "a": 0, "k": [50, 50] }, - "r": { - "a": 1, - "k": [ - { "t": 0, "s": [-45], "h": 1 }, - { - "t": 108, - "s": [-45], - "i": { "x": 0, "y": 1 }, - "o": { "x": 0.5, "y": 0 } - }, - { "t": 132, "s": [0], "h": 1 }, - { "t": 240, "s": [0], "h": 1 } - ] - }, - "s": { - "a": 1, - "k": [ - { - "t": 0, - "s": [0, 0], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { - "t": 108, - "s": [0, 0], - "i": { "x": [0, 0], "y": [1, 1] }, - "o": { "x": [0.5, 0.5], "y": [0, 0] } - }, - { - "t": 132, - "s": [100, 100], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { "t": 240, "s": [100, 100], "h": 1 } - ] - } - }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "84", - "layers": [ - { - "ind": 82, - "ty": 0, - "ks": { "p": { "a": 0, "k": [20, 20] } }, - "w": 96, - "h": 92, - "ip": 0, - "op": 241, - "st": 0, - "refId": "80" - }, - { - "ind": 83, - "ty": 4, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "rc", - "p": { "a": 0, "k": [67.917, 65.833] }, - "r": { "a": 0, "k": 0 }, - "s": { "a": 0, "k": [135.833, 131.667] } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0, 0] }, - "o": { "a": 0, "k": 0 } - } - ] - } - ] - }, - { - "id": "87", - "layers": [ - { - "ind": 86, - "ty": 0, - "parent": 69, - "ks": { "p": { "a": 0, "k": [-20, -20] } }, - "ef": [ - { - "ty": 29, - "ef": [ - { - "ty": 0, - "nm": "", - "v": { - "a": 1, - "k": [ - { "t": 0, "s": [0], "h": 1 }, - { - "t": 132, - "s": [0], - "i": { "x": 0.65, "y": 0.172 }, - "o": { "x": 0.278, "y": 0 } - }, - { - "t": 138, - "s": [3.67], - "i": { "x": 0.989, "y": 0.773 }, - "o": { "x": 0.826, "y": 0.241 } - }, - { "t": 144, "s": [33.33], "h": 1 }, - { "t": 240, "s": [33.33], "h": 1 } - ] - } - }, - { "ty": 7, "nm": "", "v": { "a": 0, "k": 1 } }, - { "ty": 7, "nm": "", "v": { "a": 0, "k": 0 } } - ] - } - ], - "w": 135.8333, - "h": 131.6667, - "ip": 0, - "op": 241, - "st": 0, - "refId": "84" - }, - { - "ind": 69, - "ty": 3, - "ks": { "p": { "a": 0, "k": [21, 21] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "96", - "layers": [ - { - "ind": 95, - "ty": 4, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "rc", - "p": { "a": 0, "k": [11, 9] }, - "r": { "a": 0, "k": 0 }, - "s": { "a": 0, "k": [22, 18] } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0, 0] }, - "o": { "a": 0, "k": 0 } - } - ] - }, - { - "ind": 0, - "ty": 4, - "ks": { "s": { "a": 0, "k": [133.33, 133.33] } }, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "gr", - "it": [ - { - "ty": "gr", - "it": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0], - [0, 0] - ], - "v": [ - [11, 5], - [11, 1], - [7, 1] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0] - ], - "v": [ - [1, 11], - [3, 11] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0] - ], - "v": [ - [19, 11], - [21, 11] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0] - ], - "v": [ - [14, 10], - [14, 12] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0] - ], - "v": [ - [8, 10], - [8, 12] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, -1.1], - [0, 0], - [1.1, 0], - [0, 0], - [0, 1.1], - [0, 0], - [-1.1, 0] - ], - "o": [ - [0, 0], - [1.1, 0], - [0, 0], - [0, 1.1], - [0, 0], - [-1.1, 0], - [0, 0], - [0, -1.1], - [0, 0] - ], - "v": [ - [5, 5], - [17, 5], - [19, 7], - [19, 15], - [17, 17], - [5, 17], - [3, 15], - [3, 7], - [5, 5] - ] - } - } - }, - { - "ty": "st", - "c": { "a": 0, "k": [0.13, 0.13, 0.13, 1] }, - "lc": 2, - "lj": 2, - "ml": 4, - "o": { "a": 0, "k": 100 }, - "w": { "a": 0, "k": 2 } - }, - { - "ty": "tr", - "o": { "a": 0, "k": 100 }, - "s": { "a": 0, "k": [75, 75] } - } - ] - }, - { "ty": "tr", "o": { "a": 0, "k": 100 } } - ] - } - ] - } - ] - }, - { - "id": "99", - "layers": [ - { - "ind": 98, - "ty": 0, - "parent": 94, - "ks": {}, - "w": 22, - "h": 18, - "ip": 0, - "op": 241, - "st": 0, - "refId": "96" - }, - { - "ind": 94, - "ty": 3, - "parent": 93, - "ks": { "s": { "a": 0, "k": [416.67, 416.67] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 93, - "ty": 3, - "ks": { "p": { "a": 0, "k": [4.167, 12.5] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "102", - "layers": [ - { - "ind": 101, - "ty": 0, - "parent": 92, - "ks": {}, - "w": 100, - "h": 100, - "ip": 0, - "op": 241, - "st": 0, - "refId": "99" - }, - { - "ind": 92, - "ty": 3, - "ks": { - "a": { "a": 0, "k": [50, 50] }, - "p": { "a": 0, "k": [50, 50] }, - "r": { - "a": 1, - "k": [ - { "t": 0, "s": [-45], "h": 1 }, - { - "t": 144, - "s": [-45], - "i": { "x": 0, "y": 1 }, - "o": { "x": 0.5, "y": 0 } - }, - { "t": 180, "s": [0], "h": 1 }, - { "t": 240, "s": [0], "h": 1 } - ] - }, - "s": { - "a": 1, - "k": [ - { - "t": 0, - "s": [0, 0], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { - "t": 144, - "s": [0, 0], - "i": { "x": [0, 0], "y": [1, 1] }, - "o": { "x": [0.5, 0.5], "y": [0, 0] } - }, - { - "t": 180, - "s": [100, 100], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { "t": 240, "s": [100, 100], "h": 1 } - ] - } - }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "106", - "layers": [ - { - "ind": 104, - "ty": 0, - "ks": { "p": { "a": 0, "k": [20, 20] } }, - "w": 96, - "h": 88, - "ip": 0, - "op": 241, - "st": 0, - "refId": "102" - }, - { - "ind": 105, - "ty": 4, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "rc", - "p": { "a": 0, "k": [67.917, 63.75] }, - "r": { "a": 0, "k": 0 }, - "s": { "a": 0, "k": [135.833, 127.5] } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0, 0] }, - "o": { "a": 0, "k": 0 } - } - ] - } - ] - }, - { - "id": "109", - "layers": [ - { - "ind": 108, - "ty": 0, - "parent": 91, - "ks": { "p": { "a": 0, "k": [-20, -20] } }, - "ef": [ - { - "ty": 29, - "ef": [ - { - "ty": 0, - "nm": "", - "v": { - "a": 1, - "k": [ - { "t": 0, "s": [0], "h": 1 }, - { - "t": 222, - "s": [0], - "i": { "x": 0.65, "y": 0.172 }, - "o": { "x": 0.278, "y": 0 } - }, - { - "t": 231, - "s": [3.67], - "i": { "x": 0.989, "y": 0.773 }, - "o": { "x": 0.826, "y": 0.241 } - }, - { "t": 240, "s": [33.33], "h": 1 } - ] - } - }, - { "ty": 7, "nm": "", "v": { "a": 0, "k": 1 } }, - { "ty": 7, "nm": "", "v": { "a": 0, "k": 0 } } - ] - } - ], - "w": 135.8333, - "h": 127.5, - "ip": 0, - "op": 241, - "st": 0, - "refId": "106" - }, - { - "ind": 91, - "ty": 3, - "ks": { "p": { "a": 0, "k": [21, 21] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - } - ], - "fr": 60, - "h": 900, - "ip": 0, - "layers": [ - { - "ind": 23, - "ty": 0, - "parent": 2, - "ks": { - "a": { "a": 0, "k": [21, 21] }, - "o": { - "a": 1, - "k": [ - { "t": 0, "s": [100], "h": 1 }, - { - "t": 24, - "s": [100], - "i": { "x": 1, "y": 1 }, - "o": { "x": 1, "y": 0 } - }, - { "t": 36, "s": [0], "h": 1 }, - { "t": 240, "s": [0], "h": 1 } - ] - } - }, - "w": 137, - "h": 137, - "ip": 0, - "op": 241, - "st": 0, - "refId": "21" - }, - { - "ind": 2, - "ty": 3, - "parent": 1, - "ks": { "p": { "a": 0, "k": [670, 400] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 45, - "ty": 0, - "parent": 24, - "ks": { - "a": { "a": 0, "k": [21, 21] }, - "o": { - "a": 1, - "k": [ - { "t": 0, "s": [100], "h": 1 }, - { - "t": 60, - "s": [100], - "i": { "x": 1, "y": 1 }, - "o": { "x": 1, "y": 0 } - }, - { "t": 72, "s": [0], "h": 1 }, - { "t": 240, "s": [0], "h": 1 } - ] - } - }, - "w": 129, - "h": 137, - "ip": 0, - "op": 241, - "st": 0, - "refId": "43" - }, - { - "ind": 24, - "ty": 3, - "parent": 1, - "ks": { "p": { "a": 0, "k": [670, 400] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 67, - "ty": 0, - "parent": 46, - "ks": { - "a": { "a": 0, "k": [21, 21] }, - "o": { - "a": 1, - "k": [ - { "t": 0, "s": [100], "h": 1 }, - { - "t": 96, - "s": [100], - "i": { "x": 1, "y": 1 }, - "o": { "x": 1, "y": 0 } - }, - { "t": 108, "s": [0], "h": 1 }, - { "t": 240, "s": [0], "h": 1 } - ] - } - }, - "w": 137, - "h": 121, - "ip": 0, - "op": 241, - "st": 0, - "refId": "65" - }, - { - "ind": 46, - "ty": 3, - "parent": 1, - "ks": { "p": { "a": 0, "k": [670, 400] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 89, - "ty": 0, - "parent": 68, - "ks": { - "a": { "a": 0, "k": [21, 21] }, - "o": { - "a": 1, - "k": [ - { "t": 0, "s": [100], "h": 1 }, - { - "t": 132, - "s": [100], - "i": { "x": 1, "y": 1 }, - "o": { "x": 1, "y": 0 } - }, - { "t": 144, "s": [0], "h": 1 }, - { "t": 240, "s": [0], "h": 1 } - ] - } - }, - "w": 137, - "h": 133, - "ip": 0, - "op": 241, - "st": 0, - "refId": "87" - }, - { - "ind": 68, - "ty": 3, - "parent": 1, - "ks": { "p": { "a": 0, "k": [670, 400] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 111, - "ty": 0, - "parent": 90, - "ks": { - "a": { "a": 0, "k": [21, 21] }, - "o": { - "a": 1, - "k": [ - { "t": 0, "s": [100], "h": 1 }, - { - "t": 222, - "s": [100], - "i": { "x": 1, "y": 1 }, - "o": { "x": 1, "y": 0 } - }, - { "t": 240, "s": [0], "h": 1 } - ] - } - }, - "w": 137, - "h": 129, - "ip": 0, - "op": 241, - "st": 0, - "refId": "109" - }, - { - "ind": 90, - "ty": 3, - "parent": 1, - "ks": { "p": { "a": 0, "k": [670, 400] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { "ind": 1, "ty": 3, "parent": 0, "ks": {}, "ip": 0, "op": 241, "st": 0 }, - { "ind": 0, "ty": 3, "ks": {}, "ip": 0, "op": 241, "st": 0 } - ], - "meta": { "g": "https://jitter.video" }, - "op": 240, - "v": "5.7.4", - "w": 1440 -} diff --git a/src/assets/animation/openning_animaiton.json b/src/assets/animation/openning_animaiton.json deleted file mode 100644 index 80f4e823f..000000000 --- a/src/assets/animation/openning_animaiton.json +++ /dev/null @@ -1,6246 +0,0 @@ -{ - "assets": [ - { - "id": "22", - "layers": [ - { - "ind": 5, - "ty": 4, - "parent": 4, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, -7.8], - [0, 0], - [-1.2, -0.1], - [-1.2, 0], - [0, 0], - [-1.2, 0.1], - [-1.2, 0.1], - [0, 0], - [0.1, 1.4], - [0.5, 1.1], - [0.9, 0.8], - [1.6, 0] - ], - "v": [ - [21.2, 51.6], - [21.2, 51.6], - [13.9, 63.3], - [13.9, 63.3], - [17.5, 63.4], - [21, 63.5], - [21, 63.5], - [24.5, 63.4], - [28.1, 63.3], - [28.1, 63.3], - [27.8, 59.3], - [26.9, 55.6], - [24.9, 52.7], - [21.2, 51.6] - ], - "o": [ - [0, 0], - [-4.8, 0], - [0, 0], - [1.2, 0.1], - [1.2, 0.1], - [0, 0], - [1.2, 0], - [1.2, -0.1], - [0, 0], - [0, -1.2], - [-0.1, -1.4], - [-0.5, -1.1], - [-0.9, -0.8], - [0, 0] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [-0.6, -1.7], - [-1.1, -1.3], - [-1.7, -0.7], - [-2.2, 0], - [0, 0], - [-1.7, 0.6], - [-1.3, 0.8], - [0, 0], - [0, 0], - [0, 0], - [2, -0.5], - [2.5, 0], - [0, 0], - [3.3, 3.4], - [0, 6], - [0, 0], - [-0.2, 1.6], - [-0.6, 1.5], - [-1, 1.3], - [-1.8, 1.1], - [0, 0], - [0, 0], - [-2.5, 0], - [0, 0], - [-2, -0.6], - [-1.5, -1.2], - [-0.8, -1.8], - [0, -2.5], - [0, 0], - [0, 0], - [2.4, -0.3], - [1.8, 0], - [0, 0] - ], - "v": [ - [25.5, 67.7], - [14.4, 67.7], - [15.3, 73.3], - [17.9, 77.8], - [22.1, 80.8], - [27.8, 81.9], - [27.8, 81.9], - [32.4, 81], - [36.9, 79], - [36.9, 79], - [37.9, 80.2], - [36, 84.3], - [29.3, 87.1], - [22.6, 87.9], - [22.6, 87.9], - [8.4, 82.7], - [3.5, 68.5], - [3.5, 68.5], - [3.8, 63.4], - [4.9, 58.8], - [7.3, 54.7], - [11.5, 51.2], - [11.5, 51.2], - [15.5, 48.8], - [22.3, 46.9], - [22.3, 46.9], - [28.9, 47.8], - [34, 50.6], - [37.5, 55.2], - [38.7, 61.7], - [38.7, 61.7], - [38.7, 66], - [31.8, 67.3], - [25.5, 67.7], - [25.5, 67.7] - ], - "o": [ - [0, 0], - [0, 2], - [0.6, 1.7], - [1.1, 1.3], - [1.7, 0.7], - [0, 0], - [1.4, 0], - [1.7, -0.6], - [0, 0], - [0, 0], - [0, 0], - [-2.5, 1.3], - [-2, 0.5], - [0, 0], - [-6.2, 0], - [-3.3, -3.4], - [0, 0], - [0, -1.7], - [0.2, -1.6], - [0.6, -1.5], - [1, -1.3], - [0, 0], - [0, 0], - [2.1, -1.3], - [0, 0], - [2.4, 0], - [2, 0.6], - [1.5, 1.2], - [0.8, 1.8], - [0, 0], - [0, 0], - [-2.3, 0.6], - [-2.4, 0.3], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 4, - "ty": 3, - "parent": 3, - "ks": { "p": { "a": 0, "k": [357.84, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 7, - "ty": 4, - "parent": 6, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, 0], - [0.5, 0.4], - [0.7, 0.3], - [0.8, 0.1], - [0.7, 0], - [0, 0], - [1.1, -0.7], - [0.6, -1.1], - [0.2, -1.4], - [0, -1.5], - [0, 0], - [-0.5, -2], - [-1.1, -1.6], - [-1.7, -1], - [-2.5, 0], - [0, 0], - [-2.5, 1.1], - [0, 0], - [0, 0], - [0, 0], - [3.9, 0], - [0, 0], - [2.4, 1], - [1.5, 1.8], - [0.8, 2.4], - [0, 2.9], - [0, 0], - [-0.1, 1.4], - [-0.7, 1.3], - [-1.4, 1.4], - [-2.4, 1.5], - [0, 0], - [0, 0], - [-1.3, 0.2], - [-1.6, 0], - [0, 0], - [-1.3, -0.3], - [-1.5, -0.6], - [0, 0], - [0, 0], - [0.3, -2], - [0.2, -2.1], - [0, 0] - ], - "v": [ - [32.8, 61.5], - [29.7, 61.5], - [29.1, 56.4], - [28.3, 54.9], - [26.4, 53.7], - [24.2, 53], - [21.9, 52.8], - [21.9, 52.8], - [17.7, 53.9], - [15.1, 56.6], - [13.9, 60.4], - [13.5, 64.7], - [13.5, 64.7], - [14.2, 71], - [16.6, 76.5], - [20.8, 80.4], - [27.1, 81.9], - [27.1, 81.9], - [34, 80.3], - [34, 80.3], - [34.8, 81.1], - [33, 85], - [22.1, 87.9], - [22.1, 87.9], - [13.6, 86.4], - [7.7, 82.3], - [4.2, 76], - [3.1, 68], - [3.1, 68], - [3.3, 63.7], - [4.5, 59.6], - [7.6, 55.5], - [13.3, 51.2], - [13.3, 51.2], - [17.6, 48.6], - [21.5, 47.2], - [26, 46.9], - [26, 46.9], - [29.7, 47.3], - [33.9, 48.6], - [33.9, 48.6], - [34.4, 49.6], - [33.5, 55.4], - [32.8, 61.5], - [32.8, 61.5] - ], - "o": [ - [0, 0], - [0, 0], - [-0.1, -0.6], - [-0.5, -0.4], - [-0.7, -0.3], - [-0.8, -0.1], - [0, 0], - [-1.7, 0], - [-1.1, 0.7], - [-0.6, 1.1], - [-0.2, 1.4], - [0, 0], - [0, 2.2], - [0.5, 2], - [1.1, 1.6], - [1.7, 1], - [0, 0], - [2.1, 0], - [0, 0], - [0, 0], - [0, 0], - [-3.4, 1.9], - [0, 0], - [-3.3, 0], - [-2.4, -1], - [-1.5, -1.8], - [-0.8, -2.4], - [0, 0], - [0, -1.5], - [0.1, -1.4], - [0.7, -1.3], - [1.4, -1.4], - [0, 0], - [0, 0], - [1.3, -0.8], - [1.3, -0.2], - [0, 0], - [1.2, 0], - [1.3, 0.3], - [0, 0], - [0, 0], - [-0.4, 1.8], - [-0.3, 2], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 6, - "ty": 3, - "parent": 3, - "ks": { "p": { "a": 0, "k": [320.544, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 9, - "ty": 4, - "parent": 8, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, 0.8], - [0.2, 0.4], - [0.3, 0.2], - [0.5, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-1.6, 0.2], - [-1.6, 0.5], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-1, 0.8], - [-1.2, 0], - [0, 0], - [-0.6, -0.1], - [-0.4, -0.2], - [0, 0], - [0, 0], - [0, 0], - [2.3, 0], - [0, 0], - [0.8, -0.3], - [0.6, -0.5], - [0.4, -0.6], - [0, -0.6], - [0, 0], - [0, 0], - [-0.2, -0.7], - [0, 0], - [-0.7, -0.2], - [-0.9, -0.1], - [0, 0], - [0, 0], - [0, 0], - [1.9, 0.1], - [2.1, 0], - [0, 0], - [1.8, -0.1], - [1.8, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.4, 0.2], - [-0.1, 0.4], - [0, 0], - [0, 1.9], - [0, 0] - ], - "v": [ - [8.5, 78], - [8.5, 59.6], - [8.4, 56.7], - [8.1, 55], - [7.5, 54.1], - [6.3, 53.8], - [6.3, 53.8], - [2.5, 53.5], - [2.5, 50.5], - [7.3, 49.8], - [12, 48.7], - [12, 48.7], - [17.9, 46.9], - [18.7, 47.4], - [18.7, 55.4], - [22.4, 51.3], - [25.8, 48.1], - [29.1, 46.9], - [29.1, 46.9], - [31.1, 47], - [32.7, 47.4], - [32.7, 47.4], - [32.3, 58.2], - [30.2, 59.1], - [25.3, 56], - [25.3, 56], - [22.8, 56.5], - [20.7, 57.8], - [19.3, 59.6], - [18.7, 61.4], - [18.7, 61.4], - [18.7, 78], - [19.1, 82], - [19.1, 82], - [20.3, 83], - [22.8, 83.4], - [22.8, 83.4], - [26, 83.6], - [26, 86.7], - [19.4, 86.5], - [13.4, 86.4], - [13.4, 86.4], - [7.9, 86.5], - [2.5, 86.7], - [2.5, 86.7], - [2.5, 83.6], - [5.5, 83.4], - [7.4, 83], - [8.1, 82], - [8.1, 82], - [8.5, 78], - [8.5, 78] - ], - "o": [ - [0, 0], - [0, -1.2], - [0, -0.8], - [-0.2, -0.4], - [-0.3, -0.2], - [0, 0], - [0, 0], - [0, 0], - [1.6, -0.2], - [1.6, -0.2], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [1.2, -1.3], - [1, -0.8], - [0, 0], - [0.8, 0], - [0.6, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-1, -2], - [0, 0], - [-0.8, 0], - [-0.8, 0.3], - [-0.6, 0.5], - [-0.4, 0.6], - [0, 0], - [0, 0], - [0, 1.9], - [0, 0], - [0.1, 0.4], - [0.7, 0.2], - [0, 0], - [0, 0], - [0, 0], - [-2.5, -0.1], - [-1.9, -0.1], - [0, 0], - [-1.8, 0], - [-1.8, 0.1], - [0, 0], - [0, 0], - [0, 0], - [0.9, -0.1], - [0.4, -0.2], - [0, 0], - [0.2, -0.7], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 8, - "ty": 3, - "parent": 3, - "ks": { "p": { "a": 0, "k": [287.868, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 11, - "ty": 4, - "parent": 10, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [-1.1, 1], - [-0.5, 1.4], - [-0.1, 1.6], - [0, 1.3], - [0, 0], - [0.1, 2.2], - [0.5, 2], - [1.2, 1.4], - [2.2, 0], - [0, 0], - [1.1, -1], - [0.6, -1.5], - [0.1, -1.7], - [0, -1.5], - [0, 0], - [-0.1, -2.1], - [-0.6, -1.9], - [-1.2, -1.3], - [-2.2, 0] - ], - "v": [ - [24, 83.8], - [24, 83.8], - [28.4, 82.4], - [30.8, 78.9], - [31.8, 74.4], - [32, 70], - [32, 70], - [31.8, 64.5], - [30.9, 58.2], - [28.3, 53], - [23.2, 50.9], - [23.2, 50.9], - [18.6, 52.4], - [16, 56], - [15, 60.8], - [14.8, 65.5], - [14.8, 65.5], - [15, 70.9], - [16.1, 76.9], - [18.9, 81.8], - [24, 83.8] - ], - "o": [ - [0, 0], - [1.8, 0], - [1.1, -1], - [0.5, -1.4], - [0.1, -1.6], - [0, 0], - [0, -1.5], - [-0.1, -2.2], - [-0.5, -2], - [-1.2, -1.4], - [0, 0], - [-2, 0], - [-1.1, 1], - [-0.6, 1.5], - [-0.1, 1.7], - [0, 0], - [0, 1.5], - [0.1, 2.1], - [0.6, 1.9], - [1.2, 1.3], - [0, 0] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [-3.3, -3.4], - [0, -6.3], - [0, 0], - [3.6, -3.7], - [7, 0], - [0, 0], - [2.4, 1], - [1.5, 1.8], - [0.8, 2.5], - [0, 3], - [0, 0], - [-3.5, 3.6], - [-6.8, 0] - ], - "v": [ - [23.9, 46.9], - [23.9, 46.9], - [38.5, 52], - [43.4, 66.5], - [43.4, 66.5], - [38.1, 82.3], - [22.2, 87.9], - [22.2, 87.9], - [13.8, 86.4], - [7.9, 82.2], - [4.5, 75.7], - [3.4, 67.5], - [3.4, 67.5], - [8.6, 52.3], - [23.9, 46.9] - ], - "o": [ - [0, 0], - [6.4, 0], - [3.3, 3.4], - [0, 0], - [0, 6.8], - [-3.6, 3.7], - [0, 0], - [-3.2, 0], - [-2.4, -1], - [-1.5, -1.8], - [-0.8, -2.5], - [0, 0], - [0, -6.6], - [3.5, -3.6], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 10, - "ty": 3, - "parent": 3, - "ks": { "p": { "a": 0, "k": [241.164, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 13, - "ty": 4, - "parent": 12, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, 0], - [0.9, 0.4], - [0.9, 0], - [0, 0], - [0.7, -0.4], - [0.3, -0.7], - [0.1, -1], - [0, -1.1], - [0, 0], - [0, 0], - [-1.9, 0.1], - [-1.7, 0.1], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-0.2, -0.7], - [0, 0], - [-0.8, -0.2], - [-0.9, -0.1], - [0, 0], - [0, 0], - [0, 0], - [2, 0.1], - [2.1, 0], - [0, 0], - [1.8, -0.1], - [2.4, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.3, 0.8], - [0, 0], - [0, 1.9], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-1.1, 0.6], - [-1, 0.7], - [0, 0], - [0, 0], - [-0.1, 1.1], - [-0.3, 0.8], - [-0.6, 0.7], - [-1.1, 0.9], - [0, 0], - [0, 0], - [-0.7, 0.4], - [-0.6, 0.3], - [-0.5, 0.1], - [-0.6, 0], - [0, 0], - [-1.2, -0.3], - [0, 0] - ], - "v": [ - [32, 26.5], - [32, 34.6], - [30.7, 35.2], - [28.1, 33.8], - [25.4, 33.2], - [25.4, 33.2], - [22.3, 33.8], - [20.7, 35.4], - [20.2, 37.9], - [20.1, 40.9], - [20.1, 40.9], - [20.1, 50], - [25.7, 49.9], - [31.1, 49.6], - [31.1, 49.6], - [31.4, 50.3], - [30.5, 55.2], - [20.1, 55.2], - [20.1, 78], - [20.4, 82], - [20.4, 82], - [21.8, 83], - [24.3, 83.4], - [24.3, 83.4], - [28.1, 83.6], - [28.1, 86.7], - [20.8, 86.5], - [14.7, 86.4], - [14.7, 86.4], - [9.2, 86.5], - [2.9, 86.7], - [2.9, 86.7], - [2.9, 83.6], - [6.3, 83.4], - [9.5, 82], - [9.5, 82], - [9.8, 78], - [9.8, 78], - [9.8, 55.2], - [3.2, 55.6], - [3.2, 53.6], - [6.6, 51.8], - [9.8, 49.8], - [9.8, 49.8], - [9.8, 46.2], - [10, 42.1], - [10.5, 39.3], - [11.9, 37], - [14.4, 34.7], - [14.4, 34.7], - [20.7, 29.5], - [23.3, 27.6], - [25.3, 26.5], - [27, 26], - [28.6, 26], - [28.6, 26], - [32, 26.5], - [32, 26.5] - ], - "o": [ - [0, 0], - [0, 0], - [-0.8, -0.5], - [-0.9, -0.4], - [0, 0], - [-1.3, 0], - [-0.7, 0.4], - [-0.3, 0.7], - [-0.1, 1], - [0, 0], - [0, 0], - [1.9, 0], - [1.9, -0.1], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 1.9], - [0, 0], - [0.1, 0.4], - [0.8, 0.2], - [0, 0], - [0, 0], - [0, 0], - [-2.8, -0.1], - [-2, -0.1], - [0, 0], - [-1.8, 0], - [-1.8, 0.1], - [0, 0], - [0, 0], - [0, 0], - [1.8, -0.1], - [0, 0], - [0.2, -0.7], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [1.2, -0.6], - [1.1, -0.6], - [0, 0], - [0, 0], - [0, -1.7], - [0.1, -1.1], - [0.3, -0.8], - [0.6, -0.7], - [0, 0], - [0, 0], - [1, -0.8], - [0.7, -0.4], - [0.6, -0.3], - [0.5, -0.1], - [0, 0], - [1.1, 0], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 12, - "ty": 3, - "parent": 3, - "ks": { "p": { "a": 0, "k": [208.488, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 15, - "ty": 4, - "parent": 14, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, 0.8], - [0.2, 0.4], - [0.3, 0.2], - [0.5, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-1.6, 0.3], - [-1.6, 0.4], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-1.5, 1.4], - [-1.4, 1.5], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0.7, -0.3], - [0.5, -0.4], - [0, 0], - [0, 0], - [1.1, -1.1], - [1, -1], - [0, 0], - [0, 0], - [-0.8, -0.6], - [-1.1, -0.1], - [0, 0], - [0, 0], - [0, 0], - [0.6, 0.8], - [0.7, 0.9], - [0.7, 0.8], - [0.5, 0.6], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-0.2, -0.7], - [0, 0], - [-0.4, -0.2], - [-0.9, -0.1], - [0, 0], - [0, 0], - [0, 0], - [1.8, 0.1], - [2.1, 0], - [0, 0], - [1.8, -0.1], - [1.8, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.4, 0.2], - [-0.1, 0.4], - [0, 0], - [0, 1.9], - [0, 0] - ], - "v": [ - [8.4, 78], - [8.4, 38.7], - [8.4, 35.8], - [8.1, 34.1], - [7.4, 33.2], - [6.2, 32.8], - [6.2, 32.8], - [1.8, 32.6], - [1.8, 29.6], - [7.1, 28.9], - [11.9, 27.8], - [11.9, 27.8], - [18, 26], - [18.6, 26.5], - [18.6, 65.5], - [23.1, 61.6], - [27.6, 57.4], - [27.6, 57.4], - [36.5, 47.9], - [47.5, 47.9], - [47.5, 51.1], - [44.9, 51.1], - [42.6, 51.5], - [40.8, 52.6], - [40.8, 52.6], - [35.6, 56.4], - [32.2, 59.3], - [29.1, 62.4], - [29.1, 62.4], - [45.5, 80.9], - [47.4, 82.5], - [50.1, 83.6], - [50.1, 83.6], - [50.1, 86.4], - [35.7, 86.4], - [34.3, 84.7], - [32.4, 82.3], - [30.2, 79.7], - [28.4, 77.5], - [28.4, 77.5], - [19.5, 67.5], - [18.6, 67.5], - [18.6, 78], - [19, 82], - [19, 82], - [19.7, 83], - [21.7, 83.4], - [21.7, 83.4], - [24.6, 83.6], - [24.6, 86.7], - [19.2, 86.5], - [13.3, 86.4], - [13.3, 86.4], - [7.8, 86.5], - [2.4, 86.7], - [2.4, 86.7], - [2.4, 83.6], - [5.4, 83.4], - [7.3, 83], - [8.1, 82], - [8.1, 82], - [8.4, 78], - [8.4, 78] - ], - "o": [ - [0, 0], - [0, -1.2], - [0, -0.8], - [-0.2, -0.4], - [-0.3, -0.2], - [0, 0], - [0, 0], - [0, 0], - [2, -0.2], - [1.6, -0.3], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [1.5, -1.2], - [1.5, -1.4], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-0.8, 0], - [-0.7, 0.3], - [0, 0], - [0, 0], - [-1.2, 0.9], - [-1.1, 1.1], - [0, 0], - [0, 0], - [0.4, 0.5], - [0.8, 0.6], - [0, 0], - [0, 0], - [0, 0], - [-0.3, -0.4], - [-0.6, -0.8], - [-0.7, -0.9], - [-0.7, -0.8], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 1.9], - [0, 0], - [0.1, 0.4], - [0.4, 0.2], - [0, 0], - [0, 0], - [0, 0], - [-1.8, -0.1], - [-1.8, -0.1], - [0, 0], - [-1.8, 0], - [-1.8, 0.1], - [0, 0], - [0, 0], - [0, 0], - [0.9, -0.1], - [0.4, -0.2], - [0, 0], - [0.2, -0.7], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 14, - "ty": 3, - "parent": 3, - "ks": { "p": { "a": 0, "k": [157.164, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 17, - "ty": 4, - "parent": 16, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, 0.8], - [0.2, 0.4], - [0.3, 0.2], - [0.5, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-1.6, 0.2], - [-1.6, 0.5], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-1, 0.8], - [-1.2, 0], - [0, 0], - [-0.6, -0.1], - [-0.4, -0.2], - [0, 0], - [0, 0], - [0, 0], - [2.3, 0], - [0, 0], - [0.8, -0.3], - [0.6, -0.5], - [0.4, -0.6], - [0, -0.6], - [0, 0], - [0, 0], - [-0.2, -0.7], - [0, 0], - [-0.7, -0.2], - [-0.9, -0.1], - [0, 0], - [0, 0], - [0, 0], - [1.9, 0.1], - [2.1, 0], - [0, 0], - [1.8, -0.1], - [1.8, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.4, 0.2], - [-0.1, 0.4], - [0, 0], - [0, 1.9], - [0, 0] - ], - "v": [ - [8.5, 78], - [8.5, 59.6], - [8.4, 56.7], - [8.1, 55], - [7.5, 54.1], - [6.3, 53.8], - [6.3, 53.8], - [2.5, 53.5], - [2.5, 50.5], - [7.3, 49.8], - [12, 48.7], - [12, 48.7], - [17.9, 46.9], - [18.7, 47.4], - [18.7, 55.4], - [22.4, 51.3], - [25.8, 48.1], - [29.1, 46.9], - [29.1, 46.9], - [31.1, 47], - [32.7, 47.4], - [32.7, 47.4], - [32.3, 58.2], - [30.2, 59.1], - [25.3, 56], - [25.3, 56], - [22.8, 56.5], - [20.7, 57.8], - [19.3, 59.6], - [18.7, 61.4], - [18.7, 61.4], - [18.7, 78], - [19.1, 82], - [19.1, 82], - [20.3, 83], - [22.8, 83.4], - [22.8, 83.4], - [26, 83.6], - [26, 86.7], - [19.4, 86.5], - [13.4, 86.4], - [13.4, 86.4], - [7.9, 86.5], - [2.5, 86.7], - [2.5, 86.7], - [2.5, 83.6], - [5.5, 83.4], - [7.4, 83], - [8.1, 82], - [8.1, 82], - [8.5, 78], - [8.5, 78] - ], - "o": [ - [0, 0], - [0, -1.2], - [0, -0.8], - [-0.2, -0.4], - [-0.3, -0.2], - [0, 0], - [0, 0], - [0, 0], - [1.6, -0.2], - [1.6, -0.2], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [1.2, -1.3], - [1, -0.8], - [0, 0], - [0.8, 0], - [0.6, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-1, -2], - [0, 0], - [-0.8, 0], - [-0.8, 0.3], - [-0.6, 0.5], - [-0.4, 0.6], - [0, 0], - [0, 0], - [0, 1.9], - [0, 0], - [0.1, 0.4], - [0.7, 0.2], - [0, 0], - [0, 0], - [0, 0], - [-2.5, -0.1], - [-1.9, -0.1], - [0, 0], - [-1.8, 0], - [-1.8, 0.1], - [0, 0], - [0, 0], - [0, 0], - [0.9, -0.1], - [0.4, -0.2], - [0, 0], - [0.2, -0.7], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 16, - "ty": 3, - "parent": 3, - "ks": { "p": { "a": 0, "k": [124.488, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 19, - "ty": 4, - "parent": 18, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [-1.1, 1], - [-0.5, 1.4], - [-0.1, 1.6], - [0, 1.3], - [0, 0], - [0.1, 2.2], - [0.5, 2], - [1.2, 1.4], - [2.2, 0], - [0, 0], - [1.1, -1], - [0.6, -1.5], - [0.1, -1.7], - [0, -1.5], - [0, 0], - [-0.1, -2.1], - [-0.6, -1.9], - [-1.2, -1.3], - [-2.2, 0] - ], - "v": [ - [24, 83.8], - [24, 83.8], - [28.4, 82.4], - [30.8, 78.9], - [31.8, 74.4], - [32, 70], - [32, 70], - [31.8, 64.5], - [30.9, 58.2], - [28.3, 53], - [23.2, 50.9], - [23.2, 50.9], - [18.6, 52.4], - [16, 56], - [15, 60.8], - [14.8, 65.5], - [14.8, 65.5], - [15, 70.9], - [16.1, 76.9], - [18.9, 81.8], - [24, 83.8] - ], - "o": [ - [0, 0], - [1.8, 0], - [1.1, -1], - [0.5, -1.4], - [0.1, -1.6], - [0, 0], - [0, -1.5], - [-0.1, -2.2], - [-0.5, -2], - [-1.2, -1.4], - [0, 0], - [-2, 0], - [-1.1, 1], - [-0.6, 1.5], - [-0.1, 1.7], - [0, 0], - [0, 1.5], - [0.1, 2.1], - [0.6, 1.9], - [1.2, 1.3], - [0, 0] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [-3.3, -3.4], - [0, -6.3], - [0, 0], - [3.6, -3.7], - [7, 0], - [0, 0], - [2.4, 1], - [1.5, 1.8], - [0.8, 2.5], - [0, 3], - [0, 0], - [-3.5, 3.6], - [-6.8, 0] - ], - "v": [ - [23.9, 46.9], - [23.9, 46.9], - [38.5, 52], - [43.4, 66.5], - [43.4, 66.5], - [38.1, 82.3], - [22.2, 87.9], - [22.2, 87.9], - [13.8, 86.4], - [7.9, 82.2], - [4.5, 75.7], - [3.4, 67.5], - [3.4, 67.5], - [8.6, 52.3], - [23.9, 46.9] - ], - "o": [ - [0, 0], - [6.4, 0], - [3.3, 3.4], - [0, 0], - [0, 6.8], - [-3.6, 3.7], - [0, 0], - [-3.2, 0], - [-2.4, -1], - [-1.5, -1.8], - [-0.8, -2.5], - [0, 0], - [0, -6.6], - [3.5, -3.6], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 18, - "ty": 3, - "parent": 3, - "ks": { "p": { "a": 0, "k": [77.784, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 21, - "ty": 4, - "parent": 20, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [-0.7, 1.7], - [-0.7, 1.7], - [0, 0], - [0, 0], - [-0.7, -1.8], - [-0.7, -1.8], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 1], - [0, 0], - [0.6, 0.3], - [0.7, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-1.5, -0.1], - [-1.2, 0], - [0, 0], - [-1.5, 0.1], - [-2, 0.1], - [0, 0], - [0, 0], - [0, 0], - [0.6, -1], - [0.5, -1.8], - [0, 0], - [0, 0], - [0.4, -2], - [0.4, -2], - [0, 0], - [0, 0], - [0.5, 1.5], - [0.6, 1.5], - [0, 0], - [0, 0], - [0, 0], - [0.6, -1.7], - [0.7, -1.8], - [0, 0], - [0, 0], - [0, 0], - [0.5, 0.5], - [0.8, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-2.2, -0.1], - [-1.6, 0], - [0, 0], - [-2, 0.1], - [-1.9, 0.1], - [0, 0], - [0, 0], - [0, 0], - [0.6, -0.4], - [0, -0.4], - [0, 0], - [-0.2, -0.9], - [0, 0], - [0, 0], - [0, 0] - ], - "v": [ - [27, 68.8], - [39.5, 39.2], - [41.6, 34], - [43.8, 28.8], - [43.8, 28.8], - [47.5, 28.8], - [49.6, 34.5], - [51.7, 40], - [51.7, 40], - [63.1, 68.5], - [63.3, 68.5], - [71.3, 37], - [71.7, 34.3], - [71.7, 34.3], - [70.7, 33.1], - [68.7, 32.6], - [68.7, 32.6], - [64.8, 32.3], - [64.8, 29.2], - [69.9, 29.4], - [73.9, 29.5], - [73.9, 29.5], - [77.8, 29.4], - [83, 29.2], - [83, 29.2], - [83, 32.3], - [80.2, 32.6], - [78.2, 34.2], - [76.6, 38.4], - [76.6, 38.4], - [66.4, 74.7], - [64.9, 80.6], - [63.6, 86.7], - [63.6, 86.7], - [57.5, 86.7], - [55.9, 82.3], - [54.3, 77.9], - [54.3, 77.9], - [41.7, 46.4], - [28.7, 76.2], - [26.7, 81.4], - [24.8, 86.7], - [24.8, 86.7], - [18.9, 86.7], - [7.2, 36.1], - [6, 33.5], - [4.1, 32.6], - [4.1, 32.6], - [1.4, 32.3], - [1.4, 29.2], - [8.9, 29.4], - [14.5, 29.5], - [14.5, 29.5], - [20, 29.4], - [25.8, 29.2], - [25.8, 29.2], - [25.8, 32.3], - [22.3, 32.6], - [20.6, 33.3], - [19.7, 34.5], - [19.7, 34.5], - [20, 36.9], - [20, 36.9], - [26.8, 68.8], - [27, 68.8] - ], - "o": [ - [0, 0], - [0.7, -1.7], - [0.7, -1.7], - [0, 0], - [0, 0], - [0.7, 2], - [0.7, 1.8], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0.2, -0.9], - [0, 0], - [0, -0.5], - [-0.6, -0.3], - [0, 0], - [0, 0], - [0, 0], - [1.9, 0.1], - [1.5, 0.1], - [0, 0], - [1.1, 0], - [1.5, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.8, 0.1], - [-0.6, 1], - [0, 0], - [0, 0], - [-0.6, 2], - [-0.4, 2], - [0, 0], - [0, 0], - [-0.6, -1.5], - [-0.5, -1.5], - [0, 0], - [0, 0], - [0, 0], - [-0.8, 1.7], - [-0.6, 1.7], - [0, 0], - [0, 0], - [0, 0], - [-0.3, -1.2], - [-0.5, -0.5], - [0, 0], - [0, 0], - [0, 0], - [2.8, 0.1], - [2.2, 0.1], - [0, 0], - [1.6, 0], - [2, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.6, 0.1], - [-0.6, 0.4], - [0, 0], - [0, 0.7], - [0, 0], - [0, 0], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 20, - "ty": 3, - "parent": 3, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 3, - "ty": 3, - "ks": { "p": { "a": 0, "k": [-1, -25] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "30", - "layers": [ - { - "ind": 27, - "ty": 4, - "parent": 26, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [-0.1, -1.1], - [-0.3, -0.5], - [0, 0], - [-0.5, -0.2], - [-1.1, -0.1], - [0, 0], - [0, 0], - [0, 0], - [2.2, 0.1], - [2.5, 0], - [0, 0], - [2.2, -0.1], - [1.7, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.5, 0.2], - [-0.2, 0.3], - [0, 0], - [-0.1, 1.1], - [0, 1.8], - [0, 0], - [0, 0], - [0.1, 1.1], - [0.3, 0.5], - [0, 0], - [0.5, 0.2], - [1.1, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-2.2, -0.1], - [-2.5, 0], - [0, 0], - [-2.2, 0.1], - [-1.7, 0.1], - [0, 0], - [0, 0], - [0, 0], - [0.5, -0.2], - [0.2, -0.3], - [0, 0], - [0.1, -1.1], - [0, -1.8], - [0, 0] - ], - "v": [ - [22.3, 40.8], - [22.3, 75.1], - [22.3, 79.5], - [22.8, 81.9], - [22.8, 81.9], - [23.9, 82.7], - [26.3, 83.1], - [26.3, 83.1], - [29.4, 83.3], - [29.4, 86.7], - [23.5, 86.5], - [16.4, 86.4], - [16.4, 86.4], - [9.2, 86.5], - [3.3, 86.7], - [3.3, 86.7], - [3.3, 83.3], - [6.4, 83.1], - [8.8, 82.7], - [9.8, 81.9], - [9.8, 81.9], - [10.3, 79.5], - [10.4, 75.1], - [10.4, 75.1], - [10.4, 40.8], - [10.3, 36.5], - [9.8, 34], - [9.8, 34], - [8.8, 33.3], - [6.4, 32.8], - [6.4, 32.8], - [3.3, 32.6], - [3.3, 29.2], - [9.2, 29.4], - [16.4, 29.5], - [16.4, 29.5], - [23.5, 29.4], - [29.4, 29.2], - [29.4, 29.2], - [29.4, 32.6], - [26.3, 32.8], - [23.9, 33.3], - [22.8, 34], - [22.8, 34], - [22.3, 36.5], - [22.3, 40.8], - [22.3, 40.8] - ], - "o": [ - [0, 0], - [0, 1.8], - [0.1, 1.1], - [0, 0], - [0.2, 0.3], - [0.5, 0.2], - [0, 0], - [0, 0], - [0, 0], - [-1.7, -0.1], - [-2.2, -0.1], - [0, 0], - [-2.5, 0], - [-2.2, 0.1], - [0, 0], - [0, 0], - [0, 0], - [1.1, -0.1], - [0.5, -0.2], - [0, 0], - [0.3, -0.5], - [0.1, -1.1], - [0, 0], - [0, 0], - [0, -1.8], - [-0.1, -1.1], - [0, 0], - [-0.2, -0.3], - [-0.5, -0.2], - [0, 0], - [0, 0], - [0, 0], - [1.7, 0.1], - [2.2, 0.1], - [0, 0], - [2.5, 0], - [2.2, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-1.1, 0.1], - [-0.5, 0.2], - [0, 0], - [-0.3, 0.5], - [-0.1, 1.1], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 26, - "ty": 3, - "parent": 25, - "ks": { "p": { "a": 0, "k": [65.352, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 29, - "ty": 4, - "parent": 28, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, 0], - [0, 0] - ], - "v": [ - [28.7, 43.1], - [20.3, 64.4], - [37.5, 64.4], - [28.7, 43.1] - ], - "o": [ - [0, 0], - [0, 0], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, 0], - [0, -0.5], - [0, 0], - [-0.5, -0.4], - [-0.6, -0.1], - [0, 0], - [0, 0], - [0, 0], - [1.5, 0.1], - [1.2, 0], - [0, 0], - [1.5, -0.1], - [2, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.8, 1], - [-0.5, 1.2], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-0.7, -0.6], - [-1.3, -0.2], - [0, 0], - [0, 0], - [0, 0], - [2.1, 0.1], - [1.6, 0], - [0, 0], - [2.1, -0.1], - [2.8, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.3, 0.4], - [0, 0.8], - [0, 0], - [0.3, 0.8], - [0, 0], - [0, 0] - ], - "v": [ - [39.4, 69.1], - [18.3, 69.1], - [14.8, 78.5], - [14.4, 80.6], - [14.4, 80.6], - [15.1, 82.6], - [16.8, 83.3], - [16.8, 83.3], - [21, 83.6], - [21, 86.7], - [15.7, 86.5], - [11.5, 86.4], - [11.5, 86.4], - [7.4, 86.5], - [2, 86.7], - [2, 86.7], - [2, 83.6], - [4.5, 83.3], - [7.4, 81.6], - [9.2, 78.3], - [9.2, 78.3], - [24.4, 41.5], - [29.2, 28.8], - [35.2, 28.8], - [57, 79.6], - [58.6, 82.2], - [61.6, 83.3], - [61.6, 83.3], - [63.6, 83.6], - [63.6, 86.7], - [56.2, 86.5], - [50.6, 86.4], - [50.6, 86.4], - [44.9, 86.5], - [37.5, 86.7], - [37.5, 86.7], - [37.5, 83.6], - [42, 83.3], - [43.2, 82.7], - [43.7, 80.9], - [43.7, 80.9], - [43.2, 78.7], - [43.2, 78.7], - [39.4, 69.1] - ], - "o": [ - [0, 0], - [0, 0], - [-0.3, 0.8], - [0, 0], - [0, 1], - [0.5, 0.4], - [0, 0], - [0, 0], - [0, 0], - [-2, -0.1], - [-1.5, -0.1], - [0, 0], - [-1.2, 0], - [-1.5, 0.1], - [0, 0], - [0, 0], - [0, 0], - [1.1, -0.1], - [0.8, -1], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0.4, 1.1], - [0.7, 0.6], - [0, 0], - [0, 0], - [0, 0], - [-2.9, -0.1], - [-2.1, -0.1], - [0, 0], - [-1.6, 0], - [-2.1, 0.1], - [0, 0], - [0, 0], - [0, 0], - [0.4, -0.1], - [0.3, -0.4], - [0, 0], - [0, -0.6], - [0, 0], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 28, - "ty": 3, - "parent": 25, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 25, - "ty": 3, - "ks": { "p": { "a": 0, "k": [-2, -28] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "41", - "layers": [ - { - "ind": 36, - "ty": 4, - "parent": 35, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, 0.8], - [0.2, 0.4], - [0.3, 0.2], - [0.5, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-2.5, 0.8], - [-2.5, 0.8], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-1.1, 0.1], - [-1, 0], - [0, 0], - [-1.7, -0.6], - [-1.1, -1.1], - [0, 0], - [-0.3, -2], - [0, -2.5], - [0, 0], - [0, 0], - [-0.2, -0.7], - [0, 0], - [-0.4, -0.2], - [-0.9, -0.1], - [0, 0], - [0, 0], - [0, 0], - [1.3, 0.1], - [1.3, 0], - [0, 0], - [1.3, -0.1], - [1.2, -0.1], - [0, 0], - [0, 0], - [0.3, 1.2], - [0.8, 0.8], - [0, 0], - [1, 0.3], - [1.5, 0], - [0, 0], - [0.8, -0.4], - [0.6, -0.6], - [0.4, -0.7], - [0, -0.6], - [0, 0], - [0, 0], - [-0.2, -0.7], - [0, 0], - [-0.4, -0.2], - [-0.9, -0.1], - [0, 0], - [0, 0], - [0, 0], - [1.8, 0.1], - [2.1, 0], - [0, 0], - [1.8, -0.1], - [1.8, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.4, 0.2], - [-0.1, 0.4], - [0, 0], - [0, 1.9], - [0, 0] - ], - "v": [ - [8, 78], - [8, 59.6], - [7.9, 56.7], - [7.6, 55], - [7, 54.1], - [5.8, 53.8], - [5.8, 53.8], - [2, 53.5], - [2, 50.5], - [10.2, 49.2], - [17.7, 46.9], - [17.7, 46.9], - [18.2, 47.4], - [18.2, 53.7], - [24.9, 48.5], - [28.2, 47.1], - [31.3, 46.9], - [31.3, 46.9], - [36.2, 47.7], - [40.4, 50.2], - [40.4, 50.2], - [43.1, 55.6], - [43.5, 62.4], - [43.5, 62.4], - [43.5, 78], - [43.8, 82], - [43.8, 82], - [44.6, 83], - [46.5, 83.4], - [46.5, 83.4], - [49.3, 83.6], - [49.3, 86.7], - [45.1, 86.5], - [41.1, 86.4], - [41.1, 86.4], - [36.9, 86.5], - [33.3, 86.7], - [33.3, 86.7], - [33.3, 63.8], - [32.8, 59], - [31.1, 56], - [31.1, 56], - [28.5, 54.5], - [24.8, 54.1], - [24.8, 54.1], - [22.3, 54.6], - [20.2, 56], - [18.8, 57.9], - [18.2, 59.8], - [18.2, 59.8], - [18.2, 78], - [18.6, 82], - [18.6, 82], - [19.3, 83], - [21.3, 83.4], - [21.3, 83.4], - [24.2, 83.6], - [24.2, 86.7], - [18.8, 86.5], - [12.9, 86.4], - [12.9, 86.4], - [7.4, 86.5], - [2, 86.7], - [2, 86.7], - [2, 83.6], - [5, 83.4], - [6.9, 83], - [7.6, 82], - [7.6, 82], - [8, 78], - [8, 78] - ], - "o": [ - [0, 0], - [0, -1.2], - [0, -0.8], - [-0.2, -0.4], - [-0.3, -0.2], - [0, 0], - [0, 0], - [0, 0], - [2.9, -0.1], - [2.5, -0.8], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [1.1, -0.8], - [1.1, -0.1], - [0, 0], - [1.6, 0], - [1.7, 0.6], - [0, 0], - [1.5, 1.5], - [0.3, 2], - [0, 0], - [0, 0], - [0, 1.9], - [0, 0], - [0.1, 0.4], - [0.4, 0.2], - [0, 0], - [0, 0], - [0, 0], - [-1.5, -0.1], - [-1.3, -0.1], - [0, 0], - [-1.5, 0], - [-1.3, 0.1], - [0, 0], - [0, 0], - [0, -2], - [-0.3, -1.2], - [0, 0], - [-0.7, -0.7], - [-1, -0.3], - [0, 0], - [-0.8, 0], - [-0.8, 0.4], - [-0.6, 0.6], - [-0.4, 0.7], - [0, 0], - [0, 0], - [0, 1.9], - [0, 0], - [0.1, 0.4], - [0.4, 0.2], - [0, 0], - [0, 0], - [0, 0], - [-1.8, -0.1], - [-1.8, -0.1], - [0, 0], - [-1.8, 0], - [-1.8, 0.1], - [0, 0], - [0, 0], - [0, 0], - [0.9, -0.1], - [0.4, -0.2], - [0, 0], - [0.2, -0.7], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 35, - "ty": 3, - "parent": 34, - "ks": { "p": { "a": 0, "k": [139.944, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 38, - "ty": 4, - "parent": 37, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0.6, 0.4], - [0.8, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-1.9, -0.1], - [-1.9, 0], - [0, 0], - [-1.9, 0.1], - [-1.9, 0.1], - [0, 0], - [0, 0], - [0, 0], - [0.3, -0.3], - [0, -0.4], - [0, 0], - [0, -0.3], - [0, 0], - [-0.1, -0.3], - [0, 0], - [0, 0], - [0, 0], - [-0.8, 1.7], - [-0.8, 1.7], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-0.6, 1.6], - [-0.5, 1.6], - [0, 0], - [0, 0], - [0, 0.4], - [0, 0], - [0.5, 0.3], - [0.6, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-1.4, -0.1], - [-1.5, 0], - [0, 0], - [-1.4, 0.1], - [-1.5, 0.1], - [0, 0], - [0, 0], - [0, 0], - [0.5, -0.6], - [0.4, -1.3], - [0, 0], - [0, 0], - [0.4, -1.6], - [0.5, -1.6], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0.6, -1.8], - [0.7, -1.8], - [0, 0], - [0, 0] - ], - "v": [ - [16.3, 86.7], - [7.3, 54.4], - [6, 52], - [3.9, 51.2], - [3.9, 51.2], - [1.1, 51.1], - [1.1, 47.9], - [6.8, 48], - [12.5, 48.1], - [12.5, 48.1], - [18.2, 48], - [23.9, 47.9], - [23.9, 47.9], - [23.9, 51.1], - [19.9, 51.2], - [18.8, 51.7], - [18.3, 52.8], - [18.3, 52.8], - [18.3, 53.7], - [18.3, 53.7], - [18.5, 54.7], - [18.5, 54.7], - [23.4, 73.5], - [23.5, 73.5], - [25.9, 68.4], - [28.2, 63.3], - [28.2, 63.3], - [35.5, 46.9], - [39.7, 46.9], - [51.3, 73.5], - [53, 68.9], - [54.6, 64.1], - [54.6, 64.1], - [57.6, 54.6], - [57.9, 53.1], - [57.9, 53.1], - [57.1, 51.7], - [55.4, 51.2], - [55.4, 51.2], - [51.9, 51.1], - [51.9, 47.9], - [56.2, 48], - [60.4, 48.1], - [60.4, 48.1], - [64.6, 48], - [68.9, 47.9], - [68.9, 47.9], - [68.9, 51.1], - [66, 51.2], - [64.4, 52.3], - [63, 55.2], - [63, 55.2], - [55.3, 77], - [53.8, 81.9], - [52.3, 86.7], - [52.3, 86.7], - [45.5, 86.7], - [34.4, 60.9], - [27.2, 75.9], - [25, 81.3], - [22.9, 86.7], - [22.9, 86.7], - [16.3, 86.7] - ], - "o": [ - [0, 0], - [-0.3, -1.2], - [-0.6, -0.4], - [0, 0], - [0, 0], - [0, 0], - [1.9, 0.1], - [1.9, 0.1], - [0, 0], - [1.9, 0], - [1.9, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.4, 0], - [-0.3, 0.3], - [0, 0], - [0, 0.3], - [0, 0], - [0, 0.3], - [0, 0], - [0, 0], - [0, 0], - [0.8, -1.7], - [0.8, -1.7], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0.6, -1.5], - [0.6, -1.6], - [0, 0], - [0, 0], - [0.2, -0.6], - [0, 0], - [0, -0.7], - [-0.5, -0.3], - [0, 0], - [0, 0], - [0, 0], - [1.5, 0.1], - [1.4, 0.1], - [0, 0], - [1.5, 0], - [1.4, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.6, 0.1], - [-0.5, 0.6], - [0, 0], - [0, 0], - [-0.6, 1.6], - [-0.4, 1.6], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-0.8, 1.8], - [-0.6, 1.8], - [0, 0], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 37, - "ty": 3, - "parent": 34, - "ks": { "p": { "a": 0, "k": [69.972, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 40, - "ty": 4, - "parent": 39, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [2.5, -1.3], - [1.4, -2.2], - [0.6, -2.9], - [0, -3.1], - [0, 0], - [-0.7, -3.2], - [-1.5, -2.4], - [-2.2, -1.4], - [-3, 0], - [0, 0], - [-2.5, 1.3], - [-1.4, 2.2], - [-0.6, 2.9], - [0, 3.1], - [0, 0], - [0.7, 3.2], - [1.5, 2.4], - [2.2, 1.4], - [3, 0] - ], - "v": [ - [35.9, 32.8], - [35.9, 32.8], - [26.3, 34.8], - [20.4, 40.2], - [17.4, 47.9], - [16.5, 57], - [16.5, 57], - [17.6, 67], - [20.9, 75.3], - [26.4, 81], - [34.2, 83.2], - [34.2, 83.2], - [43.8, 81.1], - [49.7, 75.8], - [52.7, 68], - [53.5, 59], - [53.5, 59], - [52.4, 49], - [49.1, 40.7], - [43.6, 34.9], - [35.9, 32.8] - ], - "o": [ - [0, 0], - [-3.9, 0], - [-2.5, 1.3], - [-1.4, 2.2], - [-0.6, 2.9], - [0, 0], - [0, 3.5], - [0.7, 3.2], - [1.5, 2.4], - [2.2, 1.4], - [0, 0], - [3.9, 0], - [2.5, -1.3], - [1.4, -2.2], - [0.6, -2.9], - [0, 0], - [0, -3.5], - [-0.7, -3.2], - [-1.5, -2.4], - [-2.2, -1.4], - [0, 0] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [-3.7, -1.3], - [-2.6, -2.5], - [-1.4, -3.7], - [0, -4.8], - [0, 0], - [1.5, -3.8], - [2.8, -2.5], - [3.9, -1.3], - [4.8, 0], - [0, 0], - [3.8, 1.3], - [2.6, 2.5], - [1.4, 3.6], - [0, 4.8], - [0, 0], - [-1.5, 3.8], - [-2.8, 2.5], - [-3.9, 1.3], - [-4.8, 0] - ], - "v": [ - [35.9, 28.1], - [35.9, 28.1], - [48.5, 29.9], - [58, 35.6], - [64, 44.8], - [66.1, 57.5], - [66.1, 57.5], - [63.8, 70.6], - [57.4, 80.1], - [47.3, 85.9], - [34.2, 87.9], - [34.2, 87.9], - [21.5, 86], - [12, 80.3], - [6, 71.1], - [3.9, 58.5], - [3.9, 58.5], - [6.2, 45.4], - [12.7, 35.8], - [22.8, 30], - [35.9, 28.1] - ], - "o": [ - [0, 0], - [4.7, 0], - [3.7, 1.3], - [2.6, 2.5], - [1.4, 3.7], - [0, 0], - [0, 5], - [-1.5, 3.8], - [-2.8, 2.5], - [-3.9, 1.3], - [0, 0], - [-4.7, 0], - [-3.8, -1.3], - [-2.6, -2.5], - [-1.4, -3.6], - [0, 0], - [0, -4.9], - [1.5, -3.8], - [2.8, -2.5], - [3.9, -1.3], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 39, - "ty": 3, - "parent": 34, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 34, - "ty": 3, - "ks": { "p": { "a": 0, "k": [-1, -28] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "53", - "layers": [ - { - "ind": 46, - "ty": 4, - "parent": 45, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, 0.8], - [0.2, 0.4], - [0.3, 0.2], - [0.5, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-1.6, 0.2], - [-1.6, 0.5], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-1, 0.8], - [-1.2, 0], - [0, 0], - [-0.6, -0.1], - [-0.4, -0.2], - [0, 0], - [0, 0], - [0, 0], - [2.3, 0], - [0, 0], - [0.8, -0.3], - [0.6, -0.5], - [0.4, -0.6], - [0, -0.6], - [0, 0], - [0, 0], - [-0.2, -0.7], - [0, 0], - [-0.7, -0.2], - [-0.9, -0.1], - [0, 0], - [0, 0], - [0, 0], - [1.9, 0.1], - [2.1, 0], - [0, 0], - [1.8, -0.1], - [1.8, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.4, 0.2], - [-0.1, 0.4], - [0, 0], - [0, 1.9], - [0, 0] - ], - "v": [ - [8.5, 78], - [8.5, 59.6], - [8.4, 56.7], - [8.1, 55], - [7.5, 54.1], - [6.3, 53.8], - [6.3, 53.8], - [2.5, 53.5], - [2.5, 50.5], - [7.3, 49.8], - [12, 48.7], - [12, 48.7], - [17.9, 46.9], - [18.7, 47.4], - [18.7, 55.4], - [22.4, 51.3], - [25.8, 48.1], - [29.1, 46.9], - [29.1, 46.9], - [31.1, 47], - [32.7, 47.4], - [32.7, 47.4], - [32.3, 58.2], - [30.2, 59.1], - [25.3, 56], - [25.3, 56], - [22.8, 56.5], - [20.7, 57.8], - [19.3, 59.6], - [18.7, 61.4], - [18.7, 61.4], - [18.7, 78], - [19.1, 82], - [19.1, 82], - [20.3, 83], - [22.8, 83.4], - [22.8, 83.4], - [26, 83.6], - [26, 86.7], - [19.4, 86.5], - [13.4, 86.4], - [13.4, 86.4], - [7.9, 86.5], - [2.5, 86.7], - [2.5, 86.7], - [2.5, 83.6], - [5.5, 83.4], - [7.4, 83], - [8.1, 82], - [8.1, 82], - [8.5, 78], - [8.5, 78] - ], - "o": [ - [0, 0], - [0, -1.2], - [0, -0.8], - [-0.2, -0.4], - [-0.3, -0.2], - [0, 0], - [0, 0], - [0, 0], - [1.6, -0.2], - [1.6, -0.2], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [1.2, -1.3], - [1, -0.8], - [0, 0], - [0.8, 0], - [0.6, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-1, -2], - [0, 0], - [-0.8, 0], - [-0.8, 0.3], - [-0.6, 0.5], - [-0.4, 0.6], - [0, 0], - [0, 0], - [0, 1.9], - [0, 0], - [0.1, 0.4], - [0.7, 0.2], - [0, 0], - [0, 0], - [0, 0], - [-2.5, -0.1], - [-1.9, -0.1], - [0, 0], - [-1.8, 0], - [-1.8, 0.1], - [0, 0], - [0, 0], - [0, 0], - [0.9, -0.1], - [0.4, -0.2], - [0, 0], - [0.2, -0.7], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 45, - "ty": 3, - "parent": 44, - "ks": { "p": { "a": 0, "k": [147.84, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 48, - "ty": 4, - "parent": 47, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, 0.8], - [0.2, 0.4], - [0.3, 0.2], - [0.5, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-1.6, 0.2], - [-1.6, 0.5], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-0.1, -1.3], - [-0.4, -1], - [-0.9, -0.6], - [-1.6, 0], - [0, 0], - [-1.2, 0.8], - [-0.8, 1.2], - [0, 0], - [-0.1, 0.6], - [0, 0.6], - [0, 0], - [0, 0], - [0, 0.8], - [0.2, 0.4], - [0.3, 0.2], - [0.5, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-1.6, 0.2], - [-1.6, 0.5], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-0.2, -0.7], - [0, 0], - [-0.4, -0.2], - [-0.9, -0.1], - [0, 0], - [0, 0], - [0, 0], - [1.7, 0.1], - [1.2, 0], - [0, 0], - [1.2, -0.1], - [1.7, -0.1], - [0, 0], - [0, 0], - [0, 0], - [1, -0.3], - [1, 0], - [0, 0], - [2, 2.4], - [0, 4], - [0, 0] - ], - "v": [ - [8.1, 74.7], - [8.1, 59.6], - [8, 56.7], - [7.7, 55], - [7.1, 54.1], - [5.9, 53.8], - [5.9, 53.8], - [2.1, 53.5], - [2.1, 50.5], - [6.9, 49.8], - [11.6, 48.7], - [11.6, 48.7], - [17.5, 46.9], - [18.3, 47.4], - [18.3, 69.3], - [18.5, 73.3], - [19.3, 76.8], - [21.3, 79.3], - [25.1, 80.3], - [25.1, 80.3], - [29.1, 79.1], - [32.2, 76], - [32.2, 76], - [32.8, 74.5], - [32.9, 72.7], - [32.9, 72.7], - [32.9, 59.6], - [32.9, 56.7], - [32.6, 55], - [31.9, 54.1], - [30.7, 53.8], - [30.7, 53.8], - [27, 53.5], - [27, 50.5], - [31.8, 49.8], - [36.5, 48.7], - [36.5, 48.7], - [42.3, 46.9], - [43.2, 47.4], - [43.2, 78], - [43.5, 82], - [43.5, 82], - [44.3, 83], - [46.2, 83.4], - [46.2, 83.4], - [49, 83.6], - [49, 86.7], - [43.9, 86.5], - [39.6, 86.4], - [39.6, 86.4], - [36.8, 86.5], - [32.5, 86.7], - [32.5, 86.7], - [32.9, 79.7], - [26.5, 85.9], - [24.2, 87.4], - [21.2, 87.9], - [21.2, 87.9], - [11.1, 84.3], - [8.1, 74.7], - [8.1, 74.7] - ], - "o": [ - [0, 0], - [0, -1.2], - [0, -0.8], - [-0.2, -0.4], - [-0.3, -0.2], - [0, 0], - [0, 0], - [0, 0], - [1.6, -0.2], - [1.6, -0.2], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 1.3], - [0.1, 1.3], - [0.4, 1], - [0.9, 0.6], - [0, 0], - [1.5, 0], - [1.2, -0.8], - [0, 0], - [0.3, -0.4], - [0.1, -0.6], - [0, 0], - [0, 0], - [0, -1.2], - [0, -0.8], - [-0.2, -0.4], - [-0.3, -0.2], - [0, 0], - [0, 0], - [0, 0], - [1.6, -0.2], - [1.6, -0.2], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 1.9], - [0, 0], - [0.1, 0.4], - [0.4, 0.2], - [0, 0], - [0, 0], - [0, 0], - [-1.7, -0.1], - [-1.7, -0.1], - [0, 0], - [-0.7, 0], - [-1.2, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.6, 0.6], - [-1, 0.3], - [0, 0], - [-4.6, 0], - [-2, -2.4], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 47, - "ty": 3, - "parent": 44, - "ks": { "p": { "a": 0, "k": [96.516, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 50, - "ty": 4, - "parent": 49, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [-1.1, 1], - [-0.5, 1.4], - [-0.1, 1.6], - [0, 1.3], - [0, 0], - [0.1, 2.2], - [0.5, 2], - [1.2, 1.4], - [2.2, 0], - [0, 0], - [1.1, -1], - [0.6, -1.5], - [0.1, -1.7], - [0, -1.5], - [0, 0], - [-0.1, -2.1], - [-0.6, -1.9], - [-1.2, -1.3], - [-2.2, 0] - ], - "v": [ - [24, 83.8], - [24, 83.8], - [28.4, 82.4], - [30.8, 78.9], - [31.8, 74.4], - [32, 70], - [32, 70], - [31.8, 64.5], - [30.9, 58.2], - [28.3, 53], - [23.2, 50.9], - [23.2, 50.9], - [18.6, 52.4], - [16, 56], - [15, 60.8], - [14.8, 65.5], - [14.8, 65.5], - [15, 70.9], - [16.1, 76.9], - [18.9, 81.8], - [24, 83.8] - ], - "o": [ - [0, 0], - [1.8, 0], - [1.1, -1], - [0.5, -1.4], - [0.1, -1.6], - [0, 0], - [0, -1.5], - [-0.1, -2.2], - [-0.5, -2], - [-1.2, -1.4], - [0, 0], - [-2, 0], - [-1.1, 1], - [-0.6, 1.5], - [-0.1, 1.7], - [0, 0], - [0, 1.5], - [0.1, 2.1], - [0.6, 1.9], - [1.2, 1.3], - [0, 0] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [-3.3, -3.4], - [0, -6.3], - [0, 0], - [3.6, -3.7], - [7, 0], - [0, 0], - [2.4, 1], - [1.5, 1.8], - [0.8, 2.5], - [0, 3], - [0, 0], - [-3.5, 3.6], - [-6.8, 0] - ], - "v": [ - [23.9, 46.9], - [23.9, 46.9], - [38.5, 52], - [43.4, 66.5], - [43.4, 66.5], - [38.1, 82.3], - [22.2, 87.9], - [22.2, 87.9], - [13.8, 86.4], - [7.9, 82.2], - [4.5, 75.7], - [3.4, 67.5], - [3.4, 67.5], - [8.6, 52.3], - [23.9, 46.9] - ], - "o": [ - [0, 0], - [6.4, 0], - [3.3, 3.4], - [0, 0], - [0, 6.8], - [-3.6, 3.7], - [0, 0], - [-3.2, 0], - [-2.4, -1], - [-1.5, -1.8], - [-0.8, -2.5], - [0, 0], - [0, -6.6], - [3.5, -3.6], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 49, - "ty": 3, - "parent": 44, - "ks": { "p": { "a": 0, "k": [49.812, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 52, - "ty": 4, - "parent": 51, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0.7, -1.4], - [0.6, -1.5], - [0, 0], - [0, 0], - [-0.1, -1.1], - [-0.3, -0.5], - [0, 0], - [-0.5, -0.2], - [-1.1, -0.1], - [0, 0], - [0, 0], - [0, 0], - [2.2, 0.1], - [2.5, 0], - [0, 0], - [2.2, -0.1], - [1.7, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.5, 0.2], - [-0.2, 0.3], - [0, 0], - [-0.1, 1.1], - [0, 1.8], - [0, 0], - [0, 0], - [0.6, 1], - [0.6, 0.9], - [0, 0], - [0, 0], - [0.8, 1], - [0.7, 0.8], - [0, 0], - [1, 0.4], - [1.5, 0], - [0, 0], - [0, 0], - [0, 0], - [-1.5, -2], - [-1.2, -2.2], - [0, 0], - [0, 0] - ], - "v": [ - [33.2, 56.5], - [49, 29.5], - [55.4, 29.5], - [55.4, 31.1], - [40.7, 53], - [38.2, 57.1], - [36.2, 61.4], - [36.2, 61.4], - [36.2, 75.1], - [36.3, 79.5], - [36.8, 81.9], - [36.8, 81.9], - [37.8, 82.7], - [40.2, 83.1], - [40.2, 83.1], - [43.3, 83.3], - [43.3, 86.7], - [37.4, 86.5], - [30.3, 86.4], - [30.3, 86.4], - [23.2, 86.5], - [17.2, 86.7], - [17.2, 86.7], - [17.2, 83.3], - [20.3, 83.1], - [22.8, 82.7], - [23.8, 81.9], - [23.8, 81.9], - [24.3, 79.5], - [24.4, 75.1], - [24.4, 75.1], - [24.4, 63.4], - [22.3, 59.2], - [20.7, 56.3], - [20.7, 56.3], - [12.2, 42.6], - [10.1, 39.6], - [7.8, 37], - [7.8, 37], - [4.9, 34.8], - [1.3, 34.2], - [1.3, 34.2], - [1.3, 31.1], - [14.6, 28.1], - [20.2, 33.1], - [24.2, 39.5], - [24.2, 39.5], - [33.2, 56.5] - ], - "o": [ - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-0.9, 1.3], - [-0.7, 1.4], - [0, 0], - [0, 0], - [0, 1.8], - [0.1, 1.1], - [0, 0], - [0.2, 0.3], - [0.5, 0.2], - [0, 0], - [0, 0], - [0, 0], - [-1.7, -0.1], - [-2.2, -0.1], - [0, 0], - [-2.5, 0], - [-2.2, 0.1], - [0, 0], - [0, 0], - [0, 0], - [1.1, -0.1], - [0.5, -0.2], - [0, 0], - [0.3, -0.5], - [0.1, -1.1], - [0, 0], - [0, 0], - [-0.8, -1.8], - [-0.6, -1], - [0, 0], - [0, 0], - [-0.6, -1], - [-0.8, -1], - [0, 0], - [-1, -1.1], - [-1, -0.4], - [0, 0], - [0, 0], - [0, 0], - [2.3, 1.3], - [1.5, 2], - [0, 0], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 51, - "ty": 3, - "parent": 44, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 44, - "ty": 3, - "ks": { "p": { "a": 0, "k": [-1, -28] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "65", - "layers": [ - { - "ind": 58, - "ty": 4, - "parent": 57, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0.1, 1.3], - [0.1, 1.3], - [0, 0], - [-1.8, 0.8], - [-1.3, 0.6], - [0, 0], - [0, 0], - [0.2, -2], - [0, -2], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-0.8, -0.8], - [-1.9, 0], - [0, 0], - [-0.5, 0.2], - [-0.6, 0.3], - [0, 0], - [0, 0], - [0, 0], - [0.9, -0.3], - [1.3, 0], - [0, 0], - [1.7, 1.7], - [0, 3.8], - [0, 0] - ], - "v": [ - [7.2, 77.2], - [7.2, 55.4], - [1.8, 55.8], - [1.8, 53.9], - [7.4, 50.3], - [7.4, 45.3], - [7.3, 41.4], - [7.1, 37.5], - [7.1, 37.5], - [12.6, 35.4], - [17.3, 33.3], - [17.3, 33.3], - [18.5, 34.2], - [17.7, 39.7], - [17.5, 45.6], - [17.5, 45.6], - [17.5, 50.5], - [27, 50], - [26.4, 55.4], - [17.5, 55.4], - [17.5, 75.4], - [18.6, 79.8], - [22.7, 80.9], - [22.7, 80.9], - [24.8, 80.6], - [26.5, 79.8], - [26.5, 79.8], - [27.2, 81.4], - [23.1, 86.4], - [21.3, 87.4], - [18, 87.9], - [18, 87.9], - [9.7, 85.4], - [7.2, 77.2], - [7.2, 77.2] - ], - "o": [ - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, -1.3], - [-0.1, -1.3], - [0, 0], - [1.8, -0.7], - [1.8, -0.8], - [0, 0], - [0, 0], - [-0.3, 1.7], - [-0.2, 2], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 2.1], - [0.8, 0.8], - [0, 0], - [0.9, 0], - [0.5, -0.2], - [0, 0], - [0, 0], - [0, 0], - [-0.3, 0.4], - [-0.9, 0.3], - [0, 0], - [-3.9, 0], - [-1.7, -1.7], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 57, - "ty": 3, - "parent": 56, - "ks": { "p": { "a": 0, "k": [168, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 60, - "ty": 4, - "parent": 59, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, -7.8], - [0, 0], - [-1.2, -0.1], - [-1.2, 0], - [0, 0], - [-1.2, 0.1], - [-1.2, 0.1], - [0, 0], - [0.1, 1.4], - [0.5, 1.1], - [0.9, 0.8], - [1.6, 0] - ], - "v": [ - [21.2, 51.6], - [21.2, 51.6], - [13.9, 63.3], - [13.9, 63.3], - [17.5, 63.4], - [21, 63.5], - [21, 63.5], - [24.5, 63.4], - [28.1, 63.3], - [28.1, 63.3], - [27.8, 59.3], - [26.9, 55.6], - [24.9, 52.7], - [21.2, 51.6] - ], - "o": [ - [0, 0], - [-4.8, 0], - [0, 0], - [1.2, 0.1], - [1.2, 0.1], - [0, 0], - [1.2, 0], - [1.2, -0.1], - [0, 0], - [0, -1.2], - [-0.1, -1.4], - [-0.5, -1.1], - [-0.9, -0.8], - [0, 0] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [-0.6, -1.7], - [-1.1, -1.3], - [-1.7, -0.7], - [-2.2, 0], - [0, 0], - [-1.7, 0.6], - [-1.3, 0.8], - [0, 0], - [0, 0], - [0, 0], - [2, -0.5], - [2.5, 0], - [0, 0], - [3.3, 3.4], - [0, 6], - [0, 0], - [-0.2, 1.6], - [-0.6, 1.5], - [-1, 1.3], - [-1.8, 1.1], - [0, 0], - [0, 0], - [-2.5, 0], - [0, 0], - [-2, -0.6], - [-1.5, -1.2], - [-0.8, -1.8], - [0, -2.5], - [0, 0], - [0, 0], - [2.4, -0.3], - [1.8, 0], - [0, 0] - ], - "v": [ - [25.5, 67.7], - [14.4, 67.7], - [15.3, 73.3], - [17.9, 77.8], - [22.1, 80.8], - [27.8, 81.9], - [27.8, 81.9], - [32.4, 81], - [36.9, 79], - [36.9, 79], - [37.9, 80.2], - [36, 84.3], - [29.3, 87.1], - [22.6, 87.9], - [22.6, 87.9], - [8.4, 82.7], - [3.5, 68.5], - [3.5, 68.5], - [3.8, 63.4], - [4.9, 58.8], - [7.3, 54.7], - [11.5, 51.2], - [11.5, 51.2], - [15.5, 48.8], - [22.3, 46.9], - [22.3, 46.9], - [28.9, 47.8], - [34, 50.6], - [37.5, 55.2], - [38.7, 61.7], - [38.7, 61.7], - [38.7, 66], - [31.8, 67.3], - [25.5, 67.7], - [25.5, 67.7] - ], - "o": [ - [0, 0], - [0, 2], - [0.6, 1.7], - [1.1, 1.3], - [1.7, 0.7], - [0, 0], - [1.4, 0], - [1.7, -0.6], - [0, 0], - [0, 0], - [0, 0], - [-2.5, 1.3], - [-2, 0.5], - [0, 0], - [-6.2, 0], - [-3.3, -3.4], - [0, 0], - [0, -1.7], - [0.2, -1.6], - [0.6, -1.5], - [1, -1.3], - [0, 0], - [0, 0], - [2.1, -1.3], - [0, 0], - [2.4, 0], - [2, 0.6], - [1.5, 1.2], - [0.8, 1.8], - [0, 0], - [0, 0], - [-2.3, 0.6], - [-2.4, 0.3], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 59, - "ty": 3, - "parent": 56, - "ks": { "p": { "a": 0, "k": [126, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 62, - "ty": 4, - "parent": 61, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, -7.8], - [0, 0], - [-1.2, -0.1], - [-1.2, 0], - [0, 0], - [-1.2, 0.1], - [-1.2, 0.1], - [0, 0], - [0.1, 1.4], - [0.5, 1.1], - [0.9, 0.8], - [1.6, 0] - ], - "v": [ - [21.2, 51.6], - [21.2, 51.6], - [13.9, 63.3], - [13.9, 63.3], - [17.5, 63.4], - [21, 63.5], - [21, 63.5], - [24.5, 63.4], - [28.1, 63.3], - [28.1, 63.3], - [27.8, 59.3], - [26.9, 55.6], - [24.9, 52.7], - [21.2, 51.6] - ], - "o": [ - [0, 0], - [-4.8, 0], - [0, 0], - [1.2, 0.1], - [1.2, 0.1], - [0, 0], - [1.2, 0], - [1.2, -0.1], - [0, 0], - [0, -1.2], - [-0.1, -1.4], - [-0.5, -1.1], - [-0.9, -0.8], - [0, 0] - ] - } - } - }, - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [-0.6, -1.7], - [-1.1, -1.3], - [-1.7, -0.7], - [-2.2, 0], - [0, 0], - [-1.7, 0.6], - [-1.3, 0.8], - [0, 0], - [0, 0], - [0, 0], - [2, -0.5], - [2.5, 0], - [0, 0], - [3.3, 3.4], - [0, 6], - [0, 0], - [-0.2, 1.6], - [-0.6, 1.5], - [-1, 1.3], - [-1.8, 1.1], - [0, 0], - [0, 0], - [-2.5, 0], - [0, 0], - [-2, -0.6], - [-1.5, -1.2], - [-0.8, -1.8], - [0, -2.5], - [0, 0], - [0, 0], - [2.4, -0.3], - [1.8, 0], - [0, 0] - ], - "v": [ - [25.5, 67.7], - [14.4, 67.7], - [15.3, 73.3], - [17.9, 77.8], - [22.1, 80.8], - [27.8, 81.9], - [27.8, 81.9], - [32.4, 81], - [36.9, 79], - [36.9, 79], - [37.9, 80.2], - [36, 84.3], - [29.3, 87.1], - [22.6, 87.9], - [22.6, 87.9], - [8.4, 82.7], - [3.5, 68.5], - [3.5, 68.5], - [3.8, 63.4], - [4.9, 58.8], - [7.3, 54.7], - [11.5, 51.2], - [11.5, 51.2], - [15.5, 48.8], - [22.3, 46.9], - [22.3, 46.9], - [28.9, 47.8], - [34, 50.6], - [37.5, 55.2], - [38.7, 61.7], - [38.7, 61.7], - [38.7, 66], - [31.8, 67.3], - [25.5, 67.7], - [25.5, 67.7] - ], - "o": [ - [0, 0], - [0, 2], - [0.6, 1.7], - [1.1, 1.3], - [1.7, 0.7], - [0, 0], - [1.4, 0], - [1.7, -0.6], - [0, 0], - [0, 0], - [0, 0], - [-2.5, 1.3], - [-2, 0.5], - [0, 0], - [-6.2, 0], - [-3.3, -3.4], - [0, 0], - [0, -1.7], - [0.2, -1.6], - [0.6, -1.5], - [1, -1.3], - [0, 0], - [0, 0], - [2.1, -1.3], - [0, 0], - [2.4, 0], - [2, 0.6], - [1.5, 1.2], - [0.8, 1.8], - [0, 0], - [0, 0], - [-2.3, 0.6], - [-2.4, 0.3], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 61, - "ty": 3, - "parent": 56, - "ks": { "p": { "a": 0, "k": [84, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 64, - "ty": 4, - "parent": 63, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [-0.1, -1.1], - [-0.3, -0.5], - [0, 0], - [-0.5, -0.2], - [-1.1, -0.1], - [0, 0], - [0, 0], - [0, 0], - [2.2, 0.1], - [2.5, 0], - [0, 0], - [2.2, -0.1], - [1.7, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.5, 0.2], - [-0.2, 0.3], - [0, 0], - [-0.1, 1.1], - [0, 1.8], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0.9, -2], - [0.7, -1.7], - [0, 0], - [0, 0], - [0.5, 1.1], - [0.6, 1.2], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-0.1, -1.1], - [-0.3, -0.5], - [0, 0], - [-0.5, -0.2], - [-1.1, -0.1], - [0, 0], - [0, 0], - [0, 0], - [1.6, 0.1], - [1.6, 0], - [0, 0], - [1.6, -0.1], - [1.6, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-0.5, 0.2], - [-0.2, 0.4], - [0, 0], - [-0.1, 1.1], - [0, 1.8], - [0, 0], - [0, 0], - [0.1, 1.1], - [0.3, 0.5], - [0, 0], - [0.5, 0.2], - [1.1, 0.1], - [0, 0], - [0, 0], - [0, 0], - [-1.7, -0.1], - [-1.7, 0], - [0, 0], - [-1.7, 0.1], - [-1.7, 0.1], - [0, 0], - [-0.5, -1.2], - [-0.6, -1.2], - [-0.6, -1.2], - [-0.4, -1], - [0, 0], - [0, 0], - [0, 0], - [-0.7, 1.7], - [-0.6, 1.5], - [0, 0], - [-1.6, -0.1], - [-1.2, 0], - [0, 0], - [-1.5, 0.1], - [-2.1, 0.1], - [0, 0], - [0, 0], - [0, 0], - [0.5, -0.2], - [0.2, -0.4], - [0, 0], - [0.1, -1.1], - [0, -1.8], - [0, 0] - ], - "v": [ - [74.2, 40.8], - [74.2, 75.1], - [74.3, 79.5], - [74.8, 81.9], - [74.8, 81.9], - [75.8, 82.7], - [78.2, 83.1], - [78.2, 83.1], - [81.3, 83.3], - [81.3, 86.7], - [75.4, 86.5], - [68.3, 86.4], - [68.3, 86.4], - [61.2, 86.5], - [55.2, 86.7], - [55.2, 86.7], - [55.2, 83.3], - [58.3, 83.1], - [60.7, 82.7], - [61.7, 81.9], - [61.7, 81.9], - [62.2, 79.5], - [62.3, 75.1], - [62.3, 75.1], - [62.3, 40.5], - [62.2, 40.5], - [45, 76.2], - [42.3, 81.8], - [40, 87.3], - [40, 87.3], - [37.1, 87.3], - [35.7, 83.8], - [34.1, 80.3], - [34.1, 80.3], - [15, 40.4], - [14.8, 40.4], - [14.8, 75.1], - [14.9, 79.5], - [15.4, 81.9], - [15.4, 81.9], - [16.4, 82.9], - [18.8, 83.3], - [18.8, 83.3], - [21.9, 83.6], - [21.9, 86.7], - [17.1, 86.5], - [12.3, 86.4], - [12.3, 86.4], - [7.5, 86.5], - [2.7, 86.7], - [2.7, 86.7], - [2.7, 83.6], - [5.8, 83.3], - [8.2, 82.9], - [9.2, 81.9], - [9.2, 81.9], - [9.7, 79.5], - [9.8, 75.1], - [9.8, 75.1], - [9.8, 40.8], - [9.7, 36.5], - [9.2, 34], - [9.2, 34], - [8.2, 33.1], - [5.8, 32.6], - [5.8, 32.6], - [2.7, 32.3], - [2.7, 29.2], - [7.7, 29.4], - [12.8, 29.5], - [12.8, 29.5], - [17.8, 29.4], - [22.8, 29.2], - [22.8, 29.2], - [24.2, 32.3], - [25.9, 35.9], - [27.6, 39.5], - [29.1, 42.8], - [29.1, 42.8], - [42.4, 70.5], - [57.8, 38.7], - [60, 33.9], - [61.9, 29.2], - [61.9, 29.2], - [67.4, 29.4], - [71.6, 29.5], - [71.6, 29.5], - [75.8, 29.4], - [81.3, 29.2], - [81.3, 29.2], - [81.3, 32.3], - [78.2, 32.6], - [75.8, 33.1], - [74.8, 34], - [74.8, 34], - [74.3, 36.5], - [74.2, 40.8], - [74.2, 40.8] - ], - "o": [ - [0, 0], - [0, 1.8], - [0.1, 1.1], - [0, 0], - [0.2, 0.3], - [0.5, 0.2], - [0, 0], - [0, 0], - [0, 0], - [-1.7, -0.1], - [-2.2, -0.1], - [0, 0], - [-2.5, 0], - [-2.2, 0.1], - [0, 0], - [0, 0], - [0, 0], - [1.1, -0.1], - [0.5, -0.2], - [0, 0], - [0.3, -0.5], - [0.1, -1.1], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-0.9, 1.8], - [-0.9, 2], - [0, 0], - [0, 0], - [-0.4, -1.2], - [-0.5, -1.1], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 1.8], - [0.1, 1.1], - [0, 0], - [0.2, 0.4], - [0.5, 0.2], - [0, 0], - [0, 0], - [0, 0], - [-1.6, -0.1], - [-1.6, -0.1], - [0, 0], - [-1.6, 0], - [-1.6, 0.1], - [0, 0], - [0, 0], - [0, 0], - [1.1, -0.1], - [0.5, -0.2], - [0, 0], - [0.3, -0.5], - [0.1, -1.1], - [0, 0], - [0, 0], - [0, -1.8], - [-0.1, -1.1], - [0, 0], - [-0.2, -0.4], - [-0.5, -0.2], - [0, 0], - [0, 0], - [0, 0], - [1.7, 0.1], - [1.7, 0.1], - [0, 0], - [1.7, 0], - [1.7, -0.1], - [0, 0], - [0.4, 0.8], - [0.5, 1.2], - [0.6, 1.2], - [0.6, 1.2], - [0, 0], - [0, 0], - [0, 0], - [0.8, -1.6], - [0.7, -1.7], - [0, 0], - [2.1, 0.1], - [1.6, 0.1], - [0, 0], - [1.3, 0], - [1.5, -0.1], - [0, 0], - [0, 0], - [0, 0], - [-1.1, 0.1], - [-0.5, 0.2], - [0, 0], - [-0.3, 0.5], - [-0.1, 1.1], - [0, 0], - [0, 0] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 63, - "ty": 3, - "parent": 56, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 56, - "ty": 3, - "ks": { "p": { "a": 0, "k": [-1, -29] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "68", - "layers": [ - { - "ind": 24, - "ty": 0, - "parent": 2, - "ks": { - "a": { "a": 0, "k": [-1, -25] }, - "o": { - "a": 1, - "k": [ - { "t": 0, "s": [0], "h": 1 }, - { - "t": 46.998265, - "s": [0], - "i": { "x": 1, "y": 1 }, - "o": { "x": 0, "y": 0 } - }, - { "t": 120, "s": [100], "h": 1 }, - { "t": 240, "s": [100], "h": 1 } - ] - }, - "p": { - "a": 1, - "k": [ - { - "t": 0, - "s": [119.028, 58.8], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { - "t": 46.998, - "s": [119.028, 58.8], - "i": { "x": [1, 0], "y": [1, 1] }, - "o": { "x": [0, 0.5], "y": [0, 0] } - }, - { - "t": 120, - "s": [119.028, 0], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { "t": 240, "s": [119.028, 0], "h": 1 } - ] - } - }, - "w": 396, - "h": 63, - "ip": 0, - "op": 241, - "st": 0, - "refId": "22" - }, - { - "ind": 32, - "ty": 0, - "parent": 2, - "ks": { - "a": { "a": 0, "k": [-2, -28] }, - "o": { - "a": 1, - "k": [ - { "t": 0, "s": [0], "h": 1 }, - { - "t": 41.463666, - "s": [0], - "i": { "x": 1, "y": 1 }, - "o": { "x": 0, "y": 0 } - }, - { "t": 73.085139, "s": [100], "h": 1 }, - { "t": 240, "s": [100], "h": 1 } - ] - }, - "p": { - "a": 1, - "k": [ - { - "t": 0, - "s": [0, 58.8], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { - "t": 41.466, - "s": [0, 58.8], - "i": { "x": [1, 0], "y": [1, 1] }, - "o": { "x": [0, 0.5], "y": [0, 0] } - }, - { - "t": 73.086, - "s": [0, 0], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { "t": 240, "s": [0, 0], "h": 1 } - ] - } - }, - "w": 93, - "h": 59, - "ip": 0, - "op": 241, - "st": 0, - "refId": "30" - }, - { - "ind": 2, - "ty": 3, - "ks": { "p": { "a": 0, "k": [70.066, 117.6] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 43, - "ty": 0, - "parent": 33, - "ks": { - "a": { "a": 0, "k": [-1, -28] }, - "o": { - "a": 1, - "k": [ - { "t": 0, "s": [0], "h": 1 }, - { - "t": 37.673266, - "s": [0], - "i": { "x": 1, "y": 1 }, - "o": { "x": 0, "y": 0 } - }, - { "t": 58.492287, "s": [100], "h": 1 }, - { "t": 240, "s": [100], "h": 1 } - ] - }, - "p": { - "a": 1, - "k": [ - { - "t": 0, - "s": [418.488, 58.8], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { - "t": 37.674, - "s": [418.488, 58.8], - "i": { "x": [1, 0], "y": [1, 1] }, - "o": { "x": [0, 0.5], "y": [0, 0] } - }, - { - "t": 58.494, - "s": [418.488, 0], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { "t": 240, "s": [418.488, 0], "h": 1 } - ] - } - }, - "w": 189, - "h": 60, - "ip": 0, - "op": 241, - "st": 0, - "refId": "41" - }, - { - "ind": 55, - "ty": 0, - "parent": 33, - "ks": { - "a": { "a": 0, "k": [-1, -28] }, - "o": { - "a": 1, - "k": [ - { "t": 0, "s": [0], "h": 1 }, - { - "t": 35.269663, - "s": [0], - "i": { "x": 1, "y": 1 }, - "o": { "x": 0, "y": 0 } - }, - { "t": 49.341211, "s": [100], "h": 1 }, - { "t": 240, "s": [100], "h": 1 } - ] - }, - "p": { - "a": 1, - "k": [ - { - "t": 0, - "s": [216.972, 58.8], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { - "t": 35.268, - "s": [216.972, 58.8], - "i": { "x": [1, 0], "y": [1, 1] }, - "o": { "x": [0, 0.5], "y": [0, 0] } - }, - { - "t": 49.344, - "s": [216.972, 0], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { "t": 240, "s": [216.972, 0], "h": 1 } - ] - } - }, - "w": 180, - "h": 60, - "ip": 0, - "op": 241, - "st": 0, - "refId": "53" - }, - { - "ind": 67, - "ty": 0, - "parent": 33, - "ks": { - "a": { "a": 0, "k": [-1, -29] }, - "o": { - "a": 1, - "k": [ - { "t": 0, "s": [0], "h": 1 }, - { - "t": 34.2, - "s": [0], - "i": { "x": 1, "y": 1 }, - "o": { "x": 0, "y": 0 } - }, - { "t": 43.08868, "s": [100], "h": 1 }, - { "t": 240, "s": [100], "h": 1 } - ] - }, - "p": { - "a": 1, - "k": [ - { - "t": 0, - "s": [0, 58.8], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { - "t": 34.2, - "s": [0, 58.8], - "i": { "x": [1, 0], "y": [1, 1] }, - "o": { "x": [0, 0.5], "y": [0, 0] } - }, - { - "t": 43.086, - "s": [0, 0], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { "t": 240, "s": [0, 0], "h": 1 } - ] - } - }, - "w": 195, - "h": 59, - "ip": 0, - "op": 241, - "st": 0, - "refId": "65" - }, - { - "ind": 33, - "ty": 3, - "ks": { "p": { "a": 0, "k": [24.622, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "99", - "layers": [ - { - "ind": 98, - "ty": 4, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "rc", - "p": { "a": 0, "k": [52, 52] }, - "r": { "a": 0, "k": 0 }, - "s": { "a": 0, "k": [104, 104] } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0, 0] }, - "o": { "a": 0, "k": 0 } - } - ] - }, - { - "ind": 0, - "ty": 4, - "ks": { "s": { "a": 0, "k": [133.33, 133.33] } }, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "gr", - "it": [ - { - "ty": "gr", - "it": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [0, 0], - [0, 0.93], - [0.02, 0.78], - [0.3, 1.68], - [0.79, 1.55], - [1.21, 1.21], - [1.52, 0.78], - [1.71, 0.3], - [1.7, 0.05], - [0.78, 0], - [0.93, 0], - [0, 0], - [0, 0], - [0, 0], - [0.93, 0], - [0.78, -0.02], - [1.68, -0.3], - [1.55, -0.79], - [1.21, -1.21], - [0.78, -1.52], - [0.31, -1.71], - [0.04, -1.7], - [0, -0.78], - [0, -0.93], - [0, 0], - [0, 0], - [-0.01, -0.93], - [-0.02, -0.78], - [-0.3, -1.68], - [-0.79, -1.55], - [-1.21, -1.21], - [-1.53, -0.78], - [-1.71, -0.31], - [-1.7, -0.05], - [-0.79, -0.01], - [-0.93, 0], - [0, 0], - [-0.93, 0], - [-0.78, 0.02], - [-1.68, 0.3], - [-1.55, 0.79], - [-1.21, 1.21], - [-0.77, 1.52], - [-0.31, 1.71], - [-0.05, 1.7], - [0, 0.78], - [0, 0.93], - [0, 0.19], - [0, 0], - [0, 0] - ], - "o": [ - [0, -0.93], - [0, -0.78], - [-0.05, -1.7], - [-0.31, -1.71], - [-0.77, -1.52], - [-1.21, -1.21], - [-1.55, -0.79], - [-1.68, -0.3], - [-0.78, -0.02], - [-0.93, 0], - [0, 0], - [0, 0], - [0, 0], - [-0.93, 0], - [-0.79, 0], - [-1.7, 0.05], - [-1.71, 0.3], - [-1.53, 0.78], - [-1.21, 1.21], - [-0.79, 1.55], - [-0.3, 1.68], - [-0.02, 0.78], - [-0.01, 0.93], - [0, 0], - [0, 0], - [0, 0.93], - [0, 0.78], - [0.04, 1.7], - [0.3, 1.71], - [0.78, 1.52], - [1.21, 1.21], - [1.55, 0.79], - [1.69, 0.3], - [0.78, 0.02], - [0.93, 0], - [0, 0], - [0.93, 0], - [0.78, -0.01], - [1.7, -0.05], - [1.71, -0.31], - [1.52, -0.78], - [1.21, -1.21], - [0.79, -1.55], - [0.3, -1.68], - [0.02, -0.78], - [0, -0.93], - [0, 0], - [0, 0], - [0, -0.14], - [0, 0] - ], - "v": [ - [78, 24.29], - [78, 21.52], - [77.96, 19.18], - [77.51, 14.08], - [75.91, 9.24], - [72.92, 5.12], - [68.79, 2.12], - [63.95, 0.53], - [58.86, 0.08], - [56.51, 0.04], - [53.73, 0.04], - [42.98, 0], - [34.93, 0], - [24.36, 0.04], - [21.58, 0.04], - [19.23, 0.08], - [14.13, 0.53], - [9.27, 2.12], - [5.14, 5.12], - [2.14, 9.24], - [0.54, 14.08], - [0.09, 19.18], - [0.05, 21.52], - [0, 24.52], - [0, 43.07], - [0.05, 53.71], - [0.05, 56.5], - [0.09, 58.84], - [0.54, 63.94], - [2.14, 68.79], - [5.14, 72.91], - [9.27, 75.91], - [14.13, 77.51], - [19.23, 77.96], - [21.58, 78], - [24.36, 78], - [53.73, 78], - [56.51, 78], - [58.86, 77.96], - [63.95, 77.51], - [68.79, 75.91], - [72.92, 72.91], - [75.91, 68.79], - [77.51, 63.94], - [77.96, 58.84], - [78, 56.5], - [78, 53.71], - [78, 43.07], - [78, 34.93], - [78, 24.29] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [1, 1, 1, 1] }, - "r": 2, - "o": { "a": 0, "k": 100 } - }, - { "ty": "tr", "o": { "a": 0, "k": 100 } } - ] - }, - { "ty": "tr", "o": { "a": 0, "k": 100 } } - ] - } - ] - } - ] - }, - { - "id": "102", - "layers": [ - { - "ind": 101, - "ty": 0, - "parent": 97, - "ks": {}, - "w": 104, - "h": 104, - "ip": 0, - "op": 241, - "st": 0, - "refId": "99" - }, - { "ind": 97, "ty": 3, "ks": {}, "ip": 0, "op": 241, "st": 0 } - ] - }, - { - "id": "78", - "layers": [ - { - "ind": 77, - "ty": 4, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "rc", - "p": { "a": 0, "k": [13, 21.5] }, - "r": { "a": 0, "k": 0 }, - "s": { "a": 0, "k": [26, 43] } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0, 0] }, - "o": { "a": 0, "k": 0 } - } - ] - }, - { - "ind": 0, - "ty": 4, - "ks": { "s": { "a": 0, "k": [133.33, 133.33] } }, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "gr", - "it": [ - { - "ty": "gr", - "it": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [2.47, -5.53], - [0.87, -1.78], - [0.82, -1.05], - [0.29, -0.3], - [0.52, -0.33], - [0.48, -0.24], - [0.61, -0.12], - [0.44, 0.02], - [0, 0], - [0, 0], - [0, 0], - [-1.01, 2.58], - [-3.01, 9.48], - [0, 0], - [0, 0], - [0, 0], - [0, 0] - ], - "o": [ - [-3.05, 7.08], - [-1.66, 3.7], - [-0.31, 0.63], - [-0.81, 1.05], - [-0.17, 0.18], - [-0.5, 0.32], - [-0.27, 0.13], - [-0.6, 0.13], - [0, 0], - [0, 0], - [0, 0], - [2.22, 0], - [0.91, -2.33], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0] - ], - "v": [ - [23.42, 4.53], - [13.29, 27.33], - [11.22, 31.74], - [9.3, 34.52], - [7.41, 36.81], - [6.32, 37.62], - [4.78, 38.52], - [3.39, 38.94], - [1.72, 39.13], - [1.14, 39.11], - [1.14, 42.31], - [9.42, 42.31], - [15.23, 38.46], - [25.38, 9.95], - [25.42, 9.82], - [25.39, 9.68], - [24.52, 4.66], - [24.19, 2.74] - ] - } - } - }, - { - "ty": "st", - "c": { "a": 0, "k": [1, 1, 1, 1] }, - "lc": 1, - "lj": 1, - "ml": 4, - "o": { "a": 0, "k": 100 }, - "w": { "a": 0, "k": 1.16 } - }, - { - "ty": "tr", - "o": { "a": 0, "k": 100 }, - "s": { "a": 0, "k": [75, 75] } - } - ] - }, - { - "ty": "gr", - "it": [ - { - "ty": "gr", - "it": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [1.86, -4.14], - [0.65, -1.33], - [0.62, -0.79], - [0.21, -0.23], - [0.39, -0.25], - [0.36, -0.18], - [0.46, -0.09], - [0.33, 0.01], - [0, 0], - [0, 0], - [0, 0], - [-0.76, 1.94], - [-2.25, 7.11], - [0, 0], - [0, 0], - [0, 0], - [0, 0] - ], - "o": [ - [-2.29, 5.31], - [-1.24, 2.78], - [-0.23, 0.47], - [-0.61, 0.79], - [-0.13, 0.14], - [-0.37, 0.24], - [-0.2, 0.1], - [-0.45, 0.09], - [0, 0], - [0, 0], - [0, 0], - [1.66, 0], - [0.68, -1.75], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0] - ], - "v": [ - [17.57, 3.39], - [9.97, 20.5], - [8.42, 23.8], - [6.98, 25.89], - [5.55, 27.61], - [4.74, 28.21], - [3.59, 28.89], - [2.54, 29.2], - [1.29, 29.34], - [0.85, 29.33], - [0.85, 31.73], - [7.07, 31.73], - [11.43, 28.85], - [19.03, 7.46], - [19.06, 7.36], - [19.04, 7.26], - [18.39, 3.49], - [18.14, 2.06] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [1, 1, 1, 1] }, - "o": { "a": 0, "k": 100 } - }, - { "ty": "tr", "o": { "a": 0, "k": 100 } } - ] - }, - { "ty": "tr", "o": { "a": 0, "k": 100 } } - ] - }, - { "ty": "tr", "o": { "a": 0, "k": 100 } } - ] - } - ] - } - ] - }, - { - "id": "83", - "layers": [ - { - "ind": 82, - "ty": 4, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "rc", - "p": { "a": 0, "k": [19, 29] }, - "r": { "a": 0, "k": 0 }, - "s": { "a": 0, "k": [38, 58] } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0, 0] }, - "o": { "a": 0, "k": 0 } - } - ] - }, - { - "ind": 0, - "ty": 4, - "ks": { "s": { "a": 0, "k": [133.33, 133.33] } }, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "gr", - "it": [ - { - "ty": "gr", - "it": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [-0.61, -0.79], - [-0.28, -1.39], - [0, 0], - [-1.42, -6.39], - [0, 0], - [-1.08, -3.78], - [-0.69, -1.55], - [-0.68, -0.72], - [-0.57, -0.37], - [-0.54, -0.28], - [-0.68, -0.16], - [-0.48, 0.02], - [0, 0], - [0, 0], - [0, 0], - [1.28, 0.01], - [0.74, 0], - [0, 0], - [0.93, 0.75], - [0.53, 1.39], - [2.56, 11.55], - [0, 0], - [0, 0], - [0.65, 0.7], - [1.7, 0], - [0.55, -0.01], - [0.74, 0], - [0, 0], - [0, 0], - [0, 0], - [-1.45, 0] - ], - "o": [ - [1.12, 0], - [0.59, 0.7], - [0, 0], - [0.53, 2.46], - [0, 0], - [1.58, 6.97], - [1.23, 4.33], - [0.67, 1.5], - [0.2, 0.21], - [0.56, 0.37], - [0.31, 0.16], - [0.68, 0.17], - [0, 0], - [0, 0], - [0, 0], - [-1.31, 0], - [-1.28, -0.01], - [0, 0], - [-1.26, 0], - [-0.92, -0.74], - [-1.03, -2.69], - [0, 0], - [0, 0], - [-0.52, -2.19], - [-0.6, -0.65], - [-0.29, 0], - [-0.54, 0.01], - [0, 0], - [0, 0], - [0, 0], - [5.23, -1.17], - [0, 0] - ], - "v": [ - [13.19, 0.69], - [15.84, 1.82], - [17.11, 4.99], - [17.5, 6.8], - [20.43, 20.07], - [21.13, 23.16], - [25.12, 39.28], - [28.01, 48.08], - [30.15, 51.16], - [31.38, 52.09], - [33.1, 53.11], - [34.69, 53.65], - [36.54, 53.9], - [37.13, 53.89], - [37.13, 57.31], - [36.56, 57.31], - [32.56, 57.3], - [29.4, 57.29], - [26.01, 57.29], - [22.71, 56.17], - [20.55, 52.95], - [15.17, 31.56], - [15.17, 31.55], - [10.26, 10.24], - [8.48, 5.97], - [5.1, 4.92], - [3.83, 4.93], - [1.89, 4.94], - [1.31, 4.94], - [1.31, 2.34], - [1.77, 2.24], - [13.19, 0.69] - ] - } - } - }, - { - "ty": "st", - "c": { "a": 0, "k": [1, 1, 1, 1] }, - "lc": 1, - "lj": 1, - "ml": 4, - "o": { "a": 0, "k": 100 }, - "w": { "a": 0, "k": 1.16 } - }, - { - "ty": "tr", - "o": { "a": 0, "k": 100 }, - "s": { "a": 0, "k": [75, 75] } - } - ] - }, - { - "ty": "gr", - "it": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [-0.46, -0.59], - [-0.21, -1.04], - [0, 0], - [-1.07, -4.79], - [0, 0], - [-0.81, -2.84], - [-0.52, -1.16], - [-0.51, -0.54], - [-0.43, -0.28], - [-0.41, -0.21], - [-0.51, -0.12], - [-0.36, 0.01], - [0, 0], - [0, 0], - [0, 0], - [0.96, 0], - [0.56, 0], - [0, 0], - [0.7, 0.56], - [0.39, 1.04], - [1.92, 8.66], - [0, 0], - [0, 0], - [0.49, 0.53], - [1.28, 0], - [0.41, 0], - [0.56, 0], - [0, 0], - [0, 0], - [0, 0], - [-1.09, 0] - ], - "o": [ - [0.84, 0], - [0.44, 0.53], - [0, 0], - [0.4, 1.84], - [0, 0], - [1.19, 5.23], - [0.92, 3.25], - [0.5, 1.13], - [0.15, 0.16], - [0.42, 0.28], - [0.23, 0.12], - [0.51, 0.13], - [0, 0], - [0, 0], - [0, 0], - [-0.98, 0], - [-0.96, 0], - [0, 0], - [-0.94, 0], - [-0.69, -0.56], - [-0.77, -2.02], - [0, 0], - [0, 0], - [-0.39, -1.64], - [-0.45, -0.49], - [-0.22, 0], - [-0.41, 0], - [0, 0], - [0, 0], - [0, 0], - [3.93, -0.87], - [0, 0] - ], - "v": [ - [9.89, 0.52], - [11.88, 1.36], - [12.84, 3.75], - [13.13, 5.1], - [15.32, 15.05], - [15.85, 17.37], - [18.84, 29.46], - [21, 36.06], - [22.61, 38.37], - [23.53, 39.07], - [24.83, 39.84], - [26.02, 40.23], - [27.41, 40.43], - [27.85, 40.41], - [27.85, 42.98], - [27.42, 42.98], - [24.42, 42.97], - [22.05, 42.96], - [19.5, 42.96], - [17.04, 42.13], - [15.41, 39.71], - [11.38, 23.67], - [11.38, 23.66], - [7.7, 7.68], - [6.36, 4.48], - [3.82, 3.69], - [2.88, 3.7], - [1.42, 3.71], - [0.98, 3.71], - [0.98, 1.76], - [1.32, 1.68], - [9.89, 0.52] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [1, 1, 1, 1] }, - "o": { "a": 0, "k": 100 } - }, - { "ty": "tr", "o": { "a": 0, "k": 100 } } - ] - }, - { "ty": "tr", "o": { "a": 0, "k": 100 } } - ] - } - ] - } - ] - }, - { - "id": "88", - "layers": [ - { - "ind": 87, - "ty": 4, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "rc", - "p": { "a": 0, "k": [13.5, 29] }, - "r": { "a": 0, "k": 0 }, - "s": { "a": 0, "k": [27, 58] } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0, 0, 0, 0] }, - "o": { "a": 0, "k": 0 } - } - ] - }, - { - "ind": 0, - "ty": 4, - "ks": { "s": { "a": 0, "k": [133.33, 133.33] } }, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "gr", - "it": [ - { - "ty": "gr", - "it": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-0.61, 0.19], - [-0.17, 0.24], - [-0.09, 0.6], - [-0.04, 0.99], - [0, 0], - [-0.08, 6.85], - [0, 0], - [0.08, 3.38], - [0, 0], - [0.07, 0.52], - [0.05, 0.2], - [0.02, 0.03], - [0, 0], - [0.58, 0.18], - [0, 0], - [0, 0], - [0.95, 0.07], - [1.55, 0.03], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0.93, -0.07], - [0.26, -0.09], - [0, 0], - [0.16, -0.24], - [0.09, -0.61], - [0.04, -0.99], - [0, 0], - [0.08, -6.88], - [0, 0], - [-0.08, -3.35], - [0, 0], - [-0.07, -0.53], - [-0.05, -0.21], - [-0.02, -0.04], - [-0.55, -0.2], - [-0.95, -0.06], - [-1.55, -0.03], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0], - [0, 0], - [3.06, -0.05], - [0.6, -0.21], - [0.09, -0.16], - [0.09, -0.6], - [0, 0], - [0.02, -0.54], - [0, 0], - [0, -3.53], - [0, 0], - [-0.04, -0.82], - [-0.05, -0.35], - [-0.06, -0.21], - [0, 0], - [-0.19, -0.32], - [0, 0], - [0, 0], - [-0.24, -0.09], - [-0.92, -0.07], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-1.55, 0.03], - [-0.96, 0.07], - [0, 0], - [-0.57, 0.18], - [-0.09, 0.16], - [-0.09, 0.6], - [0, 0], - [-0.02, 0.51], - [0, 0], - [0, 3.53], - [0, 0], - [0.04, 0.82], - [0.04, 0.35], - [0.05, 0.21], - [0.23, 0.34], - [0.27, 0.08], - [0.93, 0.07], - [0, 0], - [0, 0] - ], - "v": [ - [25.56, 57.31], - [1.29, 57.31], - [1.29, 53.86], - [1.85, 53.85], - [7.34, 53.29], - [8.44, 52.59], - [8.74, 51.48], - [8.94, 49.1], - [8.94, 49.09], - [9.1, 38.04], - [9.1, 19.96], - [8.98, 9.6], - [8.94, 8.73], - [8.78, 6.71], - [8.63, 5.88], - [8.49, 5.54], - [8.47, 5.51], - [7.35, 4.74], - [7.33, 4.74], - [7.32, 4.74], - [5.57, 4.49], - [1.85, 4.34], - [1.29, 4.33], - [1.29, 0.69], - [25.56, 0.69], - [25.56, 4.33], - [24.99, 4.34], - [21.27, 4.49], - [19.47, 4.74], - [19.46, 4.74], - [18.41, 5.4], - [18.1, 6.52], - [17.9, 8.9], - [17.9, 8.91], - [17.74, 19.97], - [17.74, 38.04], - [17.86, 48.36], - [17.9, 49.23], - [18.06, 51.27], - [18.2, 52.11], - [18.32, 52.48], - [19.46, 53.29], - [21.26, 53.52], - [24.99, 53.66], - [25.56, 53.67] - ] - } - } - }, - { - "ty": "st", - "c": { "a": 0, "k": [1, 1, 1, 1] }, - "lc": 1, - "lj": 1, - "ml": 4, - "o": { "a": 0, "k": 100 }, - "w": { "a": 0, "k": 1.16 } - }, - { - "ty": "tr", - "o": { "a": 0, "k": 100 }, - "s": { "a": 0, "k": [75, 75] } - } - ] - }, - { - "ty": "gr", - "it": [ - { - "ty": "sh", - "ks": { - "a": 0, - "k": { - "c": true, - "i": [ - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-0.46, 0.14], - [-0.12, 0.18], - [-0.07, 0.45], - [-0.03, 0.74], - [0, 0], - [-0.06, 5.14], - [0, 0], - [0.06, 2.53], - [0, 0], - [0.05, 0.39], - [0.04, 0.15], - [0.02, 0.02], - [0, 0], - [0.44, 0.14], - [0, 0], - [0, 0], - [0.71, 0.05], - [1.16, 0.02], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0.7, -0.05], - [0.2, -0.07], - [0, 0], - [0.12, -0.18], - [0.07, -0.46], - [0.03, -0.74], - [0, 0], - [0.06, -5.16], - [0, 0], - [-0.06, -2.51], - [0, 0], - [-0.05, -0.4], - [-0.04, -0.16], - [-0.02, -0.03], - [-0.41, -0.15], - [-0.71, -0.05], - [-1.16, -0.02], - [0, 0] - ], - "o": [ - [0, 0], - [0, 0], - [0, 0], - [2.3, -0.04], - [0.45, -0.16], - [0.07, -0.12], - [0.07, -0.45], - [0, 0], - [0.02, -0.4], - [0, 0], - [0, -2.65], - [0, 0], - [-0.03, -0.62], - [-0.04, -0.27], - [-0.04, -0.16], - [0, 0], - [-0.14, -0.24], - [0, 0], - [0, 0], - [-0.18, -0.07], - [-0.69, -0.05], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [0, 0], - [-1.16, 0.02], - [-0.72, 0.05], - [0, 0], - [-0.43, 0.14], - [-0.07, 0.12], - [-0.07, 0.45], - [0, 0], - [-0.02, 0.38], - [0, 0], - [0, 2.65], - [0, 0], - [0.03, 0.62], - [0.03, 0.27], - [0.04, 0.16], - [0.17, 0.25], - [0.2, 0.06], - [0.7, 0.05], - [0, 0], - [0, 0] - ], - "v": [ - [19.17, 42.98], - [0.96, 42.98], - [0.96, 40.39], - [1.39, 40.39], - [5.51, 39.97], - [6.33, 39.45], - [6.55, 38.61], - [6.71, 36.82], - [6.71, 36.82], - [6.82, 28.53], - [6.82, 14.97], - [6.73, 7.2], - [6.71, 6.55], - [6.58, 5.04], - [6.47, 4.41], - [6.37, 4.16], - [6.36, 4.13], - [5.51, 3.56], - [5.5, 3.56], - [5.49, 3.55], - [4.18, 3.37], - [1.39, 3.26], - [0.96, 3.25], - [0.96, 0.52], - [19.17, 0.52], - [19.17, 3.25], - [18.74, 3.26], - [15.95, 3.37], - [14.61, 3.55], - [14.6, 3.56], - [13.8, 4.05], - [13.58, 4.89], - [13.43, 6.68], - [13.43, 6.68], - [13.31, 14.98], - [13.31, 28.53], - [13.4, 36.27], - [13.42, 36.93], - [13.54, 38.45], - [13.65, 39.09], - [13.74, 39.36], - [14.6, 39.97], - [15.95, 40.14], - [18.74, 40.24], - [19.17, 40.25] - ] - } - } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [1, 1, 1, 1] }, - "o": { "a": 0, "k": 100 } - }, - { "ty": "tr", "o": { "a": 0, "k": 100 } } - ] - }, - { "ty": "tr", "o": { "a": 0, "k": 100 } } - ] - } - ] - } - ] - }, - { - "id": "94", - "layers": [ - { - "ind": 80, - "ty": 0, - "parent": 76, - "ks": {}, - "w": 26, - "h": 43, - "ip": 0, - "op": 241, - "st": 0, - "refId": "78" - }, - { - "ind": 76, - "ty": 3, - "parent": 75, - "ks": { "s": { "a": 0, "k": [103.85, 100] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 75, - "ty": 3, - "parent": 74, - "ks": { "p": { "a": 0, "k": [11, 38] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 85, - "ty": 0, - "parent": 81, - "ks": {}, - "w": 38, - "h": 58, - "ip": 0, - "op": 241, - "st": 0, - "refId": "83" - }, - { - "ind": 81, - "ty": 3, - "parent": 74, - "ks": { "p": { "a": 0, "k": [27, 23] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 90, - "ty": 0, - "parent": 86, - "ks": {}, - "w": 27, - "h": 58, - "ip": 0, - "op": 241, - "st": 0, - "refId": "88" - }, - { - "ind": 86, - "ty": 3, - "parent": 74, - "ks": { "p": { "a": 0, "k": [64, 23] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 92, - "ty": 4, - "parent": 91, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "rc", - "p": { "a": 0, "k": [52, 52] }, - "r": { "a": 0, "k": 0 }, - "s": { "a": 0, "k": [104, 104] } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [0.114, 0.114, 0.114] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 91, - "ty": 3, - "parent": 74, - "ks": { "p": { "a": 0, "k": [0, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 74, - "ty": 3, - "parent": 73, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 93, - "ty": 4, - "parent": 73, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0, - "shapes": [ - { - "ty": "rc", - "p": { "a": 0, "k": [52, 52] }, - "r": { "a": 0, "k": 0 }, - "s": { "a": 0, "k": [104, 104] } - }, - { - "ty": "fl", - "c": { "a": 0, "k": [1, 1, 1] }, - "o": { "a": 0, "k": 100 } - } - ] - }, - { - "ind": 73, - "ty": 3, - "parent": 72, - "ks": {}, - "ip": 0, - "op": 241, - "st": 0 - }, - { - "ind": 72, - "ty": 3, - "ks": { "p": { "a": 0, "k": [1, 0] } }, - "ip": 0, - "op": 241, - "st": 0 - } - ] - }, - { - "id": "105", - "layers": [ - { - "ind": 104, - "ty": 0, - "td": 1, - "ks": {}, - "w": 104, - "h": 104, - "ip": 0, - "op": 241, - "st": 0, - "refId": "102" - }, - { - "ind": 96, - "ty": 0, - "tt": 1, - "ks": { "a": { "a": 0, "k": [1, 0] } }, - "w": 105, - "h": 104, - "ip": 0, - "op": 241, - "st": 0, - "refId": "94" - } - ] - } - ], - "fr": 60, - "h": 900, - "ip": 0, - "layers": [ - { - "ind": 70, - "ty": 0, - "parent": 1, - "ks": { - "o": { - "a": 1, - "k": [ - { "t": 0, "s": [100], "h": 1 }, - { - "t": 178.2, - "s": [100], - "i": { "x": 1, "y": 1 }, - "o": { "x": 0, "y": 0 } - }, - { "t": 226.2, "s": [0], "h": 1 }, - { "t": 240, "s": [0], "h": 1 } - ] - }, - "p": { "a": 0, "k": [391, 194] } - }, - "w": 634, - "h": 265, - "ip": 0, - "op": 241, - "st": 0, - "refId": "68" - }, - { - "ind": 107, - "ty": 0, - "parent": 71, - "ks": { - "a": { "a": 0, "k": [52, 52] }, - "o": { - "a": 1, - "k": [ - { - "t": 0, - "s": [0], - "i": { "x": 1, "y": 1 }, - "o": { "x": 0, "y": 0 } - }, - { "t": 60, "s": [100], "h": 1 }, - { - "t": 178.2, - "s": [100], - "i": { "x": 1, "y": 1 }, - "o": { "x": 0, "y": 0 } - }, - { "t": 226.2, "s": [0], "h": 1 }, - { "t": 240, "s": [0], "h": 1 } - ] - }, - "p": { - "a": 1, - "k": [ - { - "t": 0, - "s": [52, 452], - "i": { "x": [1, 0], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { - "t": 60, - "s": [52, 52], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { "t": 240, "s": [52, 52], "h": 1 } - ] - } - }, - "w": 104, - "h": 104, - "ip": 0, - "op": 241, - "st": 0, - "refId": "105" - }, - { - "ind": 71, - "ty": 3, - "parent": 1, - "ks": { - "a": { "a": 0, "k": [52, 52] }, - "p": { "a": 0, "k": [720, 611] }, - "s": { - "a": 1, - "k": [ - { - "t": 0, - "s": [100, 100], - "i": { "x": [1, 1], "y": [1, 1] }, - "o": { "x": [0, 0], "y": [0, 0] } - }, - { - "t": 150, - "s": [100, 100], - "i": { "x": [0.75, 0.75], "y": [1, 1] }, - "o": { "x": [0.35, 0.35], "y": [0, 0] } - }, - { - "t": 177, - "s": [80, 80], - "i": { "x": [0.2, 0.2], "y": [1, 1] }, - "o": { "x": [0.25, 0.25], "y": [0, 0] } - }, - { "t": 240, "s": [200, 200], "h": 1 } - ] - } - }, - "ip": 0, - "op": 241, - "st": 0 - }, - { "ind": 1, "ty": 3, "parent": 0, "ks": {}, "ip": 0, "op": 241, "st": 0 }, - { "ind": 0, "ty": 3, "ks": {}, "ip": 0, "op": 241, "st": 0 } - ], - "meta": { "g": "https://jitter.video" }, - "op": 240, - "v": "5.7.4", - "w": 1440 -} diff --git a/src/assets/animation/vite.config.animation.ts b/src/assets/animation/vite.config.animation.ts new file mode 100644 index 000000000..0746b6f9b --- /dev/null +++ b/src/assets/animation/vite.config.animation.ts @@ -0,0 +1,31 @@ +// ========= 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 react from '@vitejs/plugin-react'; +import path from 'node:path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: path.join(__dirname, 'preview'), + plugins: [react()], + resolve: { + alias: { + '@': path.join(__dirname, '../..'), + }, + }, + server: { + port: 5199, + open: false, + }, +}); diff --git a/src/assets/background.png b/src/assets/custom/background.png similarity index 100% rename from src/assets/background.png rename to src/assets/custom/background.png diff --git a/src/assets/gift-white.svg b/src/assets/custom/gift-white.svg similarity index 100% rename from src/assets/gift-white.svg rename to src/assets/custom/gift-white.svg diff --git a/src/assets/gift.svg b/src/assets/custom/gift.svg similarity index 100% rename from src/assets/gift.svg rename to src/assets/custom/gift.svg diff --git a/src/assets/token-dark.svg b/src/assets/custom/token-dark.svg similarity index 100% rename from src/assets/token-dark.svg rename to src/assets/custom/token-dark.svg diff --git a/src/assets/token-light.svg b/src/assets/custom/token-light.svg similarity index 100% rename from src/assets/token-light.svg rename to src/assets/custom/token-light.svg diff --git a/src/assets/dark.png b/src/assets/dark.png deleted file mode 100644 index d46a6a756..000000000 Binary files a/src/assets/dark.png and /dev/null differ diff --git a/src/assets/dynamic_workforce.mp4 b/src/assets/dynamic_workforce.mp4 deleted file mode 100644 index 329dd398a..000000000 Binary files a/src/assets/dynamic_workforce.mp4 and /dev/null differ diff --git a/src/assets/eye-off.svg b/src/assets/eye-off.svg deleted file mode 100644 index e17a35345..000000000 --- a/src/assets/eye-off.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/eye.svg b/src/assets/eye.svg deleted file mode 100644 index 234edeb6e..000000000 --- a/src/assets/eye.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/assets/github.svg b/src/assets/github.svg deleted file mode 100644 index 8c96ff4e9..000000000 --- a/src/assets/github.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - diff --git a/src/assets/github2.svg b/src/assets/github2.svg deleted file mode 100644 index 66fd94659..000000000 --- a/src/assets/github2.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/google.svg b/src/assets/google.svg deleted file mode 100644 index 2f535930e..000000000 --- a/src/assets/google.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/assets/icon/cursor.svg b/src/assets/icon/cursor.svg new file mode 100644 index 000000000..2104885da --- /dev/null +++ b/src/assets/icon/cursor.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/src/assets/icon/github.svg b/src/assets/icon/github.svg new file mode 100644 index 000000000..bd1fe3b66 --- /dev/null +++ b/src/assets/icon/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icon/google.svg b/src/assets/icon/google.svg new file mode 100644 index 000000000..d08583f49 --- /dev/null +++ b/src/assets/icon/google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icon/google_calendar.svg b/src/assets/icon/google_calendar.svg new file mode 100644 index 000000000..920991de6 --- /dev/null +++ b/src/assets/icon/google_calendar.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icon/google_gmail.svg b/src/assets/icon/google_gmail.svg new file mode 100644 index 000000000..fadeff8f6 --- /dev/null +++ b/src/assets/icon/google_gmail.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icon/linkedin.svg b/src/assets/icon/linkedin.svg new file mode 100644 index 000000000..2cf6f301a --- /dev/null +++ b/src/assets/icon/linkedin.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icon/notion.svg b/src/assets/icon/notion.svg new file mode 100644 index 000000000..070bcd78a --- /dev/null +++ b/src/assets/icon/notion.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icon/rag.svg b/src/assets/icon/rag.svg new file mode 100644 index 000000000..bee2d0fa1 --- /dev/null +++ b/src/assets/icon/rag.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icon/reddit.svg b/src/assets/icon/reddit.svg new file mode 100644 index 000000000..4e427bc7e --- /dev/null +++ b/src/assets/icon/reddit.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icon/slack.svg b/src/assets/icon/slack.svg index 73d69d013..53b24e829 100644 --- a/src/assets/icon/slack.svg +++ b/src/assets/icon/slack.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/src/assets/icon/vs-code.svg b/src/assets/icon/vs-code.svg new file mode 100644 index 000000000..24c1d67a9 --- /dev/null +++ b/src/assets/icon/vs-code.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icon/whatsapp.svg b/src/assets/icon/whatsapp.svg new file mode 100644 index 000000000..49f70f468 --- /dev/null +++ b/src/assets/icon/whatsapp.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icon/x.svg b/src/assets/icon/x.svg new file mode 100644 index 000000000..f414fb575 --- /dev/null +++ b/src/assets/icon/x.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/light.png b/src/assets/light.png deleted file mode 100644 index f0de3f5e5..000000000 Binary files a/src/assets/light.png and /dev/null differ diff --git a/src/assets/local_model.mp4 b/src/assets/local_model.mp4 deleted file mode 100644 index 013dda1d5..000000000 Binary files a/src/assets/local_model.mp4 and /dev/null differ diff --git a/src/assets/login.gif b/src/assets/login.gif deleted file mode 100644 index e90751aac..000000000 Binary files a/src/assets/login.gif and /dev/null differ diff --git a/src/assets/logo/eigent.svg b/src/assets/logo/eigent.svg new file mode 100644 index 000000000..717570521 --- /dev/null +++ b/src/assets/logo/eigent.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/Folder.svg b/src/assets/logo/eigent_icon_rich.svg similarity index 100% rename from src/assets/Folder.svg rename to src/assets/logo/eigent_icon_rich.svg diff --git a/src/assets/logo/icon_black.svg b/src/assets/logo/icon_black.svg index 752962fb5..a4a46348b 100644 --- a/src/assets/logo/icon_black.svg +++ b/src/assets/logo/icon_black.svg @@ -1,33 +1,32 @@ - - - - - - + + + + + + - + - - - - - - - - - + + + + + + + + - + diff --git a/src/assets/logo/icon_white.svg b/src/assets/logo/icon_white.svg index 4cefd0711..a66226908 100644 --- a/src/assets/logo/icon_white.svg +++ b/src/assets/logo/icon_white.svg @@ -1,33 +1,32 @@ - - - - - - + + + + + + - + - - - - - - - - - - + + + + + + + + + - - + + diff --git a/src/assets/rac-pause.svg b/src/assets/rac-pause.svg deleted file mode 100644 index a5fd42ed9..000000000 --- a/src/assets/rac-pause.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/transparent.png b/src/assets/transparent.png deleted file mode 100644 index 798523455..000000000 Binary files a/src/assets/transparent.png and /dev/null differ diff --git a/src/assets/version-logo.png b/src/assets/version-logo.png deleted file mode 100644 index 0b5ac648b..000000000 Binary files a/src/assets/version-logo.png and /dev/null differ 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..269d073e3 --- /dev/null +++ b/src/client/platform.ts @@ -0,0 +1,48 @@ +// ========= 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. + +import { createHost } from '@/host/createHost'; + +export type ClientType = + | 'desktop' + | 'web' + | 'cli' + | 'browser_extension' + | 'whatsapp' + | 'telegram' + | 'slack' + | 'discord' + | 'lark'; + +/** True when running inside Electron (desktop app). */ +export function isElectron(): boolean { + const host = createHost(); + return !!host.electronAPI && !!host.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..2fbe8ad89 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, @@ -19,29 +20,32 @@ 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 type { TFunction } from 'i18next'; +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; @@ -62,29 +66,216 @@ interface ToolSelectProps { initialSelectedTools?: McpItem[]; } +type ToolSelectAddOption = (item: McpItem, isLocal?: boolean) => void; + +type ToolSelectCatalogSnapshot = { + email: string | null; + configInfo: Record | null; + userMcps: any[]; + hasUserMcps: boolean; +}; + +/** Session cache so Add Worker tool list + user MCPs show immediately when reopening. */ +let toolSelectCatalogSnapshot: ToolSelectCatalogSnapshot | null = null; + +/** Built-in MCP entries hidden from picker and connectors UI. */ +const EXCLUDED_BUILTIN_MCP = ['Search', 'RAG']; + +function buildIntegrationsFromConfigInfo( + res: unknown, + keyword: string | undefined, + t: TFunction, + addOption: ToolSelectAddOption +): any[] { + if (!res || typeof res !== 'object' || (res as any).error) { + return []; + } + const body = res as Record; + const baseURL = getProxyBaseURL(); + + return Object.entries(body) + .filter(([key]) => !EXCLUDED_BUILTIN_MCP.includes(key)) + .filter(([key]) => { + if (!keyword) return true; + return key.toLowerCase().includes(keyword.toLowerCase()); + }) + .map(([key, value]: [string, any]) => { + let onInstall: IntegrationItem['onInstall'] | null = null; + + if (key.toLowerCase() === 'notion') { + onInstall = async () => { + try { + const response = await fetchPost('/install/tool/notion'); + if (response.success) { + if (response.warning) { + console.warn( + 'Notion MCP connection warning:', + response.warning + ); + } + await proxyFetchPost('/api/v1/configs', { + config_group: 'Notion', + config_name: 'MCP_REMOTE_CONFIG_DIR', + config_value: response.toolkit_name || 'NotionMCPToolkit', + }); + console.log('Notion MCP installed successfully'); + const notionItem = { + id: 0, + key: key, + name: key, + description: + 'Notion workspace integration for reading and managing Notion pages', + toolkit: 'notion_mcp_toolkit', + isLocal: true, + }; + addOption(notionItem, true); + } else { + console.error( + 'Failed to install Notion MCP:', + response.error || 'Unknown error' + ); + throw new Error(response.error || 'Failed to install Notion MCP'); + } + } catch (error: any) { + console.error('Failed to install Notion MCP:', error.message); + throw error; + } + }; + } else if (key.toLowerCase() === 'google calendar') { + onInstall = async () => { + try { + const response = await fetchPost('/install/tool/google_calendar'); + if (response.success) { + if (response.warning) { + console.warn( + 'Google Calendar connection warning:', + response.warning + ); + } + try { + const existingConfigs = await proxyFetchGet('/api/v1/configs'); + const existing = Array.isArray(existingConfigs) + ? existingConfigs.find( + (c: any) => + c.config_group?.toLowerCase() === 'google calendar' && + c.config_name === 'GOOGLE_REFRESH_TOKEN' + ) + : null; + + const configPayload = { + config_group: 'Google Calendar', + config_name: 'GOOGLE_REFRESH_TOKEN', + config_value: 'exists', + }; + + if (existing) { + await proxyFetchPut( + `/api/v1/configs/${existing.id}`, + configPayload + ); + } else { + await proxyFetchPost('/api/v1/configs', configPayload); + } + } catch (configError) { + console.warn( + 'Failed to persist Google Calendar config', + configError + ); + } + console.log('Google Calendar installed successfully'); + const calendarItem = { + id: 0, + key: key, + name: key, + description: + 'Google Calendar integration for managing events and schedules', + toolkit: 'google_calendar_toolkit', + isLocal: true, + }; + addOption(calendarItem, true); + } else if (response.status === 'authorizing') { + console.log( + 'Google Calendar authorization in progress. Please complete in browser.' + ); + if (response.message) { + console.log(response.message); + } + } else { + console.error( + 'Failed to install Google Calendar:', + response.error || 'Unknown error' + ); + throw new Error( + response.error || 'Failed to install Google Calendar' + ); + } + return response; + } catch (error: any) { + if (!error.message?.includes('authorization')) { + console.error( + 'Failed to install Google Calendar:', + error.message + ); + throw error; + } + return null; + } + }; + } else { + onInstall = () => { + window.open( + `${baseURL}/api/v1/oauth/${key.toLowerCase()}/login`, + '_blank', + 'width=600,height=700' + ); + }; + } + + return { + key: key, + name: key, + env_vars: value.env_vars, + toolkit: value.toolkit, + desc: + value.env_vars && value.env_vars.length > 0 + ? `${t('layout.environmental-variables-required')} ${value.env_vars.join( + ', ' + )}` + : key.toLowerCase() === 'notion' + ? t('layout.notion-workspace-integration') + : key.toLowerCase() === 'google calendar' + ? t('layout.google-calendar-integration') + : '', + onInstall, + }; + }); +} + 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(''); - 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 }]; @@ -102,185 +293,37 @@ const ToolSelect = forwardRef< ); const fetchIntegrationsData = useCallback( - (keyword?: string) => { + (keyword?: string, opts?: { force?: boolean }) => { + const u = email ?? null; + const snap = toolSelectCatalogSnapshot; + const hydratedFromCache = + !opts?.force && snap && snap.email === u && snap.configInfo; + if (hydratedFromCache) { + setIntegrations( + buildIntegrationsFromConfigInfo( + snap.configInfo, + keyword, + t, + addOption + ) + ); + } + proxyFetchGet('/api/v1/config/info') .then((res) => { - if (res && typeof res === 'object' && !res.error) { - const baseURL = getProxyBaseURL(); - - const list = Object.entries(res) - .filter(([key]) => { - if (!keyword) return true; - return key.toLowerCase().includes(keyword.toLowerCase()); - }) - .map(([key, value]: [string, any]) => { - let onInstall = null; - - // Special handling for Notion MCP - if (key.toLowerCase() === 'notion') { - onInstall = async () => { - try { - const response = await fetchPost('/install/tool/notion'); - if (response.success) { - // Check if there's a warning (connection failed but installation marked as complete) - if (response.warning) { - console.warn( - 'Notion MCP connection warning:', - response.warning - ); - // Still proceed but log the warning - } - // Save to config to mark as installed - await proxyFetchPost('/api/v1/configs', { - config_group: 'Notion', - config_name: 'MCP_REMOTE_CONFIG_DIR', - config_value: - response.toolkit_name || 'NotionMCPToolkit', - }); - console.log('Notion MCP installed successfully'); - // After successful installation, add to selected tools - const notionItem = { - id: 0, // Use 0 for integration items - key: key, - name: key, - description: - 'Notion workspace integration for reading and managing Notion pages', - toolkit: 'notion_mcp_toolkit', // Add the toolkit name - isLocal: true, - }; - addOption(notionItem, true); - } else { - console.error( - 'Failed to install Notion MCP:', - response.error || 'Unknown error' - ); - throw new Error( - response.error || 'Failed to install Notion MCP' - ); - } - } catch (error: any) { - console.error( - 'Failed to install Notion MCP:', - error.message - ); - throw error; - } - }; - } else if (key.toLowerCase() === 'google calendar') { - onInstall = async () => { - try { - const response = await fetchPost( - '/install/tool/google_calendar' - ); - if (response.success) { - if (response.warning) { - console.warn( - 'Google Calendar connection warning:', - response.warning - ); - } - try { - const existingConfigs = - await proxyFetchGet('/api/v1/configs'); - const existing = Array.isArray(existingConfigs) - ? existingConfigs.find( - (c: any) => - c.config_group?.toLowerCase() === - 'google calendar' && - c.config_name === 'GOOGLE_REFRESH_TOKEN' - ) - : null; - - const configPayload = { - config_group: 'Google Calendar', - config_name: 'GOOGLE_REFRESH_TOKEN', - config_value: 'exists', - }; - - if (existing) { - await proxyFetchPut( - `/api/v1/configs/${existing.id}`, - configPayload - ); - } else { - await proxyFetchPost( - '/api/v1/configs', - configPayload - ); - } - } catch (configError) { - console.warn( - 'Failed to persist Google Calendar config', - configError - ); - } - console.log('Google Calendar installed successfully'); - const calendarItem = { - id: 0, // Use 0 for integration items - key: key, - name: key, - description: - 'Google Calendar integration for managing events and schedules', - toolkit: 'google_calendar_toolkit', // Add the toolkit name - isLocal: true, - }; - addOption(calendarItem, true); - } else if (response.status === 'authorizing') { - console.log( - 'Google Calendar authorization in progress. Please complete in browser.' - ); - if (response.message) { - console.log(response.message); - } - } else { - console.error( - 'Failed to install Google Calendar:', - response.error || 'Unknown error' - ); - throw new Error( - response.error || 'Failed to install Google Calendar' - ); - } - return response; - } catch (error: any) { - if (!error.message?.includes('authorization')) { - console.error( - 'Failed to install Google Calendar:', - error.message - ); - throw error; - } - return null; // Return null on authorization flow errors - } - }; - } else { - onInstall = () => - window.open( - `${baseURL}/api/v1/oauth/${key.toLowerCase()}/login`, - '_blank', - 'width=600,height=700' - ); - } - - return { - key: key, - name: key, - env_vars: value.env_vars, - toolkit: value.toolkit, - desc: - value.env_vars && value.env_vars.length > 0 - ? `${t('layout.environmental-variables-required')} ${value.env_vars.join( - ', ' - )}` - : key.toLowerCase() === 'notion' - ? t('layout.notion-workspace-integration') - : key.toLowerCase() === 'google calendar' - ? t('layout.google-calendar-integration') - : '', - onInstall, - }; - }); - setIntegrations(list); + if (res && typeof res === 'object' && !(res as any).error) { + const info = res as Record; + const prev = toolSelectCatalogSnapshot; + const sameUser = prev?.email === u; + toolSelectCatalogSnapshot = { + email: u, + configInfo: info, + userMcps: sameUser ? (prev?.userMcps ?? []) : [], + hasUserMcps: sameUser ? (prev?.hasUserMcps ?? false) : false, + }; + setIntegrations( + buildIntegrationsFromConfigInfo(info, keyword, t, addOption) + ); } else { console.error('Failed to fetch integrations:', res); setIntegrations([]); @@ -288,91 +331,47 @@ const ToolSelect = forwardRef< }) .catch((error) => { console.error('Error fetching integrations:', error); - setIntegrations([]); + if (!hydratedFromCache) setIntegrations([]); }); }, - [addOption, t] + [addOption, email, 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[] = []; - 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); - }) - .catch((error) => { - console.error('Error fetching installed MCPs:', error); - setInstalledIds([]); - setCustomMcpList([]); - }); - }, []); + const fetchInstalledMcps = useCallback( + (opts?: { force?: boolean }) => { + const u = email ?? null; + const snap = toolSelectCatalogSnapshot; + const hydratedFromCache = + !opts?.force && snap && snap.email === u && snap.hasUserMcps; + if (hydratedFromCache) { + setUserMcpList(snap.userMcps); + } - // 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]); + proxyFetchGet('/api/v1/mcp/users') + .then((res) => { + let dataList: any[] = []; + if (Array.isArray(res)) { + dataList = res; + } else if (res && Array.isArray(res.items)) { + dataList = res.items; + } + setUserMcpList(dataList); + const prev = toolSelectCatalogSnapshot; + const sameUser = prev?.email === u; + toolSelectCatalogSnapshot = { + email: u, + configInfo: sameUser ? (prev?.configInfo ?? null) : null, + userMcps: dataList, + hasUserMcps: true, + }; + }) + .catch((error) => { + console.error('Error fetching installed MCPs:', error); + if (!hydratedFromCache) setUserMcpList([]); + }); + }, + [email] + ); // public save env/config logic const saveEnvAndConfig = async ( @@ -408,8 +407,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 @@ -507,7 +506,7 @@ const ToolSelect = forwardRef< } // Refresh integrations to update install status - fetchIntegrationsData(); + fetchIntegrationsData(undefined, { force: true }); const selectedItem = { id: activeMcp.id, @@ -576,7 +575,7 @@ const ToolSelect = forwardRef< await proxyFetchPost('/api/v1/configs', configPayload); } - fetchIntegrationsData(); + fetchIntegrationsData(undefined, { force: true }); const selectedItem = { id: activeMcp.id, @@ -630,34 +629,33 @@ 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); - if (window.ipcRenderer && installedMcp) { - const env: { [key: string]: string } = {}; + 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) { + 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) { addOption(installedMcp); } + void fetchInstalledMcps({ force: true }); } catch (e) { console.error('Failed to install MCP:', e); - } finally { - setInstalling((prev) => ({ ...prev, [id]: false })); } }; @@ -666,57 +664,93 @@ 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((row) => { + const bothLocal = + row.isLocal === true && + item.isLocal === true && + row.key != null && + item.key != null && + String(row.key) !== '' && + String(item.key) !== ''; + if (bothLocal) { + return row.key !== item.key; + } + return row.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 +758,6 @@ const ToolSelect = forwardRef< } debounceTimerRef.current = setTimeout(() => { - fetchData(keyword); fetchIntegrationsData(keyword); }, 500); @@ -733,23 +766,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 +823,14 @@ const ToolSelect = forwardRef< {(initialSelectedTools || []).map((item: any) => ( {item.name || item.mcp_name || item.key || `tool_${item.id}`} -
+
removeOption(item)} />
@@ -771,160 +839,138 @@ 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)} - -
+ const renderCustomMcpItem = (item: any) => { + const checked = !!(initialSelectedTools || []).find( + (i) => i.id === item.id + ); + const label = String(item.mcp_name || item.mcp_key || ''); + return ( + -
-
- ); + > + + + {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 flex max-h-[120px] min-h-[40px] w-full min-w-0 flex-wrap content-center items-center justify-start gap-1.5 rounded-lg bg-ds-bg-neutral-default-default px-2 py-1.5 focus-within:ring-2' + )} > {renderSelectedItems()}