feat(ai-settings): 优化MCP安装引导并补充表样例预览工具

- 拆分 Skills 区块并补充 MCP 启动模板\n- 改造 Claude/Codex 安装目标选择与状态展示\n- 新增 preview_table_rows 内置工具与目录说明
This commit is contained in:
Syngnat
2026-06-08 08:00:43 +08:00
parent 6c53fb14a6
commit 7d7b775fe0
14 changed files with 529 additions and 130 deletions

View File

@@ -22,7 +22,8 @@ describe('AISettingsModal edit password behavior', () => {
expect(source).toContain('Service.AIGetMCPServers?.()');
expect(source).toContain('Service.AIListMCPTools?.()');
expect(source).toContain('Service.AIGetSkills?.()');
expect(source).toContain('新增 Skill');
expect(source).toContain("import AISettingsSkillsSection from './ai/AISettingsSkillsSection';");
expect(source).toContain('<AISettingsSkillsSection');
});
it('delegates bulky MCP and built-in tool sections to dedicated ai components', () => {

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AISkillConfig, AISkillScope } from '../types';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AISkillConfig } from '../types';
import {
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
@@ -24,6 +24,7 @@ import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry';
import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';
import AISettingsMCPSection, { type MCPClientKey } from './ai/AISettingsMCPSection';
import AISettingsSkillsSection from './ai/AISettingsSkillsSection';
interface AISettingsModalProps {
open: boolean;
onClose: () => void;
@@ -97,16 +98,27 @@ const EMPTY_AI_USER_PROMPT_SETTINGS: AIUserPromptSettings = {
jvmDiagnostic: '',
};
const EMPTY_MCP_SERVER = (): AIMCPServerConfig => ({
id: `mcp-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: '',
transport: 'stdio',
command: '',
args: [],
env: {},
enabled: true,
timeoutSeconds: 20,
});
const EMPTY_MCP_SERVER = (seed?: Partial<AIMCPServerConfig>): AIMCPServerConfig => {
const base: AIMCPServerConfig = {
id: `mcp-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: '',
transport: 'stdio',
command: '',
args: [],
env: {},
enabled: true,
timeoutSeconds: 20,
};
return {
...base,
...seed,
transport: seed?.transport || base.transport,
args: Array.isArray(seed?.args) ? seed.args : base.args,
env: seed?.env || base.env,
enabled: seed?.enabled ?? base.enabled,
timeoutSeconds: seed?.timeoutSeconds || base.timeoutSeconds,
};
};
const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [
{
@@ -212,13 +224,6 @@ const EMPTY_SKILL = (): AISkillConfig => ({
requiredTools: [],
});
const SKILL_SCOPE_OPTIONS: Array<{ value: AISkillScope; label: string; desc: string }> = [
{ value: 'global', label: '全局', desc: '所有 AI 会话都启用' },
{ value: 'database', label: '数据库', desc: '仅 SQL / 数据库场景启用' },
{ value: 'jvm', label: 'JVM 资源', desc: '仅 JVM 资源分析场景启用' },
{ value: 'jvmDiagnostic', label: 'JVM 诊断', desc: '仅 JVM 诊断工作台启用' },
];
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
const [activeProviderId, setActiveProviderId] = useState<string>('');
@@ -581,8 +586,8 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
setMCPServers((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item));
};
const handleAddMCPServer = () => {
setMCPServers((prev) => [...prev, EMPTY_MCP_SERVER()]);
const handleAddMCPServer = (seed?: Partial<AIMCPServerConfig>) => {
setMCPServers((prev) => [...prev, EMPTY_MCP_SERVER(seed)]);
};
const handleSaveMCPServer = async (server: AIMCPServerConfig) => {
@@ -1189,75 +1194,6 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
</div>
);
const renderSkillSettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
Skill + + GitHub skill pack
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}> scope Skill </div>
<Button icon={<PlusOutlined />} onClick={handleAddSkill} style={{ borderRadius: 10 }}> Skill</Button>
</div>
{skills.length === 0 && (
<div style={{ padding: '18px 16px', borderRadius: 14, border: `1px dashed ${cardBorder}`, background: cardBg, color: overlayTheme.mutedText }}>
SkillJVM system prompt
</div>
)}
{skills.map((skill) => (
<div key={skill.id} style={{ padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 132px', gap: 12 }}>
<Input
value={skill.name}
onChange={(event) => updateSkillDraft(skill.id, { name: event.target.value })}
placeholder="Skill 名称例如SQL 审查 / JVM 诊断计划"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
<Select
value={skill.enabled ? 'enabled' : 'disabled'}
onChange={(value) => updateSkillDraft(skill.id, { enabled: value === 'enabled' })}
options={[{ label: '已启用', value: 'enabled' }, { label: '已禁用', value: 'disabled' }]}
/>
</div>
<Input
value={skill.description || ''}
onChange={(event) => updateSkillDraft(skill.id, { description: event.target.value })}
placeholder="给自己看的说明,例如:输出 SQL 前必须先确认字段名和风险"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
<Select
mode="multiple"
value={skill.scopes || []}
onChange={(value) => updateSkillDraft(skill.id, { scopes: value as AISkillScope[] })}
options={SKILL_SCOPE_OPTIONS.map((option) => ({ label: `${option.label} · ${option.desc}`, value: option.value }))}
placeholder="选择这个 Skill 要作用到哪些场景"
style={{ width: '100%' }}
/>
<Select
mode="multiple"
value={skill.requiredTools || []}
onChange={(value) => updateSkillDraft(skill.id, { requiredTools: value })}
options={skillRequiredToolOptions}
placeholder="可选:声明这个 Skill 依赖哪些工具"
style={{ width: '100%' }}
/>
<Input.TextArea
rows={6}
value={skill.systemPrompt}
onChange={(event) => updateSkillDraft(skill.id, { systemPrompt: event.target.value })}
placeholder="输入这条 Skill 要追加的 system prompt。建议聚焦一个明确能力不要和全局提示词重复。"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)', resize: 'vertical' }}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button type="primary" onClick={() => handleSaveSkill(skill)} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}></Button>
<Popconfirm title="删除这个 Skill" okText="删除" cancelText="取消" onConfirm={() => handleDeleteSkill(skill.id)}>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 10 }}></Button>
</Popconfirm>
</div>
</div>
))}
</div>
);
const modalShellStyle = {
background: overlayTheme.shellBg, border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter,
@@ -1368,7 +1304,21 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
onDeleteServer={handleDeleteMCPServer}
/>
)}
{activeSection === 'skills' && renderSkillSettings()}
{activeSection === 'skills' && (
<AISettingsSkillsSection
skills={skills}
skillRequiredToolOptions={skillRequiredToolOptions}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
inputBg={inputBg}
loading={loading}
onAddSkill={handleAddSkill}
onUpdateSkillDraft={updateSkillDraft}
onSaveSkill={handleSaveSkill}
onDeleteSkill={handleDeleteSkill}
/>
)}
{activeSection === 'tools' && (
<AIBuiltinToolsCatalog
darkMode={darkMode}

View File

@@ -22,5 +22,7 @@ describe('AIBuiltinToolsCatalog', () => {
expect(markup).toContain('get_indexes');
expect(markup).toContain('get_foreign_keys');
expect(markup).toContain('get_triggers');
expect(markup).toContain('理解样例数据');
expect(markup).toContain('preview_table_rows');
});
});

