| | |
| | | throw new Error(msg || "获取工具列表失败"); |
| | | }; |
| | | |
| | | export const testMcpConnectivity = async (mountId) => { |
| | | const res = await request.post(`aiMcpMount/${mountId}/connectivity/test`); |
| | | const { code, msg, data } = res.data; |
| | | if (code === 200) { |
| | | return data; |
| | | } |
| | | throw new Error(msg || "连通性测试失败"); |
| | | }; |
| | | |
| | | export const testMcpTool = async (mountId, payload) => { |
| | | const res = await request.post(`aiMcpMount/${mountId}/tool/test`, payload); |
| | | const { code, msg, data } = res.data; |
| | |
| | | <Grid item xs={12}> |
| | | <TextInput source="memo" label="备注" fullWidth multiline minRows={3} disabled={readOnly} /> |
| | | </Grid> |
| | | <Grid item xs={12}> |
| | | <Typography variant="h6">运行态信息</Typography> |
| | | </Grid> |
| | | <Grid item xs={12} md={4}> |
| | | <TextInput source="healthStatus" label="健康状态" fullWidth disabled /> |
| | | </Grid> |
| | | <Grid item xs={12} md={4}> |
| | | <TextInput source="lastInitElapsedMs" label="最近初始化耗时(ms)" fullWidth disabled /> |
| | | </Grid> |
| | | <Grid item xs={12} md={4}> |
| | | <TextInput source="lastTestTime$" label="最近测试时间" fullWidth disabled /> |
| | | </Grid> |
| | | <Grid item xs={12}> |
| | | <TextInput source="lastTestMessage" label="最近测试结果" fullWidth multiline minRows={3} disabled /> |
| | | </Grid> |
| | | </Grid> |
| | | ); |
| | | |
| | |
| | | import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined"; |
| | | import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; |
| | | import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; |
| | | import PlayCircleOutlineOutlinedIcon from "@mui/icons-material/PlayCircleOutlineOutlined"; |
| | | import MyExportButton from "@/page/components/MyExportButton"; |
| | | import AiMcpMountForm from "./AiMcpMountForm"; |
| | | import AiConfigDialog from "../aiShared/AiConfigDialog"; |
| | | import AiMcpMountToolsPanel from "./AiMcpMountToolsPanel"; |
| | | import { testMcpConnectivity } from "@/api/ai/mcpMount"; |
| | | |
| | | const filters = [ |
| | | <SearchInput source="condition" alwaysOn />, |
| | |
| | | requestTimeoutMs: 60000, |
| | | sort: 0, |
| | | status: 1, |
| | | healthStatus: "NOT_TESTED", |
| | | }; |
| | | |
| | | const truncateText = (value, max = 96) => { |
| | |
| | | return record.serverUrl || "--"; |
| | | }; |
| | | |
| | | const AiMcpMountCards = ({ onView, onEdit, onDelete, deleting }) => { |
| | | const transportGroups = [ |
| | | { key: "BUILTIN", title: "内置 MCP", description: "系统内置工具挂载,适合直接暴露平台能力。" }, |
| | | { key: "SSE_HTTP", title: "远程 SSE MCP", description: "通过远程 MCP Server 挂载外部工具。" }, |
| | | { key: "STDIO", title: "本地 STDIO MCP", description: "通过本地命令进程挂载外部 MCP。" }, |
| | | ]; |
| | | |
| | | const resolveHealthMeta = (record) => { |
| | | if (record.healthStatus === "HEALTHY") { |
| | | return { color: "success", label: "正常" }; |
| | | } |
| | | if (record.healthStatus === "UNHEALTHY") { |
| | | return { color: "error", label: "失败" }; |
| | | } |
| | | return { color: "default", label: "未测试" }; |
| | | }; |
| | | |
| | | const AiMcpMountCards = ({ onView, onEdit, onDelete, onConnectivityTest, deleting, testingConnectivityId }) => { |
| | | const { data, isLoading } = useListContext(); |
| | | const records = useMemo(() => (Array.isArray(data) ? data : []), [data]); |
| | | const groupedRecords = useMemo(() => { |
| | | return transportGroups.map((group) => ({ |
| | | ...group, |
| | | records: records.filter((item) => item.transportType === group.key), |
| | | })).filter((group) => group.records.length > 0); |
| | | }, [records]); |
| | | |
| | | if (isLoading) { |
| | | return ( |
| | |
| | | |
| | | return ( |
| | | <Box px={2} py={2}> |
| | | <Grid container spacing={2}> |
| | | {records.map((record) => ( |
| | | <Grid item xs={12} md={6} xl={4} key={record.id}> |
| | | <Card |
| | | variant="outlined" |
| | | sx={{ |
| | | height: "100%", |
| | | borderRadius: 3, |
| | | boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)", |
| | | }} |
| | | > |
| | | <CardContent sx={{ pb: 1.5 }}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}> |
| | | <Box> |
| | | <Typography variant="h6" sx={{ mb: 0.5 }}> |
| | | {record.name} |
| | | </Typography> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | {record.transportType$ || record.transportType || "--"} |
| | | </Typography> |
| | | </Box> |
| | | <Chip |
| | | size="small" |
| | | color={record.statusBool ? "success" : "default"} |
| | | label={record.statusBool ? "启用" : "停用"} |
| | | /> |
| | | </Stack> |
| | | <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap mt={1.5}> |
| | | <Chip size="small" variant="outlined" label={`排序 ${record.sort ?? 0}`} /> |
| | | <Chip size="small" variant="outlined" label={`${record.requestTimeoutMs ?? "--"} ms`} /> |
| | | </Stack> |
| | | <Divider sx={{ my: 1.5 }} /> |
| | | <Typography variant="caption" color="text.secondary">目标</Typography> |
| | | <Typography variant="body2" sx={{ mt: 0.5, wordBreak: "break-all" }}> |
| | | {truncateText(resolveTargetLabel(record), 120)} |
| | | </Typography> |
| | | <Typography variant="caption" color="text.secondary" display="block" mt={1.5}> |
| | | 备注 |
| | | </Typography> |
| | | <Typography variant="body2">{truncateText(record.memo)}</Typography> |
| | | </CardContent> |
| | | <CardActions sx={{ px: 2, pb: 2, pt: 0, justifyContent: "space-between" }}> |
| | | <Stack direction="row" spacing={1}> |
| | | <Button size="small" startIcon={<VisibilityOutlinedIcon />} onClick={() => onView(record.id)}> |
| | | 详情 |
| | | </Button> |
| | | <Button size="small" startIcon={<EditOutlinedIcon />} onClick={() => onEdit(record.id)}> |
| | | 编辑 |
| | | </Button> |
| | | </Stack> |
| | | <Button |
| | | size="small" |
| | | color="error" |
| | | startIcon={<DeleteOutlineOutlinedIcon />} |
| | | onClick={() => onDelete(record)} |
| | | disabled={deleting} |
| | | > |
| | | 删除 |
| | | </Button> |
| | | </CardActions> |
| | | </Card> |
| | | </Grid> |
| | | <Stack spacing={3}> |
| | | {groupedRecords.map((group) => ( |
| | | <Box key={group.key}> |
| | | <Box mb={1.5}> |
| | | <Typography variant="h6">{group.title}</Typography> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | {group.description} |
| | | </Typography> |
| | | </Box> |
| | | <Grid container spacing={2}> |
| | | {group.records.map((record) => { |
| | | const healthMeta = resolveHealthMeta(record); |
| | | return ( |
| | | <Grid item xs={12} md={6} xl={4} key={record.id}> |
| | | <Card |
| | | variant="outlined" |
| | | sx={{ |
| | | height: "100%", |
| | | borderRadius: 3, |
| | | boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)", |
| | | }} |
| | | > |
| | | <CardContent sx={{ pb: 1.5 }}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}> |
| | | <Box> |
| | | <Typography variant="h6" sx={{ mb: 0.5 }}> |
| | | {record.name} |
| | | </Typography> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | {record.transportType$ || record.transportType || "--"} |
| | | </Typography> |
| | | </Box> |
| | | <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap justifyContent="flex-end"> |
| | | <Chip |
| | | size="small" |
| | | color={record.statusBool ? "success" : "default"} |
| | | label={record.statusBool ? "启用" : "停用"} |
| | | /> |
| | | <Chip size="small" color={healthMeta.color} label={healthMeta.label} /> |
| | | </Stack> |
| | | </Stack> |
| | | <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap mt={1.5}> |
| | | <Chip size="small" variant="outlined" label={`排序 ${record.sort ?? 0}`} /> |
| | | <Chip size="small" variant="outlined" label={`${record.requestTimeoutMs ?? "--"} ms`} /> |
| | | <Chip size="small" variant="outlined" label={`Init ${record.lastInitElapsedMs ?? "--"} ms`} /> |
| | | </Stack> |
| | | <Divider sx={{ my: 1.5 }} /> |
| | | <Typography variant="caption" color="text.secondary">目标</Typography> |
| | | <Typography variant="body2" sx={{ mt: 0.5, wordBreak: "break-all" }}> |
| | | {truncateText(resolveTargetLabel(record), 120)} |
| | | </Typography> |
| | | <Typography variant="caption" color="text.secondary" display="block" mt={1.5}> |
| | | 最近测试 |
| | | </Typography> |
| | | <Typography variant="body2"> |
| | | {record.lastTestTime$ ? `${record.lastTestTime$} · ${truncateText(record.lastTestMessage, 72)}` : "尚未执行连通性测试"} |
| | | </Typography> |
| | | <Typography variant="caption" color="text.secondary" display="block" mt={1.5}> |
| | | 备注 |
| | | </Typography> |
| | | <Typography variant="body2">{truncateText(record.memo)}</Typography> |
| | | </CardContent> |
| | | <CardActions sx={{ px: 2, pb: 2, pt: 0, justifyContent: "space-between", alignItems: "flex-start" }}> |
| | | <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap> |
| | | <Button size="small" startIcon={<VisibilityOutlinedIcon />} onClick={() => onView(record.id)}> |
| | | 详情 |
| | | </Button> |
| | | <Button size="small" startIcon={<EditOutlinedIcon />} onClick={() => onEdit(record.id)}> |
| | | 编辑 |
| | | </Button> |
| | | <Button |
| | | size="small" |
| | | startIcon={<PlayCircleOutlineOutlinedIcon />} |
| | | onClick={() => onConnectivityTest(record)} |
| | | disabled={testingConnectivityId === record.id} |
| | | > |
| | | {testingConnectivityId === record.id ? "测试中..." : "连通测试"} |
| | | </Button> |
| | | </Stack> |
| | | <Button |
| | | size="small" |
| | | color="error" |
| | | startIcon={<DeleteOutlineOutlinedIcon />} |
| | | onClick={() => onDelete(record)} |
| | | disabled={deleting} |
| | | > |
| | | 删除 |
| | | </Button> |
| | | </CardActions> |
| | | </Card> |
| | | </Grid> |
| | | ); |
| | | })} |
| | | </Grid> |
| | | </Box> |
| | | ))} |
| | | </Grid> |
| | | </Stack> |
| | | </Box> |
| | | ); |
| | | }; |
| | |
| | | const refresh = useRefresh(); |
| | | const [deleteOne, { isPending: deleting }] = useDelete(); |
| | | const [dialogState, setDialogState] = useState({ open: false, mode: "create", recordId: null }); |
| | | const [testingConnectivityId, setTestingConnectivityId] = useState(null); |
| | | |
| | | const openDialog = (mode, recordId = null) => setDialogState({ open: true, mode, recordId }); |
| | | const closeDialog = () => setDialogState({ open: false, mode: "create", recordId: null }); |
| | |
| | | }, |
| | | } |
| | | ); |
| | | }; |
| | | |
| | | const handleConnectivityTest = async (record) => { |
| | | if (!record?.id) { |
| | | return; |
| | | } |
| | | setTestingConnectivityId(record.id); |
| | | try { |
| | | const result = await testMcpConnectivity(record.id); |
| | | notify(result?.message || "连通性测试完成"); |
| | | refresh(); |
| | | } catch (error) { |
| | | notify(error?.message || "连通性测试失败", { type: "error" }); |
| | | } finally { |
| | | setTestingConnectivityId(null); |
| | | } |
| | | }; |
| | | |
| | | const dialogTitle = { |
| | |
| | | onView={(id) => openDialog("show", id)} |
| | | onEdit={(id) => openDialog("edit", id)} |
| | | onDelete={handleDelete} |
| | | onConnectivityTest={handleConnectivityTest} |
| | | deleting={deleting} |
| | | testingConnectivityId={testingConnectivityId} |
| | | /> |
| | | </List> |
| | | <AiConfigDialog |
| | |
| | | import React, { useEffect, useState } from "react"; |
| | | import React, { useEffect, useMemo, useState } from "react"; |
| | | import { |
| | | Accordion, |
| | | AccordionDetails, |
| | |
| | | Card, |
| | | CardContent, |
| | | CircularProgress, |
| | | Divider, |
| | | Grid, |
| | | MenuItem, |
| | | Stack, |
| | | TextField, |
| | | Typography, |
| | |
| | | import PreviewOutlinedIcon from "@mui/icons-material/PreviewOutlined"; |
| | | import ExpandMoreOutlinedIcon from "@mui/icons-material/ExpandMoreOutlined"; |
| | | import { useNotify } from "react-admin"; |
| | | import { previewMcpTools, testMcpTool } from "@/api/ai/mcpMount"; |
| | | import { previewMcpTools, testMcpConnectivity, testMcpTool } from "@/api/ai/mcpMount"; |
| | | |
| | | const parseInputSchema = (inputSchema) => { |
| | | if (!inputSchema) { |
| | | return { pretty: "", fields: [], required: [], error: "" }; |
| | | } |
| | | try { |
| | | const schema = JSON.parse(inputSchema); |
| | | const properties = schema?.properties || {}; |
| | | const required = Array.isArray(schema?.required) ? schema.required : []; |
| | | return { |
| | | pretty: JSON.stringify(schema, null, 2), |
| | | required, |
| | | error: "", |
| | | fields: Object.entries(properties).map(([name, definition]) => ({ |
| | | name, |
| | | title: definition?.title || name, |
| | | description: definition?.description || "", |
| | | type: definition?.type || "string", |
| | | enumValues: Array.isArray(definition?.enum) ? definition.enum : [], |
| | | })), |
| | | }; |
| | | } catch (error) { |
| | | return { |
| | | pretty: inputSchema, |
| | | fields: [], |
| | | required: [], |
| | | error: `Input Schema 解析失败: ${error.message}`, |
| | | }; |
| | | } |
| | | }; |
| | | |
| | | const normalizeFieldValue = (field, rawValue) => { |
| | | if (rawValue === "" || rawValue == null) { |
| | | return undefined; |
| | | } |
| | | if (field.type === "integer") { |
| | | const parsed = Number.parseInt(rawValue, 10); |
| | | return Number.isNaN(parsed) ? rawValue : parsed; |
| | | } |
| | | if (field.type === "number") { |
| | | const parsed = Number(rawValue); |
| | | return Number.isNaN(parsed) ? rawValue : parsed; |
| | | } |
| | | if (field.type === "boolean") { |
| | | return rawValue === true || rawValue === "true"; |
| | | } |
| | | return rawValue; |
| | | }; |
| | | |
| | | const buildInputJson = (schemaInfo, fieldValues) => { |
| | | if (!schemaInfo?.fields?.length) { |
| | | return ""; |
| | | } |
| | | const payload = {}; |
| | | schemaInfo.fields.forEach((field) => { |
| | | const normalized = normalizeFieldValue(field, fieldValues?.[field.name]); |
| | | if (normalized !== undefined) { |
| | | payload[field.name] = normalized; |
| | | } |
| | | }); |
| | | return JSON.stringify(payload, null, 2); |
| | | }; |
| | | |
| | | const readStructuredValues = (schemaInfo, inputJson) => { |
| | | if (!schemaInfo?.fields?.length || !inputJson || !inputJson.trim()) { |
| | | return {}; |
| | | } |
| | | try { |
| | | const parsed = JSON.parse(inputJson); |
| | | if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { |
| | | return {}; |
| | | } |
| | | const values = {}; |
| | | schemaInfo.fields.forEach((field) => { |
| | | const value = parsed[field.name]; |
| | | if (value === undefined || value === null) { |
| | | return; |
| | | } |
| | | values[field.name] = typeof value === "boolean" ? String(value) : String(value); |
| | | }); |
| | | return values; |
| | | } catch (error) { |
| | | return {}; |
| | | } |
| | | }; |
| | | |
| | | const resolveConnectivitySeverity = (healthStatus) => { |
| | | if (healthStatus === "HEALTHY") { |
| | | return "success"; |
| | | } |
| | | if (healthStatus === "UNHEALTHY") { |
| | | return "error"; |
| | | } |
| | | return "info"; |
| | | }; |
| | | |
| | | const AiMcpMountToolsPanel = ({ mountId }) => { |
| | | const notify = useNotify(); |
| | |
| | | const [tools, setTools] = useState([]); |
| | | const [error, setError] = useState(""); |
| | | const [inputs, setInputs] = useState({}); |
| | | const [structuredInputs, setStructuredInputs] = useState({}); |
| | | const [outputs, setOutputs] = useState({}); |
| | | const [testingToolName, setTestingToolName] = useState(""); |
| | | const [testingConnectivity, setTestingConnectivity] = useState(false); |
| | | const [connectivity, setConnectivity] = useState(null); |
| | | |
| | | const schemaInfoMap = useMemo(() => { |
| | | return tools.reduce((result, tool) => { |
| | | result[tool.name] = parseInputSchema(tool.inputSchema); |
| | | return result; |
| | | }, {}); |
| | | }, [tools]); |
| | | |
| | | useEffect(() => { |
| | | if (!mountId) { |
| | | setTools([]); |
| | | setInputs({}); |
| | | setStructuredInputs({}); |
| | | setOutputs({}); |
| | | setConnectivity(null); |
| | | setError(""); |
| | | return; |
| | | } |
| | |
| | | const data = await previewMcpTools(mountId); |
| | | setTools(data); |
| | | setOutputs({}); |
| | | setInputs({}); |
| | | setStructuredInputs({}); |
| | | } catch (requestError) { |
| | | setError(requestError.message || "获取工具列表失败"); |
| | | } finally { |
| | | setLoading(false); |
| | | } |
| | | }; |
| | | |
| | | const handleConnectivityTest = async () => { |
| | | setTestingConnectivity(true); |
| | | try { |
| | | const result = await testMcpConnectivity(mountId); |
| | | setConnectivity(result); |
| | | notify(result?.message || "连通性测试完成"); |
| | | } catch (requestError) { |
| | | const message = requestError.message || "连通性测试失败"; |
| | | notify(message, { type: "error" }); |
| | | } finally { |
| | | setTestingConnectivity(false); |
| | | } |
| | | }; |
| | | |
| | |
| | | ...prev, |
| | | [toolName]: value, |
| | | })); |
| | | setStructuredInputs((prev) => ({ |
| | | ...prev, |
| | | [toolName]: readStructuredValues(schemaInfoMap[toolName], value), |
| | | })); |
| | | }; |
| | | |
| | | const handleStructuredFieldChange = (toolName, fieldName, value) => { |
| | | const schemaInfo = schemaInfoMap[toolName]; |
| | | setStructuredInputs((prev) => { |
| | | const nextToolValues = { |
| | | ...(prev[toolName] || {}), |
| | | [fieldName]: value, |
| | | }; |
| | | setInputs((prevInputs) => ({ |
| | | ...prevInputs, |
| | | [toolName]: buildInputJson(schemaInfo, nextToolValues), |
| | | })); |
| | | return { |
| | | ...prev, |
| | | [toolName]: nextToolValues, |
| | | }; |
| | | }); |
| | | }; |
| | | |
| | | const handleTest = async (toolName) => { |
| | |
| | | <Box flex={1}> |
| | | <Typography variant="h6">工具预览与测试</Typography> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | 当前挂载解析出的全部工具都显示在这里,可直接输入 JSON 做测试。 |
| | | 支持连通性测试、结构化 Schema 预览和按输入参数自动生成测试表单。 |
| | | </Typography> |
| | | </Box> |
| | | </AccordionSummary> |
| | | <AccordionDetails> |
| | | <Stack direction="row" justifyContent="flex-end" alignItems="center" mb={1.5}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="center" mb={1.5} flexWrap="wrap" useFlexGap> |
| | | <Button size="small" startIcon={<PreviewOutlinedIcon />} onClick={loadTools} disabled={loading}> |
| | | 刷新工具 |
| | | </Button> |
| | | <Button |
| | | size="small" |
| | | variant="outlined" |
| | | startIcon={<PlayCircleOutlineOutlinedIcon />} |
| | | onClick={handleConnectivityTest} |
| | | disabled={testingConnectivity} |
| | | > |
| | | {testingConnectivity ? "测试中..." : "连通性测试"} |
| | | </Button> |
| | | </Stack> |
| | | {!!connectivity && ( |
| | | <Alert severity={resolveConnectivitySeverity(connectivity.healthStatus)} sx={{ mb: 2 }}> |
| | | {connectivity.message} |
| | | {connectivity.initElapsedMs != null && ` · Init ${connectivity.initElapsedMs} ms`} |
| | | {connectivity.toolCount != null && ` · Tools ${connectivity.toolCount}`} |
| | | {connectivity.testedAt && ` · ${connectivity.testedAt}`} |
| | | </Alert> |
| | | )} |
| | | {loading && ( |
| | | <Box display="flex" justifyContent="center" py={4}> |
| | | <CircularProgress size={28} /> |
| | |
| | | <Alert severity="info">当前挂载未解析出任何工具。</Alert> |
| | | )} |
| | | <Grid container spacing={2}> |
| | | {tools.map((tool) => ( |
| | | <Grid item xs={12} key={tool.name}> |
| | | <Accordion defaultExpanded={false} sx={{ borderRadius: 3, overflow: "hidden" }}> |
| | | <AccordionSummary expandIcon={<ExpandMoreOutlinedIcon />}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2} width="100%" pr={1}> |
| | | <Box> |
| | | <Typography variant="subtitle1">{tool.name}</Typography> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | {tool.description || "暂无描述"} |
| | | </Typography> |
| | | </Box> |
| | | <Typography variant="caption" color="text.secondary"> |
| | | {tool.returnDirect ? "returnDirect" : "normal"} |
| | | </Typography> |
| | | </Stack> |
| | | </AccordionSummary> |
| | | <AccordionDetails> |
| | | <Card variant="outlined" sx={{ borderRadius: 3 }}> |
| | | <CardContent> |
| | | <TextField |
| | | label="Input Schema" |
| | | value={tool.inputSchema || ""} |
| | | fullWidth |
| | | multiline |
| | | minRows={5} |
| | | maxRows={12} |
| | | InputProps={{ readOnly: true }} |
| | | /> |
| | | <TextField |
| | | label="测试输入 JSON" |
| | | value={inputs[tool.name] || ""} |
| | | onChange={(event) => handleInputChange(tool.name, event.target.value)} |
| | | fullWidth |
| | | multiline |
| | | minRows={5} |
| | | maxRows={12} |
| | | sx={{ mt: 2 }} |
| | | placeholder='例如:{"code":"A01"}' |
| | | /> |
| | | <Stack direction="row" justifyContent="flex-end" mt={1.5}> |
| | | <Button |
| | | variant="contained" |
| | | startIcon={<PlayCircleOutlineOutlinedIcon />} |
| | | onClick={() => handleTest(tool.name)} |
| | | disabled={testingToolName === tool.name} |
| | | > |
| | | {testingToolName === tool.name ? "测试中..." : "执行测试"} |
| | | </Button> |
| | | {tools.map((tool) => { |
| | | const schemaInfo = schemaInfoMap[tool.name] || { pretty: "", fields: [], required: [], error: "" }; |
| | | const structuredValues = structuredInputs[tool.name] || {}; |
| | | return ( |
| | | <Grid item xs={12} key={tool.name}> |
| | | <Accordion defaultExpanded={false} sx={{ borderRadius: 3, overflow: "hidden" }}> |
| | | <AccordionSummary expandIcon={<ExpandMoreOutlinedIcon />}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2} width="100%" pr={1}> |
| | | <Box> |
| | | <Typography variant="subtitle1">{tool.name}</Typography> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | {tool.description || "暂无描述"} |
| | | </Typography> |
| | | </Box> |
| | | <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap> |
| | | <Typography variant="caption" color="text.secondary"> |
| | | {schemaInfo.fields.length} 个参数 |
| | | </Typography> |
| | | <Typography variant="caption" color="text.secondary"> |
| | | {tool.returnDirect ? "returnDirect" : "normal"} |
| | | </Typography> |
| | | </Stack> |
| | | <TextField |
| | | label="测试结果" |
| | | value={outputs[tool.name] || ""} |
| | | fullWidth |
| | | multiline |
| | | minRows={5} |
| | | maxRows={16} |
| | | sx={{ mt: 2 }} |
| | | InputProps={{ readOnly: true }} |
| | | /> |
| | | </CardContent> |
| | | </Card> |
| | | </AccordionDetails> |
| | | </Accordion> |
| | | </Grid> |
| | | ))} |
| | | </Stack> |
| | | </AccordionSummary> |
| | | <AccordionDetails> |
| | | <Card variant="outlined" sx={{ borderRadius: 3 }}> |
| | | <CardContent> |
| | | {!!schemaInfo.error && ( |
| | | <Alert severity="warning" sx={{ mb: 2 }}> |
| | | {schemaInfo.error} |
| | | </Alert> |
| | | )} |
| | | <TextField |
| | | label="格式化 Input Schema" |
| | | value={schemaInfo.pretty || tool.inputSchema || ""} |
| | | fullWidth |
| | | multiline |
| | | minRows={6} |
| | | maxRows={16} |
| | | InputProps={{ readOnly: true }} |
| | | /> |
| | | {!!schemaInfo.fields.length && ( |
| | | <Grid container spacing={2} sx={{ mt: 0.5 }}> |
| | | {schemaInfo.fields.map((field) => ( |
| | | <Grid item xs={12} md={field.type === "boolean" ? 6 : 12} key={`${tool.name}-${field.name}`}> |
| | | <TextField |
| | | select={field.type === "boolean" || field.enumValues.length > 0} |
| | | type={field.type === "integer" || field.type === "number" ? "number" : "text"} |
| | | label={`${field.title}${schemaInfo.required.includes(field.name) ? " *" : ""}`} |
| | | value={structuredValues[field.name] ?? ""} |
| | | onChange={(event) => handleStructuredFieldChange(tool.name, field.name, event.target.value)} |
| | | fullWidth |
| | | helperText={field.description || field.type} |
| | | sx={{ mt: 2 }} |
| | | > |
| | | {field.type === "boolean" && ( |
| | | [ |
| | | <MenuItem key="true" value="true">true</MenuItem>, |
| | | <MenuItem key="false" value="false">false</MenuItem>, |
| | | ] |
| | | )} |
| | | {field.type !== "boolean" && field.enumValues.map((value) => ( |
| | | <MenuItem key={value} value={String(value)}> |
| | | {String(value)} |
| | | </MenuItem> |
| | | ))} |
| | | </TextField> |
| | | </Grid> |
| | | ))} |
| | | </Grid> |
| | | )} |
| | | <TextField |
| | | label="测试输入 JSON" |
| | | value={inputs[tool.name] || ""} |
| | | onChange={(event) => handleInputChange(tool.name, event.target.value)} |
| | | fullWidth |
| | | multiline |
| | | minRows={5} |
| | | maxRows={12} |
| | | sx={{ mt: 2 }} |
| | | placeholder='例如:{"code":"A01"}' |
| | | /> |
| | | <Stack direction="row" justifyContent="flex-end" mt={1.5}> |
| | | <Button |
| | | variant="contained" |
| | | startIcon={<PlayCircleOutlineOutlinedIcon />} |
| | | onClick={() => handleTest(tool.name)} |
| | | disabled={testingToolName === tool.name} |
| | | > |
| | | {testingToolName === tool.name ? "测试中..." : "执行测试"} |
| | | </Button> |
| | | </Stack> |
| | | <TextField |
| | | label="测试结果" |
| | | value={outputs[tool.name] || ""} |
| | | fullWidth |
| | | multiline |
| | | minRows={5} |
| | | maxRows={16} |
| | | sx={{ mt: 2 }} |
| | | InputProps={{ readOnly: true }} |
| | | /> |
| | | </CardContent> |
| | | </Card> |
| | | </AccordionDetails> |
| | | </Accordion> |
| | | </Grid> |
| | | ); |
| | | })} |
| | | </Grid> |
| | | </AccordionDetails> |
| | | </Accordion> |
| | |
| | | public static final String MCP_BUILTIN_RSF_WMS_STOCK = "RSF_WMS_STOCK"; |
| | | public static final String MCP_BUILTIN_RSF_WMS_TASK = "RSF_WMS_TASK"; |
| | | public static final String MCP_BUILTIN_RSF_WMS_BASE = "RSF_WMS_BASE"; |
| | | public static final String MCP_HEALTH_NOT_TESTED = "NOT_TESTED"; |
| | | public static final String MCP_HEALTH_HEALTHY = "HEALTHY"; |
| | | public static final String MCP_HEALTH_UNHEALTHY = "UNHEALTHY"; |
| | | public static final long SSE_TIMEOUT_MS = 0L; |
| | | public static final int DEFAULT_TIMEOUT_MS = 60000; |
| | | public static final double DEFAULT_TEMPERATURE = 0.7D; |
| | |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiMcpMount:update')") |
| | | @PostMapping("/aiMcpMount/{id}/connectivity/test") |
| | | public R testConnectivity(@PathVariable("id") Long id) { |
| | | return R.ok().add(aiMcpMountService.testConnectivity(id, getLoginUserId(), getTenantId())); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiMcpMount:update')") |
| | | @PostMapping("/aiMcpMount/{id}/tool/test") |
| | | public R testTool(@PathVariable("id") Long id, @RequestBody AiMcpToolTestRequest request) { |
| | | return R.ok().add(aiMcpMountService.testTool(id, getLoginUserId(), getTenantId(), request)); |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.dto; |
| | | |
| | | import lombok.Builder; |
| | | import lombok.Data; |
| | | |
| | | @Data |
| | | @Builder |
| | | public class AiMcpConnectivityTestDto { |
| | | |
| | | private Long mountId; |
| | | |
| | | private String mountName; |
| | | |
| | | private String healthStatus; |
| | | |
| | | private String message; |
| | | |
| | | private Long initElapsedMs; |
| | | |
| | | private Integer toolCount; |
| | | |
| | | private String testedAt; |
| | | } |
| | |
| | | @ApiModelProperty(value = "超时时间") |
| | | private Integer requestTimeoutMs; |
| | | |
| | | @ApiModelProperty(value = "健康状态") |
| | | private String healthStatus; |
| | | |
| | | @ApiModelProperty(value = "最近测试时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date lastTestTime; |
| | | |
| | | @ApiModelProperty(value = "最近测试信息") |
| | | private String lastTestMessage; |
| | | |
| | | @ApiModelProperty(value = "最近初始化耗时") |
| | | private Long lastInitElapsedMs; |
| | | |
| | | @ApiModelProperty(value = "排序") |
| | | private Integer sort; |
| | | |
| | |
| | | return this.status == 1; |
| | | } |
| | | |
| | | public String getHealthStatus$() { |
| | | return this.healthStatus; |
| | | } |
| | | |
| | | public String getLastTestTime$() { |
| | | if (this.lastTestTime == null) { |
| | | return ""; |
| | | } |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.lastTestTime); |
| | | } |
| | | |
| | | public String getCreateTime$() { |
| | | if (this.createTime == null) { |
| | | return ""; |
| | |
| | | package com.vincent.rsf.server.ai.service; |
| | | |
| | | import com.baomidou.mybatisplus.extension.service.IService; |
| | | import com.vincent.rsf.server.ai.dto.AiMcpConnectivityTestDto; |
| | | import com.vincent.rsf.server.ai.dto.AiMcpToolPreviewDto; |
| | | import com.vincent.rsf.server.ai.dto.AiMcpToolTestDto; |
| | | import com.vincent.rsf.server.ai.dto.AiMcpToolTestRequest; |
| | |
| | | |
| | | List<AiMcpToolPreviewDto> previewTools(Long mountId, Long userId, Long tenantId); |
| | | |
| | | AiMcpConnectivityTestDto testConnectivity(Long mountId, Long userId, Long tenantId); |
| | | |
| | | AiMcpToolTestDto testTool(Long mountId, Long userId, Long tenantId, AiMcpToolTestRequest request); |
| | | } |
| | |
| | | package com.vincent.rsf.server.ai.service.impl; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; |
| | | import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import com.vincent.rsf.framework.exception.CoolException; |
| | | import com.vincent.rsf.server.ai.config.AiDefaults; |
| | | import com.vincent.rsf.server.ai.dto.AiMcpConnectivityTestDto; |
| | | import com.vincent.rsf.server.ai.dto.AiMcpToolPreviewDto; |
| | | import com.vincent.rsf.server.ai.dto.AiMcpToolTestDto; |
| | | import com.vincent.rsf.server.ai.dto.AiMcpToolTestRequest; |
| | |
| | | |
| | | import java.util.ArrayList; |
| | | import java.util.Arrays; |
| | | import java.util.Date; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | |
| | | @Override |
| | | public List<AiMcpToolPreviewDto> previewTools(Long mountId, Long userId, Long tenantId) { |
| | | AiMcpMount mount = requireMount(mountId, tenantId); |
| | | long startedAt = System.currentTimeMillis(); |
| | | try (McpMountRuntimeFactory.McpMountRuntime runtime = mcpMountRuntimeFactory.create(List.of(mount), userId)) { |
| | | List<AiMcpToolPreviewDto> tools = new ArrayList<>(); |
| | | for (ToolCallback callback : runtime.getToolCallbacks()) { |
| | | if (callback == null || callback.getToolDefinition() == null) { |
| | | continue; |
| | | } |
| | | tools.add(AiMcpToolPreviewDto.builder() |
| | | .name(callback.getToolDefinition().name()) |
| | | .description(callback.getToolDefinition().description()) |
| | | .inputSchema(callback.getToolDefinition().inputSchema()) |
| | | .returnDirect(callback.getToolMetadata() == null ? null : callback.getToolMetadata().returnDirect()) |
| | | .build()); |
| | | List<AiMcpToolPreviewDto> tools = buildToolPreviewDtos(runtime.getToolCallbacks()); |
| | | if (!runtime.getErrors().isEmpty()) { |
| | | String message = String.join(";", runtime.getErrors()); |
| | | updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_UNHEALTHY, message, System.currentTimeMillis() - startedAt); |
| | | throw new CoolException(message); |
| | | } |
| | | updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_HEALTHY, |
| | | "工具解析成功,共 " + tools.size() + " 个工具", System.currentTimeMillis() - startedAt); |
| | | return tools; |
| | | } catch (CoolException e) { |
| | | throw e; |
| | | } catch (Exception e) { |
| | | updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_UNHEALTHY, |
| | | "工具解析失败: " + e.getMessage(), System.currentTimeMillis() - startedAt); |
| | | throw new CoolException("获取工具列表失败: " + e.getMessage()); |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | public AiMcpConnectivityTestDto testConnectivity(Long mountId, Long userId, Long tenantId) { |
| | | AiMcpMount mount = requireMount(mountId, tenantId); |
| | | long startedAt = System.currentTimeMillis(); |
| | | try (McpMountRuntimeFactory.McpMountRuntime runtime = mcpMountRuntimeFactory.create(List.of(mount), userId)) { |
| | | long elapsedMs = System.currentTimeMillis() - startedAt; |
| | | if (!runtime.getErrors().isEmpty()) { |
| | | String message = String.join(";", runtime.getErrors()); |
| | | updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_UNHEALTHY, message, elapsedMs); |
| | | AiMcpMount latest = requireMount(mount.getId(), tenantId); |
| | | return buildConnectivityDto(latest, message, elapsedMs, runtime.getToolCallbacks().length); |
| | | } |
| | | String message = "连通性测试成功,解析出 " + runtime.getToolCallbacks().length + " 个工具"; |
| | | updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_HEALTHY, message, elapsedMs); |
| | | AiMcpMount latest = requireMount(mount.getId(), tenantId); |
| | | return buildConnectivityDto(latest, message, elapsedMs, runtime.getToolCallbacks().length); |
| | | } catch (CoolException e) { |
| | | throw e; |
| | | } catch (Exception e) { |
| | | long elapsedMs = System.currentTimeMillis() - startedAt; |
| | | String message = "连通性测试失败: " + e.getMessage(); |
| | | updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_UNHEALTHY, message, elapsedMs); |
| | | AiMcpMount latest = requireMount(mount.getId(), tenantId); |
| | | return buildConnectivityDto(latest, message, elapsedMs, 0); |
| | | } |
| | | } |
| | | |
| | |
| | | throw new CoolException("工具输入 JSON 格式错误: " + e.getMessage()); |
| | | } |
| | | AiMcpMount mount = requireMount(mountId, tenantId); |
| | | long startedAt = System.currentTimeMillis(); |
| | | try (McpMountRuntimeFactory.McpMountRuntime runtime = mcpMountRuntimeFactory.create(List.of(mount), userId)) { |
| | | ToolCallback callback = Arrays.stream(runtime.getToolCallbacks()) |
| | | .filter(item -> item != null && item.getToolDefinition() != null) |
| | |
| | | request.getInputJson(), |
| | | new ToolContext(Map.of("userId", userId, "tenantId", tenantId, "mountId", mountId)) |
| | | ); |
| | | updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_HEALTHY, |
| | | "工具测试成功: " + request.getToolName(), System.currentTimeMillis() - startedAt); |
| | | return AiMcpToolTestDto.builder() |
| | | .toolName(request.getToolName()) |
| | | .inputJson(request.getInputJson()) |
| | | .output(output) |
| | | .build(); |
| | | } catch (CoolException e) { |
| | | updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_UNHEALTHY, |
| | | "工具测试失败: " + e.getMessage(), System.currentTimeMillis() - startedAt); |
| | | throw e; |
| | | } catch (Exception e) { |
| | | updateHealthStatus(mount.getId(), AiDefaults.MCP_HEALTH_UNHEALTHY, |
| | | "工具测试失败: " + e.getMessage(), System.currentTimeMillis() - startedAt); |
| | | throw new CoolException("工具测试失败: " + e.getMessage()); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | if (aiMcpMount.getStatus() == null) { |
| | | aiMcpMount.setStatus(StatusType.ENABLE.val); |
| | | } |
| | | if (!StringUtils.hasText(aiMcpMount.getHealthStatus())) { |
| | | aiMcpMount.setHealthStatus(AiDefaults.MCP_HEALTH_NOT_TESTED); |
| | | } |
| | | } |
| | | |
| | |
| | | throw new CoolException("当前租户不存在"); |
| | | } |
| | | } |
| | | |
| | | private List<AiMcpToolPreviewDto> buildToolPreviewDtos(ToolCallback[] callbacks) { |
| | | List<AiMcpToolPreviewDto> tools = new ArrayList<>(); |
| | | for (ToolCallback callback : callbacks) { |
| | | if (callback == null || callback.getToolDefinition() == null) { |
| | | continue; |
| | | } |
| | | tools.add(AiMcpToolPreviewDto.builder() |
| | | .name(callback.getToolDefinition().name()) |
| | | .description(callback.getToolDefinition().description()) |
| | | .inputSchema(callback.getToolDefinition().inputSchema()) |
| | | .returnDirect(callback.getToolMetadata() == null ? null : callback.getToolMetadata().returnDirect()) |
| | | .build()); |
| | | } |
| | | return tools; |
| | | } |
| | | |
| | | private void updateHealthStatus(Long mountId, String healthStatus, String message, Long initElapsedMs) { |
| | | this.update(new LambdaUpdateWrapper<AiMcpMount>() |
| | | .eq(AiMcpMount::getId, mountId) |
| | | .set(AiMcpMount::getHealthStatus, healthStatus) |
| | | .set(AiMcpMount::getLastTestTime, new Date()) |
| | | .set(AiMcpMount::getLastTestMessage, message) |
| | | .set(AiMcpMount::getLastInitElapsedMs, initElapsedMs)); |
| | | } |
| | | |
| | | private AiMcpConnectivityTestDto buildConnectivityDto(AiMcpMount mount, String message, Long initElapsedMs, Integer toolCount) { |
| | | return AiMcpConnectivityTestDto.builder() |
| | | .mountId(mount.getId()) |
| | | .mountName(mount.getName()) |
| | | .healthStatus(mount.getHealthStatus()) |
| | | .message(message) |
| | | .initElapsedMs(initElapsedMs) |
| | | .toolCount(toolCount) |
| | | .testedAt(mount.getLastTestTime$()) |
| | | .build(); |
| | | } |
| | | } |
| | |
| | | `env_json` text COMMENT '环境变量 JSON', |
| | | `headers_json` text COMMENT '请求头 JSON', |
| | | `request_timeout_ms` int(11) DEFAULT NULL COMMENT '请求超时时间', |
| | | `health_status` varchar(32) DEFAULT 'NOT_TESTED' COMMENT '健康状态', |
| | | `last_test_time` datetime DEFAULT NULL COMMENT '最近测试时间', |
| | | `last_test_message` varchar(500) DEFAULT NULL COMMENT '最近测试信息', |
| | | `last_init_elapsed_ms` bigint(20) DEFAULT NULL COMMENT '最近初始化耗时', |
| | | `sort` int(11) DEFAULT '0' COMMENT '排序', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户', |
| | | `status` int(11) DEFAULT '1' COMMENT '状态', |
| | |
| | | EXECUTE builtin_code_stmt; |
| | | DEALLOCATE PREPARE builtin_code_stmt; |
| | | |
| | | SET @mcp_health_status_exists := ( |
| | | SELECT COUNT(1) |
| | | FROM `information_schema`.`COLUMNS` |
| | | WHERE `TABLE_SCHEMA` = DATABASE() |
| | | AND `TABLE_NAME` = 'sys_ai_mcp_mount' |
| | | AND `COLUMN_NAME` = 'health_status' |
| | | ); |
| | | SET @mcp_health_status_sql := IF( |
| | | @mcp_health_status_exists = 0, |
| | | 'ALTER TABLE `sys_ai_mcp_mount` ADD COLUMN `health_status` varchar(32) DEFAULT ''NOT_TESTED'' COMMENT ''健康状态'' AFTER `request_timeout_ms`', |
| | | 'SELECT 1' |
| | | ); |
| | | PREPARE mcp_health_status_stmt FROM @mcp_health_status_sql; |
| | | EXECUTE mcp_health_status_stmt; |
| | | DEALLOCATE PREPARE mcp_health_status_stmt; |
| | | |
| | | SET @mcp_last_test_time_exists := ( |
| | | SELECT COUNT(1) |
| | | FROM `information_schema`.`COLUMNS` |
| | | WHERE `TABLE_SCHEMA` = DATABASE() |
| | | AND `TABLE_NAME` = 'sys_ai_mcp_mount' |
| | | AND `COLUMN_NAME` = 'last_test_time' |
| | | ); |
| | | SET @mcp_last_test_time_sql := IF( |
| | | @mcp_last_test_time_exists = 0, |
| | | 'ALTER TABLE `sys_ai_mcp_mount` ADD COLUMN `last_test_time` datetime DEFAULT NULL COMMENT ''最近测试时间'' AFTER `health_status`', |
| | | 'SELECT 1' |
| | | ); |
| | | PREPARE mcp_last_test_time_stmt FROM @mcp_last_test_time_sql; |
| | | EXECUTE mcp_last_test_time_stmt; |
| | | DEALLOCATE PREPARE mcp_last_test_time_stmt; |
| | | |
| | | SET @mcp_last_test_message_exists := ( |
| | | SELECT COUNT(1) |
| | | FROM `information_schema`.`COLUMNS` |
| | | WHERE `TABLE_SCHEMA` = DATABASE() |
| | | AND `TABLE_NAME` = 'sys_ai_mcp_mount' |
| | | AND `COLUMN_NAME` = 'last_test_message' |
| | | ); |
| | | SET @mcp_last_test_message_sql := IF( |
| | | @mcp_last_test_message_exists = 0, |
| | | 'ALTER TABLE `sys_ai_mcp_mount` ADD COLUMN `last_test_message` varchar(500) DEFAULT NULL COMMENT ''最近测试信息'' AFTER `last_test_time`', |
| | | 'SELECT 1' |
| | | ); |
| | | PREPARE mcp_last_test_message_stmt FROM @mcp_last_test_message_sql; |
| | | EXECUTE mcp_last_test_message_stmt; |
| | | DEALLOCATE PREPARE mcp_last_test_message_stmt; |
| | | |
| | | SET @mcp_last_init_elapsed_exists := ( |
| | | SELECT COUNT(1) |
| | | FROM `information_schema`.`COLUMNS` |
| | | WHERE `TABLE_SCHEMA` = DATABASE() |
| | | AND `TABLE_NAME` = 'sys_ai_mcp_mount' |
| | | AND `COLUMN_NAME` = 'last_init_elapsed_ms' |
| | | ); |
| | | SET @mcp_last_init_elapsed_sql := IF( |
| | | @mcp_last_init_elapsed_exists = 0, |
| | | 'ALTER TABLE `sys_ai_mcp_mount` ADD COLUMN `last_init_elapsed_ms` bigint(20) DEFAULT NULL COMMENT ''最近初始化耗时'' AFTER `last_test_message`', |
| | | 'SELECT 1' |
| | | ); |
| | | PREPARE mcp_last_init_elapsed_stmt FROM @mcp_last_init_elapsed_sql; |
| | | EXECUTE mcp_last_init_elapsed_stmt; |
| | | DEALLOCATE PREPARE mcp_last_init_elapsed_stmt; |
| | | |
| | | UPDATE `sys_ai_mcp_mount` |
| | | SET `health_status` = 'NOT_TESTED' |
| | | WHERE `health_status` IS NULL OR `health_status` = ''; |
| | | |
| | | SET @chat_session_pinned_exists := ( |
| | | SELECT COUNT(1) |
| | | FROM `information_schema`.`COLUMNS` |