Files
Go-Web-Template/web-admin/src/features/mcp/McpTestPage.tsx
2026-04-07 13:25:39 +08:00

394 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}