diff --git a/backend/app/agent/factory/browser.py b/backend/app/agent/factory/browser.py index 6eb501da0..cf9a8724f 100644 --- a/backend/app/agent/factory/browser.py +++ b/backend/app/agent/factory/browser.py @@ -213,6 +213,8 @@ def browser_agent(options: Chat): "browser_sheet_input", "browser_get_page_snapshot", "browser_open", + "browser_upload_file", + "browser_download_file", ], ) diff --git a/package.json b/package.json index 264deabbc..a21c7821e 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "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", "type-check": "tsc -p tsconfig.build.json --noEmit", "lint": "eslint . --no-warn-ignored", "lint:fix": "eslint . --fix --no-warn-ignored", diff --git a/server/Dockerfile b/server/Dockerfile index 6beb26fc2..e3e220ebb 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -61,6 +61,11 @@ RUN sed -i 's/\r$//' /app/start.sh && chmod +x /app/start.sh RUN sed -i 's/\r$//' /app/celery/worker/start && chmod +x /app/celery/worker/start RUN sed -i 's/\r$//' /app/celery/beat/start && chmod +x /app/celery/beat/start +# Bake the latest server/ commit into the image for stale-server detection. +# Uses --mount=type=bind to access .git without adding it to a layer. +RUN --mount=type=bind,source=.git,target=/tmp/.git \ + echo "EIGENT_SERVER_GIT_COMMIT=$(git --git-dir=/tmp/.git log -1 --format=%H -- server/ 2>/dev/null || echo unknown)" > /app/.image_env + # Reset the entrypoint, don't invoke `uv` ENTRYPOINT [] diff --git a/server/README_CN.md b/server/README_CN.md index 22d733cde..a918d4887 100644 --- a/server/README_CN.md +++ b/server/README_CN.md @@ -14,7 +14,7 @@ - 配置中心 Config(保存各类工具/能力所需的密钥或参数) - `GET /configs`、`POST /configs`、`PUT /configs/{id}`、`DELETE /configs/{id}`、`GET /config/info` - 聊天与数据 - - 历史、快照、分享等接口位于 `app/controller/chat/`,数据全部落在本地数据库 + - 历史、快照、分享等接口位于 `app/domains/chat/api/`,数据全部落在本地数据库 - MCP 服务管理(导入本地/远程 MCP 服务器) - `GET /mcps`、`POST /mcp/install`、`POST /mcp/import/{Local|Remote}` 等 diff --git a/server/README_EN.md b/server/README_EN.md index 1f99833ac..a405d4ece 100644 --- a/server/README_EN.md +++ b/server/README_EN.md @@ -14,7 +14,7 @@ - Config Center (store secrets/params required by tools/capabilities) - `GET /configs`, `POST /configs`, `PUT /configs/{id}`, `DELETE /configs/{id}`, `GET /config/info` - Chat & Data - - History, snapshots, sharing, etc. in `app/controller/chat/`, all persisted to local DB + - History, snapshots, sharing, etc. in `app/domains/chat/api/`, all persisted to local DB - MCP Management (import local/remote MCP servers) - `GET /mcps`, `POST /mcp/install`, `POST /mcp/import/{Local|Remote}`, etc. diff --git a/server/doc/server-refactor-v1.md b/server/doc/server-refactor-v1.md new file mode 100644 index 000000000..5383210b6 --- /dev/null +++ b/server/doc/server-refactor-v1.md @@ -0,0 +1,79 @@ +# Server Refactor v1 - Upgrade Guide + +> Applies to: v0.0.89+ +> PR: #1509 + +## What Changed + +The server codebase has been restructured from a flat layout to a **domain-driven architecture**. No API endpoints or database schemas were changed — this is a code organization refactor only. + +### Directory Mapping + +| Before | After | Description | +|---|---|---| +| `app/component/` | `app/core/` | Infrastructure utilities (database, encryption, celery, etc.) | +| `app/controller/` | `app/domains/*/api/` | API controllers, grouped by domain | +| `app/service/` | `app/domains/*/service/` | Business logic, grouped by domain | +| `app/exception/` | `app/shared/exception/` | Exception handling | +| `app/type/` | `app/shared/types/` | Shared type definitions | +| _(new)_ | `app/shared/auth/` | Authentication & authorization | +| _(new)_ | `app/shared/middleware/` | CORS, rate limiting, trace ID | +| _(new)_ | `app/shared/http/` | HTTP client utilities | +| _(new)_ | `app/shared/logging/` | Logging & sensitive data filtering | + +### Domain Structure + +Each domain (`chat`, `config`, `mcp`, `model_provider`, `oauth`, `trigger`, `user`) follows the same layout: + +``` +app/domains// + api/ # Controllers (route handlers) + service/ # Business logic + schema/ # Request/response schemas +``` + +## Upgrade Action Required + +**This is a breaking change for local deployments.** The old server code will fail to start due to changed import paths. + +### Docker Users + +```bash +cd server +docker-compose up --build -d +``` + +You **must** include `--build` to rebuild the image. Running `docker-compose up -d` without `--build` will use the stale old image and fail. + +### Non-Docker Users (Local Development) + +If you are running the server directly (via `start_server.sh` or `uv run uvicorn`): + +1. Stop the running server process +2. Pull the latest code +3. Restart the server + +```bash +# If using start_server.sh +cd server +./start_server.sh + +# If running uvicorn directly +cd server +uv run uvicorn main:api --reload --port 3001 --host 0.0.0.0 +``` + +### Electron App Users + +If you are running Eigent as a desktop app, simply restart the application. The server will be restarted automatically. + +## FAQ + +**Q: Will I lose my data?** +A: No. Database volumes and schemas are not affected. Only the Python source code layout changed. + +**Q: Do I need to re-run database migrations?** +A: No. There are no new migrations in this change. + +**Q: I see import errors like `ModuleNotFoundError: No module named 'app.component'`** +A: This means you are running an old server binary/image. Follow the upgrade steps above. diff --git a/server/doc/server-refactor-v1_CN.md b/server/doc/server-refactor-v1_CN.md new file mode 100644 index 000000000..390892064 --- /dev/null +++ b/server/doc/server-refactor-v1_CN.md @@ -0,0 +1,79 @@ +# Server 重构 v1 - 升级指南 + +> 适用版本: v0.0.89+ +> PR: #1509 + +## 改动概述 + +Server 代码从扁平结构重构为**领域驱动架构 (Domain-Driven)**。API 接口和数据库结构均未变更,这是一次纯代码组织层面的重构。 + +### 目录变更对照 + +| 重构前 | 重构后 | 说明 | +|---|---|---| +| `app/component/` | `app/core/` | 基础设施(数据库、加密、celery 等) | +| `app/controller/` | `app/domains/*/api/` | 按领域分组的 API 控制器 | +| `app/service/` | `app/domains/*/service/` | 按领域分组的业务逻辑 | +| `app/exception/` | `app/shared/exception/` | 异常处理 | +| `app/type/` | `app/shared/types/` | 共享类型定义 | +| _(新增)_ | `app/shared/auth/` | 认证与授权 | +| _(新增)_ | `app/shared/middleware/` | CORS、限流、Trace ID | +| _(新增)_ | `app/shared/http/` | HTTP 客户端工具 | +| _(新增)_ | `app/shared/logging/` | 日志与敏感信息过滤 | + +### 领域结构 + +每个领域(`chat`、`config`、`mcp`、`model_provider`、`oauth`、`trigger`、`user`)遵循统一结构: + +``` +app/domains/<领域>/ + api/ # 控制器(路由处理) + service/ # 业务逻辑 + schema/ # 请求/响应模型 +``` + +## 升级操作(必须) + +**此改动对本地部署是 breaking change。** 旧版 server 代码因 import 路径变更将无法启动。 + +### Docker 用户 + +```bash +cd server +docker-compose up --build -d +``` + +**必须**加 `--build` 参数重新构建镜像。直接 `docker-compose up -d` 会使用旧镜像导致启动失败。 + +### 非 Docker 用户(本地开发) + +如果你通过 `start_server.sh` 或 `uv run uvicorn` 直接运行 server: + +1. 停止正在运行的 server 进程 +2. 拉取最新代码 +3. 重新启动 server + +```bash +# 使用 start_server.sh +cd server +./start_server.sh + +# 直接运行 uvicorn +cd server +uv run uvicorn main:api --reload --port 3001 --host 0.0.0.0 +``` + +### Electron 桌面应用用户 + +重启应用即可,server 会自动重启。 + +## 常见问题 + +**Q: 数据会丢失吗?** +A: 不会。数据库卷和表结构未受影响,仅 Python 源码目录结构发生了变化。 + +**Q: 需要重新执行数据库迁移吗?** +A: 不需要。此次改动没有新增数据库迁移。 + +**Q: 出现 `ModuleNotFoundError: No module named 'app.component'`** +A: 说明正在运行旧版 server。请按上述升级步骤操作。 diff --git a/server/docker-compose.yml b/server/docker-compose.yml index 35b3f9b6d..a777a7906 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -107,7 +107,7 @@ services: test: [ 'CMD-SHELL', - 'celery -A app.component.celery inspect ping -d celery@$$HOSTNAME', + 'celery -A app.core.celery inspect ping -d celery@$$HOSTNAME', ] interval: 30s timeout: 10s diff --git a/server/main.py b/server/main.py index ee64b68a7..9798067ca 100644 --- a/server/main.py +++ b/server/main.py @@ -22,7 +22,8 @@ sys.path.insert(0, str(_project_root)) import logging -import sys +import subprocess +from importlib.metadata import version as pkg_version from fastapi.staticfiles import StaticFiles from fastapi_pagination import add_pagination @@ -50,10 +51,51 @@ auto_include_routers(router, "", "app/api") api.include_router(router, prefix=f"{prefix}/v1") +# Server version — read once at import time so it reflects the running code +try: + SERVER_VERSION = pkg_version("Eigent") +except Exception: + SERVER_VERSION = "unknown" + +# Git hash of the last commit that touched server/ — used for stale-server detection. +# Captured once at startup; stays constant while the process lives. +# 1) Try git directly (works in local dev) +# 2) Fall back to .image_env baked by Dockerfile (works in Docker) +def _read_server_code_hash() -> str: + # Try git first (local dev) + try: + h = subprocess.check_output( + ["git", "log", "-1", "--format=%H", "--", "server/"], + cwd=str(_project_root), text=True, stderr=subprocess.DEVNULL, + ).strip() + if h: + return h + except Exception: + pass + # Fallback: read from Docker-baked .image_env + try: + env_file = pathlib.Path(__file__).parent / ".image_env" + for line in env_file.read_text().splitlines(): + if line.startswith("EIGENT_SERVER_GIT_COMMIT="): + v = line.split("=", 1)[1].strip() + if v: + return v + except Exception: + pass + return "unknown" + +SERVER_CODE_HASH = _read_server_code_hash() + + # Health check at root level for Docker healthcheck (GET /health) @api.get("/health", tags=["Health"]) async def health_check(): - return {"status": "ok", "service": "eigent-server"} + return { + "status": "ok", + "service": "eigent-server", + "version": SERVER_VERSION, + "server_hash": SERVER_CODE_HASH, + } # Backward-compatible webhook route (/api/webhook/...) from app.domains.trigger.api.webhook_controller import router as webhook_router @@ -99,12 +141,12 @@ def emit(self, record: logging.LogRecord) -> None: if not os.path.isdir(public_dir): try: os.makedirs(public_dir, exist_ok=True) - logger.warning(f"Public directory did not exist. Created: {public_dir}") + loguru_logger.warning(f"Public directory did not exist. Created: {public_dir}") except Exception as e: - logger.error(f"Public directory missing and could not be created: {public_dir}. Error: {e}") + loguru_logger.error(f"Public directory missing and could not be created: {public_dir}. Error: {e}") public_dir = None if public_dir and os.path.isdir(public_dir): api.mount("/public", StaticFiles(directory=public_dir), name="public") else: - logger.warning("Skipping /public mount because public directory is unavailable") + loguru_logger.warning("Skipping /public mount because public directory is unavailable") diff --git a/server/uv.lock b/server/uv.lock index b6d797fa2..b304f7075 100644 --- a/server/uv.lock +++ b/server/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = "==3.12.*" resolution-markers = [ "sys_platform == 'win32'", @@ -479,6 +479,7 @@ dependencies = [ { name = "fastapi-pagination" }, { name = "httpx" }, { name = "itsdangerous" }, + { name = "loguru" }, { name = "openai" }, { name = "openpyxl" }, { name = "pandas" }, @@ -523,6 +524,7 @@ requires-dist = [ { name = "fastapi-pagination", specifier = ">=0.12.34" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "itsdangerous", specifier = ">=2.2.0" }, + { name = "loguru", specifier = ">=0.7.3" }, { name = "openai", specifier = ">=1.99.3,<2" }, { name = "openpyxl", specifier = ">=3.1.5" }, { name = "pandas", specifier = ">=2.2.3" }, @@ -699,7 +701,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -847,6 +848,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + [[package]] name = "mako" version = "1.3.10" @@ -1675,6 +1689,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + [[package]] name = "yarl" version = "1.23.0" diff --git a/src/api/http.ts b/src/api/http.ts index fb6535998..70fa418ce 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -311,6 +311,80 @@ export async function checkBackendHealth(): Promise { } } +// =============== Local Server Stale Detection =============== + +/** + * Git hash of the last commit that touched server/, injected by Vite at build + * time. When the running server reports a different hash it means the server + * process is stale and needs to be restarted / rebuilt. + */ +const EXPECTED_SERVER_HASH: string = + import.meta.env.VITE_SERVER_CODE_HASH || ''; + +let serverStaleChecked = false; + +/** + * One-time check: when VITE_USE_LOCAL_PROXY is enabled, fetch the local + * server's /health and compare its server_hash against the expected hash + * baked into this build. Shows a persistent toast if they differ. + */ +export async function checkLocalServerStale(): Promise { + if (serverStaleChecked || !EXPECTED_SERVER_HASH) return; + serverStaleChecked = true; + + const useLocalProxy = import.meta.env.VITE_USE_LOCAL_PROXY === 'true'; + if (!useLocalProxy) return; + + const serverUrl = import.meta.env.VITE_PROXY_URL || 'http://localhost:3001'; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); + + const res = await fetch(`${serverUrl}/health`, { + signal: controller.signal, + method: 'GET', + }); + + clearTimeout(timeoutId); + + let staleReason = ''; + + if (res.status === 404) { + // /health endpoint doesn't exist — server predates v0.0.89 + staleReason = 'Server does not have /health endpoint (pre-v0.0.89)'; + } else if (res.ok) { + const data = await res.json(); + const serverHash: string | undefined = data?.server_hash; + + if (!serverHash) { + staleReason = 'Server does not report version info (pre-v0.0.89)'; + } else if ( + serverHash !== 'unknown' && + serverHash !== EXPECTED_SERVER_HASH + ) { + staleReason = `Server hash ${serverHash} != expected ${EXPECTED_SERVER_HASH}`; + } + } else { + // Other HTTP errors — skip + return; + } + + if (staleReason) { + const { toast } = await import('sonner'); + toast.warning('Server code has been updated', { + description: + 'Server is outdated. Please restart it or rebuild: docker-compose up --build -d', + duration: Infinity, + closeButton: true, + }); + console.warn(`[Server Check] ${staleReason}. Please restart the server.`); + } + } catch { + // server not reachable — skip silently + } +} + /** * Simple backend health check with retries * @param maxWaitMs - Maximum time to wait in milliseconds (default: 10000ms) @@ -331,6 +405,10 @@ export async function waitForBackendReady( console.log( `[Backend Health Check] Backend is ready after ${Date.now() - startTime}ms` ); + + // Fire-and-forget: check local server version when using local proxy + checkLocalServerStale(); + return true; } diff --git a/src/assets/wechat_qr.jpg b/src/assets/wechat_qr.jpg index 23ff83739..2c1bc9790 100644 Binary files a/src/assets/wechat_qr.jpg and b/src/assets/wechat_qr.jpg differ diff --git a/src/components/AddWorker/ToolSelect.tsx b/src/components/AddWorker/ToolSelect.tsx index 5bc863484..abc8d14ae 100644 --- a/src/components/AddWorker/ToolSelect.tsx +++ b/src/components/AddWorker/ToolSelect.tsx @@ -757,12 +757,12 @@ const ToolSelect = forwardRef< {(initialSelectedTools || []).map((item: any) => ( {item.name || item.mcp_name || item.key || `tool_${item.id}`} -
+
removeOption(item)} />
@@ -788,21 +788,21 @@ const ToolSelect = forwardRef< checkEnv(item.id); } }} - className="flex cursor-pointer justify-between px-3 py-2 hover:bg-surface-hover-subtle" + className="px-3 py-2 hover:bg-surface-hover-subtle flex cursor-pointer justify-between" > -
+
{getCategoryIcon(item.category?.name)} -
+
{item.name}
e.stopPropagation()} />
-
+
{getGithubRepoName(item.home_page) && (
- + {getGithubRepoName(item.home_page)}
@@ -847,21 +847,21 @@ const ToolSelect = forwardRef< addOption(item); setKeyword(''); }} - className="flex cursor-pointer justify-between px-3 py-2 hover:bg-surface-hover-subtle" + className="px-3 py-2 hover:bg-surface-hover-subtle flex cursor-pointer justify-between" > -
+
{/* {getCategoryIcon(item.category?.name)} */} -
+
{item.mcp_name}
e.stopPropagation()} />
-
+