Merge branch 'main' into pr-feat-i18n

This commit is contained in:
sw3205933776 2025-09-16 17:52:33 +08:00
commit 09d2d6a05c
40 changed files with 1891 additions and 1162 deletions

View file

@ -147,8 +147,6 @@ Eigent pre-defined the following agent workers:
![Workforce](https://eigent-ai.github.io/.github/assets/gif/feature_dynamic_workforce.gif)
[![][download-shield]][eigent-download]
<br/>
### 🧠 Comprehensive Model Support
@ -156,8 +154,6 @@ Deploy Eigent locally with your preferred models.
![Model](https://eigent-ai.github.io/.github/assets/gif/feature_local_model.gif)
[![][download-shield]][eigent-download]
<br/>
### 🔌 MCP Tools Integration (MCP)
@ -165,8 +161,6 @@ Eigent comes with massive built-in **Model Context Protocol (MCP)** tools (for
![MCP](https://eigent-ai.github.io/.github/assets/gif/feature_add_mcps.gif)
[![][download-shield]][eigent-download]
<br/>
### ✋ Human-in-the-Loop
@ -174,8 +168,6 @@ If a task gets stuck or encounters uncertainty, Eigent will automatically reques
![Human-in-the-loop](https://eigent-ai.github.io/.github/assets/gif/feature_human_in_the_loop.gif)
[![][download-shield]][eigent-download]
<br/>
### 👐 100% Open Source
@ -183,8 +175,6 @@ Eigent is completely open-sourced. You can download, inspect, and modify the cod
![Opensource][image-opensource]
[![][download-shield]][eigent-download]
<br/>
## 🧩 Use Cases
@ -199,8 +189,6 @@ We are two tennis fans and want to go see the tennis tournament in Palm Springs
<br>
[![][download-shield]][eigent-download]
### 2. Generate Q2 Report from CSV Bank Data [Replay ▶️](https://www.eigent.ai/download?share_token=IjE3NTM1MjY4OTE4MDgtODczOSI.aIjJmQ.WTdoX9mATwrcBr_w53BmGEHPo8U__1753526891808-8739)
<details>
@ -211,8 +199,6 @@ Please help me prepare a Q2 financial statement based on my bank transfer record
<br>
[![][download-shield]][eigent-download]
### 3. UK Healthcare Market Research Report Automation [Replay ▶️](https://www.eigent.ai/download?share_token=IjE3NTMzOTM1NTg3OTctODcwNyI.aIey-Q.Jh9QXzYrRYarY0kz_qsgoj3ewX0__1753393558797-8707)
<details>
@ -223,8 +209,6 @@ Analyze the UK healthcare industry to support the planning of my next company. P
<br>
[![][download-shield]][eigent-download]
### 4. German Electric Skateboard Market Feasibility [Replay ▶️](https://www.eigent.ai/download?share_token=IjE3NTM2NTI4MjY3ODctNjk2Ig.aIjGiA.t-qIXxk_BZ4ENqa-yVIm0wMVyXU__1753652826787-696)
<details>
@ -240,8 +224,6 @@ We are a company that produces high-end electric skateboards, and we are conside
<br>
[![][download-shield]][eigent-download]
### 5. SEO Audit for Workforce Multiagent Launch [Replay ▶️](https://www.eigent.ai/download?share_token=IjE3NTM2OTk5NzExNDQtNTY5NiI.aIex0w.jc_NIPmfIf9e3zGt-oG9fbMi3K4__1753699971144-5696)
<details>
@ -252,8 +234,6 @@ To support the launch of our new Workforce Multiagent product, please run a thor
<br>
[![][download-shield]][eigent-download]
### 6. Identify Duplicate Files in Downloads [Replay ▶️](https://www.eigent.ai/download?share_token=IjE3NTM3NjAzODgxNzEtMjQ4Ig.aIhKLQ.epOG--0Nj0o4Bqjtdqm9OZdaqRQ__1753760388171-248)
<details>
@ -264,8 +244,6 @@ I have a folder named mydocs inside my Documents directory. Please scan it and i
<br>
[![][download-shield]][eigent-download]
### 7. Add Signature to PDF [Replay ▶️](https://www.eigent.ai/download?share_token=IjE3NTQwOTU0ODM0NTItNTY2MSI.aJCHrA.Mg5yPOFqj86H_GQvvRNditzepXc__1754095483452-5661)
<details>
@ -276,8 +254,6 @@ Please add this signature image to the Signature Areas in the PDF. You could ins
<br>
[![][download-shield]][eigent-download]
## 🛠️ Tech Stack
### Backend

View file

@ -147,8 +147,6 @@ Eigent 预定义了以下智能体工作者:
![Workforce](https://eigent-ai.github.io/.github/assets/gif/feature_dynamic_workforce.gif)
[![][download-shield]][eigent-download]
<br/>
### 🧠 全面模型支持
@ -156,8 +154,6 @@ Eigent 预定义了以下智能体工作者:
![Model](https://eigent-ai.github.io/.github/assets/gif/feature_local_model.gif)
[![][download-shield]][eigent-download]
<br/>
### 🔌 MCP 工具集成
@ -165,8 +161,6 @@ Eigent 内置大量 **模型上下文协议MCP** 工具(用于网页浏
![MCP](https://eigent-ai.github.io/.github/assets/gif/feature_add_mcps.gif)
[![][download-shield]][eigent-download]
<br/>
### ✋ 人工介入
@ -174,8 +168,6 @@ Eigent 内置大量 **模型上下文协议MCP** 工具(用于网页浏
![Human-in-the-loop](https://eigent-ai.github.io/.github/assets/gif/feature_human_in_the_loop.gif)
[![][download-shield]][eigent-download]
<br/>
### 👐 100% 开源
@ -183,8 +175,6 @@ Eigent 完全开源。您可以下载、检查和修改代码,确保透明度
![Opensource][image-opensource]
[![][download-shield]][eigent-download]
<br/>
## 🧩 使用案例
@ -199,8 +189,6 @@ Eigent 完全开源。您可以下载、检查和修改代码,确保透明度
<br>
[![][download-shield]][eigent-download]
### 2. 从 CSV 银行数据生成 Q2 报告 [回放 ▶️](https://www.eigent.ai/download?share_token=IjE3NTM1MjY4OTE4MDgtODczOSI.aIjJmQ.WTdoX9mATwrcBr_w53BmGEHPo8U__1753526891808-8739)
<details>
@ -211,8 +199,6 @@ Eigent 完全开源。您可以下载、检查和修改代码,确保透明度
<br>
[![][download-shield]][eigent-download]
### 3. 英国医疗市场调研报告自动化 [回放 ▶️](https://www.eigent.ai/download?share_token=IjE3NTMzOTM1NTg3OTctODcwNyI.aIey-Q.Jh9QXzYrRYarY0kz_qsgoj3ewX0__1753393558797-8707)
<details>
@ -223,8 +209,6 @@ Eigent 完全开源。您可以下载、检查和修改代码,确保透明度
<br>
[![][download-shield]][eigent-download]
### 4. 德国电动滑板市场可行性 [回放 ▶️](https://www.eigent.ai/download?share_token=IjE3NTM2NTI4MjY3ODctNjk2Ig.aIjGiA.t-qIXxk_BZ4ENqa-yVIm0wMVyXU__1753652826787-696)
<details>
@ -235,8 +219,6 @@ Eigent 完全开源。您可以下载、检查和修改代码,确保透明度
<br>
[![][download-shield]][eigent-download]
### 5. 多智能体产品发布的 SEO 审计 [回放 ▶️](https://www.eigent.ai/download?share_token=IjE3NTM2OTk5NzExNDQtNTY5NiI.aIex0w.jc_NIPmfIf9e3zGt-oG9fbMi3K4__1753699971144-5696)
<details>
@ -247,8 +229,6 @@ Eigent 完全开源。您可以下载、检查和修改代码,确保透明度
<br>
[![][download-shield]][eigent-download]
### 6. 识别下载文件夹中的重复文件 [回放 ▶️](https://www.eigent.ai/download?share_token=IjE3NTM3NjAzODgxNzEtMjQ4Ig.aIhKLQ.epOG--0Nj0o4Bqjtdqm9OZdaqRQ__1753760388171-248)
<details>
@ -259,8 +239,6 @@ Eigent 完全开源。您可以下载、检查和修改代码,确保透明度
<br>
[![][download-shield]][eigent-download]
### 7. 添加签名到 PDF [Replay ▶️](https://www.eigent.ai/download?share_token=IjE3NTQwOTU0ODM0NTItNTY2MSI.aJCHrA.Mg5yPOFqj86H_GQvvRNditzepXc__1754095483452-5661)
<details>
@ -271,8 +249,6 @@ Eigent 完全开源。您可以下载、检查和修改代码,确保透明度
<br>
[![][download-shield]][eigent-download]
## 🛠️ 技术栈
### 后端

View file

@ -5,10 +5,36 @@ from fastapi import APIRouter, FastAPI
from dotenv import load_dotenv
import importlib
from typing import Any, overload
import threading
# Thread-local storage for user-specific environment
_thread_local = threading.local()
# Default global environment path
default_env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env")
load_dotenv(dotenv_path=default_env_path)
env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env")
load_dotenv(dotenv_path=env_path)
def set_user_env_path(env_path: str | None = None):
"""
Set user-specific environment path for current thread.
If env_path is None, uses default global environment.
"""
if env_path and os.path.exists(env_path):
_thread_local.env_path = env_path
# Load user-specific environment variables
load_dotenv(dotenv_path=env_path, override=True)
else:
# Clear thread-local env_path to fall back to global
if hasattr(_thread_local, 'env_path'):
delattr(_thread_local, 'env_path')
def get_current_env_path() -> str:
"""
Get current environment path (either user-specific or default).
"""
return getattr(_thread_local, 'env_path', default_env_path)
@overload
@ -24,6 +50,20 @@ def env(key: str, default: Any) -> Any: ...
def env(key: str, default=None):
"""
Get environment variable.
First checks thread-local user-specific environment,
then falls back to global environment.
"""
# If we have a user-specific environment path, try to reload it to get latest values
if hasattr(_thread_local, 'env_path') and os.path.exists(_thread_local.env_path):
# Temporarily load user-specific env to get the latest value
from dotenv import dotenv_values
user_env_values = dotenv_values(_thread_local.env_path)
if key in user_env_values:
return user_env_values[key] or default
# Fall back to global environment
return os.getenv(key, default)

View file

@ -20,6 +20,7 @@ from app.service.task import (
create_task_lock,
get_task_lock,
)
from app.component.environment import set_user_env_path
router = APIRouter(tags=["chat"])
@ -33,6 +34,9 @@ chat_logger = traceroot.get_logger('chat_controller')
async def post(data: Chat, request: Request):
chat_logger.info(f"Starting new chat session for task_id: {data.task_id}, user: {data.email}")
task_lock = create_task_lock(data.task_id)
# Set user-specific environment path for this thread
set_user_env_path(data.env_path)
load_dotenv(dotenv_path=data.env_path)
# logger.debug(f"start chat: {data.model_dump_json()}")

View file

@ -15,6 +15,7 @@ from app.service.task import (
task_locks,
)
import asyncio
from app.component.environment import set_user_env_path
router = APIRouter(tags=["task"])
@ -49,6 +50,8 @@ def take_control(id: str, data: TakeControl):
@router.post("/task/{id}/add-agent", name="add new agent")
def add_agent(id: str, data: NewAgent):
# Set user-specific environment path for this thread
set_user_env_path(data.env_path)
load_dotenv(dotenv_path=data.env_path)
asyncio.run(get_task_lock(id).put_queue(ActionNewAgent(**data.model_dump())))
return Response(status_code=204)

View file

@ -1,5 +1,6 @@
import asyncio
import json
import os
import platform
from threading import Event
import traceback
@ -20,7 +21,7 @@ from app.component.environment import env
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
from app.utils.toolkit.hybrid_browser_toolkit import HybridBrowserToolkit
from app.utils.toolkit.excel_toolkit import ExcelToolkit
from app.utils.toolkit.file_write_toolkit import FileWriteToolkit
from app.utils.toolkit.file_write_toolkit import FileToolkit
from app.utils.toolkit.google_calendar_toolkit import GoogleCalendarToolkit
from app.utils.toolkit.google_drive_mcp_toolkit import GoogleDriveMCPToolkit
from app.utils.toolkit.google_gmail_mcp_toolkit import GoogleGmailMCPToolkit
@ -53,7 +54,7 @@ from loguru import logger
from app.model.chat import Chat, McpServers
# Create traceroot logger for agent tracking
traceroot_logger = traceroot.get_logger('agent')
traceroot_logger = traceroot.get_logger("agent")
from app.service.task import (
Action,
ActionActivateAgentData,
@ -151,7 +152,9 @@ class ListenChatAgent(ChatAgent):
error_info = None
message = None
res = None
traceroot_logger.info(f"Agent {self.agent_name} starting step with message: {input_message.content if isinstance(input_message, BaseMessage) else input_message}")
traceroot_logger.info(
f"Agent {self.agent_name} starting step with message: {input_message.content if isinstance(input_message, BaseMessage) else input_message}"
)
try:
res = super().step(input_message, response_format)
except ModelProcessingError as e:
@ -221,7 +224,9 @@ class ListenChatAgent(ChatAgent):
error_info = None
message = None
res = None
traceroot_logger.debug(f"Agent {self.agent_name} starting async step with message: {input_message.content if isinstance(input_message, BaseMessage) else input_message}")
traceroot_logger.debug(
f"Agent {self.agent_name} starting async step with message: {input_message.content if isinstance(input_message, BaseMessage) else input_message}"
)
try:
res = await super().astep(input_message, response_format)
@ -290,7 +295,9 @@ class ListenChatAgent(ChatAgent):
task_lock = get_task_lock(self.api_task_id)
toolkit_name = getattr(tool, "_toolkit_name") if hasattr(tool, "_toolkit_name") else "mcp_toolkit"
traceroot_logger.debug(f"Agent {self.agent_name} executing tool: {func_name} from toolkit: {toolkit_name} with args: {json.dumps(args, ensure_ascii=False)}")
traceroot_logger.debug(
f"Agent {self.agent_name} executing tool: {func_name} from toolkit: {toolkit_name} with args: {json.dumps(args, ensure_ascii=False)}"
)
asyncio.create_task(
task_lock.put_queue(
ActionActivateToolkitData(
@ -353,7 +360,9 @@ class ListenChatAgent(ChatAgent):
task_lock = get_task_lock(self.api_task_id)
toolkit_name = getattr(tool, "_toolkit_name") if hasattr(tool, "_toolkit_name") else "mcp_toolkit"
traceroot_logger.info(f"Agent {self.agent_name} executing async tool: {func_name} from toolkit: {toolkit_name} with args: {json.dumps(args, ensure_ascii=False)}")
traceroot_logger.info(
f"Agent {self.agent_name} executing async tool: {func_name} from toolkit: {toolkit_name} with args: {json.dumps(args, ensure_ascii=False)}"
)
await task_lock.put_queue(
ActionActivateToolkitData(
data={
@ -861,7 +870,7 @@ async def document_agent(options: Chat):
message_integration = ToolkitMessageIntegration(
message_handler=HumanToolkit(options.task_id, Agents.task_agent).send_message_to_user
)
file_write_toolkit = FileWriteToolkit(options.task_id, working_directory=working_directory)
file_write_toolkit = FileToolkit(options.task_id, working_directory=working_directory)
pptx_toolkit = PPTXToolkit(options.task_id, working_directory=working_directory)
pptx_toolkit = message_integration.register_toolkits(pptx_toolkit)
mark_it_down_toolkit = MarkItDownToolkit(options.task_id)
@ -1043,7 +1052,7 @@ supported formats including advanced spreadsheet functionality.
options,
tools,
tool_names=[
FileWriteToolkit.toolkit_name(),
FileToolkit.toolkit_name(),
PPTXToolkit.toolkit_name(),
HumanToolkit.toolkit_name(),
MarkItDownToolkit.toolkit_name(),
@ -1342,7 +1351,9 @@ operations.
@traceroot.trace()
async def mcp_agent(options: Chat):
traceroot_logger.info(f"Creating MCP agent for task: {options.task_id} with {len(options.installed_mcp['mcpServers'])} MCP servers")
traceroot_logger.info(
f"Creating MCP agent for task: {options.task_id} with {len(options.installed_mcp['mcpServers'])} MCP servers"
)
tools = [
# *HumanToolkit.get_can_use_tools(options.task_id, Agents.mcp_agent),
*McpSearchToolkit(options.task_id).get_tools(),
@ -1400,7 +1411,7 @@ async def get_toolkits(tools: list[str], agent_name: str, api_task_id: str):
"audio_analysis_toolkit": AudioAnalysisToolkit,
"openai_image_toolkit": OpenAIImageToolkit,
"excel_toolkit": ExcelToolkit,
"file_write_toolkit": FileWriteToolkit,
"file_write_toolkit": FileToolkit,
"github_toolkit": GithubToolkit,
"google_calendar_toolkit": GoogleCalendarToolkit,
"google_drive_mcp_toolkit": GoogleDriveMCPToolkit,
@ -1438,7 +1449,17 @@ async def get_mcp_tools(mcp_server: McpServers):
traceroot_logger.info(f"Getting MCP tools for {len(mcp_server['mcpServers'])} servers")
if len(mcp_server["mcpServers"]) == 0:
return []
mcp_toolkit = MCPToolkit(config_dict={**mcp_server}, timeout=180)
# Ensure unified auth directory for all mcp-remote servers to avoid re-authentication on each task
config_dict = {**mcp_server}
for server_config in config_dict["mcpServers"].values():
if "env" not in server_config:
server_config["env"] = {}
# Set global auth directory to persist authentication across tasks
if "MCP_REMOTE_CONFIG_DIR" not in server_config["env"]:
server_config["env"]["MCP_REMOTE_CONFIG_DIR"] = env("MCP_REMOTE_CONFIG_DIR", os.path.expanduser("~/.mcp-auth"))
mcp_toolkit = MCPToolkit(config_dict=config_dict, timeout=20)
try:
await mcp_toolkit.connect()
traceroot_logger.info(f"Successfully connected to MCP toolkit with {len(mcp_server['mcpServers'])} servers")

View file

@ -1,7 +1,7 @@
import asyncio
import os
from typing import List
from camel.toolkits import FileWriteToolkit as BaseFileWriteToolkit
from camel.toolkits import FileToolkit as BaseFileToolkit
from app.component.environment import env
from app.service.task import process_task
from app.service.task import ActionWriteFileData, Agents, get_task_lock
@ -9,7 +9,7 @@ from app.utils.listen.toolkit_listen import listen_toolkit
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
class FileWriteToolkit(BaseFileWriteToolkit, AbstractToolkit):
class FileToolkit(BaseFileToolkit, AbstractToolkit):
agent_name: str = Agents.document_agent
def __init__(
@ -26,7 +26,7 @@ class FileWriteToolkit(BaseFileWriteToolkit, AbstractToolkit):
self.api_task_id = api_task_id
@listen_toolkit(
BaseFileWriteToolkit.write_to_file,
BaseFileToolkit.write_to_file,
lambda _,
title,
content,
@ -54,3 +54,15 @@ class FileWriteToolkit(BaseFileWriteToolkit, AbstractToolkit):
)
)
return res
@listen_toolkit(
BaseFileToolkit.read_file,
)
def read_file(self, file_paths: str | list[str]) -> str | dict[str, str]:
return super().read_file(file_paths)
@listen_toolkit(
BaseFileToolkit.edit_file,
)
def edit_file(self, file_path: str, old_content: str, new_content: str) -> str:
return super().edit_file(file_path, old_content, new_content)

View file

@ -12,8 +12,7 @@ from camel.models import BaseModelBackend
from camel.toolkits.hybrid_browser_toolkit.hybrid_browser_toolkit_ts import (
HybridBrowserToolkit as BaseHybridBrowserToolkit,
)
from camel.toolkits.hybrid_browser_toolkit.ws_wrapper import \
WebSocketBrowserWrapper as BaseWebSocketBrowserWrapper
from camel.toolkits.hybrid_browser_toolkit.ws_wrapper import WebSocketBrowserWrapper as BaseWebSocketBrowserWrapper
from app.component.command import bun, uv
from app.component.environment import env
from app.service.task import Agents
@ -38,18 +37,16 @@ class WebSocketBrowserWrapper(BaseWebSocketBrowserWrapper):
response_data = await self.websocket.recv()
response = json.loads(response_data)
message_id = response.get('id')
message_id = response.get("id")
if message_id and message_id in self._pending_responses:
# Set the result for the waiting coroutine
future = self._pending_responses.pop(message_id)
if not future.done():
future.set_result(response)
logger.debug(
f"Processed response for message {message_id}")
logger.debug(f"Processed response for message {message_id}")
else:
# Log unexpected messages
logger.warning(
f"Received unexpected message: {response}")
logger.warning(f"Received unexpected message: {response}")
except asyncio.CancelledError:
disconnect_reason = "Receive loop cancelled"
@ -57,22 +54,18 @@ class WebSocketBrowserWrapper(BaseWebSocketBrowserWrapper):
break
except websockets.exceptions.ConnectionClosed as e:
disconnect_reason = f"WebSocket closed: code={e.code}, reason={e.reason}"
logger.warning(
f"WebSocket disconnect: {disconnect_reason}")
logger.warning(f"WebSocket disconnect: {disconnect_reason}")
break
except websockets.exceptions.WebSocketException as e:
disconnect_reason = f"WebSocket error: {type(e).__name__}: {e}"
logger.error(
f"WebSocket disconnect: {disconnect_reason}")
logger.error(f"WebSocket disconnect: {disconnect_reason}")
break
except json.JSONDecodeError as e:
logger.error(f"Failed to decode WebSocket message: {e}")
continue # Try to continue on JSON errors
except Exception as e:
disconnect_reason = f"Unexpected error: {type(e).__name__}: {e}"
logger.error(
f"WebSocket disconnect: {disconnect_reason}",
exc_info=True)
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():
@ -80,8 +73,7 @@ class WebSocketBrowserWrapper(BaseWebSocketBrowserWrapper):
self._pending_responses.clear()
break
finally:
logger.info(
f"WebSocket receive loop terminated. Reason: {disconnect_reason or 'Normal shutdown'}")
logger.info(f"WebSocket receive loop terminated. Reason: {disconnect_reason or 'Normal shutdown'}")
# Mark the websocket as None to indicate disconnection
self.websocket = None
@ -113,13 +105,11 @@ class WebSocketBrowserWrapper(BaseWebSocketBrowserWrapper):
)
if build_result.returncode != 0:
logger.error(f"TypeScript build failed: {build_result.stderr}")
raise RuntimeError(
f"TypeScript build failed: {build_result.stderr}")
raise RuntimeError(f"TypeScript build failed: {build_result.stderr}")
else:
# Log warnings but don't fail on them
if build_result.stderr:
logger.warning(
f"TypeScript build warnings: {build_result.stderr}")
logger.warning(f"TypeScript build warnings: {build_result.stderr}")
logger.info("TypeScript build completed successfully")
# Start the WebSocket server
@ -140,8 +130,7 @@ class WebSocketBrowserWrapper(BaseWebSocketBrowserWrapper):
if self.process.poll() is not None:
# Process died
stderr = self.process.stderr.read() # type: ignore
raise RuntimeError(
f"WebSocket server failed to start: {stderr}")
raise RuntimeError(f"WebSocket server failed to start: {stderr}")
try:
line = self.process.stdout.readline() # type: ignore
@ -149,15 +138,13 @@ class WebSocketBrowserWrapper(BaseWebSocketBrowserWrapper):
if line.startswith("SERVER_READY:"):
self.server_port = int(line.split(":")[1].strip())
server_ready = True
logger.info(
f"WebSocket server ready on port {self.server_port}")
logger.info(f"WebSocket server ready on port {self.server_port}")
except (ValueError, IndexError):
continue
if not server_ready:
self.process.kill()
raise RuntimeError(
"WebSocket server failed to start within timeout")
raise RuntimeError("WebSocket server failed to start within timeout")
# Connect to the WebSocket server
try:
@ -170,8 +157,7 @@ class WebSocketBrowserWrapper(BaseWebSocketBrowserWrapper):
logger.info("Connected to WebSocket server")
except Exception as e:
self.process.kill()
raise RuntimeError(
f"Failed to connect to WebSocket server: {e}") from e
raise RuntimeError(f"Failed to connect to WebSocket server: {e}") from e
# Start the background receiver task - THIS WAS MISSING!
self._receive_task = asyncio.create_task(self._receive_loop())
@ -184,14 +170,12 @@ class WebSocketBrowserWrapper(BaseWebSocketBrowserWrapper):
logger.debug("WebSocket server initialized successfully")
except RuntimeError as e:
if "Timeout waiting for response to command: init" in str(e):
logger.warning(
"Init timeout - continuing anyway (CDP connection may be slow)")
logger.warning("Init timeout - continuing anyway (CDP connection may be slow)")
# Continue without error - the WebSocket server is likely still initializing
else:
raise
async def _send_command(self, command: str, params: Dict[str, Any]) -> \
Dict[str, Any]:
async def _send_command(self, command: str, params: Dict[str, Any]) -> Dict[str, Any]:
"""Send a command to the WebSocket server with enhanced error handling."""
try:
# First ensure we have a valid connection
@ -199,14 +183,13 @@ class WebSocketBrowserWrapper(BaseWebSocketBrowserWrapper):
raise RuntimeError("WebSocket connection not established")
# Check connection state before sending
if hasattr(self.websocket, 'state'):
if hasattr(self.websocket, "state"):
import websockets.protocol
if self.websocket.state != websockets.protocol.State.OPEN:
raise RuntimeError(
f"WebSocket is in {self.websocket.state} state, not OPEN")
logger.debug(
f"Sending command '{command}' with params: {params}")
if self.websocket.state != websockets.protocol.State.OPEN:
raise RuntimeError(f"WebSocket is in {self.websocket.state} state, not OPEN")
logger.debug(f"Sending command '{command}' with params: {params}")
# Call parent's _send_command
result = await super()._send_command(command, params)
@ -222,8 +205,7 @@ class WebSocketBrowserWrapper(BaseWebSocketBrowserWrapper):
self.websocket = None
raise
except Exception as e:
logger.error(
f"Unexpected error sending command '{command}': {type(e).__name__}: {e}")
logger.error(f"Unexpected error sending command '{command}': {type(e).__name__}: {e}")
raise
@ -235,8 +217,7 @@ class WebSocketConnectionPool:
self._connections: Dict[str, WebSocketBrowserWrapper] = {}
self._lock = asyncio.Lock()
async def get_connection(self, session_id: str, config: Dict[
str, Any]) -> WebSocketBrowserWrapper:
async def get_connection(self, session_id: str, config: Dict[str, Any]) -> WebSocketBrowserWrapper:
"""Get or create a connection for the given session ID."""
async with self._lock:
# Check if we have an existing connection for this session
@ -248,50 +229,43 @@ class WebSocketConnectionPool:
if wrapper.websocket:
try:
# Check WebSocket state based on available attributes
if hasattr(wrapper.websocket, 'state'):
if hasattr(wrapper.websocket, "state"):
import websockets.protocol
is_healthy = wrapper.websocket.state == websockets.protocol.State.OPEN
if not is_healthy:
logger.debug(
f"Session {session_id} WebSocket state: {wrapper.websocket.state}")
elif hasattr(wrapper.websocket, 'open'):
logger.debug(f"Session {session_id} WebSocket state: {wrapper.websocket.state}")
elif hasattr(wrapper.websocket, "open"):
is_healthy = wrapper.websocket.open
else:
# Try ping as last resort
try:
await asyncio.wait_for(
wrapper.websocket.ping(), timeout=1.0)
await asyncio.wait_for(wrapper.websocket.ping(), timeout=1.0)
is_healthy = True
except:
is_healthy = False
except Exception as e:
logger.debug(
f"Health check failed for session {session_id}: {e}")
logger.debug(f"Health check failed for session {session_id}: {e}")
is_healthy = False
if is_healthy:
logger.debug(
f"Reusing healthy WebSocket connection for session {session_id}")
logger.debug(f"Reusing healthy WebSocket connection for session {session_id}")
return wrapper
else:
# Connection is unhealthy, clean it up
logger.info(
f"Removing unhealthy WebSocket connection for session {session_id}")
logger.info(f"Removing unhealthy WebSocket connection for session {session_id}")
try:
await wrapper.stop()
except Exception as e:
logger.debug(
f"Error stopping unhealthy wrapper: {e}")
logger.debug(f"Error stopping unhealthy wrapper: {e}")
del self._connections[session_id]
# Create a new connection
logger.info(
f"Creating new WebSocket connection for session {session_id}")
logger.info(f"Creating new WebSocket connection for session {session_id}")
wrapper = WebSocketBrowserWrapper(config)
await wrapper.start()
self._connections[session_id] = wrapper
logger.info(
f"Successfully created WebSocket connection for session {session_id}")
logger.info(f"Successfully created WebSocket connection for session {session_id}")
return wrapper
async def close_connection(self, session_id: str):
@ -302,11 +276,9 @@ class WebSocketConnectionPool:
try:
await wrapper.stop()
except Exception as e:
logger.error(
f"Error closing WebSocket connection for session {session_id}: {e}")
logger.error(f"Error closing WebSocket connection for session {session_id}: {e}")
del self._connections[session_id]
logger.info(
f"Closed WebSocket connection for session {session_id}")
logger.info(f"Closed WebSocket connection for session {session_id}")
async def _close_connection_unlocked(self, session_id: str):
"""Close connection without acquiring lock (for internal use)."""
@ -315,11 +287,9 @@ class WebSocketConnectionPool:
try:
await wrapper.stop()
except Exception as e:
logger.error(
f"Error closing WebSocket connection for session {session_id}: {e}")
logger.error(f"Error closing WebSocket connection for session {session_id}: {e}")
del self._connections[session_id]
logger.info(
f"Closed WebSocket connection for session {session_id}")
logger.info(f"Closed WebSocket connection for session {session_id}")
async def close_all(self):
"""Close all connections in the pool."""
@ -337,28 +307,31 @@ class HybridBrowserToolkit(BaseHybridBrowserToolkit, AbstractToolkit):
agent_name: str = Agents.search_agent
def __init__(
self,
api_task_id: str,
*,
headless: bool = False,
user_data_dir: str | None = None,
stealth: bool = True,
web_agent_model: BaseModelBackend | None = None,
cache_dir: str = "tmp/",
enabled_tools: List[str] | None = None,
browser_log_to_file: bool = False,
session_id: str | None = None,
default_start_url: str = "https://google.com/",
default_timeout: int | None = None,
short_timeout: int | None = None,
navigation_timeout: int | None = None,
network_idle_timeout: int | None = None,
screenshot_timeout: int | None = None,
page_stability_timeout: int | None = None,
dom_content_loaded_timeout: int | None = None,
viewport_limit: bool = False,
connect_over_cdp: bool = True,
cdp_url: str | None = "http://localhost:9222",
self,
api_task_id: str,
*,
headless: bool = False,
user_data_dir: str | None = None,
stealth: bool = True,
web_agent_model: BaseModelBackend | None = None,
cache_dir: Optional[str] = None,
enabled_tools: List[str] | None = None,
browser_log_to_file: bool = False,
log_dir: Optional[str] = None,
session_id: str | None = None,
default_start_url: Optional[str] = None,
default_timeout: int | None = None,
short_timeout: int | None = None,
navigation_timeout: int | None = None,
network_idle_timeout: int | None = None,
screenshot_timeout: int | None = None,
page_stability_timeout: int | None = None,
dom_content_loaded_timeout: int | None = None,
viewport_limit: bool = False,
connect_over_cdp: bool = True,
cdp_url: str | None = "http://localhost:9222",
cdp_keep_current_page: bool = False,
full_visual_mode: bool = False,
) -> None:
self.api_task_id = api_task_id
super().__init__(
@ -381,6 +354,8 @@ class HybridBrowserToolkit(BaseHybridBrowserToolkit, AbstractToolkit):
viewport_limit=viewport_limit,
connect_over_cdp=connect_over_cdp,
cdp_url=cdp_url,
cdp_keep_current_page=cdp_keep_current_page,
full_visual_mode=full_visual_mode,
)
async def _ensure_ws_wrapper(self):
@ -388,22 +363,18 @@ class HybridBrowserToolkit(BaseHybridBrowserToolkit, AbstractToolkit):
global websocket_connection_pool
# Get session ID from config or use default
session_id = self._ws_config.get('session_id', 'default')
session_id = self._ws_config.get("session_id", "default")
# Get or create connection from pool
self._ws_wrapper = await websocket_connection_pool.get_connection(
session_id, self._ws_config)
self._ws_wrapper = await websocket_connection_pool.get_connection(session_id, self._ws_config)
# 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...")
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)
self._ws_wrapper = await websocket_connection_pool.get_connection(session_id, self._ws_config)
def clone_for_new_session(self,
new_session_id: str | None = None) -> "HybridBrowserToolkit":
def clone_for_new_session(self, new_session_id: str | None = None) -> "HybridBrowserToolkit":
import uuid
if new_session_id is None:
@ -418,6 +389,7 @@ class HybridBrowserToolkit(BaseHybridBrowserToolkit, AbstractToolkit):
cache_dir=f"{self._cache_dir.rstrip('/')}/_clone_{new_session_id}/",
enabled_tools=self.enabled_tools.copy(),
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=self._default_start_url,
default_timeout=self._default_timeout,
@ -430,6 +402,8 @@ class HybridBrowserToolkit(BaseHybridBrowserToolkit, AbstractToolkit):
viewport_limit=self._viewport_limit,
connect_over_cdp=self.config_loader.get_browser_config().connect_over_cdp,
cdp_url=f"http://localhost:{env('browser_port', '9222')}",
cdp_keep_current_page=self.config_loader.get_browser_config().cdp_keep_current_page,
full_visual_mode=self._full_visual_mode,
)
@classmethod
@ -446,17 +420,15 @@ class HybridBrowserToolkit(BaseHybridBrowserToolkit, AbstractToolkit):
logger.error(f"Error closing browser: {e}")
# Release connection from pool
session_id = self._ws_config.get('session_id', 'default')
session_id = self._ws_config.get("session_id", "default")
await websocket_connection_pool.close_connection(session_id)
logger.info(
f"Released WebSocket connection for session {session_id}")
logger.info(f"Released WebSocket connection for session {session_id}")
def __del__(self):
"""Cleanup when object is garbage collected."""
if hasattr(self, '_ws_wrapper') and self._ws_wrapper:
session_id = self._ws_config.get('session_id', 'default')
logger.debug(
f"HybridBrowserToolkit for session {session_id} is being garbage collected")
if hasattr(self, "_ws_wrapper") and self._ws_wrapper:
session_id = self._ws_config.get("session_id", "default")
logger.debug(f"HybridBrowserToolkit for session {session_id} is being garbage collected")
@listen_toolkit(BaseHybridBrowserToolkit.browser_open)
async def browser_open(self) -> Dict[str, Any]:
@ -474,8 +446,7 @@ class HybridBrowserToolkit(BaseHybridBrowserToolkit, AbstractToolkit):
logger.debug(f"browser_visit_page succeeded for URL: {url}")
return result
except Exception as e:
logger.error(
f"browser_visit_page failed for URL {url}: {type(e).__name__}: {e}")
logger.error(f"browser_visit_page failed for URL {url}: {type(e).__name__}: {e}")
raise
@listen_toolkit(BaseHybridBrowserToolkit.browser_back)
@ -491,10 +462,8 @@ class HybridBrowserToolkit(BaseHybridBrowserToolkit, AbstractToolkit):
return await super().browser_get_page_snapshot()
@listen_toolkit(BaseHybridBrowserToolkit.browser_get_som_screenshot)
async def browser_get_som_screenshot(self, read_image: bool = False,
instruction: str | None = None) -> str:
return await super().browser_get_som_screenshot(read_image,
instruction)
async def browser_get_som_screenshot(self, read_image: bool = False, instruction: str | None = None) -> str:
return await super().browser_get_som_screenshot(read_image, instruction)
@listen_toolkit(BaseHybridBrowserToolkit.browser_click)
async def browser_click(self, *, ref: str) -> Dict[str, Any]:
@ -505,23 +474,19 @@ class HybridBrowserToolkit(BaseHybridBrowserToolkit, AbstractToolkit):
return await super().browser_type(ref=ref, text=text)
@listen_toolkit(BaseHybridBrowserToolkit.browser_select)
async def browser_select(self, *, ref: str, value: str) -> Dict[
str, Any]:
async def browser_select(self, *, ref: str, value: str) -> Dict[str, Any]:
return await super().browser_select(ref=ref, value=value)
@listen_toolkit(BaseHybridBrowserToolkit.browser_scroll)
async def browser_scroll(self, *, direction: str, amount: int = 500) -> \
Dict[str, Any]:
return await super().browser_scroll(direction=direction,
amount=amount)
async def browser_scroll(self, *, direction: str, amount: int = 500) -> Dict[str, Any]:
return await super().browser_scroll(direction=direction, amount=amount)
@listen_toolkit(BaseHybridBrowserToolkit.browser_enter)
async def browser_enter(self) -> Dict[str, Any]:
return await super().browser_enter()
@listen_toolkit(BaseHybridBrowserToolkit.browser_wait_user)
async def browser_wait_user(self, timeout_sec: float | None = None) -> \
Dict[str, Any]:
async def browser_wait_user(self, timeout_sec: float | None = None) -> Dict[str, Any]:
return await super().browser_wait_user(timeout_sec)
@listen_toolkit(BaseHybridBrowserToolkit.browser_switch_tab)

View file

@ -51,13 +51,10 @@ class SlackToolkit(BaseSlackToolkit, AbstractToolkit):
@listen_toolkit(
BaseSlackToolkit.send_slack_message,
lambda _,
message,
channel_id,
user=None: f"send Slack message: {message} to channel id: {channel_id} for user: {user}",
lambda _, message, channel_id, file_path=None, user=None: f"send Slack message: {message} to channel id: {channel_id}, file: {file_path}, user: {user}",
)
def send_slack_message(self, message: str, channel_id: str, user: str | None = None) -> str:
return super().send_slack_message(message, channel_id, user)
def send_slack_message(self, message: str, channel_id: str, file_path: str | None = None, user: str | None = None) -> str:
return super().send_slack_message(message, channel_id, file_path, user)
@listen_toolkit(
BaseSlackToolkit.delete_slack_message,
@ -68,6 +65,20 @@ class SlackToolkit(BaseSlackToolkit, AbstractToolkit):
def delete_slack_message(self, time_stamp: str, channel_id: str) -> str:
return super().delete_slack_message(time_stamp, channel_id)
@listen_toolkit(
BaseSlackToolkit.get_slack_user_list,
lambda _: "get Slack user list",
)
def get_slack_user_list(self) -> str:
return super().get_slack_user_list()
@listen_toolkit(
BaseSlackToolkit.get_slack_user_info,
lambda _, user_id: f"get Slack user info with user id: {user_id}",
)
def get_slack_user_info(self, user_id: str) -> str:
return super().get_slack_user_info(user_id)
@classmethod
def get_can_use_tools(cls, api_task_id: str) -> list[FunctionTool]:
logger.debug(f"slack===={env('SLACK_BOT_TOKEN')}")

View file

@ -25,6 +25,8 @@ class TerminalToolkit(BaseTerminalToolkit, AbstractToolkit):
use_shell_mode: bool = True,
clone_current_env: bool = False,
safe_mode: bool = True,
interactive: bool = False,
log_dir: str | None = None,
):
self.api_task_id = api_task_id
if agent_name is not None:
@ -39,6 +41,8 @@ class TerminalToolkit(BaseTerminalToolkit, AbstractToolkit):
use_shell_mode=use_shell_mode,
clone_current_env=clone_current_env,
safe_mode=safe_mode,
interactive=interactive,
log_dir=log_dir,
)
def _update_terminal_output(self, output: str):

View file

@ -5,7 +5,7 @@ description = "Add your description here"
readme = "README.md"
requires-python = "==3.10.16"
dependencies = [
"camel-ai[eigent]>=0.2.75",
"camel-ai[eigent]>=0.2.76a2",
"fastapi>=0.115.12",
"fastapi-babel>=1.0.0",
"uvicorn[standard]>=0.34.2",

View file

@ -586,7 +586,7 @@ class TestAgentFactoryFunctions:
patch('app.utils.agent.get_toolkits') as mock_get_toolkits, \
patch('asyncio.create_task'), \
patch('app.utils.agent.HumanToolkit') as mock_human_toolkit, \
patch('app.utils.agent.FileWriteToolkit') as mock_file_toolkit, \
patch('app.utils.agent.FileToolkit') as mock_file_toolkit, \
patch('app.utils.agent.PPTXToolkit') as mock_pptx_toolkit, \
patch('app.utils.agent.MarkItDownToolkit') as mock_markdown_toolkit, \
patch('app.utils.agent.ExcelToolkit') as mock_excel_toolkit, \
@ -747,7 +747,7 @@ class TestToolkitFunctions:
with patch('app.utils.agent.SearchToolkit') as mock_search_toolkit, \
patch('app.utils.agent.TerminalToolkit') as mock_terminal_toolkit, \
patch('app.utils.agent.FileWriteToolkit') as mock_file_toolkit:
patch('app.utils.agent.FileToolkit') as mock_file_toolkit:
# Mock toolkit instances - these should return tools directly from get_can_use_tools
mock_search_instance = MagicMock()

1602
backend/uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -94,7 +94,7 @@ Eigent comes with a set of pre-configured agents, each designed for a specific d
**Equipped Toolkits:**
- FileWriteToolkit
- FileToolkit
- PPTXToolkit
- HumanToolkit
- MarkItDownToolkit
@ -137,7 +137,7 @@ This toolkit allows an agent to process audio files. It can take an audio file (
This toolkit provides comprehensive functions for interacting with Excel files (`.xlsx/.xls/. csv`). Agents can create new workbooks, add or delete worksheets, read data from specific cells or ranges, write data to the spreadsheet, and convert data into Markdown formatted table.
### [FileWriteToolkit](https://docs.camel-ai.org/reference/camel.toolkits.file_write_toolkit)
### [FileToolkit](https://docs.camel-ai.org/reference/camel.toolkits.file_write_toolkit)
*A toolkit for creating, writing, and modifying text in files.*

View file

@ -312,6 +312,24 @@ function registerIpcHandlers() {
});
ipcMain.handle('get-app-version', () => app.getVersion());
ipcMain.handle('get-backend-port', () => backendPort);
ipcMain.handle('restart-backend', async () => {
try {
if (backendPort) {
log.info('Restarting backend service...');
await cleanupPythonProcess();
await checkAndStartBackend();
log.info('Backend restart completed successfully');
return { success: true };
} else {
log.warn('No backend port found, starting fresh backend');
await checkAndStartBackend();
return { success: true };
}
} catch (error) {
log.error('Failed to restart backend:', error);
return { success: false, error: String(error) };
}
});
ipcMain.handle('get-system-language', getSystemLanguage);
ipcMain.handle('is-fullscreen', () => win?.isFullScreen() || false);
ipcMain.handle('get-home-dir', () => {
@ -516,6 +534,15 @@ function registerIpcHandlers() {
// ==================== MCP manage handler ====================
ipcMain.handle('mcp-install', async (event, name, mcp) => {
// Convert args from JSON string to array if needed
if (mcp.args && typeof mcp.args === 'string') {
try {
mcp.args = JSON.parse(mcp.args);
} catch (e) {
// If parsing fails, split by comma as fallback
mcp.args = mcp.args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== '');
}
}
addMcp(name, mcp);
return { success: true };
});
@ -526,6 +553,15 @@ function registerIpcHandlers() {
});
ipcMain.handle('mcp-update', async (event, name, mcp) => {
// Convert args from JSON string to array if needed
if (mcp.args && typeof mcp.args === 'string') {
try {
mcp.args = JSON.parse(mcp.args);
} catch (e) {
// If parsing fails, split by comma as fallback
mcp.args = mcp.args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== '');
}
}
updateMcp(name, mcp);
return { success: true };
});

View file

@ -8,6 +8,7 @@ const MCP_CONFIG_PATH = path.join(MCP_CONFIG_DIR, 'mcp.json');
type McpServerConfig = {
command: string;
args: string[];
description?: string;
env?: Record<string, string>;
} | {
url: string;
@ -17,7 +18,7 @@ type McpServersConfig = {
[name: string]: McpServerConfig;
};
type ConfigFile = {
export type ConfigFile = {
mcpServers: McpServersConfig;
};
@ -42,6 +43,28 @@ export function readMcpConfig(): ConfigFile {
if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') {
return getDefaultConfig();
}
// Normalize args field - ensure it's always an array
Object.keys(parsed.mcpServers).forEach(serverName => {
const server = parsed.mcpServers[serverName];
if (server.args) {
const args = server.args as any;
if (typeof args === 'string') {
try {
// Try to parse as JSON string first
server.args = JSON.parse(args);
} catch (e) {
// If parsing fails, split by comma as fallback
server.args = args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== '');
}
}
// Ensure it's always an array of strings
if (Array.isArray(server.args)) {
server.args = server.args.map((arg: any) => String(arg));
}
}
});
return parsed;
} catch (e) {
return getDefaultConfig();
@ -58,7 +81,22 @@ export function writeMcpConfig(config: ConfigFile): void {
export function addMcp(name: string, mcp: McpServerConfig): void {
const config = readMcpConfig();
if (!config.mcpServers[name]) {
config.mcpServers[name] = mcp;
// Ensure args is an array before adding
const normalizedMcp = { ...mcp };
if ('args' in normalizedMcp && normalizedMcp.args) {
const args = normalizedMcp.args as any;
if (typeof args === 'string') {
try {
normalizedMcp.args = JSON.parse(args);
} catch (e) {
normalizedMcp.args = args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== '');
}
}
if (Array.isArray(normalizedMcp.args)) {
normalizedMcp.args = normalizedMcp.args.map((arg: any) => String(arg));
}
}
config.mcpServers[name] = normalizedMcp;
writeMcpConfig(config);
}
}
@ -74,6 +112,21 @@ export function removeMcp(name: string): void {
export function updateMcp(name: string, mcp: McpServerConfig): void {
const config = readMcpConfig();
config.mcpServers[name] = mcp;
// Ensure args is an array before updating
const normalizedMcp = { ...mcp };
if ('args' in normalizedMcp && normalizedMcp.args) {
const args = normalizedMcp.args as any;
if (typeof args === 'string') {
try {
normalizedMcp.args = JSON.parse(args);
} catch (e) {
normalizedMcp.args = args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== '');
}
}
if (Array.isArray(normalizedMcp.args)) {
normalizedMcp.args = normalizedMcp.args.map((arg: any) => String(arg));
}
}
config.mcpServers[name] = normalizedMcp;
writeMcpConfig(config);
}

View file

@ -112,18 +112,23 @@ export class WebViewManager {
}
console.log(`Webview ${id} navigated to: ${navigationUrl}`)
if (webViewInfo.isActive && webViewInfo.isShow && navigationUrl !== 'about:blank?use=0' && navigationUrl !== 'about:blank') {
console.log("did-navigate", id, url)
this.win?.webContents.send("url-updated", url);
console.log("did-navigate", id, navigationUrl)
this.win?.webContents.send("url-updated", navigationUrl);
return
}
webViewInfo.view.setBounds({ x: -1919, y: -1079, width: 1920, height: 1080 })
const activeSize = this.getActiveWebview().length
const allSize = Array.from(this.webViews.values()).length
if (allSize - activeSize <= 3) {
const newId = Array.from(this.webViews.keys()).length + 2
this.createWebview(newId.toString(), 'about:blank?use=0')
this.createWebview((newId + 1).toString(), 'about:blank?use=0')
this.createWebview((newId + 2).toString(), 'about:blank?use=0')
const existingKeys = Array.from(this.webViews.keys()).map(Number).filter(n => !isNaN(n))
const maxId = existingKeys.length > 0 ? Math.max(...existingKeys) : 0
const startId = maxId + 1
// Create webviews sequentially to avoid race conditions
for (let i = 0; i < 3; i++) {
const nextId = (startId + i).toString()
this.createWebview(nextId, 'about:blank?use=0')
}
}
// setTimeout(() => {
@ -242,8 +247,12 @@ export class WebViewManager {
}
}
public distroy() {
// TODO: Destroy all webviews
public destroy() {
// Destroy all webviews
Array.from(this.webViews.keys()).forEach(id => {
this.destroyWebview(id)
})
this.webViews.clear()
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Before After
Before After

View file

@ -231,6 +231,7 @@ const ToolSelect = forwardRef<
// select management
const addOption = (item: McpItem, isLocal?: boolean) => {
setKeyword("");
const currentSelected = initialSelectedTools || [];
console.log(currentSelected.find((i) => i.id === item.id));
if (isLocal) {
@ -245,6 +246,7 @@ const ToolSelect = forwardRef<
const newSelected = [...currentSelected, { ...item, isLocal }];
onSelectedToolsChange?.(newSelected);
}
};
const removeOption = (item: McpItem) => {
@ -472,7 +474,7 @@ const ToolSelect = forwardRef<
onChange={(e) => setKeyword(e.target.value)}
onFocus={() => setIsOpen(true)}
ref={inputRef}
className="bg-transparent border-none !shadow-none text-sm leading-normal !ring-0 !ring-offset-0 w-10 !h-[20px] p-0"
className="bg-transparent border-none !shadow-none text-sm leading-normal !ring-0 !ring-offset-0 w-auto !h-[20px] p-0"
/>
</div>
</div>

View file

@ -22,7 +22,7 @@ import {
CircleSlash,
} from "lucide-react";
import { useMemo, useState, useRef, useEffect } from "react";
import { TaskState } from "../TaskState";
import { TaskState, TaskStateType } from "../TaskState";
interface TaskCardProps {
taskInfo: any[];
@ -34,6 +34,9 @@ interface TaskCardProps {
onAddTask: () => void;
onUpdateTask: (taskIndex: number, content: string) => void;
onDeleteTask: (taskIndex: number) => void;
selectedStates?: TaskStateType[];
onStateChange?: (selectedStates: TaskStateType[]) => void;
clickable?: boolean;
}
export function TaskCard({
@ -45,6 +48,9 @@ export function TaskCard({
onAddTask,
onUpdateTask,
onDeleteTask,
selectedStates = [],
onStateChange,
clickable = true,
}: TaskCardProps) {
const [isExpanded, setIsExpanded] = useState(true);
const contentRef = useRef<HTMLDivElement>(null);
@ -141,11 +147,33 @@ export function TaskCard({
<div className="flex items-center gap-2 ">
{taskType === 1 && (
<TaskState
done={0}
progress={
taskInfo.filter((task) => task.content !== "").length || 0
done={
taskInfo.filter(
(task) =>
task.status === "completed" || task.status === "failed"
).length || 0
}
skipped={0}
progress={
taskInfo.filter(
(task) =>
task.status !== "completed" &&
task.status !== "failed" &&
task.status !== "skipped" &&
task.status !== "waiting" &&
task.status !== ""
).length || 0
}
skipped={
taskInfo.filter(
(task) =>
task.status === "skipped" ||
task.status === "waiting" ||
task.status === ""
).length || 0
}
selectedStates={selectedStates}
onStateChange={onStateChange}
clickable={clickable}
/>
)}
{taskType !== 1 && (
@ -162,13 +190,19 @@ export function TaskCard({
task.status !== "completed" &&
task.status !== "failed" &&
task.status !== "skipped" &&
task.content !== ""
task.status !== "waiting" &&
task.status !== ""
).length || 0
}
skipped={
taskRunning?.filter((task) => task.status === "skipped")
.length || 0
taskRunning?.filter(
(task) =>
task.status === "skipped" || task.status === "waiting" || task.status === ""
).length || 0
}
selectedStates={selectedStates}
onStateChange={onStateChange}
clickable={clickable}
/>
)}
</div>
@ -279,7 +313,7 @@ export function TaskCard({
{task.status === "running" && (
<LoaderCircle
size={16}
className={`text-icon-success ${
className={`text-icon-information ${
chatStore.tasks[
chatStore.activeTaskId as string
].status === "running" && "animate-spin"
@ -316,7 +350,7 @@ export function TaskCard({
</div>
<div className="flex-1 flex flex-col items-start justify-center">
<div
className={` w-full ${
className={` w-full break-words [overflow-wrap:anywhere] whitespace-pre-line ${
task.status === "failed"
? "text-text-cuation-default"
: task.status === "blocked"

View file

@ -11,11 +11,12 @@ import { proxyFetchGet } from "@/api/http";
import { useNavigate, useSearchParams } from "react-router-dom";
import { NoticeCard } from "./NoticeCard";
import { useAuthStore } from "@/store/authStore";
import { PrivacyDialog } from "../Dialog/Privacy";
import { useTranslation } from "react-i18next";
import { TaskStateType } from "../TaskState";
export default function ChatBox(): JSX.Element {
const [message, setMessage] = useState<string>("");
const [selectedStates, setSelectedStates] = useState<TaskStateType[]>([]);
const chatStore = useChatStore();
const { t } = useTranslation();
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -464,6 +465,9 @@ export default function ChatBox(): JSX.Element {
);
chatStore.deleteTaskInfo(taskIndex);
}}
selectedStates={selectedStates}
onStateChange={setSelectedStates}
clickable={true}
/>
);
}

View file

@ -0,0 +1,39 @@
import { useCallback } from "react";
import { Button } from "../ui/button";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
trigger?: React.ReactNode;
}
export default function CloseNoticeDialog({open, onOpenChange, trigger}: Props) {
const onSubmit = useCallback(() => {
window.electronAPI.closeWindow(true)
}, [])
return <Dialog open={open} onOpenChange={onOpenChange}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent className="sm:max-w-[600px] p-0 !bg-popup-surface gap-0 !rounded-xl border border-zinc-300 shadow-sm">
<DialogHeader className="!bg-popup-surface !rounded-t-xl p-md">
<DialogTitle className="m-0">
Close notice
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-md bg-popup-bg p-md">
A task is currently running. Exiting will terminate it. Are you sure you want to exit?
</div>
<DialogFooter className="bg-white-100% !rounded-b-xl p-md">
<DialogClose asChild>
<Button variant="ghost" size="md">
Cancel
</Button>
</DialogClose>
<Button size="md" onClick={onSubmit} variant="primary">
Yes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
}

View file

@ -337,21 +337,21 @@ export default function HistorySidebar() {
className={`transition-all duration-300 flex justify-start items-center gap-1 px-sm py-xs bg-menutabs-bg-default hover:bg-white-100% rounded-lg border border-solid border-white-100% shadow-history-item ${
agentMap[
taskAssigning.type as keyof typeof agentMap
].borderColor
]?.borderColor
}`}
>
<Bot
className={`w-3 h-3 ${
agentMap[
taskAssigning.type as keyof typeof agentMap
].textColor
]?.textColor
}`}
/>
<div
className={`${
agentMap[
taskAssigning.type as keyof typeof agentMap
].textColor
]?.textColor
} text-xs leading-17 font-medium`}
>
{taskAssigning.name}

View file

@ -6,10 +6,32 @@ import { useAuthStore } from "@/store/authStore";
import { useEffect, useState } from "react";
import { AnimationJson } from "@/components/AnimationJson";
import animationData from "@/assets/animation/onboarding_success.json";
import CloseNoticeDialog from "../Dialog/CloseNotice";
import { useChatStore } from "@/store/chatStore";
const Layout = () => {
const { initState, setInitState, isFirstLaunch, setIsFirstLaunch } =
useAuthStore();
const [isInstalling, setIsInstalling] = useState(false);
const [noticeOpen, setNoticeOpen] = useState(false);
const chatStore = useChatStore();
useEffect(() => {
const handleBeforeClose = () => {
const currentStatus = chatStore.tasks[chatStore.activeTaskId as string]?.status;
if(["pending", "running", "pause"].includes(currentStatus)) {
setNoticeOpen(true);
} else {
window.electronAPI.closeWindow(true);
}
};
window.ipcRenderer.on("before-close", handleBeforeClose);
return () => {
window.ipcRenderer.removeAllListeners("before-close");
};
}, [chatStore.tasks, chatStore.activeTaskId]);
useEffect(() => {
const checkToolInstalled = async () => {
// in render process
@ -25,6 +47,7 @@ const Layout = () => {
};
checkToolInstalled();
}, []);
return (
<div className="h-full flex flex-col">
@ -46,6 +69,10 @@ const Layout = () => {
)}
<Outlet />
<HistorySidebar />
<CloseNoticeDialog
onOpenChange={setNoticeOpen}
open={noticeOpen}
/>
</div>
</div>
);

View file

@ -202,6 +202,8 @@ export default function Home() {
{agentMap[activeAgent?.type as keyof typeof agentMap]?.name}
</div>
<TaskState
all={activeAgent?.tasks?.length || 0}
reAssignTo={activeAgent?.tasks?.filter((task) => task.reAssignTo).length || 0}
done={
activeAgent?.tasks?.filter(
(task) =>
@ -213,11 +215,12 @@ export default function Home() {
(task) =>
task.status !== "failed" &&
task.status !== "completed" &&
task.status !== "skipped"
task.status !== "skipped"&&
task.status !== "waiting"
).length || 0
}
skipped={
activeAgent?.tasks?.filter((task) => task.status === "skipped")
activeAgent?.tasks?.filter((task) => task.status === "skipped"||task.status==="waiting")
.length || 0
}
/>

View file

@ -1,49 +1,186 @@
import { CircleCheckBig, LoaderCircle } from "lucide-react";
import { CircleCheckBig, CircleSlash2, LoaderCircle } from "lucide-react";
import { useChatStore } from "@/store/chatStore";
import { useTranslation } from "react-i18next";
export const TaskState = ({
done,
progress,
skipped,
}: {
export type TaskStateType =
| "all"
| "done"
| "reassigned"
| "ongoing"
| "pending";
export interface TaskStateProps {
all?: number;
done: number;
progress: number;
skipped: number;
}) => {
reAssignTo?: number;
selectedStates?: TaskStateType[];
onStateChange?: (selectedStates: TaskStateType[]) => void;
clickable?: boolean;
}
export const TaskState = ({
all,
done,
reAssignTo,
progress,
skipped,
selectedStates = [],
onStateChange,
clickable = true,
}: TaskStateProps) => {
const chatStore = useChatStore();
const { t } = useTranslation();
const handleStateClick = (state: TaskStateType) => {
if (!clickable || !onStateChange) return;
let newSelectedStates: TaskStateType[];
if (state === "all") {
newSelectedStates = selectedStates.includes("all") ? [] : ["all"];
} else {
const otherStates = selectedStates.filter((s) => s !== "all");
if (otherStates.includes(state)) {
newSelectedStates = otherStates.filter((s) => s !== state);
} else {
newSelectedStates = [...otherStates, state];
}
}
onStateChange(newSelectedStates);
};
const isSelected = (state: TaskStateType) => {
return selectedStates.includes(state);
};
const fadeWidthClass = (selected: boolean) =>
`inline-block overflow-hidden align-bottom transition-all duration-300 ease-in-out
${selected ? "max-w-[40px] opacity-100" : "max-w-0 opacity-0"}
group-hover:max-w-[40px] group-hover:opacity-100`;
return (
<div>
<div className="w-auto bg-transparent flex items-center gap-1">
<div className="flex gap-1 items-center py-0.5">
<CircleCheckBig className="w-4 h-4 text-icon-primary" />
<span className="text-text-body text-xs leading-tight font-normal">
{done} {t("chat.done")}
<div className="w-auto bg-transparent flex items-center gap-1 flex-wrap">
{/* All */}
{all && (
<div
className={`group hover:bg-tag-surface flex gap-xs items-center py-0.5 px-2 transition-all duration-200 ${
isSelected("all") ? "bg-tag-surface" : "bg-transparent"
} ${clickable ? "cursor-pointer" : ""}`}
onClick={() => handleStateClick("all")}
>
<span className="text-xs font-normal text-text-body">
All{" "}
<span className={fadeWidthClass(isSelected("all"))}>{all}</span>
</span>
</div>
)}
{/* Done */}
<div
className={`group hover:bg-tag-surface flex gap-xs items-center px-0.5 py-0.5 transition-all duration-200 ${
isSelected("done") && "bg-tag-surface"
} ${
clickable && "cursor-pointer hover:opacity-80 transition-opacity"
}`}
onClick={() => handleStateClick("done")}
>
<CircleCheckBig
className={`w-[10px] h-[10px] text-icon-secondary group-hover:text-icon-success ${
isSelected("done") && "text-icon-success"
}`}
/>
<span
className={`transition-all duration-200 text-xs leading-tight font-normal text-text-label group-hover:text-text-success ${
isSelected("done") && "text-text-success"
}`}
>
Done{" "}
<span className={fadeWidthClass(isSelected("done"))}>{done}</span>
</span>
</div>
{progress !== 0 && (
<div className="flex gap-1 items-center py-0.5">
<LoaderCircle
className={`w-4 h-4 text-icon-success ${
chatStore.tasks[chatStore.activeTaskId as string].status ===
"running" && "animate-spin"
}`}
/>
<span className="text-text-success text-xs leading-tight font-normal">
{progress} {t("chat.in-progress")}
{/* Reassigned */}
{reAssignTo && <div
className={`group hover:bg-tag-surface flex gap-xs items-center px-0.5 py-0.5 transition-all duration-200 ${
isSelected("reassigned") && "bg-tag-surface"
} ${
clickable && "cursor-pointer hover:opacity-80 transition-opacity"
}`}
onClick={() => handleStateClick("reassigned")}
>
<CircleSlash2
className={`w-[10px] h-[10px] text-icon-secondary group-hover:text-icon-warning ${
isSelected("reassigned") && "text-icon-warning"
}`}
/>
<span
className={`transition-all duration-200 text-xs leading-tight font-normal text-text-label group-hover:text-text-warning ${
isSelected("reassigned") && "text-text-warning"
}`}
>
Reassigned{" "}
<span className={fadeWidthClass(isSelected("reassigned"))}>
{reAssignTo}
</span>
</div>
)}
{skipped !== 0 && (
<div className="flex gap-1 items-center py-0.5">
<LoaderCircle
className={`w-4 h-4 text-icon-secondary`}
/>
<span className="text-text-label text-xs leading-tight font-normal">
{skipped} {t("chat.unfinished")}
</span>
</div>}
{/* Ongoing */}
<div
className={`group hover:bg-tag-surface flex gap-xs items-center px-0.5 py-0.5 ${
isSelected("ongoing") && "bg-tag-surface"
} ${
clickable && "cursor-pointer hover:opacity-80 transition-opacity"
}`}
onClick={() => handleStateClick("ongoing")}
>
<LoaderCircle
className={`w-[10px] h-[10px] text-icon-secondary group-hover:text-icon-information ${
isSelected("ongoing") && "!text-icon-information"
} ${
chatStore.tasks[chatStore.activeTaskId as string]?.status ===
"running" && "animate-spin"
}`}
/>
<span
className={`transition-all duration-200 text-xs leading-tight font-normal text-text-label group-hover:text-text-information ${
isSelected("ongoing") && "!text-text-information"
}`}
>
Ongoing{" "}
<span className={fadeWidthClass(isSelected("ongoing"))}>
{progress}
</span>
</div>
)}
</span>
</div>
{/* Pending */}
<div
className={`group hover:bg-tag-surface flex gap-xs items-center px-0.5 py-0.5 ${
isSelected("pending") ? "bg-tag-surface" : "bg-transparent"
} ${
clickable && "cursor-pointer hover:opacity-80 transition-opacity"
}`}
onClick={() => handleStateClick("pending")}
>
<LoaderCircle
className={`w-[10px] h-[10px] text-icon-secondary group-hover:text-primary-foreground ${
isSelected("pending") && "text-primary-foreground"
}`}
/>
<span
className={`text-xs leading-tight font-normal text-text-label group-hover:text-primary-foreground ${
isSelected("pending") && "text-primary-foreground"
}`}
>
Pending{" "}
<span className={fadeWidthClass(isSelected("pending"))}>
{skipped}
</span>
</span>
</div>
</div>
</div>
);

View file

@ -133,7 +133,7 @@ export default function Workflow({
return prev.map((node) => {
// calculate node width and position based on expansion state
const nodeWidth = node.data.isExpanded ? 560 : 280;
const nodeWidth = node.data.isExpanded ? 684 : 342;
const newPosition = { x: currentX, y: node.position.y };
currentX += nodeWidth + 20; // 20 is the spacing between nodes
@ -205,7 +205,7 @@ export default function Workflow({
};
// calculate node width and position based on expansion state
const nodeWidth = updatedNode.data.isExpanded ? 560 : 280;
const nodeWidth = updatedNode.data.isExpanded ? 684 : 342;
const newPosition = { x: currentX, y: node.position.y };
currentX += nodeWidth + 20; // 20 is the spacing between nodes
@ -245,7 +245,7 @@ export default function Workflow({
},
position: isEditMode
? node.position
: { x: index * 300 + 8, y: 16 },
: { x: index * (342+20) + 8, y: 16 },
};
} else {
return {
@ -259,7 +259,7 @@ export default function Workflow({
isEditMode: isEditMode,
workerInfo: agent?.workerInfo,
},
position: { x: index * 300 + 8, y: 16 },
position: { x: index * (342+20) + 8, y: 16 },
type: "node",
};
}
@ -303,7 +303,7 @@ export default function Workflow({
<div className="text-text-body font-bold text-lg leading-relaxed">
{t("workforce.your-ai-workforce")}
</div>
<div className="flex items-center justify-center gap-sm ">
<div className="flex items-center justify-center gap-sm">
{/* <Button
variant="outline"
size="icon"

View file

@ -17,6 +17,7 @@ import {
Trash2,
Edit,
SquareChevronLeft,
CircleSlash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import Folder from "../Folder";
@ -27,7 +28,7 @@ import ShinyText from "../ui/ShinyText/ShinyText";
import { MarkDown } from "./MarkDown";
import { Tooltip, TooltipTrigger } from "../ui/tooltip";
import { TooltipContent } from "@radix-ui/react-tooltip";
import { TaskState } from "../TaskState";
import { TaskState, TaskStateType } from "../TaskState";
import {
Popover,
PopoverClose,
@ -58,6 +59,46 @@ interface NodeProps {
export function Node({ id, data }: NodeProps) {
const [isExpanded, setIsExpanded] = useState(data.isExpanded);
const [selectedTask, setSelectedTask] = useState<any>(null);
const [selectedStates, setSelectedStates] = useState<TaskStateType[]>(['all']);
const [filterTasks, setFilterTasks] = useState<any[]>([]);
useEffect(() => {
const tasks = data.agent?.tasks || [];
if (selectedStates.includes("all") || selectedStates.length === 0) {
setFilterTasks(tasks);
} else {
const newFiltered = tasks.filter((task) => {
return selectedStates.some((state) => {
switch (state) {
case "done":
return (task.status === "completed" || task.status === "failed") && !task.reAssignTo;
case "reassigned":
return !!task.reAssignTo;
case "ongoing":
return (
task.status !== "failed" &&
task.status !== "completed" &&
task.status !== "skipped" &&
task.status !== "waiting" &&
task.status !== "" &&
!task.reAssignTo
);
case "pending":
return (
(task.status === "skipped" ||
task.status === "waiting" ||
task.status === "") &&
!task.reAssignTo
);
default:
return false;
}
});
});
setFilterTasks(newFiltered);
}
}, [selectedStates, data.agent?.tasks]);
const chatStore = useChatStore();
const { setCenter, getNode, setViewport, setNodes } = useReactFlow();
@ -72,7 +113,7 @@ export function Node({ id, data }: NodeProps) {
// manually control node size
useEffect(() => {
if (data.isEditMode) {
const targetWidth = isExpanded ? 560 : 280;
const targetWidth = isExpanded ? 684 : 342;
const targetHeight = 600;
setNodes((nodes) =>
@ -251,15 +292,15 @@ export function Node({ id, data }: NodeProps) {
const list = taskId.split(".");
let idStr = "";
list.shift();
list.map((i: string) => {
idStr += Number(i) + ".";
list.map((i: string, index: number) => {
idStr += Number(i) + (index === list.length - 1 ? "" : ".");
});
return idStr;
};
return (
<>
<NodeResizer
minWidth={isExpanded ? 560 : 280}
minWidth={isExpanded ? 684 : 342}
minHeight={300}
isVisible={data.isEditMode}
keepAspectRatio={false}
@ -276,10 +317,10 @@ export function Node({ id, data }: NodeProps) {
ref={nodeRef}
className={`${
data.isEditMode
? `w-full ${isExpanded ? "min-w-[560px]" : "min-w-[280px]"}`
? `w-full ${isExpanded ? "min-w-[560px]" : "min-w-[342px]"}`
: isExpanded
? "w-[560px]"
: "w-[280px]"
? "w-[684px]"
: "w-[342px]"
} ${
data.isEditMode ? "h-full" : "max-h-[calc(100vh-200px)]"
} border-worker-border-default flex border border-solid rounded-xl overflow-hidden bg-worker-surface-primary ${
@ -292,7 +333,7 @@ export function Node({ id, data }: NodeProps) {
>
<div
className={`py-2 px-3 pr-0 flex flex-col ${
data.isEditMode ? "flex-1 min-w-[280px]" : "w-[280px] "
data.isEditMode ? "flex-1 min-w-[342px]" : "w-[342px] "
}`}
>
<div className=" flex items-center justify-between gap-sm pr-3">
@ -447,28 +488,43 @@ export function Node({ id, data }: NodeProps) {
</div>
{data.agent?.tasks && data.agent?.tasks.length > 0 && (
<div className="flex flex-col items-start justify-between gap-1 pt-sm border-[0px] border-t border-solid border-task-border-default pr-3">
<div className="font-bold leading-tight text-xs">Subtasks</div>
{/* <div className="font-bold leading-tight text-xs">Subtasks</div> */}
<div className="flex-1 flex justify-end">
<TaskState
all={data.agent.tasks?.length || 0}
done={
data.agent?.tasks?.filter(
(task) =>
task.status === "failed" || task.status === "completed"
(task.status === "failed" || task.status === "completed") && !task.reAssignTo
).length || 0
}
reAssignTo={
data.agent.tasks?.filter((task) => task.reAssignTo)
?.length || 0
}
progress={
data.agent?.tasks?.filter(
(task) =>
task.status !== "failed" &&
task.status !== "completed" &&
task.status !== "skipped"
task.status !== "skipped" &&
task.status !== "waiting" &&
task.status !== "" &&
!task.reAssignTo
).length || 0
}
skipped={
data.agent?.tasks?.filter(
(task) => task.status === "skipped"
(task) =>
(task.status === "skipped" ||
task.status === "waiting" ||
task.status === "") &&
!task.reAssignTo
).length || 0
}
selectedStates={selectedStates}
onStateChange={setSelectedStates}
clickable={true}
/>
</div>
</div>
@ -489,7 +545,7 @@ export function Node({ id, data }: NodeProps) {
}}
>
{data.agent?.tasks &&
data.agent?.tasks.map((task, index) => {
filterTasks.map((task, index) => {
return (
<div
onClick={() => {
@ -510,7 +566,9 @@ export function Node({ id, data }: NodeProps) {
}}
key={`taskList-${task.id}-${task.failure_count}`}
className={`rounded-lg flex gap-2 py-sm px-sm transition-all duration-300 ease-in-out animate-in fade-in-0 slide-in-from-left-2 ${
task.status === "completed"
task.reAssignTo
? "bg-task-fill-warning"
: task.status === "completed"
? "bg-green-50"
: task.status === "failed"
? "bg-task-fill-error"
@ -543,53 +601,89 @@ export function Node({ id, data }: NodeProps) {
: "border-transparent"
}`}
>
<div className="pt-0.5">
{task.status === "running" && (
<LoaderCircle
size={16}
className={`text-icon-success ${
chatStore.tasks[chatStore.activeTaskId as string]
.status === "running" && "animate-spin"
}`}
/>
)}
{task.status === "skipped" && (
<LoaderCircle
size={16}
className={`text-icon-secondary `}
/>
)}
{task.status === "completed" && (
<CircleCheckBig
size={16}
className="text-icon-success"
/>
)}
{task.status === "failed" && (
<CircleSlash size={16} className="text-icon-cuation" />
)}
{task.status === "blocked" && (
<TriangleAlert
size={16}
className="text-icon-warning"
/>
)}
{(task.status === "" || task.status === "waiting") && (
<Circle size={16} className="text-slate-400" />
<div className="">
{task.reAssignTo ? (
// reassign to other agent
<CircleSlash2 size={16} className="text-icon-warning" />
) : (
// normal task
<>
{task.status === "running" && (
<LoaderCircle
size={16}
className={`text-icon-information ${
chatStore.tasks[
chatStore.activeTaskId as string
].status === "running" && "animate-spin"
}`}
/>
)}
{task.status === "skipped" && (
<LoaderCircle
size={16}
className={`text-icon-secondary `}
/>
)}
{task.status === "completed" && (
<CircleCheckBig
size={16}
className="text-icon-success"
/>
)}
{task.status === "failed" && (
<CircleSlash
size={16}
className="text-icon-cuation"
/>
)}
{task.status === "blocked" && (
<TriangleAlert
size={16}
className="text-icon-warning"
/>
)}
{(task.status === "" ||
task.status === "waiting") && (
<Circle size={16} className="text-slate-400" />
)}
</>
)}
</div>
<div className="flex-1 flex flex-col items-start justify-center">
<div
className={` w-full flex-grow-0 ${
className={`w-full flex-grow-0 ${
task.status === "failed"
? "text-text-cuation-default"
: task.status === "blocked"
? "text-text-body"
: "text-text-primary"
} text-sm font-medium leading-13 select-text pointer-events-auto break-all text-wrap whitespace-pre-line`}
} text-xs font-medium leading-13 select-text pointer-events-auto break-all text-wrap whitespace-pre-line`}
>
{getTaskId(task.id)}
{task.content}
<div className="flex items-center gap-sm">
<div className="text-text-body text-xs font-bold leading-13">
No. {getTaskId(task.id)}
</div>
{task.reAssignTo ? (
<div className="text-text-warning text-xs font-bold leading-none rounded-lg px-1 py-0.5 bg-tag-fill-document">
Reassigned to {task.reAssignTo}
</div>
) : (
(task.failure_count ?? 0) > 0 && (
<div
className={`${
task.status === "failed"
? "bg-red-100 text-text-cuation"
: task.status === "completed"
? "bg-tag-fill-developer text-text-success-default"
: "bg-tag-surface-hover text-text-label"
} text-xs font-bold leading-none rounded-lg px-1 py-0.5`}
>
Attempt {task.failure_count}
</div>
)
)}
</div>
<div>{task.content}</div>
</div>
{task?.status === "running" && (
<div className="flex items-center gap-2 mt-xs animate-in fade-in-0 slide-in-from-bottom-2 duration-400">
@ -597,7 +691,7 @@ export function Node({ id, data }: NodeProps) {
{task.toolkits &&
task.toolkits.length > 0 &&
task.toolkits
.filter((tool) => tool.toolkitName !== "notice")
.filter((tool: any) => tool.toolkitName !== "notice")
.at(-1)?.toolkitStatus === "running" && (
<div className="flex-1 min-w-0 flex justify-start items-center gap-sm animate-in fade-in-0 slide-in-from-right-2 duration-300">
{agentMap[data.type]?.icon ?? (
@ -621,11 +715,6 @@ export function Node({ id, data }: NodeProps) {
)}
</div>
)}
{(task.failure_count ?? 0) > 0&& (
<div className="text-text-cuation-default text-xs leading-17">
retry {task.failure_count} times
</div>
)}
</div>
</div>
);
@ -636,7 +725,7 @@ export function Node({ id, data }: NodeProps) {
<div
key={selectedTask?.id || "empty"}
className={`${
data.isEditMode ? "flex-1" : "w-[280px]"
data.isEditMode ? "flex-1" : "w-[342px]"
} flex flex-col gap-sm border-l bg-worker-surface-secondary rounded-r-xl px-sm pr-0 py-3 pt-sm animate-in fade-in-0 slide-in-from-right-2 duration-300 `}
>
<div

View file

@ -344,21 +344,21 @@ export default function Home() {
className={`transition-all duration-300 flex justify-start items-center gap-1 px-sm py-xs bg-menutabs-bg-default rounded-lg border border-solid border-white-100% ${
agentMap[
taskAssigning.type as keyof typeof agentMap
].borderColor
]?.borderColor
}`}
>
<Bot
className={`w-3 h-3 ${
agentMap[
taskAssigning.type as keyof typeof agentMap
].textColor
]?.textColor
}`}
/>
<div
className={`${
agentMap[
taskAssigning.type as keyof typeof agentMap
].textColor
]?.textColor
} text-xs leading-17 font-medium`}
>
{taskAssigning.name}

View file

@ -21,6 +21,7 @@ import { useAuthStore } from "@/store/authStore";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ConfigFile } from "electron/main/utils/mcpConfig";
export default function SettingMCP() {
const navigate = useNavigate();
@ -197,13 +198,28 @@ export default function SettingMCP() {
setSaving(true);
setErrorMsg(null);
try {
await proxyFetchPut(`/api/mcp/users/${showConfig.id}`, {
const mcpData = {
mcp_name: configForm.mcp_name,
mcp_desc: configForm.mcp_desc,
command: configForm.command,
args: arrayToArgsJson(configForm.argsArr),
env: configForm.env,
});
}
await proxyFetchPut(`/api/mcp/users/${showConfig.id}`, mcpData);
if (window.ipcRenderer) {
//Partial payload to empty env {}
const payload: any = {
description: configForm.mcp_desc,
command: configForm.command,
args: arrayToArgsJson(configForm.argsArr),
};
if (configForm.env && Object.keys(configForm.env).length > 0) {
payload.env = configForm.env;
}
window.ipcRenderer.invoke("mcp-update", mcpData.mcp_name, payload);
}
setShowConfig(null);
fetchList();
} catch (err: any) {
@ -238,9 +254,27 @@ export default function SettingMCP() {
setInstalling(true);
try {
if (addType === "local") {
let data;
let data:ConfigFile;
try {
data = JSON.parse(localJson);
// validate mcpServers structure
if (!data.mcpServers || typeof data.mcpServers !== "object") {
throw new Error("Invalid mcpServers");
}
// check for name conflicts with existing items
const serverNames = Object.keys(data.mcpServers);
const conflict = serverNames.find((name) =>
items.some((d) => d.mcp_name === name)
);
if (conflict) {
toast.error(`MCP server "${conflict}" already exists`, {
closeButton: true,
});
setInstalling(false);
return;
}
} catch (e) {
toast.error(t("setting.invalid-json"), { closeButton: true });
setInstalling(false);
@ -254,19 +288,14 @@ export default function SettingMCP() {
}
if (window.ipcRenderer) {
const mcpServers = data["mcpServers"];
Object.entries(mcpServers).forEach(async ([key, value]) => {
for (const [key, value] of Object.entries(mcpServers)) {
await window.ipcRenderer.invoke("mcp-install", key, value);
});
}
}
}
setShowAdd(false);
setLocalJson(`{
"mcp_id": 0,
"mcp_name": "",
"mcp_desc": "",
"command": "",
"args": "",
"env": {}
"mcpServers": {}
}`);
setRemoteName("");
setRemoteUrl("");
@ -337,13 +366,13 @@ export default function SettingMCP() {
{!isLoading && !error && items.length === 0 && (
<div className="text-center py-8 text-gray-400">{t("setting.no-mcp-servers")}</div>
)}
<MCPList
{!isLoading && <MCPList
items={items}
onSetting={setShowConfig}
onDelete={setDeleteTarget}
onSwitch={handleSwitch}
switchLoading={switchLoading}
/>
/>}
<MCPConfigDialog
open={!!showConfig}
form={configForm}

View file

@ -684,9 +684,6 @@ export default function SettingModels() {
<SelectItem value="gpt-5">GPT-5</SelectItem>
<SelectItem value="gpt-5-mini">GPT-5 mini</SelectItem>
<SelectItem value="gpt-5-nano">GPT-5 nano</SelectItem>
<SelectItem value="claude-opus-4-1-20250805">
Claude Opus 4.1
</SelectItem>
<SelectItem value="claude-sonnet-4-20250514">
Claude Sonnet 4
</SelectItem>

View file

@ -34,7 +34,7 @@ export default function MCPConfigDialog({ open, form, mcp, onChange, onSave, onC
<form onSubmit={onSave} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">{t("setting.name")}</label>
<input autoComplete="off" className="w-full border rounded px-3 py-2 text-sm" value={form.mcp_name} onChange={e => onChange({ ...form, mcp_name: e.target.value })} disabled={loading} />
<input autoComplete="off" className="w-full border rounded px-3 py-2 text-sm" value={form.mcp_name} onChange={e => onChange({ ...form, mcp_name: e.target.value })} disabled readOnly />
</div>
<div>
<label className="block text-sm font-medium mb-1">{t("setting.description")}</label>

View file

@ -12,9 +12,9 @@ interface MCPListProps {
export default function MCPList({ items, onSetting, onDelete, onSwitch, switchLoading }: MCPListProps) {
return (
<div className='pt-4'>
{items.map(item => (
{items.map((item) => (
<MCPListItem
key={item.mcp_id}
key={item.id}
item={item}
onSetting={onSetting}
onDelete={onDelete}

View file

@ -1,11 +1,37 @@
export function parseArgsToArray(args: string): string[] {
try {
// Try parsing as JSON array first
const arr = JSON.parse(args);
if (Array.isArray(arr)) return arr.map(String);
} catch { }
// Handle malformed JSON by manually trimming { } and trying again
if (args.trim().startsWith('{') && args.trim().endsWith('}')) {
const trimmed = args.trim().slice(1, -1); // Remove { }
try {
// Try parsing the trimmed version as JSON array
const arr = JSON.parse(`[${trimmed}]`);
if (Array.isArray(arr)) return arr.map(String);
} catch { }
// If still fails, treat as comma-separated
if (trimmed.trim()) {
return trimmed.split(',').map(arg => arg.trim()).filter(arg => arg !== '');
}
}
// If not JSON, treat as comma-separated string
if (args.trim()) {
return args.split(',').map(arg => arg.trim()).filter(arg => arg !== '');
}
return [];
}
export function arrayToArgsJson(arr: string[]): string {
return JSON.stringify(arr.filter(v => v.trim() !== ''));
const filtered = arr.filter(v => v.trim() !== '');
if (filtered.length === 0) return '';
// Return as JSON stringified array
return JSON.stringify(filtered);
}

View file

@ -450,7 +450,7 @@ const chatStore = create<ChatStore>()(
let taskRunning = [...tasks[taskId].taskRunning]
let taskAssigning = [...tasks[taskId].taskAssigning]
const targetTaskIndex = taskRunning.findIndex((task) => task.id === task_id)
const targetTaskAssigningIndex = taskAssigning.findIndex((agent) => agent.tasks.find((task: TaskInfo) => task.id === task_id && (task.failure_count == 0 || !task.failure_count)))
const targetTaskAssigningIndex = taskAssigning.findIndex((agent) => agent.tasks.find((task: TaskInfo) => task.id === task_id && !task.reAssignTo))
if (targetTaskAssigningIndex !== -1) {
const taskIndex = taskAssigning[targetTaskAssigningIndex].tasks.findIndex((task: TaskInfo) => task.id === task_id)
taskAssigning[targetTaskAssigningIndex].tasks[taskIndex].status = state === "DONE" ? "completed" : "failed";
@ -484,20 +484,19 @@ const chatStore = create<ChatStore>()(
content: targetResult,
step: "failed",
})
setStatus(taskId, 'pause')
}
}
}
if (targetTaskIndex !== -1) {
console.log("targetTaskIndex", targetTaskIndex,state)
taskRunning[targetTaskIndex].status = state === "DONE" ? "completed" : "failed";
}
setTaskRunning(taskId, taskRunning)
setTaskAssigning(taskId, taskAssigning)
return;
}
// Activate agent
if (agentMessages.step === "activate_agent" || agentMessages.step === "deactivate_agent") {
let taskAssigning = [...tasks[taskId].taskAssigning]
@ -540,7 +539,6 @@ const chatStore = create<ChatStore>()(
setTaskAssigning(taskId, [...taskAssigning]);
}
if (agentMessages.step === "deactivate_agent") {
taskAssigning[agentIndex].status = "completed";
if (message) {
const index = taskAssigning[agentIndex].log.findLastIndex((log) => log.data.method_name === agentMessages.data.method_name && log.data.toolkit_name === agentMessages.data.toolkit_name)
if (index != -1) {
@ -549,12 +547,11 @@ const chatStore = create<ChatStore>()(
}
}
// const taskIndex = taskRunning!.findLastIndex((task) => task.agent?.agent_id === agent_id && task.status !== 'completed' && task.status !== 'failed');
const taskIndex = taskRunning.findIndex((task) => task.id === process_task_id);
if (taskIndex !== -1) {
taskRunning![taskIndex].agent!.status = "completed";
taskRunning![taskIndex]!.status = "completed";
}
// const taskIndex = taskRunning.findIndex((task) => task.id === process_task_id);
// if (taskIndex !== -1) {
// taskRunning![taskIndex].agent!.status = "completed";
// taskRunning![taskIndex]!.status = "completed";
// }
if (!type && historyId) {
@ -580,11 +577,12 @@ const chatStore = create<ChatStore>()(
if (agentMessages.step === "assign_task") {
if (!agentMessages.data?.assignee_id || !agentMessages.data?.task_id) return;
const { assignee_id, task_id, content = "", state: taskState } = agentMessages.data as any;
const { assignee_id, task_id, content = "", state: taskState, failure_count } = agentMessages.data as any;
let taskAssigning = [...tasks[taskId].taskAssigning]
let taskRunning = [...tasks[taskId].taskRunning]
let taskInfo = [...tasks[taskId].taskInfo]
// Find the index of the agent corresponding to assignee_id
const assigneeAgentIndex = taskAssigning!.findIndex((agent: Agent) => agent.agent_id === assignee_id);
// Find task corresponding to task_id
const task = taskInfo!.find((task: TaskInfo) => task.id === task_id);
@ -594,6 +592,26 @@ const chatStore = create<ChatStore>()(
if (assigneeAgentIndex === -1) return;
const taskAgent = taskAssigning![assigneeAgentIndex];
// Find the agent to reassign the task to
const target = taskAssigning
.map((agent, agentIndex) => {
if (agent.agent_id === assignee_id) return null
const taskIndex = agent.tasks.findIndex(
(task: TaskInfo) => task.id === task_id && !task.reAssignTo
)
return taskIndex !== -1 ? { agentIndex, taskIndex } : null
})
.find(Boolean)
if (target) {
const { agentIndex, taskIndex } = target
const agentName = taskAssigning.find((agent: Agent) => agent.agent_id === assignee_id)?.name
taskAssigning[agentIndex].tasks[taskIndex].reAssignTo = agentName
}
// If the state is "waiting", only mark it in the agent's task list and do not add it to taskRunning
if (taskState === "waiting") {
if (!taskAssigning[assigneeAgentIndex].tasks.find(item => item.id === task_id)) {
@ -607,10 +625,13 @@ const chatStore = create<ChatStore>()(
if (taskAssigning && taskAssigning[assigneeAgentIndex]) {
// Check if task already exists in the agent's task list
const existingTaskIndex = taskAssigning[assigneeAgentIndex].tasks.findIndex(item => item.id === task_id);
if (existingTaskIndex !== -1) {
// Task already exists, update its status
taskAssigning[assigneeAgentIndex].tasks[existingTaskIndex].status = "running";
if (failure_count !== 0) {
taskAssigning[assigneeAgentIndex].tasks[existingTaskIndex].failure_count = failure_count;
}
} else {
// Task doesn't exist, add it
let taskTemp = null
@ -624,7 +645,7 @@ const chatStore = create<ChatStore>()(
taskAssigning[assigneeAgentIndex].tasks.push(taskTemp ?? { id: task_id, content, status: "running", });
}
}
// Only update or add to taskRunning, never duplicate
if (taskRunningIndex === -1) {
// Task not in taskRunning, add it
@ -640,7 +661,6 @@ const chatStore = create<ChatStore>()(
// Task already in taskRunning, update it
taskRunning![taskRunningIndex] = {
...taskRunning![taskRunningIndex],
content,
status: "",
agent: JSON.parse(JSON.stringify(taskAgent)),
};
@ -1638,7 +1658,17 @@ const chatStore = create<ChatStore>()(
clearTasks: () => {
const { create } = get()
console.log('clearTasks')
fetchDelete('/task/stop-all')
window.ipcRenderer.invoke('restart-backend')
.then((res) => {
console.log('restart-backend', res)
})
.catch((error) => {
console.error('Error in clearTasks cleanup:', error)
})
// Immediately create new task to maintain UI responsiveness
const newTaskId = create()
set((state) => ({
...state,

View file

@ -16,6 +16,7 @@ declare global {
toolkitStatus?: AgentStatus;
}[];
failure_count?: number;
reAssignTo?:string;
}
interface FileInfo {
name: string;

View file

@ -0,0 +1,201 @@
import { describe, it, expect } from 'vitest';
import { parseArgsToArray, arrayToArgsJson } from '../../../../src/pages/Setting/components/utils';
describe('parseArgsToArray', () => {
it('should parse JSON array string to array', () => {
const input = '["arg1", "arg2", "arg3"]';
const expected = ['arg1', 'arg2', 'arg3'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse JSON array string with special characters', () => {
const input = '["-y", "@modelcontextprotocol/server-sequential-thinking"]';
const expected = ['-y', '@modelcontextprotocol/server-sequential-thinking'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse JSON array string with file paths containing backslashes', () => {
const input = '["--directory", "C:\\\\Users\\\\ASUS\\\\Desktop\\\\project", "run", "main.py"]';
const expected = ['--directory', 'C:\\Users\\ASUS\\Desktop\\project', 'run', 'main.py'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse JSON array string with file paths containing forward slashes', () => {
const input = '["--directory", "C:/Users/ASUS/Desktop/project", "run", "main.py"]';
const expected = ['--directory', 'C:/Users/ASUS/Desktop/project', 'run', 'main.py'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse comma-separated string to array', () => {
const input = '-y,@modelcontextprotocol/server-filesystem,.';
const expected = ['-y', '@modelcontextprotocol/server-filesystem', '.'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse comma-separated string with spaces', () => {
const input = '-y, @modelcontextprotocol/server-filesystem, .';
const expected = ['-y', '@modelcontextprotocol/server-filesystem', '.'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse comma-separated string with file paths containing slashes', () => {
const input = '--directory,C:/Users/ASUS/Desktop/project,run,main.py';
const expected = ['--directory', 'C:/Users/ASUS/Desktop/project', 'run', 'main.py'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should handle empty string', () => {
const input = '';
const expected: string[] = [];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should handle whitespace-only string', () => {
const input = ' ';
const expected: string[] = [];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should filter out empty args from comma-separated string', () => {
const input = '-y,,@modelcontextprotocol/server-filesystem,.';
const expected = ['-y', '@modelcontextprotocol/server-filesystem', '.'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should handle invalid JSON gracefully by treating as comma-separated', () => {
const input = '[invalid json';
const expected: string[] = ['[invalid json'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should handle non-array JSON by treating as comma-separated', () => {
const input = '{"key": "value"}';
//Trim the curly braces
const expected: string[] = ['"key": "value"'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should convert array elements to strings', () => {
const input = '[123, true, "string", null]';
const expected = ['123', 'true', 'string', 'null'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
});
describe('arrayToArgsJson', () => {
it('should convert array to JSON string', () => {
const input = ['arg1', 'arg2', 'arg3'];
const expected = '["arg1","arg2","arg3"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should convert array with special characters to JSON string', () => {
const input = ['-y', '@modelcontextprotocol/server-sequential-thinking'];
const expected = '["-y","@modelcontextprotocol/server-sequential-thinking"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should convert array with file paths containing backslashes', () => {
const input = ['--directory', 'C:\\Users\\ASUS\\Desktop\\project', 'run', 'main.py'];
const expected = '["--directory","C:\\\\Users\\\\ASUS\\\\Desktop\\\\project","run","main.py"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should convert array with file paths containing forward slashes', () => {
const input = ['--directory', 'C:/Users/ASUS/Desktop/project', 'run', 'main.py'];
const expected = '["--directory","C:/Users/ASUS/Desktop/project","run","main.py"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should handle empty array', () => {
const input: string[] = [];
const expected = '';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should filter out empty strings and whitespace-only strings', () => {
const input = ['arg1', '', ' ', 'arg2'];
const expected = '["arg1","arg2"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should return empty string for array with only empty/whitespace strings', () => {
const input = ['', ' ', '\t', '\n'];
const expected = '';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should preserve strings with meaningful whitespace', () => {
const input = ['arg with spaces', 'another arg'];
const expected = '["arg with spaces","another arg"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
});
describe('bidirectional conversion', () => {
it('should correctly convert from comma-separated string to JSON and back', () => {
const original = '-y,@modelcontextprotocol/server-filesystem,.';
const array = parseArgsToArray(original);
const jsonString = arrayToArgsJson(array);
const finalArray = parseArgsToArray(jsonString);
expect(array).toEqual(['-y', '@modelcontextprotocol/server-filesystem', '.']);
expect(jsonString).toBe('["-y","@modelcontextprotocol/server-filesystem","."]');
expect(finalArray).toEqual(array);
});
it('should correctly convert from JSON string to array and back', () => {
const original = '["-y","@modelcontextprotocol/server-sequential-thinking"]';
const array = parseArgsToArray(original);
const jsonString = arrayToArgsJson(array);
expect(array).toEqual(['-y', '@modelcontextprotocol/server-sequential-thinking']);
expect(jsonString).toBe(original);
});
it('should handle file paths with various slash types bidirectionally', () => {
const windowsPath = '["--directory","C:\\\\Users\\\\ASUS\\\\Desktop\\\\project","run"]';
const unixPath = '["--directory","/home/user/project","run"]';
// Test Windows paths
const windowsArray = parseArgsToArray(windowsPath);
const windowsJson = arrayToArgsJson(windowsArray);
expect(parseArgsToArray(windowsJson)).toEqual(windowsArray);
// Test Unix paths
const unixArray = parseArgsToArray(unixPath);
const unixJson = arrayToArgsJson(unixArray);
expect(parseArgsToArray(unixJson)).toEqual(unixArray);
});
it('should handle mixed path separators in comma-separated format', () => {
const mixed = '--directory,C:/Users/ASUS\\Desktop/project,run,main.py';
const array = parseArgsToArray(mixed);
const jsonString = arrayToArgsJson(array);
const finalArray = parseArgsToArray(jsonString);
expect(array).toEqual(['--directory', 'C:/Users/ASUS\\Desktop/project', 'run', 'main.py']);
expect(finalArray).toEqual(array);
});
});