mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +08:00
✨ feat(ai-settings): 优化MCP录入引导并补充结构快照工具
- 新增完整命令自动拆分与提示词设置分区,降低 MCP 配置门槛 - 新增 inspect_table_bundle 内置工具并补充状态文案 - 补齐定向测试、前端构建与预览验证
This commit is contained in:
@@ -14,7 +14,8 @@ describe('AISettingsModal edit password behavior', () => {
|
||||
expect(source).toContain("callOrFallback(() => Service.AIGetUserPromptSettings?.(), EMPTY_AI_USER_PROMPT_SETTINGS)");
|
||||
expect(source).toContain('await Service?.AISaveUserPromptSettings?.(payload);');
|
||||
expect(source).toContain("window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'))");
|
||||
expect(source).toContain('保存自定义提示词');
|
||||
expect(source).toContain("import AISettingsPromptsSection from './ai/AISettingsPromptsSection';");
|
||||
expect(source).toContain('<AISettingsPromptsSection');
|
||||
});
|
||||
|
||||
it('loads MCP servers and skills through the AI service', () => {
|
||||
|
||||
@@ -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 AISettingsPromptsSection from './ai/AISettingsPromptsSection';
|
||||
import AISettingsSkillsSection from './ai/AISettingsSkillsSection';
|
||||
interface AISettingsModalProps {
|
||||
open: boolean;
|
||||
@@ -1096,104 +1097,6 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderPromptSettings = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: 14,
|
||||
border: `1px solid ${cardBorder}`,
|
||||
background: cardBg,
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, marginBottom: 6 }}>
|
||||
用户级自定义提示词
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 14 }}>
|
||||
这里的内容会在系统内置提示词之后,以 system message 的形式追加注入。
|
||||
适合放你的个人风格偏好、输出约束、团队规范。涉及安全红线时,系统规则仍然优先。
|
||||
</div>
|
||||
|
||||
{[
|
||||
{
|
||||
key: 'global',
|
||||
title: '全局补充提示词',
|
||||
desc: '对所有 AI 会话生效,例如“先给结论”“回答保持简洁”。',
|
||||
rows: 4,
|
||||
},
|
||||
{
|
||||
key: 'database',
|
||||
title: '数据库会话补充提示词',
|
||||
desc: '仅数据库/SQL 场景生效,例如“生成 SQL 前必须先确认字段名”。',
|
||||
rows: 5,
|
||||
},
|
||||
{
|
||||
key: 'jvm',
|
||||
title: 'JVM 资源分析补充提示词',
|
||||
desc: '仅 JVM 资源浏览/分析场景生效。',
|
||||
rows: 4,
|
||||
},
|
||||
{
|
||||
key: 'jvmDiagnostic',
|
||||
title: 'JVM 诊断补充提示词',
|
||||
desc: '仅 JVM 诊断工作台生效,例如“先给计划,再给命令”。',
|
||||
rows: 4,
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.key} style={{ marginTop: 14 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: overlayTheme.titleText, marginBottom: 4 }}>
|
||||
{item.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
|
||||
{item.desc}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={item.rows}
|
||||
value={userPromptSettings[item.key as keyof AIUserPromptSettings]}
|
||||
onChange={(event) => setUserPromptSettings((prev) => ({
|
||||
...prev,
|
||||
[item.key]: event.target.value,
|
||||
}))}
|
||||
placeholder="留空表示不额外追加"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: inputBg,
|
||||
border: `1px solid ${cardBorder}`,
|
||||
fontFamily: 'var(--gn-font-mono)',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
||||
<Button type="primary" onClick={handleSaveUserPromptSettings} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}>
|
||||
保存自定义提示词
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
|
||||
以下为当前版本 GoNavi 预设的底层 AI 提示词(只读)。它们会先于上面的用户级提示词注入到对应场景的请求上下文中。
|
||||
</div>
|
||||
{Object.entries(builtinPrompts).map(([title, promptText]) => (
|
||||
<div key={title} style={{
|
||||
padding: '12px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: cardBg,
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<RobotOutlined style={{ color: overlayTheme.iconColor }} /> {title}
|
||||
</div>
|
||||
<div style={{
|
||||
background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)',
|
||||
padding: '10px 12px', borderRadius: 8, fontSize: 13, color: overlayTheme.mutedText,
|
||||
whiteSpace: 'pre-wrap', fontFamily: 'var(--gn-font-mono)', lineHeight: 1.5,
|
||||
userSelect: 'text', border: darkMode ? '1px solid rgba(255,255,255,0.03)' : '1px solid rgba(0,0,0,0.02)'
|
||||
}}>
|
||||
{promptText}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const modalShellStyle = {
|
||||
background: overlayTheme.shellBg, border: overlayTheme.shellBorder,
|
||||
boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter,
|
||||
@@ -1327,7 +1230,23 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
cardBorder={cardBorder}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'prompts' && renderPromptSettings()}
|
||||
{activeSection === 'prompts' && (
|
||||
<AISettingsPromptsSection
|
||||
builtinPrompts={builtinPrompts}
|
||||
userPromptSettings={userPromptSettings}
|
||||
overlayTheme={overlayTheme}
|
||||
cardBg={cardBg}
|
||||
cardBorder={cardBorder}
|
||||
inputBg={inputBg}
|
||||
darkMode={darkMode}
|
||||
loading={loading}
|
||||
onChangeUserPrompt={(key, value) => setUserPromptSettings((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}))}
|
||||
onSave={handleSaveUserPromptSettings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -22,6 +22,8 @@ describe('AIBuiltinToolsCatalog', () => {
|
||||
expect(markup).toContain('get_indexes');
|
||||
expect(markup).toContain('get_foreign_keys');
|
||||
expect(markup).toContain('get_triggers');
|
||||
expect(markup).toContain('一键结构快照');
|
||||
expect(markup).toContain('inspect_table_bundle');
|
||||
expect(markup).toContain('理解样例数据');
|
||||
expect(markup).toContain('preview_table_rows');
|
||||
});
|
||||
|
||||
@@ -27,6 +27,11 @@ const BUILTIN_TOOL_FLOWS = [
|
||||
steps: 'get_columns → get_indexes → get_foreign_keys → get_triggers → get_table_ddl',
|
||||
description: '适合做索引优化、关系梳理、隐式副作用排查和 DDL 审查。',
|
||||
},
|
||||
{
|
||||
title: '一键结构快照',
|
||||
steps: 'inspect_table_bundle',
|
||||
description: '适合一次带回字段、索引、外键、触发器和 DDL;必要时还能附带样例行,减少来回调用。',
|
||||
},
|
||||
{
|
||||
title: '理解样例数据',
|
||||
steps: 'get_columns → preview_table_rows',
|
||||
|
||||
@@ -34,6 +34,8 @@ describe('AIMCPServerCard', () => {
|
||||
);
|
||||
|
||||
expect(markup).toContain('启动命令只填可执行程序本身');
|
||||
expect(markup).toContain('直接粘贴完整命令');
|
||||
expect(markup).toContain('自动拆分到下方字段');
|
||||
expect(markup).toContain('每个参数单独录入一个标签');
|
||||
expect(markup).toContain('每行一个 KEY=VALUE');
|
||||
expect(markup).toContain('当前阶段只支持 stdio');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DeleteOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
import type { AIMCPServerConfig, AIMCPToolDescriptor } from '../../types';
|
||||
import { parseMCPCommandDraft } from '../../utils/mcpCommandDraft';
|
||||
|
||||
interface AIMCPServerCardProps {
|
||||
server: AIMCPServerConfig;
|
||||
@@ -86,7 +87,20 @@ export const AIMCPServerCard: React.FC<AIMCPServerCardProps> = ({
|
||||
onSave,
|
||||
onDelete,
|
||||
}) => {
|
||||
const [rawCommandDraft, setRawCommandDraft] = React.useState('');
|
||||
const launchPreview = formatLaunchPreview(server.command, server.args);
|
||||
const parsedCommandDraft = parseMCPCommandDraft(rawCommandDraft);
|
||||
|
||||
const handleApplyCommandDraft = () => {
|
||||
if (!parsedCommandDraft.ok || !parsedCommandDraft.draft) {
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
command: parsedCommandDraft.draft.command,
|
||||
args: parsedCommandDraft.draft.args,
|
||||
env: parsedCommandDraft.draft.env,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
@@ -99,6 +113,32 @@ export const AIMCPServerCard: React.FC<AIMCPServerCardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '12px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ ...labelStyle, color: overlayTheme.titleText }}>只有一条完整命令?</div>
|
||||
<div style={hintStyle(overlayTheme.mutedText)}>
|
||||
直接粘贴完整命令,GoNavi 会自动拆成“启动命令 / 命令参数 / 环境变量”三块,适合你只拿到 README 里的一整行示例时快速录入。
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={rawCommandDraft}
|
||||
onChange={(event) => setRawCommandDraft(event.target.value)}
|
||||
placeholder={"直接粘贴完整命令,例如:\nOPENAI_API_KEY=... uvx mcp-server-fetch --stdio"}
|
||||
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ ...hintStyle(parsedCommandDraft.ok ? overlayTheme.mutedText : '#dc2626') }}>
|
||||
{rawCommandDraft.trim()
|
||||
? parsedCommandDraft.ok && parsedCommandDraft.draft
|
||||
? `将解析为:命令 ${parsedCommandDraft.draft.command},参数 ${parsedCommandDraft.draft.args.length} 个,环境变量 ${Object.keys(parsedCommandDraft.draft.env).length} 个。`
|
||||
: parsedCommandDraft.error
|
||||
: '支持带引号路径、带空格参数,以及命令前缀的 KEY=VALUE 环境变量。'}
|
||||
</div>
|
||||
<Button onClick={handleApplyCommandDraft} disabled={!parsedCommandDraft.ok} style={{ borderRadius: 10 }}>
|
||||
自动拆分到下方字段
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 132px', gap: 12 }}>
|
||||
<MCPHelpBlock title="服务名称" description="给这个 MCP 起一个你自己能识别的名字,后面 AI 工具列表里会直接显示。" overlayTheme={overlayTheme} example="Filesystem / Browser / GitHub">
|
||||
<Input
|
||||
|
||||
36
frontend/src/components/ai/AISettingsPromptsSection.test.tsx
Normal file
36
frontend/src/components/ai/AISettingsPromptsSection.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import AISettingsPromptsSection from './AISettingsPromptsSection';
|
||||
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
|
||||
describe('AISettingsPromptsSection', () => {
|
||||
it('renders editable user prompts and readonly builtin prompt blocks after extraction', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AISettingsPromptsSection
|
||||
builtinPrompts={{ 数据库: '生成 SQL 前必须先确认字段名。' }}
|
||||
userPromptSettings={{
|
||||
global: '',
|
||||
database: '',
|
||||
jvm: '',
|
||||
jvmDiagnostic: '',
|
||||
}}
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
cardBg="#fff"
|
||||
cardBorder="rgba(0,0,0,0.08)"
|
||||
inputBg="#fff"
|
||||
darkMode={false}
|
||||
loading={false}
|
||||
onChangeUserPrompt={() => {}}
|
||||
onSave={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('用户级自定义提示词');
|
||||
expect(markup).toContain('全局补充提示词');
|
||||
expect(markup).toContain('保存自定义提示词');
|
||||
expect(markup).toContain('数据库');
|
||||
expect(markup).toContain('生成 SQL 前必须先确认字段名');
|
||||
});
|
||||
});
|
||||
160
frontend/src/components/ai/AISettingsPromptsSection.tsx
Normal file
160
frontend/src/components/ai/AISettingsPromptsSection.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import { Button, Input } from 'antd';
|
||||
import { RobotOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { AIUserPromptSettings } from '../../types';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
|
||||
interface AISettingsPromptsSectionProps {
|
||||
builtinPrompts: Record<string, string>;
|
||||
userPromptSettings: AIUserPromptSettings;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
cardBg: string;
|
||||
cardBorder: string;
|
||||
inputBg: string;
|
||||
darkMode: boolean;
|
||||
loading: boolean;
|
||||
onChangeUserPrompt: (key: keyof AIUserPromptSettings, value: string) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const USER_PROMPT_FIELDS: Array<{
|
||||
key: keyof AIUserPromptSettings;
|
||||
title: string;
|
||||
desc: string;
|
||||
rows: number;
|
||||
}> = [
|
||||
{
|
||||
key: 'global',
|
||||
title: '全局补充提示词',
|
||||
desc: '对所有 AI 会话生效,例如“先给结论”“回答保持简洁”。',
|
||||
rows: 4,
|
||||
},
|
||||
{
|
||||
key: 'database',
|
||||
title: '数据库会话补充提示词',
|
||||
desc: '仅数据库/SQL 场景生效,例如“生成 SQL 前必须先确认字段名”。',
|
||||
rows: 5,
|
||||
},
|
||||
{
|
||||
key: 'jvm',
|
||||
title: 'JVM 资源分析补充提示词',
|
||||
desc: '仅 JVM 资源浏览/分析场景生效。',
|
||||
rows: 4,
|
||||
},
|
||||
{
|
||||
key: 'jvmDiagnostic',
|
||||
title: 'JVM 诊断补充提示词',
|
||||
desc: '仅 JVM 诊断工作台生效,例如“先给计划,再给命令”。',
|
||||
rows: 4,
|
||||
},
|
||||
];
|
||||
|
||||
const AISettingsPromptsSection: React.FC<AISettingsPromptsSectionProps> = ({
|
||||
builtinPrompts,
|
||||
userPromptSettings,
|
||||
overlayTheme,
|
||||
cardBg,
|
||||
cardBorder,
|
||||
inputBg,
|
||||
darkMode,
|
||||
loading,
|
||||
onChangeUserPrompt,
|
||||
onSave,
|
||||
}) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: 14,
|
||||
border: `1px solid ${cardBorder}`,
|
||||
background: cardBg,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, marginBottom: 6 }}>
|
||||
用户级自定义提示词
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 14 }}>
|
||||
这里的内容会在系统内置提示词之后,以 system message 的形式追加注入。
|
||||
适合放你的个人风格偏好、输出约束、团队规范。涉及安全红线时,系统规则仍然优先。
|
||||
</div>
|
||||
|
||||
{USER_PROMPT_FIELDS.map((item) => (
|
||||
<div key={item.key} style={{ marginTop: 14 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: overlayTheme.titleText, marginBottom: 4 }}>
|
||||
{item.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
|
||||
{item.desc}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={item.rows}
|
||||
value={userPromptSettings[item.key]}
|
||||
onChange={(event) => onChangeUserPrompt(item.key, event.target.value)}
|
||||
placeholder="留空表示不额外追加"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: inputBg,
|
||||
border: `1px solid ${cardBorder}`,
|
||||
fontFamily: 'var(--gn-font-mono)',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
||||
<Button type="primary" onClick={onSave} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}>
|
||||
保存自定义提示词
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
|
||||
以下为当前版本 GoNavi 预设的底层 AI 提示词(只读)。它们会先于上面的用户级提示词注入到对应场景的请求上下文中。
|
||||
</div>
|
||||
{Object.entries(builtinPrompts).map(([title, promptText]) => (
|
||||
<div
|
||||
key={title}
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${cardBorder}`,
|
||||
background: cardBg,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
color: overlayTheme.titleText,
|
||||
marginBottom: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<RobotOutlined style={{ color: overlayTheme.iconColor }} /> {title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
color: overlayTheme.mutedText,
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'var(--gn-font-mono)',
|
||||
lineHeight: 1.5,
|
||||
userSelect: 'text',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.03)' : '1px solid rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
{promptText}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AISettingsPromptsSection;
|
||||
@@ -193,4 +193,55 @@ describe('aiLocalToolExecutor', () => {
|
||||
expect(result.content).toContain('"status":"paid"');
|
||||
expect(result.content).toContain('"rowCount":2');
|
||||
});
|
||||
|
||||
it('returns a full table snapshot bundle with optional sample rows in one tool call', async () => {
|
||||
const result = await executeLocalAIToolCall({
|
||||
toolCall: buildToolCall('inspect_table_bundle', {
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'crm',
|
||||
tableName: 'orders',
|
||||
includeSampleRows: true,
|
||||
sampleLimit: 2,
|
||||
}),
|
||||
connections: [buildConnection()],
|
||||
mcpTools: [],
|
||||
toolContextMap: new Map(),
|
||||
runtime: {
|
||||
getDatabases: vi.fn(),
|
||||
getTables: vi.fn(),
|
||||
getColumns: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ Field: 'id', Type: 'bigint', Null: 'NO', Comment: '主键' }],
|
||||
}),
|
||||
getIndexes: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ keyName: 'PRIMARY', seqInIndex: 1 }],
|
||||
}),
|
||||
getForeignKeys: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ columnName: 'user_id', refTable: 'users' }],
|
||||
}),
|
||||
getTriggers: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ triggerName: 'orders_bi' }],
|
||||
}),
|
||||
showCreateTable: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ ddl: 'CREATE TABLE orders (...)' }],
|
||||
}),
|
||||
query: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ id: 1, status: 'paid' }, { id: 2, status: 'pending' }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('"tableName":"orders"');
|
||||
expect(result.content).toContain('"field":"id"');
|
||||
expect(result.content).toContain('"keyName":"PRIMARY"');
|
||||
expect(result.content).toContain('"triggerName":"orders_bi"');
|
||||
expect(result.content).toContain('"sampleRows"');
|
||||
expect(result.content).toContain('"status":"paid"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +123,17 @@ const normalizePreviewLimit = (input: unknown): number => {
|
||||
return value;
|
||||
};
|
||||
|
||||
const buildPreviewSQLForTable = (connection: SavedConnection, tableName: string, limit: number): string => {
|
||||
const dbType = String(connection.config?.type || '').trim();
|
||||
return buildPaginatedSelectSQL(
|
||||
dbType,
|
||||
`SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)}`,
|
||||
'',
|
||||
limit,
|
||||
0,
|
||||
);
|
||||
};
|
||||
|
||||
export async function executeLocalAIToolCall({
|
||||
toolCall,
|
||||
connections,
|
||||
@@ -339,6 +350,109 @@ export async function executeLocalAIToolCall({
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'inspect_table_bundle': {
|
||||
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 includeSampleRows = args.includeSampleRows === true;
|
||||
const sampleLimit = normalizePreviewLimit(args.sampleLimit ?? 10);
|
||||
const rpcConfig = buildRpcConnectionConfig(connection.config) as any;
|
||||
const results = await Promise.allSettled([
|
||||
mergedRuntime.getColumns(rpcConfig, safeDbName, safeTable),
|
||||
mergedRuntime.getIndexes(rpcConfig, safeDbName, safeTable),
|
||||
mergedRuntime.getForeignKeys(rpcConfig, safeDbName, safeTable),
|
||||
mergedRuntime.getTriggers(rpcConfig, safeDbName, safeTable),
|
||||
resolveAITableSchemaToolResult({
|
||||
tableName: safeTable,
|
||||
fetchDDL: () => mergedRuntime.showCreateTable(rpcConfig, safeDbName, safeTable),
|
||||
fetchColumns: () => mergedRuntime.getColumns(rpcConfig, safeDbName, safeTable),
|
||||
}),
|
||||
includeSampleRows
|
||||
? mergedRuntime.query(rpcConfig, safeDbName, buildPreviewSQLForTable(connection, safeTable, sampleLimit))
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
const warnings: string[] = [];
|
||||
const columnsResult = results[0];
|
||||
const indexesResult = results[1];
|
||||
const foreignKeysResult = results[2];
|
||||
const triggersResult = results[3];
|
||||
const ddlResult = results[4];
|
||||
const sampleRowsResult = results[5];
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
dbName: safeDbName,
|
||||
tableName: safeTable,
|
||||
columns: [],
|
||||
indexes: [],
|
||||
foreignKeys: [],
|
||||
triggers: [],
|
||||
ddl: '',
|
||||
};
|
||||
|
||||
if (columnsResult.status === 'fulfilled' && columnsResult.value?.success && Array.isArray(columnsResult.value.data)) {
|
||||
payload.columns = normalizeColumns(columnsResult.value.data);
|
||||
} else {
|
||||
warnings.push(`字段列表获取失败:${columnsResult.status === 'fulfilled' ? (columnsResult.value?.message || '未知错误') : String(columnsResult.reason)}`);
|
||||
}
|
||||
|
||||
if (indexesResult.status === 'fulfilled' && indexesResult.value?.success && Array.isArray(indexesResult.value.data)) {
|
||||
payload.indexes = indexesResult.value.data;
|
||||
} else {
|
||||
warnings.push(`索引定义获取失败:${indexesResult.status === 'fulfilled' ? (indexesResult.value?.message || '未知错误') : String(indexesResult.reason)}`);
|
||||
}
|
||||
|
||||
if (foreignKeysResult.status === 'fulfilled' && foreignKeysResult.value?.success && Array.isArray(foreignKeysResult.value.data)) {
|
||||
payload.foreignKeys = foreignKeysResult.value.data;
|
||||
} else {
|
||||
warnings.push(`外键关系获取失败:${foreignKeysResult.status === 'fulfilled' ? (foreignKeysResult.value?.message || '未知错误') : String(foreignKeysResult.reason)}`);
|
||||
}
|
||||
|
||||
if (triggersResult.status === 'fulfilled' && triggersResult.value?.success && Array.isArray(triggersResult.value.data)) {
|
||||
payload.triggers = triggersResult.value.data;
|
||||
} else {
|
||||
warnings.push(`触发器获取失败:${triggersResult.status === 'fulfilled' ? (triggersResult.value?.message || '未知错误') : String(triggersResult.reason)}`);
|
||||
}
|
||||
|
||||
if (ddlResult.status === 'fulfilled' && ddlResult.value?.success) {
|
||||
payload.ddl = ddlResult.value.content;
|
||||
} else {
|
||||
warnings.push(`DDL 获取失败:${ddlResult.status === 'fulfilled' ? (ddlResult.value?.content || '未知错误') : String(ddlResult.reason)}`);
|
||||
}
|
||||
|
||||
if (includeSampleRows) {
|
||||
if (sampleRowsResult.status === 'fulfilled' && sampleRowsResult.value?.success) {
|
||||
const rows = Array.isArray(sampleRowsResult.value.data) ? sampleRowsResult.value.data : [];
|
||||
payload.sampleRows = {
|
||||
limit: sampleLimit,
|
||||
rowCount: rows.length,
|
||||
rows: rows.slice(0, sampleLimit),
|
||||
};
|
||||
} else {
|
||||
warnings.push(`样例数据获取失败:${sampleRowsResult.status === 'fulfilled' ? (sampleRowsResult.value?.message || '未知错误') : String(sampleRowsResult.reason)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
payload.warnings = warnings;
|
||||
}
|
||||
|
||||
content = JSON.stringify(payload);
|
||||
success = true;
|
||||
} catch (error: any) {
|
||||
content = `获取表结构快照失败: ${error?.message || error}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'preview_table_rows': {
|
||||
const connection = findConnection(connections, args.connectionId);
|
||||
if (!connection) {
|
||||
@@ -353,14 +467,7 @@ export async function executeLocalAIToolCall({
|
||||
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 previewSQL = buildPreviewSQLForTable(connection, safeTable, safeLimit);
|
||||
const result = await mergedRuntime.query(buildRpcConnectionConfig(connection.config) as any, safeDbName, previewSQL);
|
||||
if (result?.success) {
|
||||
const rows = Array.isArray(result.data) ? result.data : [];
|
||||
|
||||
@@ -31,6 +31,7 @@ const TOOL_ACTION_LABELS: Record<string, string> = {
|
||||
get_foreign_keys: '梳理外键关系',
|
||||
get_triggers: '检查触发器逻辑',
|
||||
get_table_ddl: '提取建表语句',
|
||||
inspect_table_bundle: '抓取完整表结构快照',
|
||||
execute_sql: '执行只读 SQL 验证',
|
||||
};
|
||||
|
||||
|
||||
@@ -255,6 +255,33 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inspect_table_bundle",
|
||||
icon: "🧰",
|
||||
desc: "一次抓取指定表的结构快照",
|
||||
detail:
|
||||
"传入 connectionId、dbName 和 tableName,返回字段、索引、外键、触发器和 DDL;还可以附带前几行样例数据。适合在写 SQL、评审表设计或排查副作用前先做完整摸底。",
|
||||
params: "connectionId, dbName, tableName, includeSampleRows?, sampleLimit?",
|
||||
tool: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "inspect_table_bundle",
|
||||
description:
|
||||
"一次性获取指定表的结构快照,返回字段、索引、外键、触发器、DDL,以及可选样例数据。适用于做完整表设计摸底、快速理解表关系和降低模型多次往返调用。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
connectionId: { type: "string", description: "连接ID" },
|
||||
dbName: { type: "string", description: "数据库名" },
|
||||
tableName: { type: "string", description: "表名" },
|
||||
includeSampleRows: { type: "boolean", description: "可选,是否附带前几行样例数据" },
|
||||
sampleLimit: { type: "number", description: "可选,样例行数,默认 10,最大 100" },
|
||||
},
|
||||
required: ["connectionId", "dbName", "tableName"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "execute_sql",
|
||||
icon: "▶️",
|
||||
|
||||
40
frontend/src/utils/mcpCommandDraft.test.ts
Normal file
40
frontend/src/utils/mcpCommandDraft.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseMCPCommandDraft, splitShellLikeCommand } from './mcpCommandDraft';
|
||||
|
||||
describe('mcpCommandDraft helpers', () => {
|
||||
it('splits quoted command lines and leading env assignments into dedicated fields', () => {
|
||||
const result = parseMCPCommandDraft('OPENAI_API_KEY="abc 123" "C:\\Program Files\\GoNavi\\gonavi-mcp-server.exe" stdio --port 8811');
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
draft: {
|
||||
command: 'C:\\Program Files\\GoNavi\\gonavi-mcp-server.exe',
|
||||
args: ['stdio', '--port', '8811'],
|
||||
env: {
|
||||
OPENAI_API_KEY: 'abc 123',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps python module style launches as command plus independent args', () => {
|
||||
const result = parseMCPCommandDraft('PYTHONPATH=./tools python -m my_mcp_server --stdio');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.draft).toEqual({
|
||||
command: 'python',
|
||||
args: ['-m', 'my_mcp_server', '--stdio'],
|
||||
env: {
|
||||
PYTHONPATH: './tools',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('reports unclosed quotes instead of producing a broken parse', () => {
|
||||
expect(splitShellLikeCommand('uvx "broken command')).toEqual({
|
||||
tokens: ['uvx'],
|
||||
error: '命令中存在未闭合的引号,请检查后重试。',
|
||||
});
|
||||
});
|
||||
});
|
||||
123
frontend/src/utils/mcpCommandDraft.ts
Normal file
123
frontend/src/utils/mcpCommandDraft.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
export interface ParsedMCPCommandDraft {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ParseMCPCommandDraftResult {
|
||||
ok: boolean;
|
||||
draft?: ParsedMCPCommandDraft;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=.*/u;
|
||||
|
||||
const pushToken = (tokens: string[], current: string) => {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
};
|
||||
|
||||
export const splitShellLikeCommand = (input: string): { tokens: string[]; error?: string } => {
|
||||
const text = String(input || '').trim();
|
||||
if (!text) {
|
||||
return { tokens: [] };
|
||||
}
|
||||
|
||||
const tokens: string[] = [];
|
||||
let current = '';
|
||||
let quoteMode: '"' | "'" | null = null;
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
|
||||
if (quoteMode) {
|
||||
if (char === quoteMode) {
|
||||
quoteMode = null;
|
||||
continue;
|
||||
}
|
||||
if (char === '\\' && quoteMode === '"' && index + 1 < text.length) {
|
||||
const nextChar = text[index + 1];
|
||||
if (nextChar === '"' || nextChar === '\\') {
|
||||
current += nextChar;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quoteMode = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/u.test(char)) {
|
||||
pushToken(tokens, current);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\' && index + 1 < text.length) {
|
||||
const nextChar = text[index + 1];
|
||||
if (/\s/u.test(nextChar) || nextChar === '"' || nextChar === "'" || nextChar === '\\') {
|
||||
current += nextChar;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (quoteMode) {
|
||||
return {
|
||||
tokens,
|
||||
error: '命令中存在未闭合的引号,请检查后重试。',
|
||||
};
|
||||
}
|
||||
|
||||
pushToken(tokens, current);
|
||||
return { tokens };
|
||||
};
|
||||
|
||||
export const parseMCPCommandDraft = (input: string): ParseMCPCommandDraftResult => {
|
||||
const { tokens, error } = splitShellLikeCommand(input);
|
||||
if (error) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
if (tokens.length === 0) {
|
||||
return { ok: false, error: '请先粘贴完整命令。' };
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
let commandIndex = 0;
|
||||
|
||||
while (commandIndex < tokens.length && ENV_ASSIGNMENT_RE.test(tokens[commandIndex])) {
|
||||
const token = tokens[commandIndex];
|
||||
const separatorIndex = token.indexOf('=');
|
||||
const key = token.slice(0, separatorIndex).trim();
|
||||
if (key) {
|
||||
env[key] = token.slice(separatorIndex + 1);
|
||||
}
|
||||
commandIndex += 1;
|
||||
}
|
||||
|
||||
const command = String(tokens[commandIndex] || '').trim();
|
||||
if (!command) {
|
||||
return {
|
||||
ok: false,
|
||||
error: '没有解析出启动命令,请至少提供可执行程序名。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
draft: {
|
||||
command,
|
||||
args: tokens.slice(commandIndex + 1),
|
||||
env,
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user