🎉 完善基础模块

This commit is contained in:
2026-04-07 13:25:39 +08:00
parent c6f6930416
commit 22bb5fdc94
40 changed files with 2003 additions and 1 deletions

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