mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-10 12:10:53 +00:00
Merge branch 'main' into fix/task-log-reassign-incomplete-110
This commit is contained in:
commit
4dc76a0ed9
16 changed files with 521 additions and 36 deletions
|
|
@ -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
|
||||
|
|
@ -1438,7 +1439,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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
39
src/components/Dialog/CloseNotice.tsx
Normal file
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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { getProxyBaseURL } from "@/lib";
|
|||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { ConfigFile } from "electron/main/utils/mcpConfig";
|
||||
|
||||
export default function SettingMCP() {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -195,13 +196,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) {
|
||||
|
|
@ -236,9 +252,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("Invalid JSON", { closeButton: true });
|
||||
setInstalling(false);
|
||||
|
|
@ -252,19 +286,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("");
|
||||
|
|
@ -335,13 +364,13 @@ export default function SettingMCP() {
|
|||
{!isLoading && !error && items.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-400">No MCP servers</div>
|
||||
)}
|
||||
<MCPList
|
||||
{!isLoading && <MCPList
|
||||
items={items}
|
||||
onSetting={setShowConfig}
|
||||
onDelete={setDeleteTarget}
|
||||
onSwitch={handleSwitch}
|
||||
switchLoading={switchLoading}
|
||||
/>
|
||||
/>}
|
||||
<MCPConfigDialog
|
||||
open={!!showConfig}
|
||||
form={configForm}
|
||||
|
|
|
|||
|
|
@ -682,9 +682,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">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">Description</label>
|
||||
|
|
@ -45,7 +45,7 @@ export default function MCPConfigDialog({ open, form, mcp, onChange, onSave, onC
|
|||
<input autoComplete="off" className="w-full border rounded px-3 py-2 text-sm" value={form.command} onChange={e => onChange({ ...form, command: e.target.value })} disabled={loading} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Args (one per line)</label>
|
||||
<label className="block text-sm font-medium mb-1">Args (one per line, no quotes or commas)</label>
|
||||
<textarea
|
||||
autoComplete="off"
|
||||
className="w-full border rounded px-3 py-2 text-sm"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1658,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,
|
||||
|
|
|
|||
201
test/unit/components/Setting/utils.test.ts
Normal file
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue