mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-21 22:14:02 +08:00
✨ feat(ai): 显示 MCP 工具参数摘要
This commit is contained in:
@@ -8,6 +8,7 @@ import type { ParsedMCPEnvDraft } from '../../utils/mcpEnvDraft';
|
||||
import type { MCPServerDraftValidation } from '../../utils/mcpServerValidation';
|
||||
import AIMCPHelpBlock, { buildMCPHintStyle, mcpLabelStyle } from './AIMCPHelpBlock';
|
||||
import AIMCPServerValidationPanel from './AIMCPServerValidationPanel';
|
||||
import AIMCPToolSchemaSummary from './AIMCPToolSchemaSummary';
|
||||
|
||||
interface AIMCPServerFormPanelProps {
|
||||
server: AIMCPServerConfig;
|
||||
@@ -166,18 +167,12 @@ const AIMCPServerFormPanel: React.FC<AIMCPServerFormPanelProps> = ({
|
||||
overlayTheme={overlayTheme}
|
||||
/>
|
||||
|
||||
{serverTools.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: overlayTheme.titleText }}>已发现工具</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{serverTools.map((tool) => (
|
||||
<span key={tool.alias} style={{ padding: '4px 8px', borderRadius: 999, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', fontSize: 12, color: overlayTheme.mutedText }}>
|
||||
{tool.alias}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AIMCPToolSchemaSummary
|
||||
tools={serverTools}
|
||||
cardBorder={cardBorder}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
/>
|
||||
|
||||
<div style={{ padding: '10px 12px', borderRadius: 10, border: `1px solid ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)' }}>
|
||||
<div style={{ ...mcpLabelStyle, color: overlayTheme.titleText }}>操作说明</div>
|
||||
|
||||
145
frontend/src/components/ai/AIMCPToolSchemaSummary.tsx
Normal file
145
frontend/src/components/ai/AIMCPToolSchemaSummary.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { AIMCPToolDescriptor } from '../../types';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
import { buildMCPHintStyle } from './AIMCPHelpBlock';
|
||||
|
||||
const MAX_PARAMETER_PREVIEW = 6;
|
||||
|
||||
type JSONSchemaRecord = Record<string, any>;
|
||||
|
||||
const isRecord = (value: unknown): value is JSONSchemaRecord =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const readSchemaType = (schema: JSONSchemaRecord): string => {
|
||||
if (Array.isArray(schema.type)) {
|
||||
return schema.type.map((item) => String(item)).filter(Boolean).join('|') || 'unknown';
|
||||
}
|
||||
if (typeof schema.type === 'string' && schema.type.trim()) {
|
||||
return schema.type.trim();
|
||||
}
|
||||
if (Array.isArray(schema.enum)) {
|
||||
return 'enum';
|
||||
}
|
||||
if (isRecord(schema.properties)) {
|
||||
return 'object';
|
||||
}
|
||||
if (isRecord(schema.items)) {
|
||||
return 'array';
|
||||
}
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
const readRequiredSet = (schema: JSONSchemaRecord): Set<string> => new Set(
|
||||
Array.isArray(schema.required)
|
||||
? schema.required.map((item) => String(item)).filter(Boolean)
|
||||
: [],
|
||||
);
|
||||
|
||||
const summarizeToolParameters = (tool: AIMCPToolDescriptor) => {
|
||||
const inputSchema = isRecord(tool.inputSchema) ? tool.inputSchema : {};
|
||||
const properties = isRecord(inputSchema.properties) ? inputSchema.properties : {};
|
||||
const requiredSet = readRequiredSet(inputSchema);
|
||||
const parameters = Object.entries(properties).map(([name, rawSchema]) => {
|
||||
const schema = isRecord(rawSchema) ? rawSchema : {};
|
||||
return {
|
||||
name,
|
||||
required: requiredSet.has(name),
|
||||
type: readSchemaType(schema),
|
||||
description: String(schema.description || schema.title || '').trim(),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
hasInputSchema: Object.keys(inputSchema).length > 0,
|
||||
parameters,
|
||||
requiredCount: parameters.filter((item) => item.required).length,
|
||||
truncated: parameters.length > MAX_PARAMETER_PREVIEW,
|
||||
};
|
||||
};
|
||||
|
||||
interface AIMCPToolSchemaSummaryProps {
|
||||
tools: AIMCPToolDescriptor[];
|
||||
cardBorder: string;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
}
|
||||
|
||||
const AIMCPToolSchemaSummary: React.FC<AIMCPToolSchemaSummaryProps> = ({
|
||||
tools,
|
||||
cardBorder,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
}) => {
|
||||
if (tools.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: overlayTheme.titleText }}>已发现工具和参数提示</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(230px, 1fr))', gap: 8 }}>
|
||||
{tools.map((tool) => {
|
||||
const summary = summarizeToolParameters(tool);
|
||||
const previewParameters = summary.parameters.slice(0, MAX_PARAMETER_PREVIEW);
|
||||
return (
|
||||
<div
|
||||
key={tool.alias}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${cardBorder}`,
|
||||
background: darkMode ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.78)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: overlayTheme.titleText, overflowWrap: 'anywhere' }}>
|
||||
{tool.alias}
|
||||
</div>
|
||||
{tool.description ? (
|
||||
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>{tool.description}</div>
|
||||
) : null}
|
||||
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>
|
||||
{summary.hasInputSchema
|
||||
? `参数 ${summary.parameters.length} 个,必填 ${summary.requiredCount} 个;星号表示必填。`
|
||||
: '未声明 inputSchema,调用参数需参考服务文档或用 /mcptool 继续查看。'}
|
||||
</div>
|
||||
{previewParameters.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{previewParameters.map((parameter) => (
|
||||
<span
|
||||
key={parameter.name}
|
||||
title={parameter.description || undefined}
|
||||
style={{
|
||||
padding: '3px 7px',
|
||||
borderRadius: 999,
|
||||
background: parameter.required
|
||||
? (darkMode ? 'rgba(59,130,246,0.18)' : 'rgba(37,99,235,0.10)')
|
||||
: (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(15,23,42,0.05)'),
|
||||
color: parameter.required ? '#2563eb' : overlayTheme.mutedText,
|
||||
fontSize: 12,
|
||||
fontFamily: 'var(--gn-font-mono)',
|
||||
}}
|
||||
>
|
||||
{parameter.name}
|
||||
{parameter.required ? '*' : ''}: {parameter.type}
|
||||
</span>
|
||||
))}
|
||||
{summary.truncated ? (
|
||||
<span style={{ ...buildMCPHintStyle(overlayTheme.mutedText), padding: '3px 0' }}>
|
||||
还有 {summary.parameters.length - MAX_PARAMETER_PREVIEW} 个参数,使用 /mcptool 查看完整 schema
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIMCPToolSchemaSummary;
|
||||
@@ -138,6 +138,31 @@ describe('AISettingsMCPSection', () => {
|
||||
enabled: true,
|
||||
timeoutSeconds: 20,
|
||||
}],
|
||||
mcpTools: [
|
||||
{
|
||||
alias: 'execute_sql',
|
||||
serverId: 'mcp-local',
|
||||
serverName: 'Local MCP',
|
||||
originalName: 'execute_sql',
|
||||
description: '执行 SQL',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['connectionId', 'sql'],
|
||||
properties: {
|
||||
connectionId: { type: 'string', description: '连接 ID' },
|
||||
dbName: { type: 'string', description: '数据库名' },
|
||||
sql: { type: 'string', description: 'SQL 文本' },
|
||||
allowMutating: { type: 'boolean', description: '显式允许写操作' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
alias: 'legacy_tool',
|
||||
serverId: 'mcp-local',
|
||||
serverName: 'Local MCP',
|
||||
originalName: 'legacy_tool',
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
@@ -147,6 +172,14 @@ describe('AISettingsMCPSection', () => {
|
||||
expect(markup).toContain('认证失败、401 或 403');
|
||||
expect(markup).toContain('当前只支持 stdio');
|
||||
expect(markup).toContain('不要把密钥写进聊天内容');
|
||||
expect(markup).toContain('已发现工具和参数提示');
|
||||
expect(markup).toContain('execute_sql');
|
||||
expect(markup).toContain('参数 4 个,必填 2 个');
|
||||
expect(markup).toContain('connectionId*: string');
|
||||
expect(markup).toContain('sql*: string');
|
||||
expect(markup).toContain('allowMutating: boolean');
|
||||
expect(markup).toContain('legacy_tool');
|
||||
expect(markup).toContain('未声明 inputSchema');
|
||||
});
|
||||
|
||||
it('seeds a new draft when a launch template is selected', () => {
|
||||
|
||||
Reference in New Issue
Block a user