394 lines
12 KiB
TypeScript
394 lines
12 KiB
TypeScript
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>
|
||
)
|
||
}
|