mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-18 02:16:07 +00:00
perf: ui
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { MCPServer, MCPServerConfig } from '@/app/infra/entities/api';
|
import { MCPServer } from '@/app/infra/entities/api';
|
||||||
|
|
||||||
export class MCPCardVO {
|
export class MCPCardVO {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -7,20 +7,24 @@ export class MCPCardVO {
|
|||||||
status: 'connected' | 'disconnected' | 'error' | 'disabled';
|
status: 'connected' | 'disconnected' | 'error' | 'disabled';
|
||||||
tools: number;
|
tools: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
config: MCPServerConfig;
|
|
||||||
|
|
||||||
constructor(data: MCPServer) {
|
constructor(data: MCPServer) {
|
||||||
this.name = data.name;
|
this.name = data.name;
|
||||||
this.mode = data.mode;
|
this.mode = data.mode;
|
||||||
this.enable = data.enable;
|
this.enable = data.enable;
|
||||||
|
|
||||||
this.status =
|
// Determine status from runtime_info
|
||||||
(data.status as string) === 'enabled' ? 'connected' : data.status;
|
if (!data.runtime_info) {
|
||||||
this.tools = Array.isArray(data.tools)
|
this.status = 'disconnected';
|
||||||
? data.tools.length
|
this.tools = 0;
|
||||||
: data.tools || 0;
|
} else if (data.runtime_info.connected) {
|
||||||
this.error = data.error;
|
this.status = 'connected';
|
||||||
this.config = data.config;
|
this.tools = data.runtime_info.tool_count || 0;
|
||||||
|
} else {
|
||||||
|
this.status = 'error';
|
||||||
|
this.tools = 0;
|
||||||
|
this.error = data.runtime_info.error_message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getStatusColor(): string {
|
getStatusColor(): string {
|
||||||
|
|||||||
@@ -9,41 +9,24 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
|||||||
|
|
||||||
export default function MCPComponent({
|
export default function MCPComponent({
|
||||||
onEditServer,
|
onEditServer,
|
||||||
toolsCountCache = {},
|
|
||||||
}: {
|
}: {
|
||||||
askInstallServer?: (githubURL: string) => void;
|
askInstallServer?: (githubURL: string) => void;
|
||||||
onEditServer?: (serverName: string) => void;
|
onEditServer?: (serverName: string) => void;
|
||||||
toolsCountCache?: Record<string, number>;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [installedServers, setInstalledServers] = useState<MCPCardVO[]>([]);
|
const [installedServers, setInstalledServers] = useState<MCPCardVO[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initData();
|
fetchInstalledServers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchInstalledServers();
|
|
||||||
}, [toolsCountCache]);
|
|
||||||
|
|
||||||
function initData() {
|
|
||||||
fetchInstalledServers();
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchInstalledServers() {
|
function fetchInstalledServers() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
httpClient
|
httpClient
|
||||||
.getMCPServers()
|
.getMCPServers()
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
const servers = resp.servers.map((server) => {
|
const servers = resp.servers.map((server) => new MCPCardVO(server));
|
||||||
const vo = new MCPCardVO(server);
|
|
||||||
|
|
||||||
if (toolsCountCache[server.name] !== undefined) {
|
|
||||||
vo.tools = toolsCountCache[server.name];
|
|
||||||
}
|
|
||||||
return vo;
|
|
||||||
});
|
|
||||||
setInstalledServers(servers);
|
setInstalledServers(servers);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';
|
import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { RefreshCcw, Wrench } from 'lucide-react';
|
import { RefreshCcw, Wrench, Ban, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
export default function MCPCardComponent({
|
export default function MCPCardComponent({
|
||||||
cardVO,
|
cardVO,
|
||||||
@@ -23,19 +22,12 @@ export default function MCPCardComponent({
|
|||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [toolsCount, setToolsCount] = useState(cardVO.tools);
|
const [toolsCount, setToolsCount] = useState(cardVO.tools);
|
||||||
const [status, setStatus] = useState(cardVO.status);
|
const [status, setStatus] = useState(cardVO.status);
|
||||||
const [error, setError] = useState(cardVO.error);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(`[MCPCard ${cardVO.name}] Status updated:`, {
|
|
||||||
status: cardVO.status,
|
|
||||||
tools: cardVO.tools,
|
|
||||||
error: cardVO.error,
|
|
||||||
});
|
|
||||||
setStatus(cardVO.status);
|
setStatus(cardVO.status);
|
||||||
setError(cardVO.error);
|
|
||||||
setToolsCount(cardVO.tools);
|
setToolsCount(cardVO.tools);
|
||||||
setEnabled(cardVO.enable);
|
setEnabled(cardVO.enable);
|
||||||
}, [cardVO.name, cardVO.status, cardVO.error, cardVO.tools, cardVO.enable]);
|
}, [cardVO.status, cardVO.tools, cardVO.enable]);
|
||||||
|
|
||||||
function handleEnable(checked: boolean) {
|
function handleEnable(checked: boolean) {
|
||||||
setSwitchEnable(false);
|
setSwitchEnable(false);
|
||||||
@@ -54,65 +46,28 @@ export default function MCPCardComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleTest(e: React.MouseEvent) {
|
function handleTest(e: React.MouseEvent) {
|
||||||
e.stopPropagation(); // 阻止事件冒泡
|
e.stopPropagation();
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
|
|
||||||
httpClient
|
httpClient
|
||||||
.testMCPServer(cardVO.name)
|
.testMCPServer(cardVO.name)
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
const taskId = resp.task_id;
|
const taskId = resp.task_id;
|
||||||
// 监控任务状态
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
httpClient.getAsyncTask(taskId).then((taskResp) => {
|
httpClient.getAsyncTask(taskId).then((taskResp) => {
|
||||||
if (taskResp.runtime.done) {
|
if (taskResp.runtime.done) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
setTesting(false);
|
||||||
|
|
||||||
if (taskResp.runtime.exception) {
|
if (taskResp.runtime.exception) {
|
||||||
toast.error(t('mcp.testFailed') + taskResp.runtime.exception);
|
toast.error(t('mcp.testFailed') + taskResp.runtime.exception);
|
||||||
} else {
|
} else {
|
||||||
// 解析测试结果获取工具数量
|
toast.success(t('mcp.testSuccess'));
|
||||||
try {
|
|
||||||
const rawResult = taskResp.runtime.result as
|
|
||||||
| string
|
|
||||||
| {
|
|
||||||
status?: string;
|
|
||||||
tools_count?: number;
|
|
||||||
tools_names_lists?: string[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (rawResult) {
|
|
||||||
let result: {
|
|
||||||
status?: string;
|
|
||||||
tools_count?: number;
|
|
||||||
tools_names_lists?: string[];
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof rawResult === 'string') {
|
|
||||||
result = JSON.parse(rawResult.replace(/'/g, '"'));
|
|
||||||
} else {
|
|
||||||
result = rawResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.tools_count !== undefined) {
|
|
||||||
setToolsCount(result.tools_count);
|
|
||||||
toast.success(
|
|
||||||
t('mcp.testSuccess') +
|
|
||||||
` - ${result.tools_count} ${t('mcp.toolsFound')}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
toast.success(t('mcp.testSuccess'));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.success(t('mcp.testSuccess'));
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error('Failed to parse test result:', parseError);
|
|
||||||
toast.success(t('mcp.testSuccess'));
|
|
||||||
}
|
|
||||||
onRefresh();
|
|
||||||
}
|
}
|
||||||
setTesting(false);
|
|
||||||
|
// Refresh to get updated runtime_info
|
||||||
|
onRefresh();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -141,28 +96,37 @@ export default function MCPCardComponent({
|
|||||||
|
|
||||||
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
|
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
|
||||||
<div className="flex flex-col items-start justify-start">
|
<div className="flex flex-col items-start justify-start">
|
||||||
<div className="flex flex-col items-start justify-start">
|
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] font-medium">
|
||||||
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
|
{cardVO.name}
|
||||||
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] font-medium">
|
|
||||||
{cardVO.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="text-[0.7rem] text-red-500 dark:text-red-400 line-clamp-2 mt-1">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
|
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
|
||||||
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
|
{!enabled ? (
|
||||||
<Wrench className="w-5 h-5" />
|
// 未启用 - 橙色
|
||||||
<div className="text-base text-black dark:text-[#f0f0f0] font-medium">
|
<div className="flex flex-row items-center gap-[0.4rem]">
|
||||||
{t('mcp.toolCount', { count: toolsCount })}
|
<Ban className="w-4 h-4 text-orange-500 dark:text-orange-400" />
|
||||||
|
<div className="text-sm text-orange-500 dark:text-orange-400 font-medium">
|
||||||
|
{t('mcp.statusDisabled')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : status === 'connected' ? (
|
||||||
|
// 连接成功 - 显示工具数量
|
||||||
|
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
|
||||||
|
<Wrench className="w-5 h-5" />
|
||||||
|
<div className="text-base text-black dark:text-[#f0f0f0] font-medium">
|
||||||
|
{t('mcp.toolCount', { count: toolsCount })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 连接失败 - 红色
|
||||||
|
<div className="flex flex-row items-center gap-[0.4rem]">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-500 dark:text-red-400" />
|
||||||
|
<div className="text-sm text-red-500 dark:text-red-400 font-medium">
|
||||||
|
{t('mcp.connectionFailed')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -32,6 +38,98 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
import {
|
||||||
|
MCPServerRuntimeInfo,
|
||||||
|
MCPTool,
|
||||||
|
MCPServer,
|
||||||
|
} from '@/app/infra/entities/api';
|
||||||
|
|
||||||
|
// Status Display Component - 只在测试中或连接失败时使用
|
||||||
|
function StatusDisplay({
|
||||||
|
testing,
|
||||||
|
runtimeInfo,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
testing: boolean;
|
||||||
|
runtimeInfo: MCPServerRuntimeInfo;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}) {
|
||||||
|
if (testing) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-blue-600">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 animate-spin"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('mcp.testing')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接失败
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-red-600">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
||||||
|
</div>
|
||||||
|
{runtimeInfo.error_message && (
|
||||||
|
<div className="text-sm text-red-500 pl-7">
|
||||||
|
{runtimeInfo.error_message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools List Component
|
||||||
|
function ToolsList({ tools }: { tools: MCPTool[] }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||||
|
{tools.map((tool, index) => (
|
||||||
|
<Card key={index} className="py-3 shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">{tool.name}</CardTitle>
|
||||||
|
{tool.description && (
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
{tool.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const getFormSchema = (t: (key: string) => string) =>
|
const getFormSchema = (t: (key: string) => string) =>
|
||||||
z.object({
|
z.object({
|
||||||
@@ -72,7 +170,6 @@ interface MCPFormDialogProps {
|
|||||||
isEditMode?: boolean;
|
isEditMode?: boolean;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onUpdateToolsCache?: (serverName: string, toolsCount: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MCPFormDialog({
|
export default function MCPFormDialog({
|
||||||
@@ -82,7 +179,6 @@ export default function MCPFormDialog({
|
|||||||
isEditMode = false,
|
isEditMode = false,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onDelete,
|
onDelete,
|
||||||
onUpdateToolsCache,
|
|
||||||
}: MCPFormDialogProps) {
|
}: MCPFormDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const formSchema = getFormSchema(t);
|
const formSchema = getFormSchema(t);
|
||||||
@@ -102,11 +198,9 @@ export default function MCPFormDialog({
|
|||||||
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
|
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
|
||||||
>([]);
|
>([]);
|
||||||
const [mcpTesting, setMcpTesting] = useState(false);
|
const [mcpTesting, setMcpTesting] = useState(false);
|
||||||
const [mcpTestStatus, setMcpTestStatus] = useState<
|
const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>(
|
||||||
'idle' | 'testing' | 'success' | 'failed'
|
null,
|
||||||
>('idle');
|
);
|
||||||
const [mcpToolNames, setMcpToolNames] = useState<string[]>([]);
|
|
||||||
const [mcpTestError, setMcpTestError] = useState<string>('');
|
|
||||||
|
|
||||||
// Load server data when editing
|
// Load server data when editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -116,9 +210,7 @@ export default function MCPFormDialog({
|
|||||||
// Reset form when creating new server
|
// Reset form when creating new server
|
||||||
form.reset();
|
form.reset();
|
||||||
setExtraArgs([]);
|
setExtraArgs([]);
|
||||||
setMcpTestStatus('idle');
|
setRuntimeInfo(null);
|
||||||
setMcpToolNames([]);
|
|
||||||
setMcpTestError('');
|
|
||||||
}
|
}
|
||||||
}, [open, isEditMode, serverName]);
|
}, [open, isEditMode, serverName]);
|
||||||
|
|
||||||
@@ -145,88 +237,11 @@ export default function MCPFormDialog({
|
|||||||
form.setValue('extra_args', headers);
|
form.setValue('extra_args', headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
setMcpTestStatus('testing');
|
// Set runtime_info from server data
|
||||||
setMcpToolNames([]);
|
if (server.runtime_info) {
|
||||||
setMcpTestError('');
|
setRuntimeInfo(server.runtime_info);
|
||||||
|
} else {
|
||||||
try {
|
setRuntimeInfo(null);
|
||||||
const res = await httpClient.testMCPServer(server.name);
|
|
||||||
if (res.task_id) {
|
|
||||||
const taskId = res.task_id;
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
httpClient
|
|
||||||
.getAsyncTask(taskId)
|
|
||||||
.then((taskResp) => {
|
|
||||||
if (taskResp.runtime && taskResp.runtime.done) {
|
|
||||||
clearInterval(interval);
|
|
||||||
|
|
||||||
if (taskResp.runtime.exception) {
|
|
||||||
setMcpTestStatus('failed');
|
|
||||||
setMcpToolNames([]);
|
|
||||||
setMcpTestError(taskResp.runtime.exception || '未知错误');
|
|
||||||
} else if (taskResp.runtime.result) {
|
|
||||||
try {
|
|
||||||
let result: {
|
|
||||||
status?: string;
|
|
||||||
tools_count?: number;
|
|
||||||
tools_names_lists?: string[];
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const rawResult: unknown = taskResp.runtime.result;
|
|
||||||
if (typeof rawResult === 'string') {
|
|
||||||
result = JSON.parse(rawResult.replace(/'/g, '"'));
|
|
||||||
} else {
|
|
||||||
result = rawResult as typeof result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
result.tools_names_lists &&
|
|
||||||
result.tools_names_lists.length > 0
|
|
||||||
) {
|
|
||||||
setMcpTestStatus('success');
|
|
||||||
setMcpToolNames(result.tools_names_lists);
|
|
||||||
// Update tools cache
|
|
||||||
if (onUpdateToolsCache && serverName) {
|
|
||||||
onUpdateToolsCache(
|
|
||||||
serverName,
|
|
||||||
result.tools_names_lists.length,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setMcpTestStatus('failed');
|
|
||||||
setMcpToolNames([]);
|
|
||||||
setMcpTestError('未找到任何工具');
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
setMcpTestStatus('failed');
|
|
||||||
setMcpToolNames([]);
|
|
||||||
setMcpTestError('解析测试结果失败');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setMcpTestStatus('failed');
|
|
||||||
setMcpToolNames([]);
|
|
||||||
setMcpTestError('测试未返回结果');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
clearInterval(interval);
|
|
||||||
setMcpTestStatus('failed');
|
|
||||||
setMcpToolNames([]);
|
|
||||||
setMcpTestError(err.message || '获取任务状态失败');
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
|
||||||
setMcpTestStatus('failed');
|
|
||||||
setMcpToolNames([]);
|
|
||||||
setMcpTestError('未获取到任务ID');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setMcpTestStatus('failed');
|
|
||||||
setMcpToolNames([]);
|
|
||||||
setMcpTestError((error as Error).message || '测试连接时发生错误');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load server:', error);
|
console.error('Failed to load server:', error);
|
||||||
@@ -235,167 +250,103 @@ export default function MCPFormDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
|
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
|
||||||
const extraArgsObj: Record<string, string | number | boolean> = {};
|
// Convert extra_args to headers - all values must be strings according to MCPServerExtraArgsSSE
|
||||||
value.extra_args?.forEach(
|
const headers: Record<string, string> = {};
|
||||||
(arg: { key: string; type: string; value: string }) => {
|
value.extra_args?.forEach((arg) => {
|
||||||
if (arg.type === 'number') {
|
// Convert all values to strings to match MCPServerExtraArgsSSE.headers type
|
||||||
extraArgsObj[arg.key] = Number(arg.value);
|
headers[arg.key] = String(arg.value);
|
||||||
} else if (arg.type === 'boolean') {
|
});
|
||||||
extraArgsObj[arg.key] = arg.value === 'true';
|
|
||||||
} else {
|
|
||||||
extraArgsObj[arg.key] = arg.value;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const serverConfig = {
|
const serverConfig: Omit<
|
||||||
|
MCPServer,
|
||||||
|
'uuid' | 'created_at' | 'updated_at' | 'runtime_info'
|
||||||
|
> = {
|
||||||
name: value.name,
|
name: value.name,
|
||||||
mode: 'sse' as const,
|
mode: 'sse' as const,
|
||||||
enable: true,
|
enable: true,
|
||||||
url: value.url,
|
extra_args: {
|
||||||
headers: extraArgsObj as Record<string, string>,
|
url: value.url,
|
||||||
timeout: value.timeout,
|
headers: headers,
|
||||||
ssereadtimeout: value.ssereadtimeout,
|
timeout: value.timeout,
|
||||||
|
ssereadtimeout: value.ssereadtimeout,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEditMode && serverName) {
|
if (isEditMode && serverName) {
|
||||||
await httpClient.updateMCPServer(serverName, serverConfig);
|
await httpClient.updateMCPServer(serverName, serverConfig);
|
||||||
toast.success(t('mcp.updateSuccess'));
|
toast.success(t('mcp.updateSuccess'));
|
||||||
} else {
|
} else {
|
||||||
await httpClient.createMCPServer({
|
await httpClient.createMCPServer(serverConfig);
|
||||||
extra_args: {
|
|
||||||
url: value.url,
|
|
||||||
headers: extraArgsObj as Record<string, string>,
|
|
||||||
timeout: value.timeout,
|
|
||||||
ssereadtimeout: value.ssereadtimeout,
|
|
||||||
},
|
|
||||||
name: value.name,
|
|
||||||
mode: 'sse' as const,
|
|
||||||
enable: true,
|
|
||||||
});
|
|
||||||
toast.success(t('mcp.createSuccess'));
|
toast.success(t('mcp.createSuccess'));
|
||||||
}
|
}
|
||||||
|
|
||||||
onOpenChange(false);
|
handleDialogClose(false);
|
||||||
form.reset();
|
onSuccess?.();
|
||||||
setExtraArgs([]);
|
|
||||||
|
|
||||||
if (onSuccess) {
|
|
||||||
onSuccess();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save MCP server:', error);
|
console.error('Failed to save MCP server:', error);
|
||||||
toast.error(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed'));
|
toast.error(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function testMcp() {
|
async function testMcp() {
|
||||||
|
const serverName = form.getValues('name');
|
||||||
setMcpTesting(true);
|
setMcpTesting(true);
|
||||||
const extraArgsObj: Record<string, string | number | boolean> = {};
|
|
||||||
form
|
|
||||||
.getValues('extra_args')
|
|
||||||
?.forEach((arg: { key: string; type: string; value: string }) => {
|
|
||||||
if (arg.type === 'number') {
|
|
||||||
extraArgsObj[arg.key] = Number(arg.value);
|
|
||||||
} else if (arg.type === 'boolean') {
|
|
||||||
extraArgsObj[arg.key] = arg.value === 'true';
|
|
||||||
} else {
|
|
||||||
extraArgsObj[arg.key] = arg.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
httpClient
|
|
||||||
.testMCPServer(form.getValues('name'))
|
|
||||||
.then((res) => {
|
|
||||||
if (res.task_id) {
|
|
||||||
const taskId = res.task_id;
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
try {
|
||||||
httpClient
|
const { task_id } = await httpClient.testMCPServer(serverName);
|
||||||
.getAsyncTask(taskId)
|
if (!task_id) {
|
||||||
.then((taskResp) => {
|
throw new Error(t('mcp.noTaskId'));
|
||||||
if (taskResp.runtime && taskResp.runtime.done) {
|
}
|
||||||
clearInterval(interval);
|
|
||||||
setMcpTesting(false);
|
|
||||||
|
|
||||||
if (taskResp.runtime.exception) {
|
const interval = setInterval(async () => {
|
||||||
toast.error(
|
try {
|
||||||
t('mcp.testError') +
|
const taskResp = await httpClient.getAsyncTask(task_id);
|
||||||
': ' +
|
|
||||||
(taskResp.runtime.exception || t('mcp.unknownError')),
|
|
||||||
);
|
|
||||||
} else if (taskResp.runtime.result) {
|
|
||||||
try {
|
|
||||||
let result: {
|
|
||||||
status?: string;
|
|
||||||
tools_count?: number;
|
|
||||||
tools_names_lists?: string[];
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const rawResult: unknown = taskResp.runtime.result;
|
if (taskResp.runtime?.done) {
|
||||||
if (typeof rawResult === 'string') {
|
clearInterval(interval);
|
||||||
result = JSON.parse(rawResult.replace(/'/g, '"'));
|
setMcpTesting(false);
|
||||||
} else {
|
|
||||||
result = rawResult as typeof result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (taskResp.runtime.exception) {
|
||||||
result.tools_names_lists &&
|
const errorMsg =
|
||||||
result.tools_names_lists.length > 0
|
taskResp.runtime.exception || t('mcp.unknownError');
|
||||||
) {
|
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||||
toast.success(
|
setRuntimeInfo({
|
||||||
t('mcp.testSuccess') +
|
connected: false,
|
||||||
' - ' +
|
error_message: errorMsg,
|
||||||
result.tools_names_lists.length +
|
tool_count: 0,
|
||||||
' ' +
|
tools: [],
|
||||||
t('mcp.toolsFound'),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
toast.error(
|
|
||||||
t('mcp.testError') + ': ' + t('mcp.noToolsFound'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
console.error('Failed to parse test result:', parseError);
|
|
||||||
toast.error(
|
|
||||||
t('mcp.testError') + ': ' + t('mcp.parseResultFailed'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error(
|
|
||||||
t('mcp.testError') + ': ' + t('mcp.noResultReturned'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('获取测试任务状态失败:', err);
|
|
||||||
clearInterval(interval);
|
|
||||||
setMcpTesting(false);
|
|
||||||
toast.error(
|
|
||||||
t('mcp.testError') +
|
|
||||||
': ' +
|
|
||||||
(err.message || t('mcp.getTaskFailed')),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}, 1000);
|
} else if (taskResp.runtime.result) {
|
||||||
} else {
|
await loadServerForEdit(serverName);
|
||||||
|
toast.success(t('mcp.testSuccess'));
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
`${t('mcp.testError')}: ${t('mcp.noResultReturned')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
clearInterval(interval);
|
||||||
setMcpTesting(false);
|
setMcpTesting(false);
|
||||||
toast.error(t('mcp.testError') + ': ' + t('mcp.noTaskId'));
|
const errorMsg = (err as Error).message || t('mcp.getTaskFailed');
|
||||||
|
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||||
}
|
}
|
||||||
})
|
}, 1000);
|
||||||
.catch((err) => {
|
} catch (err) {
|
||||||
console.error('启动测试失败:', err);
|
setMcpTesting(false);
|
||||||
setMcpTesting(false);
|
const errorMsg = (err as Error).message || t('mcp.unknownError');
|
||||||
toast.error(
|
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||||
t('mcp.testError') + ': ' + (err.message || t('mcp.unknownError')),
|
}
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const addExtraArg = () => {
|
const addExtraArg = () => {
|
||||||
setExtraArgs([...extraArgs, { key: '', type: 'string', value: '' }]);
|
const newArgs = [
|
||||||
|
...extraArgs,
|
||||||
|
{ key: '', type: 'string' as const, value: '' },
|
||||||
|
];
|
||||||
|
setExtraArgs(newArgs);
|
||||||
|
form.setValue('extra_args', newArgs);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeExtraArg = (index: number) => {
|
const removeExtraArg = (index: number) => {
|
||||||
@@ -410,25 +361,22 @@ export default function MCPFormDialog({
|
|||||||
value: string,
|
value: string,
|
||||||
) => {
|
) => {
|
||||||
const newArgs = [...extraArgs];
|
const newArgs = [...extraArgs];
|
||||||
newArgs[index] = {
|
newArgs[index] = { ...newArgs[index], [field]: value };
|
||||||
...newArgs[index],
|
|
||||||
[field]: value,
|
|
||||||
};
|
|
||||||
setExtraArgs(newArgs);
|
setExtraArgs(newArgs);
|
||||||
form.setValue('extra_args', newArgs);
|
form.setValue('extra_args', newArgs);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDialogClose = (open: boolean) => {
|
||||||
|
onOpenChange(open);
|
||||||
|
if (!open) {
|
||||||
|
form.reset();
|
||||||
|
setExtraArgs([]);
|
||||||
|
setRuntimeInfo(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||||
open={open}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
onOpenChange(open);
|
|
||||||
if (!open) {
|
|
||||||
form.reset();
|
|
||||||
setExtraArgs([]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
@@ -436,97 +384,25 @@ export default function MCPFormDialog({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{isEditMode && (
|
{isEditMode && runtimeInfo && (
|
||||||
<div className="mb-4 p-3 rounded-lg border">
|
<div className="mb-4 space-y-3">
|
||||||
{mcpTestStatus === 'testing' && (
|
{/* 测试中或连接失败时显示状态 */}
|
||||||
<div className="flex items-center gap-2 text-blue-600">
|
{(mcpTesting || !runtimeInfo.connected) && (
|
||||||
<svg
|
<div className="p-3 rounded-lg border">
|
||||||
className="w-5 h-5 animate-spin"
|
<StatusDisplay
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
testing={mcpTesting}
|
||||||
fill="none"
|
runtimeInfo={runtimeInfo}
|
||||||
viewBox="0 0 24 24"
|
t={t}
|
||||||
>
|
/>
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<span className="font-medium">{t('mcp.testing')}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mcpTestStatus === 'success' && (
|
{/* 连接成功时只显示工具列表 */}
|
||||||
<div className="space-y-2">
|
{!mcpTesting &&
|
||||||
<div className="flex items-center gap-2 text-green-600">
|
runtimeInfo.connected &&
|
||||||
<svg
|
runtimeInfo.tools?.length > 0 && (
|
||||||
className="w-5 h-5"
|
<ToolsList tools={runtimeInfo.tools} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
)}
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className="font-medium">
|
|
||||||
{t('mcp.connectionSuccess')} - {mcpToolNames.length}{' '}
|
|
||||||
{t('mcp.toolsFound')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
|
||||||
{mcpToolNames.map((toolName, index) => (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 text-xs rounded-md"
|
|
||||||
>
|
|
||||||
{toolName}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mcpTestStatus === 'failed' && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2 text-red-600">
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className="font-medium">
|
|
||||||
{t('mcp.connectionFailed')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{mcpTestError && (
|
|
||||||
<div className="text-sm text-red-500 pl-7">
|
|
||||||
{mcpTestError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -695,11 +571,7 @@ export default function MCPFormDialog({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => handleDialogClose(false)}
|
||||||
onOpenChange(false);
|
|
||||||
form.reset();
|
|
||||||
setExtraArgs([]);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -70,11 +70,6 @@ export default function PluginConfigPage() {
|
|||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
// 缓存每个服务器测试后的工具数量
|
|
||||||
const [serverToolsCache, setServerToolsCache] = useState<
|
|
||||||
Record<string, number>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPluginSystemStatus = async () => {
|
const fetchPluginSystemStatus = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -404,7 +399,6 @@ export default function PluginConfigPage() {
|
|||||||
setIsEditMode(true);
|
setIsEditMode(true);
|
||||||
setMcpSSEModalOpen(true);
|
setMcpSSEModalOpen(true);
|
||||||
}}
|
}}
|
||||||
toolsCountCache={serverToolsCache}
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -496,12 +490,6 @@ export default function PluginConfigPage() {
|
|||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
setShowDeleteConfirmModal(true);
|
setShowDeleteConfirmModal(true);
|
||||||
}}
|
}}
|
||||||
onUpdateToolsCache={(serverName, toolsCount) => {
|
|
||||||
setServerToolsCache((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[serverName]: toolsCount,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MCPDeleteConfirmDialog
|
<MCPDeleteConfirmDialog
|
||||||
|
|||||||
Reference in New Issue
Block a user