Merge branch 'main' into chore/readme

This commit is contained in:
Wendong-Fan 2026-01-20 01:40:54 +00:00 committed by GitHub
commit 8facbf34e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 362 additions and 48 deletions

View file

@ -859,7 +859,8 @@ async def developer_agent(options: Chat):
terminal_toolkit = TerminalToolkit(
options.project_id,
Agents.document_agent,
Agents.developer_agent,
working_directory=working_directory,
safe_mode=True,
clone_current_env=True,
)
@ -1069,6 +1070,7 @@ def browser_agent(options: Chat):
terminal_toolkit = TerminalToolkit(
options.project_id,
Agents.browser_agent,
working_directory=working_directory,
safe_mode=True,
clone_current_env=True,
)
@ -1259,6 +1261,7 @@ async def document_agent(options: Chat):
terminal_toolkit = TerminalToolkit(
options.project_id,
Agents.document_agent,
working_directory=working_directory,
safe_mode=True,
clone_current_env=True,
)
@ -1482,6 +1485,7 @@ def multi_modal_agent(options: Chat):
terminal_toolkit = TerminalToolkit(
options.project_id,
agent_name=Agents.multi_modal_agent,
working_directory=working_directory,
safe_mode=True,
clone_current_env=True,
)
@ -1676,6 +1680,7 @@ async def social_medium_agent(options: Chat):
*TerminalToolkit(
options.project_id,
agent_name=Agents.social_medium_agent,
working_directory=working_directory,
clone_current_env=True,
).get_tools(),
*NoteTakingToolkit(

View file

@ -1,7 +1,9 @@
import asyncio
import logging
import os
import platform
import shutil
import subprocess
import threading
import time
from concurrent.futures import ThreadPoolExecutor
@ -17,6 +19,20 @@ from utils import traceroot_wrapper as traceroot
logger = traceroot.get_logger("terminal_toolkit")
# App version - should match electron app version
# TODO: Consider getting this from a shared config
APP_VERSION = "0.0.80"
def get_terminal_base_venv_path() -> str:
"""Get the path to the terminal base venv created during app installation."""
return os.path.join(
os.path.expanduser("~"),
".eigent",
"venvs",
f"terminal_base-{APP_VERSION}"
)
@auto_listen_toolkit(BaseTerminalToolkit)
class TerminalToolkit(BaseTerminalToolkit, AbstractToolkit):
@ -40,13 +56,18 @@ class TerminalToolkit(BaseTerminalToolkit, AbstractToolkit):
self.api_task_id = api_task_id
if agent_name is not None:
self.agent_name = agent_name
# Get base directory from environment
base_dir = env("file_save_path", os.path.expanduser("~/.eigent/terminal/"))
if working_directory is None:
working_directory = env("file_save_path", os.path.expanduser("~/.eigent/terminal/"))
working_directory = base_dir
self._agent_venv_dir = os.path.join(base_dir, self.agent_name)
logger.debug(f"Initializing TerminalToolkit for agent={self.agent_name}", extra={
"api_task_id": api_task_id,
"working_directory": working_directory,
"clone_current_env": clone_current_env
"agent_venv_dir": self._agent_venv_dir,
})
if TerminalToolkit._thread_pool is None:
@ -63,16 +84,10 @@ class TerminalToolkit(BaseTerminalToolkit, AbstractToolkit):
session_logs_dir=session_logs_dir,
safe_mode=safe_mode,
allowed_commands=allowed_commands,
clone_current_env=clone_current_env,
install_dependencies=[
"pandas",
"numpy",
"matplotlib",
"requests",
"openpyxl",
],
clone_current_env=True,
install_dependencies=[],
)
# Auto-register with TaskLock for cleanup when task ends
from app.service.task import get_task_lock_if_exists
task_lock = get_task_lock_if_exists(api_task_id)
@ -83,6 +98,116 @@ class TerminalToolkit(BaseTerminalToolkit, AbstractToolkit):
"working_directory": working_directory
})
def _setup_cloned_environment(self):
"""Override to clone from terminal_base venv instead of current process venv.
Creates a lightweight clone using symlinks to the terminal_base venv,
which contains pre-installed packages (pandas, numpy, matplotlib, etc.).
"""
self.cloned_env_path = os.path.join(self._agent_venv_dir, ".venv")
terminal_base_path = get_terminal_base_venv_path()
# Check if terminal_base exists
if platform.system() == 'Windows':
base_python = os.path.join(terminal_base_path, "Scripts", "python.exe")
else:
base_python = os.path.join(terminal_base_path, "bin", "python")
if not os.path.exists(base_python):
logger.warning(
f"Terminal base venv not found at {terminal_base_path}, "
"falling back to system Python"
)
return
# Check if cloned env already exists
if platform.system() == 'Windows':
cloned_python = os.path.join(self.cloned_env_path, "Scripts", "python.exe")
else:
cloned_python = os.path.join(self.cloned_env_path, "bin", "python")
if os.path.exists(cloned_python):
logger.info(f"Using existing cloned environment: {self.cloned_env_path}")
self.python_executable = cloned_python
return
logger.info(f"Cloning terminal_base venv to: {self.cloned_env_path}")
try:
# Create the cloned venv directory
os.makedirs(self.cloned_env_path, exist_ok=True)
# Clone using symlinks for efficiency
# We need to create proper venv structure with symlinks to terminal_base
self._clone_venv_with_symlinks(terminal_base_path, self.cloned_env_path)
self.python_executable = cloned_python
logger.info(f"Successfully cloned environment to: {self.cloned_env_path}")
except Exception as e:
logger.error(f"Failed to clone terminal_base venv: {e}", exc_info=True)
# Cleanup partial clone
if os.path.exists(self.cloned_env_path):
shutil.rmtree(self.cloned_env_path, ignore_errors=True)
logger.warning("Falling back to system Python")
def _get_venv_path(self):
return None
def _clone_venv_with_symlinks(self, source_venv: str, target_venv: str):
"""Clone a venv using symlinks for efficiency.
Only creates the minimum structure needed: pyvenv.cfg, bin/python, and lib symlink.
Activation scripts are not needed since we use python_executable directly.
"""
is_windows = platform.system() == 'Windows'
# Read source pyvenv.cfg to get Python home
source_cfg = os.path.join(source_venv, "pyvenv.cfg")
python_home = None
with open(source_cfg, 'r') as f:
for line in f:
if line.startswith('home = '):
python_home = line.split('=', 1)[1].strip()
break
if not python_home:
raise RuntimeError(f"Could not determine Python home from {source_cfg}")
# Copy pyvenv.cfg (simpler than recreating)
shutil.copy2(source_cfg, os.path.join(target_venv, "pyvenv.cfg"))
if is_windows:
# Windows: copy executables from source
target_bin = os.path.join(target_venv, "Scripts")
os.makedirs(target_bin, exist_ok=True)
source_scripts = os.path.join(source_venv, "Scripts")
for exe in ["python.exe", "pythonw.exe"]:
src = os.path.join(source_scripts, exe)
if os.path.exists(src):
shutil.copy2(src, os.path.join(target_bin, exe))
# Use directory junction for Lib (no admin rights needed, unlike symlink)
source_lib = os.path.join(source_venv, "Lib")
target_lib = os.path.join(target_venv, "Lib")
subprocess.run(["cmd", "/c", "mklink", "/J", target_lib, source_lib],
check=True, capture_output=True)
else:
# Unix: symlink python executable and lib directory
target_bin = os.path.join(target_venv, "bin")
os.makedirs(target_bin, exist_ok=True)
# Symlink python to the base Python
python_exe = os.path.join(python_home, "python3")
if not os.path.exists(python_exe):
python_exe = os.path.join(python_home, "python")
os.symlink(python_exe, os.path.join(target_bin, "python"))
os.symlink("python", os.path.join(target_bin, "python3"))
# Symlink lib directory
source_lib = os.path.join(source_venv, "lib")
os.symlink(source_lib, os.path.join(target_venv, "lib"))
def _write_to_log(self, log_file: str, content: str) -> None:
r"""Write content to log file with optional ANSI stripping.
@ -198,32 +323,34 @@ class TerminalToolkit(BaseTerminalToolkit, AbstractToolkit):
return
# Remove cloned env (.venv) if it exists
if self.cloned_env_path and os.path.exists(self.cloned_env_path):
cloned_env_path = getattr(self, 'cloned_env_path', None)
if cloned_env_path and os.path.exists(cloned_env_path):
try:
shutil.rmtree(self.cloned_env_path)
shutil.rmtree(cloned_env_path)
logger.info("Removed cloned venv", extra={
"api_task_id": self.api_task_id,
"path": self.cloned_env_path
"path": cloned_env_path
})
except Exception as e:
logger.warning("Failed to remove cloned venv", extra={
"api_task_id": self.api_task_id,
"path": self.cloned_env_path,
"path": cloned_env_path,
"error": str(e)
})
# Remove initial env (.initial_env) if it exists
if self.initial_env_path and os.path.exists(self.initial_env_path):
initial_env_path = getattr(self, 'initial_env_path', None)
if initial_env_path and os.path.exists(initial_env_path):
try:
shutil.rmtree(self.initial_env_path)
shutil.rmtree(initial_env_path)
logger.info("Removed initial env", extra={
"api_task_id": self.api_task_id,
"path": self.initial_env_path
"path": initial_env_path
})
except Exception as e:
logger.warning("Failed to remove initial env", extra={
"api_task_id": self.api_task_id,
"path": self.initial_env_path,
"path": initial_env_path,
"error": str(e)
})

View file

@ -8,10 +8,12 @@ import {
getBinaryPath,
getCachePath,
getVenvPath,
getTerminalVenvPath,
getUvEnv,
cleanupOldVenvs,
isBinaryExists,
runInstallScript,
TERMINAL_BASE_PACKAGES,
} from './utils/process';
import { spawn } from 'child_process';
import { safeMainWindowSend } from './utils/safeWebContentsSend';
@ -482,6 +484,124 @@ const runInstall = (extraArgs: string[], version: string) => {
});
};
/**
* Install terminal base venv with common packages for terminal tasks.
* This is a lightweight venv separate from the backend venv.
*/
async function installTerminalBaseVenv(version: string): Promise<PromiseReturnType> {
const terminalVenvPath = getTerminalVenvPath(version);
const pythonPath = process.platform === 'win32'
? path.join(terminalVenvPath, 'Scripts', 'python.exe')
: path.join(terminalVenvPath, 'bin', 'python');
// Check if terminal base venv already exists and is valid
if (fs.existsSync(pythonPath)) {
log.info('[DEPS INSTALL] Terminal base venv already exists, skipping creation');
return { message: 'Terminal base venv already exists', success: true };
}
log.info('[DEPS INSTALL] Creating terminal base venv...');
safeMainWindowSend('install-dependencies-log', {
type: 'stdout',
data: 'Creating terminal base environment...\n',
});
try {
// Create the venv using uv
await new Promise<void>((resolve, reject) => {
const createVenv = spawn(
uv_path,
['venv', '--python', '3.10', terminalVenvPath],
{
env: {
...process.env,
UV_PYTHON_INSTALL_DIR: getCachePath('uv_python'),
},
}
);
createVenv.stdout.on('data', (data) => {
log.info(`[DEPS INSTALL] terminal venv: ${data}`);
});
createVenv.stderr.on('data', (data) => {
log.info(`[DEPS INSTALL] terminal venv: ${data}`);
});
createVenv.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Failed to create terminal venv, exit code: ${code}`));
}
});
createVenv.on('error', reject);
});
// Install base packages
log.info('[DEPS INSTALL] Installing terminal base packages...');
safeMainWindowSend('install-dependencies-log', {
type: 'stdout',
data: `Installing packages: ${TERMINAL_BASE_PACKAGES.join(', ')}...\n`,
});
await new Promise<void>((resolve, reject) => {
const installPkgs = spawn(
uv_path,
[
'pip',
'install',
'--python',
pythonPath,
...TERMINAL_BASE_PACKAGES,
],
{
env: {
...process.env,
UV_PYTHON_INSTALL_DIR: getCachePath('uv_python'),
},
}
);
installPkgs.stdout.on('data', (data) => {
log.info(`[DEPS INSTALL] terminal packages: ${data}`);
safeMainWindowSend('install-dependencies-log', {
type: 'stdout',
data: data.toString(),
});
});
installPkgs.stderr.on('data', (data) => {
log.info(`[DEPS INSTALL] terminal packages: ${data}`);
safeMainWindowSend('install-dependencies-log', {
type: 'stdout',
data: data.toString(),
});
});
installPkgs.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Failed to install terminal packages, exit code: ${code}`));
}
});
installPkgs.on('error', reject);
});
log.info('[DEPS INSTALL] Terminal base venv created successfully');
return { message: 'Terminal base venv created successfully', success: true };
} catch (error) {
log.error('[DEPS INSTALL] Failed to create terminal base venv:', error);
return {
message: `Failed to create terminal base venv: ${error}`,
success: false,
};
}
}
export async function installDependencies(
version: string
): Promise<PromiseReturnType> {
@ -890,6 +1010,13 @@ export async function installDependencies(
// try default install
const installSuccess = await runInstall([], version);
if (installSuccess.success) {
// Install terminal base venv (lightweight venv for terminal tasks)
log.info('[DEPS INSTALL] Installing terminal base venv...');
const terminalResult = await installTerminalBaseVenv(version);
if (!terminalResult.success) {
log.warn('[DEPS INSTALL] Terminal base venv installation failed, but continuing...', terminalResult.message);
}
// Install hybrid_browser_toolkit npm dependencies after Python packages are installed
log.info(
'[DEPS INSTALL] Installing hybrid_browser_toolkit dependencies...'
@ -922,6 +1049,13 @@ export async function installDependencies(
: await runInstall([], version);
if (mirrorInstallSuccess.success) {
// Install terminal base venv (lightweight venv for terminal tasks)
log.info('[DEPS INSTALL] Installing terminal base venv...');
const terminalResult = await installTerminalBaseVenv(version);
if (!terminalResult.success) {
log.warn('[DEPS INSTALL] Terminal base venv installation failed, but continuing...', terminalResult.message);
}
// Install hybrid_browser_toolkit npm dependencies after Python packages are installed
log.info(
'[DEPS INSTALL] Installing hybrid_browser_toolkit dependencies...'

View file

@ -185,6 +185,43 @@ export function getVenvsBaseDir(): string {
return path.join(os.homedir(), '.eigent', 'venvs');
}
/**
* Packages to install in the terminal base venv.
* These are commonly used packages for terminal tasks (data processing, visualization, etc.)
* Keep this list minimal - users can install additional packages as needed.
*/
export const TERMINAL_BASE_PACKAGES = [
'pandas',
'numpy',
'matplotlib',
'requests',
'openpyxl',
'beautifulsoup4',
'pillow',
];
/**
* Get path to the terminal base venv.
* This is a lightweight venv with common packages for terminal tasks,
* separate from the backend venv.
*/
export function getTerminalVenvPath(version: string): string {
const venvDir = path.join(
os.homedir(),
'.eigent',
'venvs',
`terminal_base-${version}`
);
// Ensure venvs directory exists
const venvsBaseDir = path.dirname(venvDir);
if (!fs.existsSync(venvsBaseDir)) {
fs.mkdirSync(venvsBaseDir, { recursive: true });
}
return venvDir;
}
export async function cleanupOldVenvs(currentVersion: string): Promise<void> {
const venvsBaseDir = getVenvsBaseDir();
@ -193,23 +230,34 @@ export async function cleanupOldVenvs(currentVersion: string): Promise<void> {
return;
}
// Patterns to match: backend-{version} and terminal_base-{version}
const venvPatterns = [
{ prefix: 'backend-', regex: /^backend-(.+)$/ },
{ prefix: 'terminal_base-', regex: /^terminal_base-(.+)$/ },
];
try {
const entries = fs.readdirSync(venvsBaseDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('backend-')) {
const versionMatch = entry.name.match(/^backend-(.+)$/);
if (versionMatch && versionMatch[1] !== currentVersion) {
const oldVenvPath = path.join(venvsBaseDir, entry.name);
console.log(`Cleaning up old venv: ${oldVenvPath}`);
if (!entry.isDirectory()) continue;
try {
// Remove old venv directory recursively
fs.rmSync(oldVenvPath, { recursive: true, force: true });
console.log(`Successfully removed old venv: ${entry.name}`);
} catch (err) {
console.error(`Failed to remove old venv ${entry.name}:`, err);
for (const pattern of venvPatterns) {
if (entry.name.startsWith(pattern.prefix)) {
const versionMatch = entry.name.match(pattern.regex);
if (versionMatch && versionMatch[1] !== currentVersion) {
const oldVenvPath = path.join(venvsBaseDir, entry.name);
console.log(`Cleaning up old venv: ${oldVenvPath}`);
try {
// Remove old venv directory recursively
fs.rmSync(oldVenvPath, { recursive: true, force: true });
console.log(`Successfully removed old venv: ${entry.name}`);
} catch (err) {
console.error(`Failed to remove old venv ${entry.name}:`, err);
}
}
break; // Found matching pattern, no need to check others
}
}
}

View file

@ -10,7 +10,7 @@ def permissions():
return [
{
"name": _("User"),
"description": _("User manger"),
"description": _("User manager"),
"children": [
{
"identity": "user:view",
@ -26,7 +26,7 @@ def permissions():
},
{
"name": _("Admin"),
"description": _("Admin manger"),
"description": _("Admin manager"),
"children": [
{
"identity": "admin:view",
@ -42,7 +42,7 @@ def permissions():
},
{
"name": _("Role"),
"description": _("Role manger"),
"description": _("Role manager"),
"children": [
{
"identity": "role:view",
@ -58,7 +58,7 @@ def permissions():
},
{
"name": _("Mcp"),
"description": _("Mcp manger"),
"description": _("Mcp manager"),
"children": [
{
"identity": "mcp:edit",

View file

@ -31,7 +31,7 @@ msgid "User"
msgstr ""
#: app/component/permission.py:13
msgid "User manger"
msgid "User manager"
msgstr ""
#: app/component/permission.py:17
@ -55,7 +55,7 @@ msgid "Admin"
msgstr ""
#: app/component/permission.py:29
msgid "Admin manger"
msgid "Admin manager"
msgstr ""
#: app/component/permission.py:33
@ -79,7 +79,7 @@ msgid "Role"
msgstr ""
#: app/component/permission.py:45
msgid "Role manger"
msgid "Role manager"
msgstr ""
#: app/component/permission.py:49
@ -103,7 +103,7 @@ msgid "Mcp"
msgstr ""
#: app/component/permission.py:61
msgid "Mcp manger"
msgid "Mcp manager"
msgstr ""
#: app/component/permission.py:65

View file

@ -30,7 +30,7 @@ msgid "User"
msgstr ""
#: app/component/permission.py:13
msgid "User manger"
msgid "User manager"
msgstr ""
#: app/component/permission.py:17
@ -54,7 +54,7 @@ msgid "Admin"
msgstr ""
#: app/component/permission.py:29
msgid "Admin manger"
msgid "Admin manager"
msgstr ""
#: app/component/permission.py:33
@ -78,7 +78,7 @@ msgid "Role"
msgstr ""
#: app/component/permission.py:45
msgid "Role manger"
msgid "Role manager"
msgstr ""
#: app/component/permission.py:49
@ -102,7 +102,7 @@ msgid "Mcp"
msgstr ""
#: app/component/permission.py:61
msgid "Mcp manger"
msgid "Mcp manager"
msgstr ""
#: app/component/permission.py:65

View file

@ -215,11 +215,11 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
computedProgressValue(taskId: string) {
const { tasks, setProgressValue, activeTaskId } = get()
const taskRunning = [...tasks[taskId].taskRunning]
const finshedTask = taskRunning?.filter(
const finishedTask = taskRunning?.filter(
(task) => task.status === "completed" || task.status === "failed"
).length;
const taskProgress = (
((finshedTask || 0) / (taskRunning?.length || 0)) *
((finishedTask || 0) / (taskRunning?.length || 0)) *
100
).toFixed(2);
setProgressValue(
@ -1072,7 +1072,7 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
if (agentMessages.step === "new_task_state") {
const { task_id, content, state, result, failure_count } = agentMessages.data;
//new chatStore logic is handled along side "confirmed" event
console.log(`Recieved new task: ${task_id} with content: ${content}`);
console.log(`Received new task: ${task_id} with content: ${content}`);
return;
}

View file

@ -172,7 +172,7 @@ describe('Integration Test: Case 2 - same session new chat', () => {
console.log("Progress test - task status:", task?.status);
}, { timeout: 1500 })
// Test 3: Rerender untill status is "finished"
// Test 3: Rerender until status is "finished"
await waitFor(() => {
rerender()
const {chatStore: newChatStore} = result.current;
@ -392,7 +392,7 @@ describe('Integration Test: Case 2 - same session new chat', () => {
})
})
//TODO: Don't let new startTask untill newChatStore appended
//TODO: Don't let new startTask until newChatStore appended
it("Parallel startTask calls with separate chatStores (startTask -> wait for append -> startTask)", async () => {
const { result, rerender } = renderHook(() => useChatStoreAdapter())