Merge branch 'main' into pr-feat-i18n
24
README.md
|
|
@ -147,8 +147,6 @@ Eigent pre-defined the following agent workers:
|
|||
|
||||

|
||||
|
||||
[![][download-shield]][eigent-download]
|
||||
|
||||
<br/>
|
||||
|
||||
### 🧠 Comprehensive Model Support
|
||||
|
|
@ -156,8 +154,6 @@ Deploy Eigent locally with your preferred models.
|
|||
|
||||

|
||||
|
||||
[![][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
|
|||
|
||||

|
||||
|
||||
[![][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
|
|||
|
||||

|
||||
|
||||
[![][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
|
||||
|
|
|
|||
24
README_CN.md
|
|
@ -147,8 +147,6 @@ Eigent 预定义了以下智能体工作者:
|
|||
|
||||

|
||||
|
||||
[![][download-shield]][eigent-download]
|
||||
|
||||
<br/>
|
||||
|
||||
### 🧠 全面模型支持
|
||||
|
|
@ -156,8 +154,6 @@ Eigent 预定义了以下智能体工作者:
|
|||
|
||||

|
||||
|
||||
[![][download-shield]][eigent-download]
|
||||
|
||||
<br/>
|
||||
|
||||
### 🔌 MCP 工具集成
|
||||
|
|
@ -165,8 +161,6 @@ Eigent 内置大量 **模型上下文协议(MCP)** 工具(用于网页浏
|
|||
|
||||

|
||||
|
||||
[![][download-shield]][eigent-download]
|
||||
|
||||
<br/>
|
||||
|
||||
### ✋ 人工介入
|
||||
|
|
@ -174,8 +168,6 @@ Eigent 内置大量 **模型上下文协议(MCP)** 工具(用于网页浏
|
|||
|
||||

|
||||
|
||||
[![][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]
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
### 后端
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')}")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.*
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 162 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
39
src/components/Dialog/CloseNotice.tsx
Normal 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>
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
1
src/types/chatbox.d.ts
vendored
|
|
@ -16,6 +16,7 @@ declare global {
|
|||
toolkitStatus?: AgentStatus;
|
||||
}[];
|
||||
failure_count?: number;
|
||||
reAssignTo?:string;
|
||||
}
|
||||
interface FileInfo {
|
||||
name: string;
|
||||
|
|
|
|||
201
test/unit/components/Setting/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||