import React, { useEffect, useMemo, useState } from "react";
|
import {
|
Accordion,
|
AccordionDetails,
|
AccordionSummary,
|
Alert,
|
Box,
|
Button,
|
Chip,
|
Dialog,
|
DialogActions,
|
DialogContent,
|
DialogTitle,
|
FormControl,
|
Grid,
|
InputLabel,
|
MenuItem,
|
Paper,
|
Select,
|
Stack,
|
Switch,
|
TextField,
|
Typography,
|
} from "@mui/material";
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
import AddIcon from "@mui/icons-material/Add";
|
import EditIcon from "@mui/icons-material/Edit";
|
import StorageIcon from "@mui/icons-material/Storage";
|
import HubIcon from "@mui/icons-material/Hub";
|
import BuildIcon from "@mui/icons-material/Build";
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
import { useNotify } from "react-admin";
|
import request from "@/utils/request";
|
import EmptyData from "@/page/components/EmptyData";
|
import { AiConsoleLayout, AiConsolePanel, aiCardSx } from "@/page/components/AiConsoleLayout";
|
|
const usageChoices = [
|
{ id: "DIAGNOSE_ONLY", name: "仅诊断使用" },
|
{ id: "CHAT_AND_DIAGNOSE", name: "聊天与诊断都可用" },
|
{ id: "DISABLED", name: "禁用" },
|
];
|
|
const transportChoices = [
|
{ id: "AUTO", name: "自动识别" },
|
{ id: "HTTP", name: "Streamable HTTP" },
|
{ id: "SSE", name: "SSE" },
|
];
|
|
const authChoices = [
|
{ id: "NONE", name: "无认证" },
|
{ id: "BEARER", name: "Bearer Token" },
|
{ id: "API_KEY", name: "X-API-Key" },
|
];
|
|
const defaultServiceForm = {
|
id: null,
|
name: "",
|
url: "",
|
transportType: "AUTO",
|
authType: "NONE",
|
authValue: "",
|
usageScope: "DIAGNOSE_ONLY",
|
timeoutMs: 10000,
|
enabledFlag: 1,
|
memo: "",
|
};
|
|
const usageLabel = (usageScope) => {
|
const matched = usageChoices.find((item) => item.id === usageScope);
|
return matched ? matched.name : "仅诊断使用";
|
};
|
|
const transportLabel = (transportType) => {
|
const matched = transportChoices.find((item) => item.id === transportType);
|
return matched ? matched.name : transportType || "自动识别";
|
};
|
|
const BuiltInToolCard = ({ tool, onSave }) => {
|
const notify = useNotify();
|
const [usageScope, setUsageScope] = useState(tool.usageScope || "DIAGNOSE_ONLY");
|
const [priority, setPriority] = useState(tool.priority || 10);
|
const [toolPrompt, setToolPrompt] = useState(tool.toolPrompt || "");
|
const [saving, setSaving] = useState(false);
|
|
useEffect(() => {
|
setUsageScope(tool.usageScope || "DIAGNOSE_ONLY");
|
setPriority(tool.priority || 10);
|
setToolPrompt(tool.toolPrompt || "");
|
}, [tool]);
|
|
const handleSave = async () => {
|
try {
|
setSaving(true);
|
const { data: res } = await request.post("/ai/mcp/console/builtin-tool/save", {
|
toolCode: tool.toolCode,
|
toolName: tool.toolName,
|
priority: priority || 10,
|
toolPrompt,
|
usageScope,
|
});
|
if (res?.code !== 200) {
|
throw new Error(res?.msg || "保存失败");
|
}
|
notify("内置工具策略已更新");
|
onSave?.(res?.data || []);
|
} catch (error) {
|
notify(error.message || "保存失败", { type: "error" });
|
} finally {
|
setSaving(false);
|
}
|
};
|
|
const enabled = usageScope !== "DISABLED";
|
|
return (
|
<Box sx={aiCardSx(enabled)}>
|
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}>
|
<Box>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: "#284059" }}>
|
{tool.toolName}
|
</Typography>
|
<Typography variant="caption" sx={{ color: "#8093a8" }}>
|
{tool.toolCode}
|
</Typography>
|
</Box>
|
<Stack direction="row" spacing={0.75} flexWrap="wrap" justifyContent="flex-end">
|
<Chip size="small" color={enabled ? "success" : "default"} label={enabled ? "启用" : "停用"} />
|
<Chip size="small" color="primary" label={usageLabel(usageScope)} />
|
</Stack>
|
</Stack>
|
<Typography variant="body2" sx={{ mt: 1.5, minHeight: 44, color: "#31465d" }}>
|
{tool.description || "系统内置工具,默认由平台托管。"}
|
</Typography>
|
<FormControl size="small" fullWidth sx={{ mt: 1.5 }}>
|
<InputLabel>用途预设</InputLabel>
|
<Select value={usageScope} label="用途预设" onChange={(event) => setUsageScope(event.target.value)}>
|
{usageChoices.map((item) => (
|
<MenuItem key={item.id} value={item.id}>{item.name}</MenuItem>
|
))}
|
</Select>
|
</FormControl>
|
<Accordion elevation={0} disableGutters sx={{ mt: 1.5, borderRadius: 2, border: "1px solid #dbe5f1" }}>
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>高级设置</Typography>
|
</AccordionSummary>
|
<AccordionDetails>
|
<Stack spacing={1.5}>
|
<TextField
|
label="执行优先级"
|
size="small"
|
type="number"
|
value={priority}
|
onChange={(event) => setPriority(Number(event.target.value || 10))}
|
/>
|
<TextField
|
label="附加规则"
|
size="small"
|
value={toolPrompt}
|
multiline
|
minRows={3}
|
onChange={(event) => setToolPrompt(event.target.value)}
|
helperText="这里只在需要覆盖默认工具说明时填写。"
|
/>
|
</Stack>
|
</AccordionDetails>
|
</Accordion>
|
<Stack direction="row" justifyContent="flex-end" sx={{ mt: 1.5 }}>
|
<Button variant="contained" size="small" onClick={handleSave} disabled={saving}>
|
{saving ? "保存中..." : "保存"}
|
</Button>
|
</Stack>
|
</Box>
|
);
|
};
|
|
const ServiceDialog = ({ open, record, onClose, onSaved }) => {
|
const notify = useNotify();
|
const [form, setForm] = useState(defaultServiceForm);
|
const [saving, setSaving] = useState(false);
|
|
useEffect(() => {
|
if (open) {
|
setForm(record ? {
|
...defaultServiceForm,
|
...record,
|
authType: record.authType || "NONE",
|
transportType: record.transportType || "AUTO",
|
usageScope: record.usageScope || "DIAGNOSE_ONLY",
|
timeoutMs: record.timeoutMs || 10000,
|
} : defaultServiceForm);
|
}
|
}, [open, record]);
|
|
const handleChange = (field, value) => {
|
setForm((prev) => ({ ...prev, [field]: value }));
|
};
|
|
const handleSave = async () => {
|
try {
|
setSaving(true);
|
const payload = { ...form };
|
if (payload.authType === "NONE") {
|
payload.authValue = "";
|
}
|
const { data: res } = await request.post("/ai/mcp/console/service/save", payload);
|
if (res?.code !== 200) {
|
throw new Error(res?.msg || "保存失败");
|
}
|
notify("外部 MCP 服务已保存");
|
onSaved?.(res?.data);
|
} catch (error) {
|
notify(error.message || "保存失败", { type: "error" });
|
} finally {
|
setSaving(false);
|
}
|
};
|
|
return (
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
<DialogTitle>{form.id ? "编辑外部 MCP 服务" : "新增外部 MCP 服务"}</DialogTitle>
|
<DialogContent dividers>
|
<Grid container spacing={2} sx={{ mt: 0.25 }}>
|
<Grid item xs={12} md={6}>
|
<TextField label="服务名称" fullWidth size="small" value={form.name} onChange={(event) => handleChange("name", event.target.value)} />
|
</Grid>
|
<Grid item xs={12} md={6}>
|
<FormControl fullWidth size="small">
|
<InputLabel>连接方式</InputLabel>
|
<Select value={form.transportType} label="连接方式" onChange={(event) => handleChange("transportType", event.target.value)}>
|
{transportChoices.map((item) => (
|
<MenuItem key={item.id} value={item.id}>{item.name}</MenuItem>
|
))}
|
</Select>
|
</FormControl>
|
</Grid>
|
<Grid item xs={12}>
|
<TextField
|
label="服务地址"
|
fullWidth
|
size="small"
|
value={form.url}
|
onChange={(event) => handleChange("url", event.target.value)}
|
helperText="直接填写远程 MCP 服务地址;系统会优先按自动识别协议连接。"
|
/>
|
</Grid>
|
<Grid item xs={12} md={4}>
|
<FormControl fullWidth size="small">
|
<InputLabel>认证方式</InputLabel>
|
<Select value={form.authType} label="认证方式" onChange={(event) => handleChange("authType", event.target.value)}>
|
{authChoices.map((item) => (
|
<MenuItem key={item.id} value={item.id}>{item.name}</MenuItem>
|
))}
|
</Select>
|
</FormControl>
|
</Grid>
|
<Grid item xs={12} md={8}>
|
<TextField
|
label="认证信息"
|
fullWidth
|
size="small"
|
type={form.authType === "NONE" ? "text" : "password"}
|
value={form.authValue}
|
disabled={form.authType === "NONE"}
|
onChange={(event) => handleChange("authValue", event.target.value)}
|
/>
|
</Grid>
|
<Grid item xs={12} md={6}>
|
<FormControl fullWidth size="small">
|
<InputLabel>用途预设</InputLabel>
|
<Select value={form.usageScope} label="用途预设" onChange={(event) => handleChange("usageScope", event.target.value)}>
|
{usageChoices.filter((item) => item.id !== "DISABLED").map((item) => (
|
<MenuItem key={item.id} value={item.id}>{item.name}</MenuItem>
|
))}
|
</Select>
|
</FormControl>
|
</Grid>
|
<Grid item xs={12} md={3}>
|
<TextField
|
label="超时毫秒"
|
type="number"
|
size="small"
|
fullWidth
|
value={form.timeoutMs}
|
onChange={(event) => handleChange("timeoutMs", Number(event.target.value || 10000))}
|
/>
|
</Grid>
|
<Grid item xs={12} md={3}>
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ height: "100%" }}>
|
<Switch checked={Number(form.enabledFlag) === 1} onChange={(event) => handleChange("enabledFlag", event.target.checked ? 1 : 0)} />
|
<Typography variant="body2">启用服务</Typography>
|
</Stack>
|
</Grid>
|
<Grid item xs={12}>
|
<TextField
|
label="备注"
|
fullWidth
|
size="small"
|
value={form.memo}
|
multiline
|
minRows={2}
|
onChange={(event) => handleChange("memo", event.target.value)}
|
/>
|
</Grid>
|
</Grid>
|
</DialogContent>
|
<DialogActions>
|
<Button onClick={onClose}>取消</Button>
|
<Button variant="contained" onClick={handleSave} disabled={saving}>{saving ? "保存中..." : "保存"}</Button>
|
</DialogActions>
|
</Dialog>
|
);
|
};
|
|
const ExternalServiceCard = ({ service, onEdit, onTest, onInspect, onRemove }) => (
|
<Box sx={aiCardSx(service.enabledFlag === 1)}>
|
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}>
|
<Box>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: "#284059" }}>
|
{service.name}
|
</Typography>
|
<Typography variant="caption" sx={{ color: "#8093a8" }}>
|
{transportLabel(service.transportType)} · {usageLabel(service.usageScope)}
|
</Typography>
|
</Box>
|
<Stack direction="row" spacing={0.75} flexWrap="wrap" justifyContent="flex-end">
|
<Chip size="small" color={service.enabledFlag === 1 ? "success" : "default"} label={service.enabledFlag === 1 ? "启用" : "停用"} />
|
<Chip size="small" color={service.lastTestResult === 1 ? "success" : "default"} label={service.lastTestResult$ || "未测试"} />
|
</Stack>
|
</Stack>
|
<Typography variant="body2" sx={{ mt: 1.25, color: "#31465d", minHeight: 42 }}>
|
{service.url}
|
</Typography>
|
{service.lastTestMessage ? (
|
<Alert severity={service.lastTestResult === 1 ? "success" : "warning"} sx={{ mt: 1.25 }}>
|
{service.lastTestMessage}
|
</Alert>
|
) : null}
|
<Typography variant="caption" display="block" sx={{ mt: 1.25, color: "#70839a" }}>
|
{service.lastTestTime ? `最近测试: ${service.lastTestTime}` : "最近测试: -"}
|
</Typography>
|
<Typography variant="caption" display="block" sx={{ color: "#70839a" }}>
|
{service.lastToolCount !== null && service.lastToolCount !== undefined ? `已发现工具: ${service.lastToolCount}` : "已发现工具: -"}
|
</Typography>
|
<Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mt: 1.5 }}>
|
<Button size="small" variant="outlined" onClick={() => onInspect(service)}>查看工具</Button>
|
<Button size="small" variant="outlined" onClick={() => onTest(service)}>连接测试</Button>
|
<Button size="small" startIcon={<EditIcon />} onClick={() => onEdit(service)}>编辑</Button>
|
<Button size="small" color="error" startIcon={<DeleteOutlineIcon />} onClick={() => onRemove(service)}>删除</Button>
|
</Stack>
|
</Box>
|
);
|
|
const ExternalToolsPanel = ({ selectedService, tools, preview, onPreview }) => (
|
<Grid container spacing={2}>
|
<Grid item xs={12} lg={7}>
|
<AiConsolePanel
|
title="外部服务工具目录"
|
subtitle={selectedService ? `当前服务:${selectedService.name}` : "选中任一外部服务后,会展示最新发现的工具目录。"}
|
minHeight={320}
|
>
|
<Box sx={{ display: "grid", gap: 1.5 }}>
|
{tools.map((tool) => (
|
<Paper key={tool.mcpToolName} variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
|
<Stack direction="row" justifyContent="space-between" spacing={1}>
|
<Box>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
{tool.toolName || tool.mcpToolName}
|
</Typography>
|
<Typography variant="caption" color="text.secondary">
|
{tool.mcpToolName}
|
</Typography>
|
</Box>
|
<Button size="small" variant="text" onClick={() => onPreview(tool)}>执行预览</Button>
|
</Stack>
|
<Typography variant="body2" sx={{ mt: 1 }}>
|
{tool.description || tool.toolPrompt || "暂无说明"}
|
</Typography>
|
</Paper>
|
))}
|
{!tools.length ? <EmptyData /> : null}
|
</Box>
|
</AiConsolePanel>
|
</Grid>
|
<Grid item xs={12} lg={5}>
|
<AiConsolePanel
|
title="工具预览"
|
subtitle="用于快速确认远程工具是否真的能返回结果。"
|
minHeight={320}
|
>
|
{preview ? (
|
<Box>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
{preview.toolName || preview.mcpToolName || preview.toolCode}
|
</Typography>
|
<Typography variant="caption" color="text.secondary">
|
{preview.severity || "INFO"}
|
</Typography>
|
<Typography variant="body2" sx={{ mt: 1.5, whiteSpace: "pre-wrap" }}>
|
{preview.summaryText || "暂无摘要"}
|
</Typography>
|
</Box>
|
) : (
|
<EmptyData />
|
)}
|
</AiConsolePanel>
|
</Grid>
|
</Grid>
|
);
|
|
const AiMcpMountList = () => {
|
const notify = useNotify();
|
const [overview, setOverview] = useState({ builtInMount: null, builtInTools: [], externalServices: [] });
|
const [serviceDialogOpen, setServiceDialogOpen] = useState(false);
|
const [editingService, setEditingService] = useState(null);
|
const [selectedService, setSelectedService] = useState(null);
|
const [serviceTools, setServiceTools] = useState([]);
|
const [preview, setPreview] = useState(null);
|
|
const loadOverview = async () => {
|
try {
|
const { data: res } = await request.get("/ai/mcp/console/overview");
|
if (res?.code !== 200) {
|
throw new Error(res?.msg || "加载失败");
|
}
|
setOverview(res.data || { builtInMount: null, builtInTools: [], externalServices: [] });
|
} catch (error) {
|
notify(error.message || "加载失败", { type: "error" });
|
}
|
};
|
|
useEffect(() => {
|
loadOverview();
|
}, []);
|
|
const stats = useMemo(() => {
|
const builtInEnabled = (overview.builtInTools || []).filter((item) => item.usageScope !== "DISABLED").length;
|
const externalEnabled = (overview.externalServices || []).filter((item) => item.enabledFlag === 1).length;
|
const successCount = (overview.externalServices || []).filter((item) => item.lastTestResult === 1).length;
|
const discoveredTools = (overview.externalServices || []).reduce((sum, item) => sum + (item.lastToolCount || 0), 0);
|
return [
|
{ label: "内置工具启用", value: builtInEnabled },
|
{ label: "外部服务", value: (overview.externalServices || []).length },
|
{ label: "最近测试成功", value: successCount },
|
{ label: "发现外部工具", value: discoveredTools },
|
];
|
}, [overview]);
|
|
const handleBuiltInSaved = (tools) => {
|
setOverview((prev) => ({ ...prev, builtInTools: tools }));
|
};
|
|
const handleServiceSaved = async () => {
|
setServiceDialogOpen(false);
|
setEditingService(null);
|
await loadOverview();
|
};
|
|
const handleTestService = async (service) => {
|
try {
|
const { data: res } = await request.post("/ai/mcp/console/service/test", { id: service.id }, { timeout: (service.timeoutMs || 10000) + 5000 });
|
if (res?.code !== 200) {
|
throw new Error(res?.msg || "测试失败");
|
}
|
notify(res?.data?.message || "连接测试完成");
|
await loadOverview();
|
setSelectedService(res?.data?.service || service);
|
setServiceTools(res?.data?.tools || []);
|
setPreview(null);
|
} catch (error) {
|
notify(error.message || "测试失败", { type: "error" });
|
}
|
};
|
|
const handleInspectService = async (service) => {
|
try {
|
const { data: res } = await request.get("/ai/mcp/mount/toolList", { params: { mountId: service.id } });
|
if (res?.code !== 200) {
|
throw new Error(res?.msg || "加载工具失败");
|
}
|
setSelectedService(service);
|
setServiceTools(res.data || []);
|
setPreview(null);
|
} catch (error) {
|
notify(error.message || "加载工具失败", { type: "error" });
|
}
|
};
|
|
const handlePreviewTool = async (tool) => {
|
try {
|
const { data: res } = await request.post("/ai/mcp/mount/toolPreview", {
|
mountCode: tool.mountCode,
|
toolCode: tool.toolCode,
|
sceneCode: tool.sceneCode,
|
question: "请返回该工具当前的摘要预览结果",
|
});
|
if (res?.code !== 200) {
|
throw new Error(res?.msg || "预览失败");
|
}
|
setPreview(res.data);
|
} catch (error) {
|
notify(error.message || "预览失败", { type: "error" });
|
}
|
};
|
|
const handleRemoveService = async (service) => {
|
try {
|
const { data: res } = await request.post(`/ai/mcp/console/service/remove/${service.id}`);
|
if (res?.code !== 200) {
|
throw new Error(res?.msg || "删除失败");
|
}
|
notify("服务已删除");
|
if (selectedService?.id === service.id) {
|
setSelectedService(null);
|
setServiceTools([]);
|
setPreview(null);
|
}
|
await loadOverview();
|
} catch (error) {
|
notify(error.message || "删除失败", { type: "error" });
|
}
|
};
|
|
return (
|
<Box sx={{ width: "100%" }}>
|
<AiConsoleLayout
|
title="MCP中心"
|
subtitle="内置工具由系统自动托管;外部 MCP 服务只保留接入地址、认证和用途预设,复杂协议细节由系统自动处理。"
|
stats={stats}
|
>
|
<AiConsolePanel
|
title="内置工具"
|
subtitle="这些工具直接连接当前 WMS 内部数据,默认开箱即用,不需要手动配置挂载地址或协议。"
|
action={(
|
<Chip
|
icon={<StorageIcon />}
|
label={overview.builtInMount ? "系统托管" : "待初始化"}
|
color={overview.builtInMount ? "success" : "default"}
|
variant="outlined"
|
/>
|
)}
|
minHeight={260}
|
>
|
<Grid container spacing={2}>
|
{(overview.builtInTools || []).map((tool) => (
|
<Grid item xs={12} md={6} xl={4} key={tool.toolCode}>
|
<BuiltInToolCard tool={tool} onSave={handleBuiltInSaved} />
|
</Grid>
|
))}
|
{!overview?.builtInTools?.length ? <EmptyData /> : null}
|
</Grid>
|
</AiConsolePanel>
|
|
<Box sx={{ mt: 1.5 }}>
|
<AiConsolePanel
|
title="外部 MCP 服务"
|
subtitle="默认只需要录入服务名称、地址和用途。系统会优先自动识别协议,连接成功后再展示发现到的工具。"
|
action={(
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setEditingService(null); setServiceDialogOpen(true); }}>
|
新增服务
|
</Button>
|
)}
|
minHeight={280}
|
>
|
<Grid container spacing={2}>
|
{(overview.externalServices || []).map((service) => (
|
<Grid item xs={12} md={6} xl={4} key={service.id}>
|
<ExternalServiceCard
|
service={service}
|
onEdit={(item) => { setEditingService(item); setServiceDialogOpen(true); }}
|
onTest={handleTestService}
|
onInspect={handleInspectService}
|
onRemove={handleRemoveService}
|
/>
|
</Grid>
|
))}
|
{!overview?.externalServices?.length ? <EmptyData /> : null}
|
</Grid>
|
</AiConsolePanel>
|
</Box>
|
|
<Box sx={{ mt: 1.5 }}>
|
<AiConsolePanel
|
title="工具使用策略"
|
subtitle="内置工具按用途预设自动参与聊天或诊断;外部服务在连接成功后可以查看其实际工具目录并做调用预览。"
|
minHeight={120}
|
>
|
<Stack direction="row" spacing={1} flexWrap="wrap">
|
<Chip icon={<BuildIcon />} label="内置工具默认自动参与诊断" color="primary" variant="outlined" />
|
<Chip icon={<HubIcon />} label="外部服务默认按用途预设参与运行时工具选择" color="primary" variant="outlined" />
|
<Chip label="高级规则已折叠到各卡片内部" variant="outlined" />
|
</Stack>
|
</AiConsolePanel>
|
</Box>
|
|
<Box sx={{ mt: 1.5 }}>
|
<ExternalToolsPanel
|
selectedService={selectedService}
|
tools={serviceTools}
|
preview={preview}
|
onPreview={handlePreviewTool}
|
/>
|
</Box>
|
</AiConsoleLayout>
|
|
<ServiceDialog
|
open={serviceDialogOpen}
|
record={editingService}
|
onClose={() => {
|
setServiceDialogOpen(false);
|
setEditingService(null);
|
}}
|
onSaved={handleServiceSaved}
|
/>
|
</Box>
|
);
|
};
|
|
export default AiMcpMountList;
|