🎉 完善基础模块
This commit is contained in:
393
web-admin/src/features/mcp/McpTestPage.tsx
Normal file
393
web-admin/src/features/mcp/McpTestPage.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Input,
|
||||
List,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { mcpApi } from '@/lib/api'
|
||||
import type { JsonSchema, McpContent, McpManagedStatus, McpServerConfig, McpToolDescriptor } from '@/types/system'
|
||||
|
||||
const { TextArea } = Input
|
||||
|
||||
function statusColor(state?: string) {
|
||||
switch (state) {
|
||||
case 'running':
|
||||
return 'green'
|
||||
case 'starting':
|
||||
return 'orange'
|
||||
case 'external':
|
||||
return 'blue'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
function asSchemaRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {}
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function buildExampleValue(schema?: JsonSchema | Record<string, unknown> | unknown): unknown {
|
||||
const current = asSchemaRecord(schema)
|
||||
if ('default' in current) {
|
||||
return current.default
|
||||
}
|
||||
|
||||
const enumValues = Array.isArray(current.enum) ? current.enum : []
|
||||
if (enumValues.length > 0) {
|
||||
return enumValues[0]
|
||||
}
|
||||
|
||||
const schemaType = typeof current.type === 'string' ? current.type : ''
|
||||
switch (schemaType) {
|
||||
case 'string':
|
||||
return ''
|
||||
case 'number':
|
||||
case 'integer':
|
||||
return 0
|
||||
case 'boolean':
|
||||
return false
|
||||
case 'array':
|
||||
return current.items ? [buildExampleValue(current.items)] : []
|
||||
case 'object':
|
||||
break
|
||||
default:
|
||||
if (!current.properties) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const properties = asSchemaRecord(current.properties)
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
result[key] = buildExampleValue(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function buildExampleArguments(tool?: McpToolDescriptor | null) {
|
||||
if (!tool?.inputSchema) {
|
||||
return '{}'
|
||||
}
|
||||
|
||||
return JSON.stringify(buildExampleValue(tool.inputSchema), null, 2)
|
||||
}
|
||||
|
||||
function renderContent(content: McpContent, index: number) {
|
||||
if (content.type === 'image' && content.data) {
|
||||
const mimeType = content.mimeType || 'image/png'
|
||||
return (
|
||||
<Card key={`content-${index}`} type="inner" title={`结果 ${index + 1} · image`}>
|
||||
<img
|
||||
alt={`mcp-result-${index + 1}`}
|
||||
src={`data:${mimeType};base64,${content.data}`}
|
||||
style={{ maxWidth: '100%', borderRadius: 12 }}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card key={`content-${index}`} type="inner" title={`结果 ${index + 1} · ${content.type || 'unknown'}`}>
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{content.text || JSON.stringify(content, null, 2)}
|
||||
</pre>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function McpTestPage() {
|
||||
const [status, setStatus] = useState<McpManagedStatus | null>(null)
|
||||
const [serverConfig, setServerConfig] = useState<McpServerConfig | null>(null)
|
||||
const [tools, setTools] = useState<McpToolDescriptor[]>([])
|
||||
const [selectedToolName, setSelectedToolName] = useState('')
|
||||
const [argumentsText, setArgumentsText] = useState('{}')
|
||||
const [result, setResult] = useState<McpContent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [calling, setCalling] = useState(false)
|
||||
|
||||
const selectedTool = useMemo(
|
||||
() => tools.find((item) => item.name === selectedToolName) || null,
|
||||
[selectedToolName, tools],
|
||||
)
|
||||
|
||||
const reload = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const statusRes = await mcpApi.getStatus()
|
||||
setStatus(statusRes.data.status)
|
||||
setServerConfig(statusRes.data.mcpServerConfig)
|
||||
|
||||
if (!statusRes.data.status.reachable) {
|
||||
setTools([])
|
||||
return
|
||||
}
|
||||
|
||||
const listRes = await mcpApi.getList()
|
||||
const nextTools = listRes.data.list?.tools || []
|
||||
setStatus(listRes.data.status)
|
||||
setServerConfig(listRes.data.mcpServerConfig)
|
||||
setTools(nextTools)
|
||||
if (nextTools.length > 0) {
|
||||
setSelectedToolName((current) =>
|
||||
nextTools.some((item) => item.name === current) ? current : nextTools[0].name,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setArgumentsText(buildExampleArguments(selectedTool))
|
||||
}, [selectedTool])
|
||||
|
||||
const runAction = async (action: 'start' | 'stop') => {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
if (action === 'start') {
|
||||
await mcpApi.start()
|
||||
message.success('MCP 独立服务已启动')
|
||||
} else {
|
||||
await mcpApi.stop()
|
||||
message.success('MCP 独立服务已停用')
|
||||
setResult([])
|
||||
}
|
||||
await reload()
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const callTool = async () => {
|
||||
if (!selectedToolName) {
|
||||
message.warning('请先选择一个工具')
|
||||
return
|
||||
}
|
||||
|
||||
let parsedArguments: Record<string, unknown> = {}
|
||||
try {
|
||||
parsedArguments = JSON.parse(argumentsText || '{}') as Record<string, unknown>
|
||||
} catch {
|
||||
message.error('参数 JSON 解析失败,请先修正格式')
|
||||
return
|
||||
}
|
||||
|
||||
setCalling(true)
|
||||
try {
|
||||
const response = await mcpApi.test({
|
||||
name: selectedToolName,
|
||||
arguments: parsedArguments,
|
||||
})
|
||||
setResult(response.data)
|
||||
message.success('工具调用成功')
|
||||
} finally {
|
||||
setCalling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const activeServer = useMemo(() => {
|
||||
if (!serverConfig?.mcpServers) {
|
||||
return null
|
||||
}
|
||||
const [serverName, serverValue] = Object.entries(serverConfig.mcpServers)[0] || []
|
||||
if (!serverName || !serverValue) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
serverName,
|
||||
...serverValue,
|
||||
}
|
||||
}, [serverConfig])
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card className="glass-panel page-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<Typography.Title level={2} style={{ marginBottom: 8 }}>
|
||||
MCP Tools 管理
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="text-muted" style={{ marginBottom: 0 }}>
|
||||
当前页直接管理 MCP 独立服务,并可在登录态下调用工具验证新项目接入是否正常。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Space>
|
||||
<Button onClick={reload} loading={loading}>
|
||||
刷新状态
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => runAction('start')} loading={actionLoading}>
|
||||
启动服务
|
||||
</Button>
|
||||
<Button danger onClick={() => runAction('stop')} loading={actionLoading}>
|
||||
停用服务
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
{status?.lastError ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={status.message || 'MCP 状态异常'}
|
||||
description={status.lastError}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="服务状态" value={status?.state || 'stopped'} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="已注册工具" value={tools.length} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="鉴权请求头" value={status?.authHeader || '-'} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="监听地址" value={status?.listenAddr || '-'} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-panel page-panel" loading={loading}>
|
||||
<Descriptions
|
||||
title="服务连接信息"
|
||||
bordered
|
||||
column={1}
|
||||
items={[
|
||||
{
|
||||
key: 'status',
|
||||
label: '运行状态',
|
||||
children: (
|
||||
<Space>
|
||||
<Tag color={statusColor(status?.state)}>{status?.state || 'stopped'}</Tag>
|
||||
<span>{status?.message || '未获取状态信息'}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ key: 'managed', label: '托管状态', children: status?.managed ? '页面托管' : '外部或未启动' },
|
||||
{ key: 'reachable', label: '健康检查', children: status?.reachable ? '可访问' : '不可访问' },
|
||||
{ key: 'baseURL', label: 'MCP 地址', children: status?.baseURL || '-' },
|
||||
{ key: 'healthURL', label: '健康检查地址', children: status?.healthURL || '-' },
|
||||
{ key: 'logPath', label: '日志文件', children: status?.logPath || '-' },
|
||||
{ key: 'config', label: '客户端配置', children: activeServer ? JSON.stringify(activeServer, null, 2) : '-' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} xl={8}>
|
||||
<Card className="glass-panel page-panel" title={`工具列表${loading ? '' : ` (${tools.length})`}`}>
|
||||
{loading ? (
|
||||
<div className="fullscreen-status" style={{ minHeight: 240 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : tools.length === 0 ? (
|
||||
<Empty description={status?.reachable ? '当前服务未返回工具' : '请先启动 MCP 独立服务'} />
|
||||
) : (
|
||||
<List
|
||||
dataSource={tools}
|
||||
renderItem={(tool) => (
|
||||
<List.Item
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 12,
|
||||
paddingInline: 12,
|
||||
background: tool.name === selectedToolName ? 'rgba(24, 144, 255, 0.08)' : 'transparent',
|
||||
}}
|
||||
onClick={() => setSelectedToolName(tool.name)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space>
|
||||
<span>{tool.name}</span>
|
||||
{tool.name === selectedToolName ? <Tag color="blue">当前</Tag> : null}
|
||||
</Space>
|
||||
}
|
||||
description={tool.description || '未提供工具描述'}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} xl={16}>
|
||||
<Card
|
||||
className="glass-panel page-panel"
|
||||
title={selectedTool ? `工具调试 · ${selectedTool.name}` : '工具调试'}
|
||||
extra={
|
||||
<Button type="primary" loading={calling} onClick={callTool} disabled={!selectedTool}>
|
||||
调用工具
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{selectedTool ? (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={selectedTool.description || '当前工具未提供描述'}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Title level={5}>输入 Schema</Typography.Title>
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{JSON.stringify(selectedTool.inputSchema || {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={5}>调用参数 JSON</Typography.Title>
|
||||
<TextArea
|
||||
value={argumentsText}
|
||||
onChange={(event) => setArgumentsText(event.target.value)}
|
||||
autoSize={{ minRows: 12, maxRows: 24 }}
|
||||
style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
) : (
|
||||
<Empty description="请先从左侧选择一个工具" />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card className="glass-panel page-panel" title="调用结果">
|
||||
{result.length === 0 ? (
|
||||
<Empty description="工具调用结果会显示在这里" />
|
||||
) : (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
{result.map((item, index) => renderContent(item, index))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user