View File

@@ -27,9 +27,14 @@ const BUILTIN_TOOL_FLOWS = [
steps: 'get_columns → get_indexes → get_foreign_keys → get_triggers → get_table_ddl',
description: '适合做索引优化、关系梳理、隐式副作用排查和 DDL 审查。',
},
{
title: '理解样例数据',
steps: 'get_columns → preview_table_rows',
description: '适合先确认字段,再直接查看前几行真实样例数据和空值形态。',
},
{
title: '只读验证',
steps: 'get_columns → execute_sql',
steps: 'get_columns → preview_table_rows → execute_sql',
description: '适合生成 SQL 后做小范围结果核对,仍会受 AI 安全级别控制。',
},
];

View File

@@ -55,9 +55,10 @@ describe('AIMCPClientInstallPanel', () => {
);
expect(markup).toContain('不是给 GoNavi 自己安装 MCP');
expect(markup).toContain('接入外部客户端');
expect(markup).toContain('目标客户端');
expect(markup).toContain('未接入');
expect(markup).toContain('安装到外部 AI 客户端');
expect(markup).toContain('第 1 步:选择安装目标');
expect(markup).toContain('第 2 步:确认当前状态并安装');
expect(markup).toContain('待安装');
expect(markup).toContain('需更新');
expect(markup).toContain('复制配置路径');
expect(markup).toContain('复制启动命令');

View File

@@ -29,7 +29,7 @@ const getStatusTone = (status: AIMCPClientInstallStatus | undefined, darkMode: b
const messageText = String(status?.message || '');
if (status?.matchesCurrent) {
return {
label: '已接入',
label: '已安装',
color: '#16a34a',
bg: darkMode ? 'rgba(34,197,94,0.18)' : 'rgba(34,197,94,0.12)',
};
@@ -49,7 +49,7 @@ const getStatusTone = (status: AIMCPClientInstallStatus | undefined, darkMode: b
};
}
return {
label: '未接入',
label: '待安装',
color: darkMode ? 'rgba(255,255,255,0.72)' : '#64748b',
bg: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(100,116,139,0.08)',
};
@@ -58,36 +58,36 @@ const getStatusTone = (status: AIMCPClientInstallStatus | undefined, darkMode: b
const getStatusSummary = (status: AIMCPClientInstallStatus | undefined) => {
const messageText = String(status?.message || '');
if (status?.matchesCurrent) {
return '已经是当前 GoNavi 路径,无需重复接入。';
return '这个客户端已经安装当前 GoNavi MCP不需要再装一遍。';
}
if (status?.installed) {
return '检测到已有 GoNavi 记录,但不是当前路径,建议更新。';
return '这个客户端已经有旧配置,更新后会改成当前 GoNavi 安装路径。';
}
if (messageText.includes('失败') || messageText.includes('异常')) {
return '状态读取异常,建议刷新后再检查一次。';
return '状态读取异常,建议刷新,再决定是否安装。';
}
return '当前还没有 GoNavi MCP 写入这个客户端。';
return '这个客户端还没有安装 GoNavi MCP。';
};
const getClientCardDescription = (status: AIMCPClientInstallStatus | undefined) => {
if (status?.matchesCurrent) {
return '当前 GoNavi 路径已经写入,可直接在客户端调用。';
return '当前 GoNavi 路径已经写入,可直接在这个客户端调用。';
}
if (status?.installed) {
return '检测到旧的 GoNavi 记录,建议更新为当前安装路径。';
}
return '尚未写入 GoNavi MCP 配置。';
return '还没有写入 GoNavi MCP 配置。';
};
const resolveActionLabel = (status: AIMCPClientInstallStatus | undefined) => {
const label = status?.displayName || '客户端';
if (status?.matchesCurrent) {
return `${label} 已接入`;
return `已安装到 ${label}`;
}
if (status?.installed) {
return `更新 ${label} 配置`;
}
return `接入 ${label}`;
return `安装到 ${label}`;
};
const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
@@ -109,7 +109,7 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4, lineHeight: 1.7 }}>
GoNavi MCP GoNavi MCP Server Claude Code Codex AI
GoNavi MCP GoNavi MCP Server Claude CodeCodex AI 使
</div>
<div
@@ -124,9 +124,9 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}></div>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}> AI </div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
GoNavi MCP exe
1 GoNavi MCP exe
</div>
</div>
@@ -141,11 +141,16 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
lineHeight: 1.7,
}}
>
MCP GoNavi GoNavi
MCP GoNavi GoNavi
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}> 1 </div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}>
{statuses.map((status) => {
const client = status.client === 'codex' ? 'codex' : 'claude-code';
@@ -227,24 +232,29 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
gap: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
{selectedStatus?.displayName || '客户端'}
2
</div>
{selectedStatus && (
<div
style={{
padding: '3px 9px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: getStatusTone(selectedStatus, darkMode).color,
background: getStatusTone(selectedStatus, darkMode).bg,
}}
>
{getStatusTone(selectedStatus, darkMode).label}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
{selectedStatus?.displayName || '客户端'}
</div>
)}
{selectedStatus && (
<div
style={{
padding: '3px 9px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: getStatusTone(selectedStatus, darkMode).color,
background: getStatusTone(selectedStatus, darkMode).bg,
}}
>
{getStatusTone(selectedStatus, darkMode).label}
</div>
)}
</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.titleText, lineHeight: 1.7 }}>
{getStatusSummary(selectedStatus)}
@@ -295,7 +305,7 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
</div>
<Button
type={selectedStatus?.matchesCurrent ? 'default' : 'primary'}

View File

@@ -1,10 +1,42 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import AISettingsMCPSection from './AISettingsMCPSection';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
const flattenElementText = (node: any): string => {
if (node == null || typeof node === 'boolean') {
return '';
}
if (typeof node === 'string' || typeof node === 'number') {
return String(node);
}
if (Array.isArray(node)) {
return node.map((item) => flattenElementText(item)).join('');
}
return flattenElementText(node.props?.children);
};
const findElement = (node: any, predicate: (element: any) => boolean): any => {
if (node == null || typeof node === 'boolean' || typeof node === 'string' || typeof node === 'number') {
return null;
}
if (Array.isArray(node)) {
for (const item of node) {
const match = findElement(item, predicate);
if (match) {
return match;
}
}
return null;
}
if (predicate(node)) {
return node;
}
return findElement(node.props?.children, predicate);
};
describe('AISettingsMCPSection', () => {
it('renders the extracted MCP client installer and server management entry point', () => {
const markup = renderToStaticMarkup(
@@ -56,8 +88,51 @@ describe('AISettingsMCPSection', () => {
/>,
);
expect(markup).toContain('接入外部客户端');
expect(markup).toContain('安装到外部 AI 客户端');
expect(markup).toContain('常见启动方式模板');
expect(markup).toContain('Node 脚本');
expect(markup).toContain('新增 MCP 服务');
expect(markup).toContain('还没有 MCP 服务');
});
it('seeds a new draft when a launch template is selected', () => {
const onAddServer = vi.fn();
const tree = AISettingsMCPSection({
mcpClientStatuses: [],
selectedMCPClient: 'claude-code',
selectedMCPClientStatus: undefined,
selectedMCPClientCommandText: '',
mcpServers: [],
mcpTools: [],
darkMode: false,
overlayTheme: buildOverlayWorkbenchTheme(false),
cardBg: '#fff',
cardBorder: 'rgba(0,0,0,0.08)',
inputBg: '#fff',
loading: false,
mcpClientStatusLoading: false,
onSelectClient: () => {},
onRefreshStatus: () => {},
onCopyConfigPath: () => {},
onCopyLaunchCommand: () => {},
onInstallSelectedClient: () => {},
onAddServer,
onUpdateServerDraft: () => {},
onTestServer: () => {},
onSaveServer: () => {},
onDeleteServer: () => {},
});
const nodeTemplateButton = findElement(
tree,
(node) => node.type === 'button' && flattenElementText(node.props?.children).includes('Node 脚本'),
);
expect(nodeTemplateButton).toBeTruthy();
nodeTemplateButton.props.onClick();
expect(onAddServer).toHaveBeenCalledWith(expect.objectContaining({
command: 'node',
args: ['server.js', '--stdio'],
}));
});
});

View File

@@ -3,6 +3,7 @@ import { Button } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { AIMCPClientInstallStatus, AIMCPServerConfig, AIMCPToolDescriptor } from '../../types';
import { MCP_SERVER_DRAFT_TEMPLATES } from '../../utils/mcpServerTemplates';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import AIMCPClientInstallPanel from './AIMCPClientInstallPanel';
import AIMCPServerCard from './AIMCPServerCard';
@@ -28,7 +29,7 @@ interface AISettingsMCPSectionProps {
onCopyConfigPath: () => void;
onCopyLaunchCommand: () => void;
onInstallSelectedClient: () => void;
onAddServer: () => void;
onAddServer: (seed?: Partial<AIMCPServerConfig>) => void;
onUpdateServerDraft: (id: string, patch: Partial<AIMCPServerConfig>) => void;
onTestServer: (server: AIMCPServerConfig) => void;
onSaveServer: (server: AIMCPServerConfig) => void;
@@ -78,9 +79,47 @@ const AISettingsMCPSection: React.FC<AISettingsMCPSectionProps> = ({
onCopyLaunchCommand={onCopyLaunchCommand}
onInstall={onInstallSelectedClient}
/>
<div
style={{
padding: '14px 16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: cardBg,
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}></div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
GoNavi exe
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10 }}>
{MCP_SERVER_DRAFT_TEMPLATES.map((template) => (
<button
key={template.key}
type="button"
onClick={() => onAddServer(template.seed)}
style={{
textAlign: 'left',
padding: '12px 13px',
borderRadius: 12,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)',
color: overlayTheme.titleText,
cursor: 'pointer',
}}
>
<div style={{ fontSize: 13, fontWeight: 700 }}>{template.title}</div>
<div style={{ marginTop: 4, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>{template.description}</div>
<div style={{ marginTop: 6, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>{template.detail}</div>
</button>
))}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}> AI </div>
<Button icon={<PlusOutlined />} onClick={onAddServer} style={{ borderRadius: 10 }}> MCP </Button>
<Button icon={<PlusOutlined />} onClick={() => onAddServer()} style={{ borderRadius: 10 }}> MCP </Button>
</div>
{mcpServers.length === 0 && (
<div style={{ padding: '18px 16px', borderRadius: 14, border: `1px dashed ${cardBorder}`, background: cardBg, color: overlayTheme.mutedText }}>

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import AISettingsSkillsSection from './AISettingsSkillsSection';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
describe('AISettingsSkillsSection', () => {
it('renders the extracted skill configuration section', () => {
const markup = renderToStaticMarkup(
<AISettingsSkillsSection
skills={[]}
skillRequiredToolOptions={[]}
overlayTheme={buildOverlayWorkbenchTheme(false)}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
inputBg="#fff"
loading={false}
onAddSkill={() => {}}
onUpdateSkillDraft={() => {}}
onSaveSkill={() => {}}
onDeleteSkill={() => {}}
/>,
);
expect(markup).toContain('新增 Skill');
expect(markup).toContain('还没有 Skill');
expect(markup).toContain('命名的提示模块');
});
});

View File

@@ -0,0 +1,110 @@
import React from 'react';
import { Button, Input, Popconfirm, Select } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import type { AISkillConfig, AISkillScope } from '../../types';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
interface AISettingsSkillsSectionProps {
skills: AISkillConfig[];
skillRequiredToolOptions: Array<{ label: string; value: string }>;
overlayTheme: OverlayWorkbenchTheme;
cardBg: string;
cardBorder: string;
inputBg: string;
loading: boolean;
onAddSkill: () => void;
onUpdateSkillDraft: (id: string, patch: Partial<AISkillConfig>) => void;
onSaveSkill: (skill: AISkillConfig) => void;
onDeleteSkill: (id: string) => void;
}
const SKILL_SCOPE_OPTIONS: Array<{ value: AISkillScope; label: string; desc: string }> = [
{ value: 'global', label: '全局', desc: '所有 AI 会话都启用' },
{ value: 'database', label: '数据库', desc: '仅 SQL / 数据库场景启用' },
{ value: 'jvm', label: 'JVM 资源', desc: '仅 JVM 资源分析场景启用' },
{ value: 'jvmDiagnostic', label: 'JVM 诊断', desc: '仅 JVM 诊断工作台启用' },
];
const AISettingsSkillsSection: React.FC<AISettingsSkillsSectionProps> = ({
skills,
skillRequiredToolOptions,
overlayTheme,
cardBg,
cardBorder,
inputBg,
loading,
onAddSkill,
onUpdateSkillDraft,
onSaveSkill,
onDeleteSkill,
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
Skill + + GitHub skill pack
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}> scope Skill </div>
<Button icon={<PlusOutlined />} onClick={onAddSkill} style={{ borderRadius: 10 }}> Skill</Button>
</div>
{skills.length === 0 && (
<div style={{ padding: '18px 16px', borderRadius: 14, border: `1px dashed ${cardBorder}`, background: cardBg, color: overlayTheme.mutedText }}>
SkillJVM system prompt
</div>
)}
{skills.map((skill) => (
<div key={skill.id} style={{ padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 132px', gap: 12 }}>
<Input
value={skill.name}
onChange={(event) => onUpdateSkillDraft(skill.id, { name: event.target.value })}
placeholder="Skill 名称例如SQL 审查 / JVM 诊断计划"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
<Select
value={skill.enabled ? 'enabled' : 'disabled'}
onChange={(value) => onUpdateSkillDraft(skill.id, { enabled: value === 'enabled' })}
options={[{ label: '已启用', value: 'enabled' }, { label: '已禁用', value: 'disabled' }]}
/>
</div>
<Input
value={skill.description || ''}
onChange={(event) => onUpdateSkillDraft(skill.id, { description: event.target.value })}
placeholder="给自己看的说明,例如:输出 SQL 前必须先确认字段名和风险"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
<Select
mode="multiple"
value={skill.scopes || []}
onChange={(value) => onUpdateSkillDraft(skill.id, { scopes: value as AISkillScope[] })}
options={SKILL_SCOPE_OPTIONS.map((option) => ({ label: `${option.label} · ${option.desc}`, value: option.value }))}
placeholder="选择这个 Skill 要作用到哪些场景"
style={{ width: '100%' }}
/>
<Select
mode="multiple"
value={skill.requiredTools || []}
onChange={(value) => onUpdateSkillDraft(skill.id, { requiredTools: value })}
options={skillRequiredToolOptions}
placeholder="可选:声明这个 Skill 依赖哪些工具"
style={{ width: '100%' }}
/>
<Input.TextArea
rows={6}
value={skill.systemPrompt}
onChange={(event) => onUpdateSkillDraft(skill.id, { systemPrompt: event.target.value })}
placeholder="输入这条 Skill 要追加的 system prompt。建议聚焦一个明确能力不要和全局提示词重复。"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)', resize: 'vertical' }}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button type="primary" onClick={() => onSaveSkill(skill)} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}></Button>
<Popconfirm title="删除这个 Skill" okText="删除" cancelText="取消" onConfirm={() => onDeleteSkill(skill.id)}>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 10 }}></Button>
</Popconfirm>
</div>
</div>
))}
</div>
);
export default AISettingsSkillsSection;

View File

@@ -156,4 +156,41 @@ describe('aiLocalToolExecutor', () => {
expect(indexResult.content).toContain('idx_users_email');
expect(message.tool_name).toBe('自定义探针');
});
it('previews sample rows for a table without forcing the model to handwrite select limit sql', async () => {
const query = vi.fn().mockResolvedValue({
success: true,
data: [
{ id: 1, status: 'paid', amount: 120.5 },
{ id: 2, status: 'pending', amount: null },
],
});
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('preview_table_rows', {
connectionId: 'conn-1',
dbName: 'crm',
tableName: 'orders',
limit: 5,
}),
connections: [buildConnection()],
mcpTools: [],
toolContextMap: new Map(),
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
getColumns: vi.fn(),
getIndexes: vi.fn(),
getForeignKeys: vi.fn(),
getTriggers: vi.fn(),
showCreateTable: vi.fn(),
query,
},
});
expect(result.success).toBe(true);
expect(query).toHaveBeenCalledWith(expect.anything(), 'crm', 'SELECT * FROM `orders` LIMIT 5 OFFSET 0');
expect(result.content).toContain('"tableName":"orders"');
expect(result.content).toContain('"status":"paid"');
expect(result.content).toContain('"rowCount":2');
});
});

View File

@@ -3,6 +3,7 @@ import { DBGetAllColumns, DBGetDatabases, DBGetTables } from '../../../wailsjs/g
import type { AIChatMessage, AIMCPToolDescriptor, AIToolCall, SavedConnection } from '../../types';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { buildAIReadonlyPreviewSQL } from '../../utils/aiSqlLimit';
import { buildPaginatedSelectSQL, quoteQualifiedIdent } from '../../utils/sql';
import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool';
export interface AIToolContextEntry {
@@ -115,6 +116,13 @@ const buildToolName = (toolCall: AIToolCall, descriptor?: AIMCPToolDescriptor) =
const findConnection = (connections: SavedConnection[], connectionId: string) =>
connections.find((connection) => connection.id === connectionId);
const normalizePreviewLimit = (input: unknown): number => {
const value = Math.floor(Number(input) || 20);
if (value < 1) return 1;
if (value > 100) return 100;
return value;
};
export async function executeLocalAIToolCall({
toolCall,
connections,
@@ -331,6 +339,47 @@ export async function executeLocalAIToolCall({
}
break;
}
case 'preview_table_rows': {
const connection = findConnection(connections, args.connectionId);
if (!connection) {
content = 'Connection not found';
break;
}
try {
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
const safeTable = args.tableName ? String(args.tableName).trim() : '';
if (!safeTable) {
content = 'tableName 不能为空';
break;
}
const safeLimit = normalizePreviewLimit(args.limit);
const dbType = String(connection.config?.type || '').trim();
const previewSQL = buildPaginatedSelectSQL(
dbType,
`SELECT * FROM ${quoteQualifiedIdent(dbType, safeTable)}`,
'',
safeLimit,
0,
);
const result = await mergedRuntime.query(buildRpcConnectionConfig(connection.config) as any, safeDbName, previewSQL);
if (result?.success) {
const rows = Array.isArray(result.data) ? result.data : [];
content = JSON.stringify({
dbName: safeDbName,
tableName: safeTable,
limit: safeLimit,
rowCount: rows.length,
rows: rows.slice(0, safeLimit),
});
success = true;
} else {
content = result?.message || 'Failed to preview table rows';
}
} catch (error: any) {
content = `预览表样例数据失败: ${error?.message || error}`;
}
break;
}
case 'execute_sql': {
const connection = findConnection(connections, args.connectionId);
if (!connection) {

View File

@@ -229,6 +229,32 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [
},
},
},
{
name: "preview_table_rows",
icon: "👀",
desc: "抽样预览指定表的前几行数据",
detail:
"传入 connectionId、dbName、tableName 和可选 limit返回该表的前几行真实样例数据。适合先看数据形态、空值分布和枚举值再决定怎么写 SQL。",
params: "connectionId, dbName, tableName, limit?",
tool: {
type: "function",
function: {
name: "preview_table_rows",
description:
"预览指定表的前几行样例数据。适用于快速理解字段取值形态、空值情况、时间格式和状态枚举,减少模型盲写 SQL。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
limit: { type: "number", description: "可选,预览行数,默认 20最大 100" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "execute_sql",
icon: "▶️",

View File

@@ -0,0 +1,64 @@
import type { AIMCPServerConfig } from '../types';
export interface MCPServerDraftTemplate {
key: string;
title: string;
description: string;
detail: string;
seed: Partial<AIMCPServerConfig>;
}
export const MCP_SERVER_DRAFT_TEMPLATES: MCPServerDraftTemplate[] = [
{
key: 'uvx',
title: 'uvx 工具',
description: '适合 Python/uv 生态里已经发布好的 MCP 包。',
detail: '示例会填成 `uvx some-mcp-server`,保存前把包名改成你自己的。',
seed: {
name: 'uvx 工具',
command: 'uvx',
args: ['some-mcp-server'],
env: {},
timeoutSeconds: 20,
},
},
{
key: 'node',
title: 'Node 脚本',
description: '适合本地 js/ts 脚本或 npm 安装后的 node 启动器。',
detail: '示例会填成 `node server.js --stdio`,脚本名和参数可以继续改。',
seed: {
name: 'Node 脚本',
command: 'node',
args: ['server.js', '--stdio'],
env: {},
timeoutSeconds: 20,
},
},
{
key: 'python',
title: 'Python 模块',
description: '适合 `python -m xxx` 这种按模块启动的服务。',
detail: '示例会填成 `python -m your_mcp_server`,模块名改成实际值即可。',
seed: {
name: 'Python 模块',
command: 'python',
args: ['-m', 'your_mcp_server'],
env: {},
timeoutSeconds: 20,
},
},
{
key: 'exe',
title: '本机 EXE',
description: '适合已经编译好的本机二进制或公司内部工具。',
detail: '示例会填成 `your-mcp-server.exe stdio`,把 exe 路径换成真实值。',
seed: {
name: '本机 EXE',
command: 'your-mcp-server.exe',
args: ['stdio'],
env: {},
timeoutSeconds: 20,
},
},
];