feat(ai): 增加用户提示词与 MCP/Skills 扩展能力

- 支持用户级自定义提示词加载保存与聊天会话注入

- 新增 MCP 服务配置、工具发现与按次调用链路

- 增加 Skills 配置、作用域注入与前后端类型同步

- 补充配置存储与前端行为测试并更新依赖
This commit is contained in:
Syngnat
2026-06-07 17:56:45 +08:00
parent dda8bbb6e3
commit f7d71c6c5c
18 changed files with 2195 additions and 173 deletions

106
AI_EXTENSIONS_ROADMAP.md Normal file
View File

@@ -0,0 +1,106 @@
# AI 扩展能力路线
当前 GoNavi 的 AI 链路是:
1. 前端 `AIChatPanel` 组装 system messages。
2. 前端声明本地固定工具 `LOCAL_TOOLS`
3. 后端 `aiservice.Service` 只负责 Provider 配置、安全级别与模型转发。
这套结构已经足够承接“用户级提示词”,但要继续承接 MCP 和 Skills需要先把“提示词 / 工具 / 技能”三层职责拆开。
## 1. 用户级自定义提示词
已落地的方向:
- 配置存储在 `ai_config.json``userPromptSettings`
-`AISettingsModal` 提供编辑入口。
-`AIChatPanel` 在运行时追加为 system message。
建议长期保持 4 个层级:
- `global`: 所有 AI 会话统一追加。
- `database`: 数据库 / SQL 场景追加。
- `jvm`: JVM 资源浏览与分析场景追加。
- `jvmDiagnostic`: JVM 诊断命令规划场景追加。
这样既能满足“个人习惯”定制,也不会把所有场景揉成一条超长总提示词。
## 2. MCP 能力开放
目标不是把 MCP 做成新的聊天面板,而是把它变成“外部工具源”。
建议后续拆成三层:
1. `tool registry`
- 统一收口内置工具、本地扩展工具、MCP 工具。
- 对模型只暴露统一的 `tools[]`
2. `mcp server config`
- 保存 server 名称、transport、启动命令或 URL、超时、启用状态。
- 由后端维护生命周期与连通性。
3. `mcp runtime bridge`
- 负责 `list tools / call tool / errors / timeout / auth`
### MCP 是否需要单独 GitHub 仓库
不需要把“GoNavi 对 MCP 的支持”单独拆仓库。
更合理的边界是:
- `GoNavi 主仓库`
- 维护 MCP client、配置、UI、工具注册和运行时桥接。
- `单独仓库(可选)`
- 只在你要发布一个可复用的 MCP Server 时才有价值。
- 例如 `gonavi-mcp-sql-tools``gonavi-mcp-jvm-agent` 这类独立 server。
结论:
- “客户端支持 MCP” 不需要新仓库。
- “某个独立 MCP Server” 是否拆仓库,取决于它要不要单独发布、复用或部署。
## 3. Skills 设计
Skills 不建议直接等同于“另一种提示词”。
更合适的定义是:
- `skill manifest`
- 名称、说明、适用场景、是否默认启用。
- `skill prompt`
- 该技能追加的 system prompt / few-shot / 输出约束。
- `skill tool requirements`
- 该技能依赖哪些内置工具或 MCP 工具。
- `skill shortcuts`
- 可选地给欢迎卡片、斜杠命令或快速动作提供入口。
一个 Skill 本质上应该是“提示词 + 工具依赖 + 使用入口”的组合,而不是单独一段文案。
### Skills 是否需要单独 GitHub 仓库
第一阶段不需要。
建议顺序:
1. 先在 GoNavi 主仓库内把 Skills manifest/runtime 跑通。
2. 等格式稳定后再考虑增加“本地目录导入”或“Git 仓库导入”。
只有当你明确要做下面两件事时,独立仓库才值得:
- 把 Skills 当作社区共享资产分发。
- 让不同团队独立维护自己的 skill pack。
## 建议的下一步实现顺序
1. 抽出统一 `ToolRegistry`,让 `LOCAL_TOOLS` 不再硬编码在聊天面板内部。
2. 在 AI 设置中新增 `MCP Servers` 配置页。
3. 后端先支持最小 transport
- `stdio`
- `http/sse`(如果后续确认需要)
4. 在 AI 设置中新增 `Skills` 配置页。
5. 让 Skill 以 manifest 形式声明:
- `id`
- `name`
- `description`
- `systemPrompt`
- `requiredTools`
- `scopes`
6. 再决定是否增加“从 Git 仓库同步 MCP/Skills 包”的分发能力。

View File

@@ -12,4 +12,19 @@ describe('AIChatPanel message render isolation', () => {
expect(source).toContain('<AIMessageRenderBoundary');
expect(source).toContain('onDeleteMessage={handleDeleteMessage}');
});
it('loads user prompt settings and appends them as system messages', () => {
expect(source).toContain('AIGetUserPromptSettings');
expect(source).toContain("window.addEventListener('gonavi:ai:config-changed'");
expect(source).toContain('以下是当前用户的自定义补充提示词');
expect(source).toContain("appendCustomPromptGroup(['database'])");
});
it('loads MCP tools and skills into the runtime tool chain', () => {
expect(source).toContain('AIListMCPTools');
expect(source).toContain('AIGetSkills');
expect(source).toContain('AICallMCPTool');
expect(source).toContain('以下是当前启用的 Skill');
expect(source).toContain('buildAvailableAIChatTools');
});
});

View File

@@ -6,6 +6,9 @@ import { DBGetDatabases, DBGetTables } from '../../wailsjs/go/app/App';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import type {
AIChatMessage,
AIMCPToolDescriptor,
AISkillConfig,
AIUserPromptSettings,
AIToolCall,
JVMAIPlanContext,
JVMDiagnosticPlanContext,
@@ -31,6 +34,7 @@ import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut'
import { toAIRequestMessage } from '../utils/aiMessagePayload';
import { getShortcutPlatform, resolveShortcutBinding } from '../utils/shortcuts';
import { isMacLikePlatform } from '../utils/appearance';
import { buildAvailableAIChatTools } from '../utils/aiToolRegistry';
interface AIChatPanelProps {
width?: number;
@@ -233,93 +237,12 @@ const sanitizeErrorMsg = (raw: string): string => {
return raw;
};
const LOCAL_TOOLS = [
{
type: 'function',
function: {
name: 'get_connections',
description: '当需要查询、操作数据库但用户没有选择任何连接上下文时获取当前软件中可用的所有数据库连接信息。返回的数据包含连接ID(id)和名称(name)。',
parameters: { type: 'object', properties: {} }
}
},
{
type: 'function',
function: {
name: 'get_databases',
description: '获取指定连接connectionId下的所有数据库(Database/Schema)名。',
parameters: {
type: 'object',
properties: {
connectionId: { type: 'string', description: '连接ID (从 get_connections 获取)' }
},
required: ['connectionId']
}
}
},
{
type: 'function',
function: {
name: 'get_tables',
description: '当已经确定了目标连接和数据库名后,如果用户询问或隐式提到了表但你不知道确切表名,调用此工具获取该数据库下的所有表名列表(只含表名,帮助你推断目标表)。',
parameters: {
type: 'object',
properties: {
connectionId: { type: 'string', description: '连接ID' },
dbName: { type: 'string', description: '数据库名' },
},
required: ['connectionId', 'dbName']
}
}
},
{
type: 'function',
function: {
name: 'get_columns',
description: '获取指定表的字段列表(字段名、类型、是否可空、默认值、注释等)。在生成 SQL 之前必须先调用此工具确认真实字段名,禁止猜测字段名。',
parameters: {
type: 'object',
properties: {
connectionId: { type: 'string', description: '连接ID' },
dbName: { type: 'string', description: '数据库名' },
tableName: { type: 'string', description: '表名' },
},
required: ['connectionId', 'dbName', 'tableName']
}
}
},
{
type: 'function',
function: {
name: 'get_table_ddl',
description: '获取指定表的完整建表语句CREATE TABLE DDL包含字段、索引、约束等完整结构信息。',
parameters: {
type: 'object',
properties: {
connectionId: { type: 'string', description: '连接ID' },
dbName: { type: 'string', description: '数据库名' },
tableName: { type: 'string', description: '表名' },
},
required: ['connectionId', 'dbName', 'tableName']
}
}
},
{
type: 'function',
function: {
name: 'execute_sql',
description: '在指定连接和数据库上执行 SQL 查询并返回结果。受安全级别控制,只读模式下只能执行 SELECT/SHOW/DESCRIBE 等查询操作。结果最多返回 50 行。',
parameters: {
type: 'object',
properties: {
connectionId: { type: 'string', description: '连接ID' },
dbName: { type: 'string', description: '数据库名' },
sql: { type: 'string', description: '要执行的 SQL 语句' },
},
required: ['connectionId', 'dbName', 'sql']
}
}
}
];
const EMPTY_AI_USER_PROMPT_SETTINGS: AIUserPromptSettings = {
global: '',
database: '',
jvm: '',
jvmDiagnostic: '',
};
export const AIChatPanel: React.FC<AIChatPanelProps> = ({
width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme
@@ -328,6 +251,9 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const [draftImages, setDraftImages] = useState<string[]>([]);
const [sending, setSending] = useState(false);
const [activeProvider, setActiveProvider] = useState<any>(null);
const [userPromptSettings, setUserPromptSettings] = useState<AIUserPromptSettings>(EMPTY_AI_USER_PROMPT_SETTINGS);
const [mcpTools, setMcpTools] = useState<AIMCPToolDescriptor[]>([]);
const [skills, setSkills] = useState<AISkillConfig[]>([]);
const [dynamicModels, setDynamicModels] = useState<string[]>([]);
const [showScrollBottom, setShowScrollBottom] = useState(false);
const [loadingModels, setLoadingModels] = useState(false);
@@ -375,6 +301,10 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const aiPanelVisible = useStore(state => state.aiPanelVisible);
const isV2Ui = appearance.uiVersion === 'v2';
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
const availableTools = useMemo(
() => buildAvailableAIChatTools(mcpTools),
[mcpTools],
);
const aiChatSendShortcutBinding = useStore(state => resolveShortcutBinding(
state.shortcutOptions,
'sendAIChatMessage',
@@ -519,6 +449,69 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
useEffect(() => { loadActiveProvider(); }, [loadActiveProvider]);
const loadUserPromptSettings = useCallback(async () => {
try {
const Service = (window as any).go?.aiservice?.Service;
if (!Service?.AIGetUserPromptSettings) {
setUserPromptSettings(EMPTY_AI_USER_PROMPT_SETTINGS);
return;
}
const nextSettings = await Service.AIGetUserPromptSettings();
setUserPromptSettings({
...EMPTY_AI_USER_PROMPT_SETTINGS,
...nextSettings,
});
} catch (e) {
console.warn('Failed to load user prompt settings', e);
}
}, []);
const loadMCPTools = useCallback(async () => {
try {
const Service = (window as any).go?.aiservice?.Service;
if (!Service?.AIListMCPTools) {
setMcpTools([]);
return;
}
const nextTools = await Service.AIListMCPTools();
setMcpTools(Array.isArray(nextTools) ? nextTools : []);
} catch (e) {
console.warn('Failed to load MCP tools', e);
setMcpTools([]);
}
}, []);
const loadSkills = useCallback(async () => {
try {
const Service = (window as any).go?.aiservice?.Service;
if (!Service?.AIGetSkills) {
setSkills([]);
return;
}
const nextSkills = await Service.AIGetSkills();
setSkills(Array.isArray(nextSkills) ? nextSkills : []);
} catch (e) {
console.warn('Failed to load skills', e);
setSkills([]);
}
}, []);
useEffect(() => {
void loadUserPromptSettings();
void loadMCPTools();
void loadSkills();
const handleAIConfigChanged = () => {
void loadUserPromptSettings();
void loadMCPTools();
void loadSkills();
void loadActiveProvider();
};
window.addEventListener('gonavi:ai:config-changed', handleAIConfigChanged as EventListener);
return () => {
window.removeEventListener('gonavi:ai:config-changed', handleAIConfigChanged as EventListener);
};
}, [loadActiveProvider, loadMCPTools, loadSkills, loadUserPromptSettings]);
// 监听供应商配置变更(来自设置面板的删除/新增/切换操作),重新加载 active provider 并清空已缓存的模型
useEffect(() => {
const handler = () => {
@@ -817,7 +810,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
messagesPayload.push({ role: 'user', content: '请直接使用 function call 调用工具执行操作,不要只用文字描述计划。' });
const allMsg = [...sysMessages, ...messagesPayload];
const Service = (window as any).go?.aiservice?.Service;
if (Service?.AIChatStream) await Service.AIChatStream(sid, allMsg, LOCAL_TOOLS);
if (Service?.AIChatStream) await Service.AIChatStream(sid, allMsg, availableTools);
} catch (e) {
console.error('Nudge failed', e);
setSending(false);
@@ -942,9 +935,9 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const Service = (window as any).go?.aiservice?.Service;
if (Service?.AIChatStream) {
await Service.AIChatStream(sid, allMessages, LOCAL_TOOLS);
await Service.AIChatStream(sid, allMessages, availableTools);
} else if (Service?.AIChatSend) {
const result = await Service.AIChatSend(allMessages, LOCAL_TOOLS);
const result = await Service.AIChatSend(allMessages, availableTools);
const errRaw = result?.error || '未知错误';
const errClean = sanitizeErrorMsg(errRaw);
addAIChatMessage(sid, {
@@ -994,6 +987,57 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const connectionKey = ctx?.connectionId ? `${ctx.connectionId}:${ctx.dbName || ''}` : 'default';
const activeContextItems = ctxMap[connectionKey] || [];
const systemMessages: { role: string; content: string; images?: string[] }[] = [];
const appendCustomPrompt = (label: string, content: string) => {
const trimmed = String(content || '').trim();
if (!trimmed) {
return;
}
systemMessages.push({
role: 'system',
content: `以下是当前用户的自定义补充提示词(${label})。在不违反安全规则和事实约束的前提下,请优先遵循:\n${trimmed}`,
});
};
const appendCustomPromptGroup = (prompts: string[]) => {
appendCustomPrompt('全局', userPromptSettings.global);
prompts.forEach((prompt) => {
if (prompt === 'database') {
appendCustomPrompt('数据库会话', userPromptSettings.database);
} else if (prompt === 'jvm') {
appendCustomPrompt('JVM 资源分析', userPromptSettings.jvm);
} else if (prompt === 'jvmDiagnostic') {
appendCustomPrompt('JVM 诊断', userPromptSettings.jvmDiagnostic);
}
});
};
const availableToolNameSet = new Set(availableTools.map((tool) => tool.function.name));
const appendSkillPromptGroup = (scopes: string[]) => {
const wantedScopes = new Set<string>(['global', ...scopes]);
skills.forEach((skill) => {
if (!skill?.enabled) {
return;
}
if (!Array.isArray(skill.scopes) || !skill.scopes.some((scope) => wantedScopes.has(scope))) {
return;
}
if (Array.isArray(skill.requiredTools) && skill.requiredTools.length > 0) {
const hasAllRequiredTools = skill.requiredTools.every((toolName) => availableToolNameSet.has(toolName));
if (!hasAllRequiredTools) {
return;
}
}
const promptText = String(skill.systemPrompt || '').trim();
if (!promptText) {
return;
}
const requiredToolText = Array.isArray(skill.requiredTools) && skill.requiredTools.length > 0
? `\n依赖工具${skill.requiredTools.join(', ')}`
: '';
systemMessages.push({
role: 'system',
content: `以下是当前启用的 Skill「${skill.name}${skill.description ? `${skill.description}` : ''}。请在本次回答中遵循它的约束和工作方式:${requiredToolText}\n${promptText}`,
});
});
};
const matchesDiagnosticContext = (tab: typeof allTabs[number]) => {
if (!overrideJVMDiagnosticPlanContext || tab.type !== 'jvm-diagnostic') {
return false;
@@ -1055,6 +1099,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
6. expectedSignals 必须是字符串数组,描述执行后需要重点观察的信号。
7. 如果命令权限不允许某类操作,就不要输出该类命令;无法满足时直接说明限制。`,
});
appendCustomPromptGroup(['jvmDiagnostic']);
appendSkillPromptGroup(['jvmDiagnostic']);
return systemMessages;
}
@@ -1086,6 +1132,8 @@ ${resourcePath ? `当前资源路径:${resourcePath}` : '当前未选中具体
5. payload 只能使用 {"format":"json","value":{...}} 或 {"format":"text","value":"..."} 这两种包装形式,不要输出脚本、命令或裸值。
6. 不要输出脚本、命令或“已经执行成功”之类的表述。`
});
appendCustomPromptGroup(['jvm']);
appendSkillPromptGroup(['jvm']);
return systemMessages;
}
@@ -1149,8 +1197,10 @@ SELECT * FROM users WHERE status = 1;
当前存在的连接:[${connList || '无连接'}]`
});
}
appendCustomPromptGroup(['database']);
appendSkillPromptGroup(['database']);
return systemMessages;
}, []); // 零依赖:函数内部通过 useStore.getState() 实时读取
}, [availableTools, skills, userPromptSettings]);
// 记录所有成功的 get_tables 调用结果,用于表级精确匹配
const toolContextMapRef = useRef<Map<string, { connectionId: string; dbName: string; tables: string[] }>>(new Map());
@@ -1180,12 +1230,14 @@ SELECT * FROM users WHERE status = 1;
}
const results: AIChatMessage[] = [];
const mcpToolMap = new Map(mcpTools.map((tool) => [tool.alias, tool]));
// 【串行逐条执行 + 实时写入 store】
for (const tc of toolCalls) {
let resStr = '';
let success = false;
try {
const args = JSON.parse(tc.function.arguments || '{}');
const mcpToolDescriptor = mcpToolMap.get(tc.function.name);
switch (tc.function.name) {
case 'get_connections':
const conns = useStore.getState().connections.map(c => ({
@@ -1324,19 +1376,31 @@ SELECT * FROM users WHERE status = 1;
break;
}
default:
resStr = `Unknown function: ${tc.function.name}`;
if (mcpToolDescriptor) {
try {
const Service = (window as any).go?.aiservice?.Service;
const toolResult = await Service?.AICallMCPTool?.(tc.function.name, tc.function.arguments || '{}');
resStr = String(toolResult?.content || (toolResult?.isError ? 'MCP 工具调用失败' : ''));
success = !!toolResult && !toolResult.isError;
} catch (e: any) {
resStr = `MCP 工具调用失败: ${e?.message || e}`;
}
} else {
resStr = `Unknown function: ${tc.function.name}`;
}
}
} catch (e: any) {
resStr = e.message;
}
const resolvedToolDescriptor = mcpToolMap.get(tc.function.name);
const toolResultMsg: AIChatMessage = {
id: genId(),
role: 'tool',
content: resStr,
timestamp: Date.now(),
tool_call_id: tc.id,
tool_name: tc.function.name,
tool_name: resolvedToolDescriptor?.title || resolvedToolDescriptor?.originalName || tc.function.name,
success
};
results.push(toolResultMsg);
@@ -1425,7 +1489,7 @@ SELECT * FROM users WHERE status = 1;
// 【软收敛】超过 10 轮工具调用后,不再传递 tools 参数,从物理层面强制模型只能用文本回答
const SOFT_LIMIT_ROUNDS = 10;
const chainTools = totalToolRoundRef.current >= SOFT_LIMIT_ROUNDS ? [] : LOCAL_TOOLS;
const chainTools = totalToolRoundRef.current >= SOFT_LIMIT_ROUNDS ? [] : availableTools;
const Service = (window as any).go?.aiservice?.Service;
if (Service?.AIChatStream) {
@@ -1450,7 +1514,7 @@ SELECT * FROM users WHERE status = 1;
console.error('Failed to chain tool call', e);
setSending(false);
}
}, [sid, buildSystemContextMessages]);
}, [availableTools, buildSystemContextMessages, mcpTools, sid]);
const handleSend = useCallback(async () => {
const text = input.trim();
@@ -1534,9 +1598,9 @@ SELECT * FROM users WHERE status = 1;
try {
const Service = (window as any).go?.aiservice?.Service;
if (Service?.AIChatStream) {
await Service.AIChatStream(sid, allMessages, LOCAL_TOOLS);
await Service.AIChatStream(sid, allMessages, availableTools);
} else if (Service?.AIChatSend) {
const result = await Service.AIChatSend(allMessages, LOCAL_TOOLS);
const result = await Service.AIChatSend(allMessages, availableTools);
const errR2 = result?.error || '未知错误';
const errC2 = sanitizeErrorMsg(errR2);
const assistantMsg: AIChatMessage = {
@@ -1589,6 +1653,7 @@ SELECT * FROM users WHERE status = 1;
addAIChatMessage,
sid,
activeProvider,
availableTools,
buildSystemContextMessages,
getCurrentJVMPlanContext,
getCurrentJVMDiagnosticPlanContext,

View File

@@ -10,6 +10,21 @@ describe('AISettingsModal edit password behavior', () => {
expect(source).toContain('await Service.AIGetEditableProvider(p.id)');
});
it('loads and saves user-level custom prompts through the AI service', () => {
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('保存自定义提示词');
});
it('loads MCP servers and skills through the AI service', () => {
expect(source).toContain('Service.AIGetMCPServers?.()');
expect(source).toContain('Service.AIListMCPTools?.()');
expect(source).toContain('Service.AIGetSkills?.()');
expect(source).toContain('新增 MCP 服务');
expect(source).toContain('新增 Skill');
});
it('keeps the prefilled api key masked by default', () => {
expect(source).toContain('const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);');
expect(source).toContain('visible: primaryPasswordVisible,');

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
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 } from '../types';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AISkillConfig, AISkillScope } from '../types';
import {
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
@@ -21,6 +21,7 @@ import {
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry';
interface AISettingsModalProps {
open: boolean;
onClose: () => void;
@@ -78,17 +79,77 @@ const CONTEXT_OPTIONS: { label: string; value: AIContextLevel; desc: string; ico
{ label: '含查询结果', value: 'with_results', desc: '传递最近的查询结果作为上下文', icon: '📑' },
];
const EMPTY_AI_USER_PROMPT_SETTINGS: AIUserPromptSettings = {
global: '',
database: '',
jvm: '',
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_SKILL = (): AISkillConfig => ({
id: `skill-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: '',
description: '',
systemPrompt: '',
enabled: true,
scopes: ['global'],
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 parseMCPEnvText = (text: string): Record<string, string> => {
const result: Record<string, string> = {};
String(text || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
const index = line.indexOf('=');
if (index <= 0) return;
const key = line.slice(0, index).trim();
if (!key) return;
result[key] = line.slice(index + 1);
});
return result;
};
const stringifyMCPEnv = (env?: Record<string, string>): string =>
Object.entries(env || {})
.map(([key, value]) => `${key}=${value}`)
.join('\n');
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
const [activeProviderId, setActiveProviderId] = useState<string>('');
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
const [contextLevel, setContextLevel] = useState<AIContextLevel>('schema_only');
const [mcpServers, setMCPServers] = useState<AIMCPServerConfig[]>([]);
const [mcpTools, setMCPTools] = useState<AIMCPToolDescriptor[]>([]);
const [skills, setSkills] = useState<AISkillConfig[]>([]);
const [editingProvider, setEditingProvider] = useState<AIProviderConfig | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(false);
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [builtinPrompts, setBuiltinPrompts] = useState<Record<string, string>>({});
const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'prompts' | 'tools'>('providers');
const [userPromptSettings, setUserPromptSettings] = useState<AIUserPromptSettings>(EMPTY_AI_USER_PROMPT_SETTINGS);
const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'mcp' | 'skills' | 'prompts' | 'tools'>('providers');
const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);
const [form] = Form.useForm();
const modalBodyRef = useRef<HTMLDivElement>(null);
@@ -107,16 +168,41 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const watchedType = Form.useWatch('type', form);
const watchedPresetKey = Form.useWatch('presetKey', form);
const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai';
const skillRequiredToolOptions = useMemo(() => ([
...BUILTIN_AI_TOOL_INFO.map((tool) => ({
label: `${tool.name} · 内置工具`,
value: tool.name,
})),
...mcpTools.map((tool) => ({
label: `${tool.alias} · ${tool.serverName}`,
value: tool.alias,
})),
]), [mcpTools]);
const loadConfig = useCallback(async () => {
try {
const Service = (window as any).go?.aiservice?.Service;
if (!Service) { console.warn('[AI] Service not found on window.go'); return; }
const [provRes, safeRes, ctxRes, promptsRes] = await Promise.all([
Service.AIGetProviders?.() || [],
Service.AIGetSafetyLevel?.() || 'readonly',
Service.AIGetContextLevel?.() || 'schema_only',
Service.AIGetBuiltinPrompts?.() || {},
const callOrFallback = async <T,>(loader: (() => Promise<T>) | undefined, fallback: T): Promise<T> => {
if (typeof loader !== 'function') {
return fallback;
}
try {
return await loader();
} catch (error) {
console.warn('[AI] settings load fallback', error);
return fallback;
}
};
const [provRes, safeRes, ctxRes, promptsRes, userPromptsRes, mcpServersRes, mcpToolsRes, skillsRes] = await Promise.all([
callOrFallback(() => Service.AIGetProviders?.(), []),
callOrFallback<AISafetyLevel>(() => Service.AIGetSafetyLevel?.(), 'readonly'),
callOrFallback<AIContextLevel>(() => Service.AIGetContextLevel?.(), 'schema_only'),
callOrFallback(() => Service.AIGetBuiltinPrompts?.(), {}),
callOrFallback(() => Service.AIGetUserPromptSettings?.(), EMPTY_AI_USER_PROMPT_SETTINGS),
callOrFallback(() => Service.AIGetMCPServers?.(), []),
callOrFallback(() => Service.AIListMCPTools?.(), []),
callOrFallback(() => Service.AIGetSkills?.(), []),
]);
console.log('[AI] AIGetProviders result:', JSON.stringify(provRes), 'isArray:', Array.isArray(provRes));
if (Array.isArray(provRes)) {
@@ -128,6 +214,15 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
if (safeRes) setSafetyLevel(safeRes);
if (ctxRes) setContextLevel(ctxRes);
if (promptsRes) setBuiltinPrompts(promptsRes);
if (userPromptsRes) {
setUserPromptSettings({
...EMPTY_AI_USER_PROMPT_SETTINGS,
...userPromptsRes,
});
}
if (Array.isArray(mcpServersRes)) setMCPServers(mcpServersRes);
if (Array.isArray(mcpToolsRes)) setMCPTools(mcpToolsRes);
if (Array.isArray(skillsRes)) setSkills(skillsRes);
} catch (e) { console.warn('Failed to load AI config', e); }
}, []);
@@ -310,6 +405,134 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
} catch (e) { /* ignore */ }
};
const handleSaveUserPromptSettings = async () => {
try {
setLoading(true);
const Service = (window as any).go?.aiservice?.Service;
const payload = {
global: String(userPromptSettings.global || ''),
database: String(userPromptSettings.database || ''),
jvm: String(userPromptSettings.jvm || ''),
jvmDiagnostic: String(userPromptSettings.jvmDiagnostic || ''),
};
await Service?.AISaveUserPromptSettings?.(payload);
setUserPromptSettings(payload);
void messageApi.success('自定义提示词已保存');
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
} catch (e: any) {
void messageApi.error(e?.message || '保存自定义提示词失败');
} finally {
setLoading(false);
}
};
const updateMCPServerDraft = (id: string, patch: Partial<AIMCPServerConfig>) => {
setMCPServers((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item));
};
const handleAddMCPServer = () => {
setMCPServers((prev) => [...prev, EMPTY_MCP_SERVER()]);
};
const handleSaveMCPServer = async (server: AIMCPServerConfig) => {
try {
setLoading(true);
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISaveMCPServer?.(server);
await loadConfig();
void messageApi.success('MCP 服务已保存');
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
} catch (e: any) {
void messageApi.error(e?.message || '保存 MCP 服务失败');
} finally {
setLoading(false);
}
};
const handleDeleteMCPServer = async (id: string) => {
try {
setLoading(true);
const Service = (window as any).go?.aiservice?.Service;
if (typeof Service?.AIDeleteMCPServer === 'function' && !String(id).startsWith('mcp-draft-')) {
await Service.AIDeleteMCPServer(id);
await loadConfig();
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
} else {
setMCPServers((prev) => prev.filter((item) => item.id !== id));
}
void messageApi.success('MCP 服务已删除');
} catch (e: any) {
void messageApi.error(e?.message || '删除 MCP 服务失败');
} finally {
setLoading(false);
}
};
const handleTestMCPServer = async (server: AIMCPServerConfig) => {
try {
setLoading(true);
const Service = (window as any).go?.aiservice?.Service;
const res = await Service?.AITestMCPServer?.(server);
if (res?.success) {
void messageApi.success(res?.message || 'MCP 服务连接成功');
if (typeof Service?.AIListMCPTools === 'function') {
const nextTools = await Service.AIListMCPTools();
if (Array.isArray(nextTools)) setMCPTools(nextTools);
} else if (Array.isArray(res?.tools)) {
setMCPTools(res.tools);
}
} else {
void messageApi.error(res?.message || 'MCP 服务测试失败');
}
} catch (e: any) {
void messageApi.error(e?.message || '测试 MCP 服务失败');
} finally {
setLoading(false);
}
};
const updateSkillDraft = (id: string, patch: Partial<AISkillConfig>) => {
setSkills((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item));
};
const handleAddSkill = () => {
setSkills((prev) => [...prev, EMPTY_SKILL()]);
};
const handleSaveSkill = async (skill: AISkillConfig) => {
try {
setLoading(true);
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISaveSkill?.(skill);
await loadConfig();
void messageApi.success('Skill 已保存');
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
} catch (e: any) {
void messageApi.error(e?.message || '保存 Skill 失败');
} finally {
setLoading(false);
}
};
const handleDeleteSkill = async (id: string) => {
try {
setLoading(true);
const Service = (window as any).go?.aiservice?.Service;
if (typeof Service?.AIDeleteSkill === 'function' && !String(id).startsWith('skill-draft-')) {
await Service.AIDeleteSkill(id);
await loadConfig();
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
} else {
setSkills((prev) => prev.filter((item) => item.id !== id));
}
void messageApi.success('Skill 已删除');
} catch (e: any) {
void messageApi.error(e?.message || '删除 Skill 失败');
} finally {
setLoading(false);
}
};
const handleTestProvider = async () => {
try {
const values = await form.validateFields();
@@ -660,10 +883,83 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
</div>
);
const renderBuiltinPrompts = () => (
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
GoNavi AI
</div>
{Object.entries(builtinPrompts).map(([title, promptText]) => (
<div key={title} style={{
@@ -685,14 +981,166 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
</div>
);
const BUILTIN_TOOLS_INFO = [
{ name: 'get_connections', icon: '🔗', desc: '获取所有可用的数据库连接', detail: '返回连接 ID、名称、类型 (MySQL/PostgreSQL 等) 和 Host 地址。AI 根据返回信息决定优先探索哪个连接。', params: '无参数' },
{ name: 'get_databases', icon: '🗄️', desc: '获取指定连接下的所有数据库', detail: '传入 connectionId返回该连接下的数据库/Schema 名称列表。', params: 'connectionId: 连接 ID' },
{ name: 'get_tables', icon: '📋', desc: '获取指定数据库下的所有表名', detail: '传入 connectionId 和 dbName返回表名列表。AI 用它来定位用户提到的目标表。', params: 'connectionId, dbName' },
{ name: 'get_columns', icon: '🔍', desc: '获取指定表的字段结构', detail: '传入 connectionId、dbName 和 tableName返回每个字段的名称、类型、是否可空、默认值和注释。AI 在生成 SQL 前必须调用此工具确认真实字段名。', params: 'connectionId, dbName, tableName' },
{ name: 'get_table_ddl', icon: '📝', desc: '获取表的建表语句 (DDL)', detail: '传入 connectionId、dbName 和 tableName返回完整的 CREATE TABLE 语句,包含字段定义、索引、约束等信息。', params: 'connectionId, dbName, tableName' },
{ name: 'execute_sql', icon: '▶️', desc: '执行 SQL 查询并返回结果', detail: '传入 connectionId、dbName 和 sql在目标数据库上执行 SQL 并返回结果(最多 50 行)。受安全级别控制,只读模式下仅允许 SELECT/SHOW/DESCRIBE。', params: 'connectionId, dbName, sql' },
];
const renderMCPSettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
MCP AI `stdio` GoNavi MCP client MCP Server
</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={handleAddMCPServer} 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 }}>
MCP `node server.js``uvx some-mcp-server``python -m server`
</div>
)}
{mcpServers.map((server) => {
const serverTools = mcpTools.filter((tool) => tool.serverId === server.id);
return (
<div key={server.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={server.name}
onChange={(event) => updateMCPServerDraft(server.id, { name: event.target.value })}
placeholder="服务名称例如Filesystem / Browser / GitHub"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
<Select
value={server.enabled ? 'enabled' : 'disabled'}
onChange={(value) => updateMCPServerDraft(server.id, { enabled: value === 'enabled' })}
options={[{ label: '已启用', value: 'enabled' }, { label: '已禁用', value: 'disabled' }]}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '132px minmax(0,1fr) 132px', gap: 12 }}>
<Select
value={server.transport}
onChange={(value) => updateMCPServerDraft(server.id, { transport: value as AIMCPServerConfig['transport'] })}
options={[{ label: 'stdio', value: 'stdio' }]}
/>
<Input
value={server.command}
onChange={(event) => updateMCPServerDraft(server.id, { command: event.target.value })}
placeholder="启动命令例如node / uvx / python"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
<Input
type="number"
min={3}
max={120}
value={server.timeoutSeconds}
onChange={(event) => updateMCPServerDraft(server.id, { timeoutSeconds: Number(event.target.value) || 20 })}
placeholder="超时(秒)"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
</div>
<Select
mode="tags"
value={server.args || []}
onChange={(value) => updateMCPServerDraft(server.id, { args: value })}
placeholder="命令参数回车录入例如server.js、--stdio"
style={{ width: '100%' }}
/>
<Input.TextArea
rows={3}
value={stringifyMCPEnv(server.env)}
onChange={(event) => updateMCPServerDraft(server.id, { env: parseMCPEnvText(event.target.value) })}
placeholder={"环境变量,每行一个 KEY=VALUE例如\nOPENAI_API_KEY=...\nGITHUB_TOKEN=..."}
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)' }}
/>
{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>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={() => handleTestMCPServer(server)} loading={loading} style={{ borderRadius: 10 }}></Button>
<Button type="primary" onClick={() => handleSaveMCPServer(server)} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}></Button>
<Popconfirm title="删除这个 MCP 服务?" okText="删除" cancelText="取消" onConfirm={() => handleDeleteMCPServer(server.id)}>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 10 }}></Button>
</Popconfirm>
</div>
</div>
);
})}
</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 renderBuiltinTools = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -702,7 +1150,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
<div style={{ fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, padding: '8px 12px', borderRadius: 8, background: cardBg, border: `1px solid ${cardBorder}` }}>
💡 get_connections get_databases get_tables get_columns SQL
</div>
{BUILTIN_TOOLS_INFO.map(tool => (
{BUILTIN_AI_TOOL_INFO.map(tool => (
<div key={tool.name} style={{
padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg,
transition: 'all 0.2s ease',
@@ -776,6 +1224,8 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
{ key: 'providers', title: '模型供应商', description: '配置大模型接口与秘钥', icon: <ApiOutlined /> },
{ key: 'safety', title: '安全控制', description: '限制 AI 操作风险级别', icon: <SafetyCertificateOutlined /> },
{ key: 'context', title: '上下文', description: '配置携带的数据架构信息', icon: <RobotOutlined /> },
{ key: 'mcp', title: 'MCP 服务', description: '接入外部工具源', icon: <AppstoreOutlined /> },
{ key: 'skills', title: 'Skills', description: '配置可复用提示模块', icon: <ExperimentOutlined /> },
{ key: 'tools', title: '内置工具', description: '查看 AI 可调用的数据探针', icon: <ToolOutlined /> },
{ key: 'prompts', title: '内置提示词', description: '查看系统预设的底层要求', icon: <ExperimentOutlined /> },
].map((item) => {
@@ -815,8 +1265,10 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
{activeSection === 'providers' && (isEditing ? renderProviderForm() : renderProviderList())}
{activeSection === 'safety' && renderSafetySettings()}
{activeSection === 'context' && renderContextSettings()}
{activeSection === 'mcp' && renderMCPSettings()}
{activeSection === 'skills' && renderSkillSettings()}
{activeSection === 'tools' && renderBuiltinTools()}
{activeSection === 'prompts' && renderBuiltinPrompts()}
{activeSection === 'prompts' && renderPromptSettings()}
</div>
</div>
</Modal>

View File

@@ -28,6 +28,14 @@ if (typeof window !== 'undefined' && !(window as any).go) {
const mockProviders: any[] = [];
const mockProviderSecrets = new Map<string, string>();
let mockActiveProviderId = '';
let mockAIUserPromptSettings: any = {
global: '',
database: '',
jvm: '',
jvmDiagnostic: '',
};
let mockMCPServers: any[] = [];
let mockSkills: any[] = [];
let mockGlobalProxy: any = { enabled: false, type: 'socks5', host: '', port: 1080, user: '', password: '', hasPassword: false };
let mockDataRootInfo: any = {
path: 'C:/mock/.gonavi',
@@ -286,6 +294,71 @@ if (typeof window !== 'undefined' && !(window as any).go) {
AIGetSafetyLevel: async () => 'readonly',
AIGetContextLevel: async () => 'schema_only',
AIGetBuiltinPrompts: async () => ({}),
AIGetUserPromptSettings: async () => cloneBrowserMockValue(mockAIUserPromptSettings),
AISaveUserPromptSettings: async (input: any) => {
mockAIUserPromptSettings = {
global: String(input?.global || ''),
database: String(input?.database || ''),
jvm: String(input?.jvm || ''),
jvmDiagnostic: String(input?.jvmDiagnostic || ''),
};
return null;
},
AIGetMCPServers: async () => cloneBrowserMockValue(mockMCPServers),
AISaveMCPServer: async (input: any) => {
const next = {
id: String(input?.id || `mcp-${Date.now()}`),
name: String(input?.name || ''),
transport: 'stdio',
command: String(input?.command || ''),
args: Array.isArray(input?.args) ? [...input.args] : [],
env: { ...(input?.env || {}) },
enabled: input?.enabled !== false,
timeoutSeconds: Number(input?.timeoutSeconds) || 20,
};
const index = mockMCPServers.findIndex((item) => item.id === next.id);
if (index >= 0) mockMCPServers[index] = next;
else mockMCPServers.push(next);
return null;
},
AIDeleteMCPServer: async (id: string) => {
mockMCPServers = mockMCPServers.filter((item) => item.id !== id);
return null;
},
AITestMCPServer: async (input: any) => ({
success: String(input?.command || '').trim() !== '',
message: String(input?.command || '').trim() !== '' ? 'MCP mock 测试成功' : 'MCP 命令不能为空',
tools: [],
}),
AIListMCPTools: async () => [],
AICallMCPTool: async (_alias: string, _argumentsJSON: string) => ({
alias: _alias,
serverId: '',
serverName: '',
originalName: _alias,
content: '浏览器 mock 未接入真实 MCP 服务',
isError: true,
}),
AIGetSkills: async () => cloneBrowserMockValue(mockSkills),
AISaveSkill: async (input: any) => {
const next = {
id: String(input?.id || `skill-${Date.now()}`),
name: String(input?.name || ''),
description: String(input?.description || ''),
systemPrompt: String(input?.systemPrompt || ''),
enabled: input?.enabled !== false,
scopes: Array.isArray(input?.scopes) ? [...input.scopes] : ['global'],
requiredTools: Array.isArray(input?.requiredTools) ? [...input.requiredTools] : [],
};
const index = mockSkills.findIndex((item) => item.id === next.id);
if (index >= 0) mockSkills[index] = next;
else mockSkills.push(next);
return null;
},
AIDeleteSkill: async (id: string) => {
mockSkills = mockSkills.filter((item) => item.id !== id);
return null;
},
AITestProvider: async (input: any) => ({
success: String(input?.apiKey || '').trim() !== '',
message: String(input?.apiKey || '').trim() !== '' ? '端点连通性测试成功!' : '连接测试失败: missing api key',

View File

@@ -551,6 +551,59 @@ export interface AIProviderConfig {
temperature: number;
}
export interface AIUserPromptSettings {
global: string;
database: string;
jvm: string;
jvmDiagnostic: string;
}
export type AIMCPTransport = "stdio";
export interface AIMCPServerConfig {
id: string;
name: string;
transport: AIMCPTransport;
command: string;
args?: string[];
env?: Record<string, string>;
enabled: boolean;
timeoutSeconds: number;
}
export interface AIMCPToolDescriptor {
alias: string;
serverId: string;
serverName: string;
originalName: string;
title?: string;
description?: string;
inputSchema?: Record<string, any>;
}
export interface AIMCPToolCallResult {
alias: string;
serverId: string;
serverName: string;
originalName: string;
title?: string;
content: string;
structuredContent?: any;
isError: boolean;
}
export type AISkillScope = "global" | "database" | "jvm" | "jvmDiagnostic";
export interface AISkillConfig {
id: string;
name: string;
description?: string;
systemPrompt: string;
enabled: boolean;
scopes: AISkillScope[];
requiredTools?: string[];
}
export interface AIToolCall {
id: string;
type: string;

View File

@@ -0,0 +1,185 @@
import type { AIMCPToolDescriptor } from "../types";
export interface AIChatToolDefinition {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, any>;
};
}
export interface AIBuiltinToolInfo {
name: string;
icon: string;
desc: string;
detail: string;
params: string;
tool: AIChatToolDefinition;
}
export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [
{
name: "get_connections",
icon: "🔗",
desc: "获取所有可用的数据库连接",
detail:
"返回连接 ID、名称、类型 (MySQL/PostgreSQL 等) 和 Host 地址。AI 根据返回信息决定优先探索哪个连接。",
params: "无参数",
tool: {
type: "function",
function: {
name: "get_connections",
description:
"当需要查询、操作数据库但用户没有选择任何连接上下文时获取当前软件中可用的所有数据库连接信息。返回的数据包含连接ID(id)和名称(name)。",
parameters: { type: "object", properties: {} },
},
},
},
{
name: "get_databases",
icon: "🗄️",
desc: "获取指定连接下的所有数据库",
detail: "传入 connectionId返回该连接下的数据库/Schema 名称列表。",
params: "connectionId: 连接 ID",
tool: {
type: "function",
function: {
name: "get_databases",
description: "获取指定连接connectionId下的所有数据库(Database/Schema)名。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID (从 get_connections 获取)" },
},
required: ["connectionId"],
},
},
},
},
{
name: "get_tables",
icon: "📋",
desc: "获取指定数据库下的所有表名",
detail:
"传入 connectionId 和 dbName返回表名列表。AI 用它来定位用户提到的目标表。",
params: "connectionId, dbName",
tool: {
type: "function",
function: {
name: "get_tables",
description:
"当已经确定了目标连接和数据库名后,如果用户询问或隐式提到了表但你不知道确切表名,调用此工具获取该数据库下的所有表名列表(只含表名,帮助你推断目标表)。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
},
required: ["connectionId", "dbName"],
},
},
},
},
{
name: "get_columns",
icon: "🔍",
desc: "获取指定表的字段结构",
detail:
"传入 connectionId、dbName 和 tableName返回每个字段的名称、类型、是否可空、默认值和注释。AI 在生成 SQL 前必须调用此工具确认真实字段名。",
params: "connectionId, dbName, tableName",
tool: {
type: "function",
function: {
name: "get_columns",
description:
"获取指定表的字段列表(字段名、类型、是否可空、默认值、注释等)。在生成 SQL 之前必须先调用此工具确认真实字段名,禁止猜测字段名。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "get_table_ddl",
icon: "📝",
desc: "获取表的建表语句 (DDL)",
detail:
"传入 connectionId、dbName 和 tableName返回完整的 CREATE TABLE 语句,包含字段定义、索引、约束等信息。",
params: "connectionId, dbName, tableName",
tool: {
type: "function",
function: {
name: "get_table_ddl",
description: "获取指定表的完整建表语句CREATE TABLE DDL包含字段、索引、约束等完整结构信息。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "execute_sql",
icon: "▶️",
desc: "执行 SQL 查询并返回结果",
detail:
"传入 connectionId、dbName 和 sql在目标数据库上执行 SQL 并返回结果(最多 50 行)。受安全级别控制,只读模式下仅允许 SELECT/SHOW/DESCRIBE。",
params: "connectionId, dbName, sql",
tool: {
type: "function",
function: {
name: "execute_sql",
description:
"在指定连接和数据库上执行 SQL 查询并返回结果。受安全级别控制,只读模式下只能执行 SELECT/SHOW/DESCRIBE 等查询操作。结果最多返回 50 行。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
sql: { type: "string", description: "要执行的 SQL 语句" },
},
required: ["connectionId", "dbName", "sql"],
},
},
},
},
];
export const BUILTIN_AI_TOOLS: AIChatToolDefinition[] = BUILTIN_AI_TOOL_INFO.map((item) => item.tool);
export const BUILTIN_AI_TOOL_NAME_SET = new Set<string>(
BUILTIN_AI_TOOL_INFO.map((item) => item.name),
);
export const buildMCPAIChatTools = (
tools: AIMCPToolDescriptor[],
): AIChatToolDefinition[] =>
(tools || []).map((tool) => ({
type: "function",
function: {
name: tool.alias,
description:
tool.description ||
`${tool.serverName} 提供的 MCP 工具 ${tool.title || tool.originalName}`,
parameters:
tool.inputSchema && Object.keys(tool.inputSchema).length > 0
? tool.inputSchema
: { type: "object", properties: {} },
},
}));
export const buildAvailableAIChatTools = (
tools: AIMCPToolDescriptor[],
): AIChatToolDefinition[] => [...BUILTIN_AI_TOOLS, ...buildMCPAIChatTools(tools)];

View File

@@ -2,6 +2,8 @@
// This file is automatically generated. DO NOT EDIT
import {ai} from '../models';
export function AICallMCPTool(arg1:string,arg2:string):Promise<ai.MCPToolCallResult>;
export function AIChatCancel(arg1:string):Promise<void>;
export function AIChatSend(arg1:Array<ai.Message>,arg2:Array<ai.Tool>):Promise<Record<string, any>>;
@@ -10,10 +12,14 @@ export function AIChatStream(arg1:string,arg2:Array<ai.Message>,arg3:Array<ai.To
export function AICheckSQL(arg1:string):Promise<ai.SafetyResult>;
export function AIDeleteMCPServer(arg1:string):Promise<void>;
export function AIDeleteProvider(arg1:string):Promise<void>;
export function AIDeleteSession(arg1:string):Promise<void>;
export function AIDeleteSkill(arg1:string):Promise<void>;
export function AIGetActiveProvider():Promise<string>;
export function AIGetBuiltinPrompts():Promise<Record<string, string>>;
@@ -22,24 +28,40 @@ export function AIGetContextLevel():Promise<string>;
export function AIGetEditableProvider(arg1:string):Promise<ai.ProviderConfig>;
export function AIGetMCPServers():Promise<Array<ai.MCPServerConfig>>;
export function AIGetProviders():Promise<Array<ai.ProviderConfig>>;
export function AIGetSafetyLevel():Promise<string>;
export function AIGetSessions():Promise<Array<Record<string, any>>>;
export function AIGetSkills():Promise<Array<ai.SkillConfig>>;
export function AIGetUserPromptSettings():Promise<ai.UserPromptSettings>;
export function AIListMCPTools():Promise<Array<ai.MCPToolDescriptor>>;
export function AIListModels():Promise<Record<string, any>>;
export function AILoadSession(arg1:string):Promise<Record<string, any>>;
export function AISaveMCPServer(arg1:ai.MCPServerConfig):Promise<void>;
export function AISaveProvider(arg1:ai.ProviderConfig):Promise<void>;
export function AISaveSession(arg1:string,arg2:string,arg3:number,arg4:string):Promise<void>;
export function AISaveSkill(arg1:ai.SkillConfig):Promise<void>;
export function AISaveUserPromptSettings(arg1:ai.UserPromptSettings):Promise<void>;
export function AISetActiveProvider(arg1:string):Promise<void>;
export function AISetContextLevel(arg1:string):Promise<void>;
export function AISetSafetyLevel(arg1:string):Promise<void>;
export function AITestMCPServer(arg1:ai.MCPServerConfig):Promise<Record<string, any>>;
export function AITestProvider(arg1:ai.ProviderConfig):Promise<Record<string, any>>;

View File

@@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AICallMCPTool(arg1, arg2) {
return window['go']['aiservice']['Service']['AICallMCPTool'](arg1, arg2);
}
export function AIChatCancel(arg1) {
return window['go']['aiservice']['Service']['AIChatCancel'](arg1);
}
@@ -18,6 +22,10 @@ export function AICheckSQL(arg1) {
return window['go']['aiservice']['Service']['AICheckSQL'](arg1);
}
export function AIDeleteMCPServer(arg1) {
return window['go']['aiservice']['Service']['AIDeleteMCPServer'](arg1);
}
export function AIDeleteProvider(arg1) {
return window['go']['aiservice']['Service']['AIDeleteProvider'](arg1);
}
@@ -26,6 +34,10 @@ export function AIDeleteSession(arg1) {
return window['go']['aiservice']['Service']['AIDeleteSession'](arg1);
}
export function AIDeleteSkill(arg1) {
return window['go']['aiservice']['Service']['AIDeleteSkill'](arg1);
}
export function AIGetActiveProvider() {
return window['go']['aiservice']['Service']['AIGetActiveProvider']();
}
@@ -42,6 +54,10 @@ export function AIGetEditableProvider(arg1) {
return window['go']['aiservice']['Service']['AIGetEditableProvider'](arg1);
}
export function AIGetMCPServers() {
return window['go']['aiservice']['Service']['AIGetMCPServers']();
}
export function AIGetProviders() {
return window['go']['aiservice']['Service']['AIGetProviders']();
}
@@ -54,6 +70,18 @@ export function AIGetSessions() {
return window['go']['aiservice']['Service']['AIGetSessions']();
}
export function AIGetSkills() {
return window['go']['aiservice']['Service']['AIGetSkills']();
}
export function AIGetUserPromptSettings() {
return window['go']['aiservice']['Service']['AIGetUserPromptSettings']();
}
export function AIListMCPTools() {
return window['go']['aiservice']['Service']['AIListMCPTools']();
}
export function AIListModels() {
return window['go']['aiservice']['Service']['AIListModels']();
}
@@ -62,6 +90,10 @@ export function AILoadSession(arg1) {
return window['go']['aiservice']['Service']['AILoadSession'](arg1);
}
export function AISaveMCPServer(arg1) {
return window['go']['aiservice']['Service']['AISaveMCPServer'](arg1);
}
export function AISaveProvider(arg1) {
return window['go']['aiservice']['Service']['AISaveProvider'](arg1);
}
@@ -70,6 +102,14 @@ export function AISaveSession(arg1, arg2, arg3, arg4) {
return window['go']['aiservice']['Service']['AISaveSession'](arg1, arg2, arg3, arg4);
}
export function AISaveSkill(arg1) {
return window['go']['aiservice']['Service']['AISaveSkill'](arg1);
}
export function AISaveUserPromptSettings(arg1) {
return window['go']['aiservice']['Service']['AISaveUserPromptSettings'](arg1);
}
export function AISetActiveProvider(arg1) {
return window['go']['aiservice']['Service']['AISetActiveProvider'](arg1);
}
@@ -82,6 +122,10 @@ export function AISetSafetyLevel(arg1) {
return window['go']['aiservice']['Service']['AISetSafetyLevel'](arg1);
}
export function AITestMCPServer(arg1) {
return window['go']['aiservice']['Service']['AITestMCPServer'](arg1);
}
export function AITestProvider(arg1) {
return window['go']['aiservice']['Service']['AITestProvider'](arg1);
}

View File

@@ -1,5 +1,81 @@
export namespace ai {
export class MCPServerConfig {
id: string;
name: string;
transport: string;
command: string;
args?: string[];
env?: Record<string, string>;
enabled: boolean;
timeoutSeconds: number;
static createFrom(source: any = {}) {
return new MCPServerConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.transport = source["transport"];
this.command = source["command"];
this.args = source["args"];
this.env = source["env"];
this.enabled = source["enabled"];
this.timeoutSeconds = source["timeoutSeconds"];
}
}
export class MCPToolCallResult {
alias: string;
serverId: string;
serverName: string;
originalName: string;
title?: string;
content: string;
structuredContent?: any;
isError: boolean;
static createFrom(source: any = {}) {
return new MCPToolCallResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.alias = source["alias"];
this.serverId = source["serverId"];
this.serverName = source["serverName"];
this.originalName = source["originalName"];
this.title = source["title"];
this.content = source["content"];
this.structuredContent = source["structuredContent"];
this.isError = source["isError"];
}
}
export class MCPToolDescriptor {
alias: string;
serverId: string;
serverName: string;
originalName: string;
title?: string;
description?: string;
inputSchema?: Record<string, any>;
static createFrom(source: any = {}) {
return new MCPToolDescriptor(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.alias = source["alias"];
this.serverId = source["serverId"];
this.serverName = source["serverName"];
this.originalName = source["originalName"];
this.title = source["title"];
this.description = source["description"];
this.inputSchema = source["inputSchema"];
}
}
export class ToolCallFunction {
name: string;
arguments: string;
@@ -142,6 +218,30 @@ export namespace ai {
this.warningMessage = source["warningMessage"];
}
}
export class SkillConfig {
id: string;
name: string;
description?: string;
systemPrompt: string;
enabled: boolean;
scopes?: string[];
requiredTools?: string[];
static createFrom(source: any = {}) {
return new SkillConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.description = source["description"];
this.systemPrompt = source["systemPrompt"];
this.enabled = source["enabled"];
this.scopes = source["scopes"];
this.requiredTools = source["requiredTools"];
}
}
export class ToolFunction {
name: string;
description: string;
@@ -192,6 +292,25 @@ export namespace ai {
}
export class UserPromptSettings {
global: string;
database: string;
jvm: string;
jvmDiagnostic: string;
static createFrom(source: any = {}) {
return new UserPromptSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.global = source["global"];
this.database = source["database"];
this.jvm = source["jvm"];
this.jvmDiagnostic = source["jvmDiagnostic"];
}
}
}

23
go.mod
View File

@@ -1,6 +1,6 @@
module GoNavi-Wails
go 1.24.3
go 1.25.0
require (
gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3
@@ -14,6 +14,7 @@ require (
github.com/highgo/pq-sm3 v0.0.0
github.com/lib/pq v1.11.1
github.com/microsoft/go-mssqldb v1.9.6
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/redis/go-redis/v9 v9.17.3
github.com/sijms/go-ora/v2 v2.9.0
github.com/taosdata/driver-go/v3 v3.7.8
@@ -21,11 +22,11 @@ require (
github.com/xuri/excelize/v2 v2.10.0
go.mongodb.org/mongo-driver v1.17.9
go.mongodb.org/mongo-driver/v2 v2.5.0
golang.org/x/crypto v0.47.0
golang.org/x/crypto v0.48.0
golang.org/x/image v0.25.0
golang.org/x/mod v0.32.0
golang.org/x/net v0.49.0
golang.org/x/text v0.33.0
golang.org/x/mod v0.33.0
golang.org/x/net v0.50.0
golang.org/x/text v0.34.0
modernc.org/sqlite v1.44.3
)
@@ -33,8 +34,12 @@ require (
github.com/elastic/elastic-transport-go/v8 v8.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
)
require (
@@ -116,10 +121,10 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect

46
go.sum
View File

@@ -90,8 +90,8 @@ github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
@@ -107,6 +107,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -173,6 +175,8 @@ github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpsp
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -216,6 +220,8 @@ github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg=
@@ -266,6 +272,8 @@ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBL
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
@@ -298,8 +306,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
@@ -307,8 +315,8 @@ golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -317,8 +325,10 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -339,29 +349,29 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5 h1:i0p03B68+xC1kD2QUO8JzDTPXCzhN56OLJ+IhHY8U3A=
golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -13,23 +13,29 @@ import (
)
const (
aiConfigSchemaVersion = 2
aiConfigSchemaVersion = 4
aiConfigFileName = "ai_config.json"
)
type aiConfig struct {
SchemaVersion int `json:"schemaVersion,omitempty"`
Providers []ai.ProviderConfig `json:"providers"`
ActiveProvider string `json:"activeProvider"`
SafetyLevel string `json:"safetyLevel"`
ContextLevel string `json:"contextLevel"`
SchemaVersion int `json:"schemaVersion,omitempty"`
Providers []ai.ProviderConfig `json:"providers"`
ActiveProvider string `json:"activeProvider"`
SafetyLevel string `json:"safetyLevel"`
ContextLevel string `json:"contextLevel"`
UserPromptSettings ai.UserPromptSettings `json:"userPromptSettings,omitempty"`
MCPServers []ai.MCPServerConfig `json:"mcpServers,omitempty"`
Skills []ai.SkillConfig `json:"skills,omitempty"`
}
type ProviderConfigStoreSnapshot struct {
Providers []ai.ProviderConfig
ActiveProvider string
SafetyLevel ai.SQLPermissionLevel
ContextLevel ai.ContextLevel
Providers []ai.ProviderConfig
ActiveProvider string
SafetyLevel ai.SQLPermissionLevel
ContextLevel ai.ContextLevel
UserPromptSettings ai.UserPromptSettings
MCPServers []ai.MCPServerConfig
Skills []ai.SkillConfig
}
type ProviderConfigStoreInspection struct {
@@ -145,11 +151,14 @@ func (s *ProviderConfigStore) Save(snapshot ProviderConfigStoreSnapshot) error {
}
cfg := aiConfig{
SchemaVersion: aiConfigSchemaVersion,
Providers: providers,
ActiveProvider: snapshot.ActiveProvider,
SafetyLevel: string(snapshot.SafetyLevel),
ContextLevel: string(snapshot.ContextLevel),
SchemaVersion: aiConfigSchemaVersion,
Providers: providers,
ActiveProvider: snapshot.ActiveProvider,
SafetyLevel: string(snapshot.SafetyLevel),
ContextLevel: string(snapshot.ContextLevel),
UserPromptSettings: snapshot.UserPromptSettings,
MCPServers: snapshot.MCPServers,
Skills: snapshot.Skills,
}
data, err := json.MarshalIndent(cfg, "", " ")
@@ -170,6 +179,8 @@ func (s *ProviderConfigStore) readStoredSnapshot() (aiConfig, ProviderConfigStor
Providers: []ai.ProviderConfig{},
SafetyLevel: ai.PermissionReadOnly,
ContextLevel: ai.ContextSchemaOnly,
MCPServers: []ai.MCPServerConfig{},
Skills: []ai.SkillConfig{},
}
data, err := os.ReadFile(s.configPath())
@@ -194,6 +205,9 @@ func (s *ProviderConfigStore) readStoredSnapshot() (aiConfig, ProviderConfigStor
case ai.ContextSchemaOnly, ai.ContextWithSamples, ai.ContextWithResults:
snapshot.ContextLevel = ai.ContextLevel(cfg.ContextLevel)
}
snapshot.UserPromptSettings = cfg.UserPromptSettings
snapshot.MCPServers = append([]ai.MCPServerConfig(nil), cfg.MCPServers...)
snapshot.Skills = append([]ai.SkillConfig(nil), cfg.Skills...)
providers := make([]ai.ProviderConfig, 0, len(cfg.Providers))
for _, providerConfig := range cfg.Providers {

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -197,3 +198,129 @@ func TestProviderConfigStoreSaveKeepsExistingSecretRef(t *testing.T) {
t.Fatalf("expected reload to restore existing sensitive header, got %#v", snapshot.Providers[0].Headers)
}
}
func TestProviderConfigStoreSaveAndLoadUserPromptSettings(t *testing.T) {
configStore := newProviderConfigStore(t.TempDir(), failOnUseSecretStore{})
expected := ai.UserPromptSettings{
Global: "所有回答先给结论。",
Database: "生成 SQL 前先确认字段名。",
JVM: "优先输出资源级风险判断。",
JVMDiagnostic: "先给诊断计划,再给命令。",
}
err := configStore.Save(ProviderConfigStoreSnapshot{
Providers: []ai.ProviderConfig{},
ActiveProvider: "",
SafetyLevel: ai.PermissionReadOnly,
ContextLevel: ai.ContextSchemaOnly,
UserPromptSettings: expected,
})
if err != nil {
t.Fatalf("Save returned error: %v", err)
}
snapshot, err := configStore.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if snapshot.UserPromptSettings != expected {
t.Fatalf("expected user prompt settings %#v, got %#v", expected, snapshot.UserPromptSettings)
}
configData, err := os.ReadFile(filepath.Join(configStore.configDir, aiConfigFileName))
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
text := string(configData)
if !strings.Contains(text, `"userPromptSettings"`) {
t.Fatalf("expected config file to contain userPromptSettings, got %s", text)
}
}
func TestProviderConfigStoreSaveAndLoadMCPServers(t *testing.T) {
configStore := newProviderConfigStore(t.TempDir(), failOnUseSecretStore{})
expected := []ai.MCPServerConfig{
{
ID: "mcp-local",
Name: "本地文件助手",
Transport: ai.MCPTransportStdio,
Command: "node",
Args: []string{"server.js", "--stdio"},
Env: map[string]string{"API_KEY": "test"},
Enabled: true,
TimeoutSeconds: 18,
},
}
err := configStore.Save(ProviderConfigStoreSnapshot{
Providers: []ai.ProviderConfig{},
ActiveProvider: "",
SafetyLevel: ai.PermissionReadOnly,
ContextLevel: ai.ContextSchemaOnly,
MCPServers: expected,
})
if err != nil {
t.Fatalf("Save returned error: %v", err)
}
snapshot, err := configStore.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if !reflect.DeepEqual(snapshot.MCPServers, expected) {
t.Fatalf("expected MCP servers %#v, got %#v", expected, snapshot.MCPServers)
}
configData, err := os.ReadFile(filepath.Join(configStore.configDir, aiConfigFileName))
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if !strings.Contains(string(configData), `"mcpServers"`) {
t.Fatalf("expected config file to contain mcpServers, got %s", string(configData))
}
}
func TestProviderConfigStoreSaveAndLoadSkills(t *testing.T) {
configStore := newProviderConfigStore(t.TempDir(), failOnUseSecretStore{})
expected := []ai.SkillConfig{
{
ID: "skill-sql-review",
Name: "SQL 审查",
Description: "生成 SQL 前先校验字段和风险",
SystemPrompt: "先确认字段,再输出 SQL。",
Enabled: true,
Scopes: []string{string(ai.SkillScopeDatabase)},
RequiredTools: []string{"get_columns", "execute_sql"},
},
}
err := configStore.Save(ProviderConfigStoreSnapshot{
Providers: []ai.ProviderConfig{},
ActiveProvider: "",
SafetyLevel: ai.PermissionReadOnly,
ContextLevel: ai.ContextSchemaOnly,
Skills: expected,
})
if err != nil {
t.Fatalf("Save returned error: %v", err)
}
snapshot, err := configStore.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if !reflect.DeepEqual(snapshot.Skills, expected) {
t.Fatalf("expected skills %#v, got %#v", expected, snapshot.Skills)
}
configData, err := os.ReadFile(filepath.Join(configStore.configDir, aiConfigFileName))
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if !strings.Contains(string(configData), `"skills"`) {
t.Fatalf("expected config file to contain skills, got %s", string(configData))
}
}

View File

@@ -0,0 +1,599 @@
package aiservice
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"slices"
"strings"
"time"
"GoNavi-Wails/internal/ai"
"GoNavi-Wails/internal/logger"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
const (
defaultMCPServerTimeoutSeconds = 20
minMCPServerTimeoutSeconds = 3
maxMCPServerTimeoutSeconds = 120
mcpToolAliasPrefix = "mcp__"
)
// AIGetMCPServers 获取 MCP 服务配置
func (s *Service) AIGetMCPServers() []ai.MCPServerConfig {
s.mu.RLock()
defer s.mu.RUnlock()
return cloneMCPServerConfigs(s.mcpServers)
}
// AISaveMCPServer 保存/更新 MCP 服务配置
func (s *Service) AISaveMCPServer(config ai.MCPServerConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
normalized := normalizeMCPServerConfig(config)
if normalized.Enabled && strings.TrimSpace(normalized.Command) == "" {
return fmt.Errorf("MCP 服务命令不能为空")
}
for i := range s.mcpServers {
if s.mcpServers[i].ID == normalized.ID {
s.mcpServers[i] = normalized
return s.saveConfig()
}
}
s.mcpServers = append(s.mcpServers, normalized)
return s.saveConfig()
}
// AIDeleteMCPServer 删除 MCP 服务配置
func (s *Service) AIDeleteMCPServer(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
filtered := s.mcpServers[:0]
for _, serverConfig := range s.mcpServers {
if serverConfig.ID == id {
continue
}
filtered = append(filtered, serverConfig)
}
s.mcpServers = append([]ai.MCPServerConfig(nil), filtered...)
return s.saveConfig()
}
// AITestMCPServer 测试 MCP 服务连通性
func (s *Service) AITestMCPServer(config ai.MCPServerConfig) map[string]any {
normalized := normalizeMCPServerConfig(config)
if strings.TrimSpace(normalized.Command) == "" {
return map[string]any{"success": false, "message": "MCP 服务命令不能为空", "tools": []ai.MCPToolDescriptor{}}
}
tools, err := s.listMCPToolsForServer(normalized)
if err != nil {
return map[string]any{"success": false, "message": err.Error(), "tools": []ai.MCPToolDescriptor{}}
}
return map[string]any{
"success": true,
"message": fmt.Sprintf("MCP 服务连接成功,发现 %d 个工具", len(tools)),
"toolCount": len(tools),
"tools": tools,
}
}
// AIListMCPTools 聚合所有启用的 MCP 工具
func (s *Service) AIListMCPTools() []ai.MCPToolDescriptor {
s.mu.RLock()
servers := cloneMCPServerConfigs(s.mcpServers)
s.mu.RUnlock()
descriptors := make([]ai.MCPToolDescriptor, 0)
for _, serverConfig := range servers {
if !serverConfig.Enabled {
continue
}
tools, err := s.listMCPToolsForServer(serverConfig)
if err != nil {
logger.Warnf("列出 MCP 工具失败(server=%s): %v", serverConfig.Name, err)
continue
}
descriptors = append(descriptors, tools...)
}
return descriptors
}
// AICallMCPTool 调用指定的 MCP 工具
func (s *Service) AICallMCPTool(alias string, argumentsJSON string) (ai.MCPToolCallResult, error) {
serverID, originalName, err := parseMCPToolAlias(alias)
if err != nil {
return ai.MCPToolCallResult{}, err
}
s.mu.RLock()
serverConfig, ok := findMCPServerConfigByID(s.mcpServers, serverID)
s.mu.RUnlock()
if !ok {
return ai.MCPToolCallResult{}, fmt.Errorf("未找到 MCP 服务: %s", serverID)
}
if !serverConfig.Enabled {
return ai.MCPToolCallResult{}, fmt.Errorf("MCP 服务未启用: %s", serverConfig.Name)
}
var arguments any = map[string]any{}
trimmedArguments := strings.TrimSpace(argumentsJSON)
if trimmedArguments != "" {
if err := json.Unmarshal([]byte(trimmedArguments), &arguments); err != nil {
return ai.MCPToolCallResult{}, fmt.Errorf("解析 MCP 工具参数失败: %w", err)
}
}
var callResult *mcp.CallToolResult
err = s.withMCPClientSession(serverConfig, func(ctx context.Context, session *mcp.ClientSession) error {
result, callErr := session.CallTool(ctx, &mcp.CallToolParams{
Name: originalName,
Arguments: arguments,
})
if callErr != nil {
return callErr
}
callResult = result
return nil
})
if err != nil {
return ai.MCPToolCallResult{}, fmt.Errorf("调用 MCP 工具失败: %w", err)
}
return ai.MCPToolCallResult{
Alias: alias,
ServerID: serverConfig.ID,
ServerName: serverConfig.Name,
OriginalName: originalName,
Title: originalName,
Content: formatMCPToolCallContent(callResult),
StructuredContent: callResult.StructuredContent,
IsError: callResult.IsError,
}, nil
}
// AIGetSkills 获取 Skill 配置
func (s *Service) AIGetSkills() []ai.SkillConfig {
s.mu.RLock()
defer s.mu.RUnlock()
return cloneSkillConfigs(s.skills)
}
// AISaveSkill 保存/更新 Skill 配置
func (s *Service) AISaveSkill(config ai.SkillConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
normalized := normalizeSkillConfig(config)
for i := range s.skills {
if s.skills[i].ID == normalized.ID {
s.skills[i] = normalized
return s.saveConfig()
}
}
s.skills = append(s.skills, normalized)
return s.saveConfig()
}
// AIDeleteSkill 删除 Skill 配置
func (s *Service) AIDeleteSkill(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
filtered := s.skills[:0]
for _, skillConfig := range s.skills {
if skillConfig.ID == id {
continue
}
filtered = append(filtered, skillConfig)
}
s.skills = append([]ai.SkillConfig(nil), filtered...)
return s.saveConfig()
}
func (s *Service) listMCPToolsForServer(serverConfig ai.MCPServerConfig) ([]ai.MCPToolDescriptor, error) {
descriptors := make([]ai.MCPToolDescriptor, 0)
err := s.withMCPClientSession(serverConfig, func(ctx context.Context, session *mcp.ClientSession) error {
cursor := ""
for {
result, err := session.ListTools(ctx, &mcp.ListToolsParams{Cursor: cursor})
if err != nil {
return err
}
for _, tool := range result.Tools {
if tool == nil {
continue
}
descriptors = append(descriptors, ai.MCPToolDescriptor{
Alias: buildMCPToolAlias(serverConfig.ID, tool.Name),
ServerID: serverConfig.ID,
ServerName: serverConfig.Name,
OriginalName: tool.Name,
Title: firstNonEmpty(tool.Title, toolAnnotationsTitle(tool), tool.Name),
Description: strings.TrimSpace(tool.Description),
InputSchema: normalizeToolSchema(tool.InputSchema),
})
}
if strings.TrimSpace(result.NextCursor) == "" {
break
}
cursor = result.NextCursor
}
return nil
})
return descriptors, err
}
func (s *Service) withMCPClientSession(serverConfig ai.MCPServerConfig, fn func(context.Context, *mcp.ClientSession) error) error {
serverConfig = normalizeMCPServerConfig(serverConfig)
if serverConfig.Transport != ai.MCPTransportStdio {
return fmt.Errorf("暂不支持的 MCP transport: %s", serverConfig.Transport)
}
if strings.TrimSpace(serverConfig.Command) == "" {
return fmt.Errorf("MCP 服务命令不能为空")
}
timeout := time.Duration(serverConfig.TimeoutSeconds) * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
command := exec.CommandContext(ctx, serverConfig.Command, serverConfig.Args...)
command.Env = append(os.Environ(), formatMCPEnv(serverConfig.Env)...)
client := mcp.NewClient(&mcp.Implementation{
Name: "GoNavi",
Version: "dev",
}, nil)
session, err := client.Connect(ctx, &mcp.CommandTransport{Command: command}, nil)
if err != nil {
return err
}
defer session.Close()
return fn(ctx, session)
}
func normalizeMCPServerConfigs(configs []ai.MCPServerConfig) []ai.MCPServerConfig {
normalized := make([]ai.MCPServerConfig, 0, len(configs))
for _, config := range configs {
normalized = append(normalized, normalizeMCPServerConfig(config))
}
return normalized
}
func normalizeMCPServerConfig(config ai.MCPServerConfig) ai.MCPServerConfig {
id := sanitizeExtensionID(strings.TrimSpace(config.ID), "mcp")
if id == "" {
id = "mcp-" + uuid.New().String()[:8]
}
transport := config.Transport
if transport != ai.MCPTransportStdio {
transport = ai.MCPTransportStdio
}
args := make([]string, 0, len(config.Args))
for _, arg := range config.Args {
trimmed := strings.TrimSpace(arg)
if trimmed == "" {
continue
}
args = append(args, trimmed)
}
env := make(map[string]string, len(config.Env))
for key, value := range config.Env {
trimmedKey := strings.TrimSpace(key)
if trimmedKey == "" {
continue
}
env[trimmedKey] = value
}
timeout := config.TimeoutSeconds
if timeout <= 0 {
timeout = defaultMCPServerTimeoutSeconds
}
if timeout < minMCPServerTimeoutSeconds {
timeout = minMCPServerTimeoutSeconds
}
if timeout > maxMCPServerTimeoutSeconds {
timeout = maxMCPServerTimeoutSeconds
}
return ai.MCPServerConfig{
ID: id,
Name: firstNonEmpty(strings.TrimSpace(config.Name), strings.TrimSpace(config.Command), "MCP Server"),
Transport: transport,
Command: strings.TrimSpace(config.Command),
Args: args,
Env: env,
Enabled: config.Enabled,
TimeoutSeconds: timeout,
}
}
func cloneMCPServerConfigs(configs []ai.MCPServerConfig) []ai.MCPServerConfig {
cloned := make([]ai.MCPServerConfig, 0, len(configs))
for _, config := range configs {
next := config
next.Args = append([]string(nil), config.Args...)
if len(config.Env) > 0 {
next.Env = make(map[string]string, len(config.Env))
for key, value := range config.Env {
next.Env[key] = value
}
} else {
next.Env = map[string]string{}
}
cloned = append(cloned, next)
}
return cloned
}
func buildMCPToolAlias(serverID string, originalName string) string {
return mcpToolAliasPrefix + sanitizeAliasPart(serverID) + "__" + sanitizeAliasPart(originalName)
}
func parseMCPToolAlias(alias string) (string, string, error) {
trimmed := strings.TrimSpace(alias)
if !strings.HasPrefix(trimmed, mcpToolAliasPrefix) {
return "", "", fmt.Errorf("无效的 MCP 工具别名: %s", alias)
}
parts := strings.SplitN(strings.TrimPrefix(trimmed, mcpToolAliasPrefix), "__", 2)
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
return "", "", fmt.Errorf("无效的 MCP 工具别名: %s", alias)
}
return parts[0], parts[1], nil
}
func formatMCPEnv(env map[string]string) []string {
if len(env) == 0 {
return nil
}
lines := make([]string, 0, len(env))
for key, value := range env {
lines = append(lines, key+"="+value)
}
slices.Sort(lines)
return lines
}
func normalizeToolSchema(schema any) map[string]any {
if schema == nil {
return map[string]any{
"type": "object",
"properties": map[string]any{},
}
}
if typed, ok := schema.(map[string]any); ok {
return typed
}
data, err := json.Marshal(schema)
if err != nil {
return map[string]any{
"type": "object",
"properties": map[string]any{},
}
}
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil || result == nil {
return map[string]any{
"type": "object",
"properties": map[string]any{},
}
}
return result
}
func formatMCPToolCallContent(result *mcp.CallToolResult) string {
if result == nil {
return ""
}
parts := make([]string, 0, len(result.Content))
for _, item := range result.Content {
switch typed := item.(type) {
case *mcp.TextContent:
if strings.TrimSpace(typed.Text) != "" {
parts = append(parts, typed.Text)
}
default:
data, err := json.Marshal(typed)
if err != nil {
continue
}
if strings.TrimSpace(string(data)) != "" {
parts = append(parts, string(data))
}
}
}
if len(parts) == 0 && result.StructuredContent != nil {
if data, err := json.Marshal(result.StructuredContent); err == nil {
parts = append(parts, string(data))
}
}
if len(parts) == 0 && result.IsError {
return "MCP 工具调用失败"
}
return strings.Join(parts, "\n\n")
}
func findMCPServerConfigByID(configs []ai.MCPServerConfig, id string) (ai.MCPServerConfig, bool) {
for _, config := range configs {
if config.ID == id {
return cloneMCPServerConfigs([]ai.MCPServerConfig{config})[0], true
}
}
return ai.MCPServerConfig{}, false
}
func normalizeSkillConfigs(configs []ai.SkillConfig) []ai.SkillConfig {
normalized := make([]ai.SkillConfig, 0, len(configs))
for _, config := range configs {
normalized = append(normalized, normalizeSkillConfig(config))
}
return normalized
}
func normalizeSkillConfig(config ai.SkillConfig) ai.SkillConfig {
id := sanitizeExtensionID(strings.TrimSpace(config.ID), "skill")
if id == "" {
id = "skill-" + uuid.New().String()[:8]
}
requiredTools := make([]string, 0, len(config.RequiredTools))
seenRequiredTools := make(map[string]struct{}, len(config.RequiredTools))
for _, toolName := range config.RequiredTools {
trimmed := strings.TrimSpace(toolName)
if trimmed == "" {
continue
}
if _, ok := seenRequiredTools[trimmed]; ok {
continue
}
seenRequiredTools[trimmed] = struct{}{}
requiredTools = append(requiredTools, trimmed)
}
return ai.SkillConfig{
ID: id,
Name: firstNonEmpty(strings.TrimSpace(config.Name), "未命名 Skill"),
Description: strings.TrimSpace(config.Description),
SystemPrompt: normalizeUserPromptText(config.SystemPrompt),
Enabled: config.Enabled,
Scopes: normalizeSkillScopes(config.Scopes),
RequiredTools: requiredTools,
}
}
func cloneSkillConfigs(configs []ai.SkillConfig) []ai.SkillConfig {
cloned := make([]ai.SkillConfig, 0, len(configs))
for _, config := range configs {
next := config
next.Scopes = append([]string(nil), config.Scopes...)
next.RequiredTools = append([]string(nil), config.RequiredTools...)
cloned = append(cloned, next)
}
return cloned
}
func normalizeSkillScopes(scopes []string) []string {
if len(scopes) == 0 {
return []string{string(ai.SkillScopeGlobal)}
}
allowed := map[string]struct{}{
string(ai.SkillScopeGlobal): {},
string(ai.SkillScopeDatabase): {},
string(ai.SkillScopeJVM): {},
string(ai.SkillScopeJVMDiagnostic): {},
}
seen := make(map[string]struct{}, len(scopes))
normalized := make([]string, 0, len(scopes))
for _, scope := range scopes {
trimmed := strings.TrimSpace(scope)
if _, ok := allowed[trimmed]; !ok {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
normalized = append(normalized, trimmed)
}
if len(normalized) == 0 {
return []string{string(ai.SkillScopeGlobal)}
}
return normalized
}
func sanitizeExtensionID(raw string, prefix string) string {
if raw == "" {
return ""
}
var builder strings.Builder
builder.Grow(len(raw))
lastWasDash := false
for _, r := range strings.ToLower(raw) {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
lastWasDash = false
case r >= '0' && r <= '9':
builder.WriteRune(r)
lastWasDash = false
case r == '-' || r == '_':
if builder.Len() == 0 || lastWasDash {
continue
}
builder.WriteByte('-')
lastWasDash = true
default:
if builder.Len() == 0 || lastWasDash {
continue
}
builder.WriteByte('-')
lastWasDash = true
}
}
sanitized := strings.Trim(builder.String(), "-")
if sanitized == "" {
return ""
}
if prefix != "" && !strings.HasPrefix(sanitized, prefix+"-") && sanitized != prefix {
return prefix + "-" + sanitized
}
return sanitized
}
func sanitizeAliasPart(raw string) string {
var builder strings.Builder
builder.Grow(len(raw))
for _, r := range strings.TrimSpace(raw) {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
builder.WriteRune(r)
case r == '_', r == '-', r == '.':
builder.WriteRune(r)
default:
builder.WriteByte('_')
}
}
return strings.Trim(builder.String(), "_")
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func toolAnnotationsTitle(tool *mcp.Tool) string {
if tool == nil || tool.Annotations == nil {
return ""
}
return strings.TrimSpace(tool.Annotations.Title)
}

View File

@@ -27,16 +27,19 @@ import (
// Service AI 服务,作为 Wails Binding 暴露给前端
type Service struct {
ctx context.Context
mu sync.RWMutex
providers []ai.ProviderConfig
activeProvider string // active provider ID
safetyLevel ai.SQLPermissionLevel
contextLevel ai.ContextLevel
guard *safety.Guard
configDir string // 配置存储目录
secretStore secretstore.SecretStore
cancelFuncs map[string]context.CancelFunc // 记录每个 session 的 context 取消函数
ctx context.Context
mu sync.RWMutex
providers []ai.ProviderConfig
activeProvider string // active provider ID
safetyLevel ai.SQLPermissionLevel
contextLevel ai.ContextLevel
userPromptSettings ai.UserPromptSettings
mcpServers []ai.MCPServerConfig
skills []ai.SkillConfig
guard *safety.Guard
configDir string // 配置存储目录
secretStore secretstore.SecretStore
cancelFuncs map[string]context.CancelFunc // 记录每个 session 的 context 取消函数
}
var miniMaxAnthropicModels = []string{
@@ -107,6 +110,8 @@ func NewServiceWithSecretStore(store secretstore.SecretStore) *Service {
providers: make([]ai.ProviderConfig, 0),
safetyLevel: ai.PermissionReadOnly,
contextLevel: ai.ContextSchemaOnly,
mcpServers: make([]ai.MCPServerConfig, 0),
skills: make([]ai.SkillConfig, 0),
guard: safety.NewGuard(ai.PermissionReadOnly),
secretStore: store,
cancelFuncs: make(map[string]context.CancelFunc),
@@ -643,6 +648,22 @@ func (s *Service) AIGetBuiltinPrompts() map[string]string {
return aicontext.GetBuiltinPrompts()
}
// AIGetUserPromptSettings 获取用户级自定义提示词配置
func (s *Service) AIGetUserPromptSettings() ai.UserPromptSettings {
s.mu.RLock()
defer s.mu.RUnlock()
return s.userPromptSettings
}
// AISaveUserPromptSettings 保存用户级自定义提示词配置
func (s *Service) AISaveUserPromptSettings(settings ai.UserPromptSettings) error {
s.mu.Lock()
defer s.mu.Unlock()
s.userPromptSettings = normalizeUserPromptSettings(settings)
return s.saveConfig()
}
// AIListModels 获取当前活跃 Provider 的可用模型列表
func (s *Service) AIListModels() map[string]interface{} {
s.mu.RLock()
@@ -988,17 +1009,43 @@ func (s *Service) loadConfig() {
s.safetyLevel = snapshot.SafetyLevel
s.guard.SetPermissionLevel(s.safetyLevel)
s.contextLevel = snapshot.ContextLevel
s.userPromptSettings = snapshot.UserPromptSettings
s.mcpServers = normalizeMCPServerConfigs(snapshot.MCPServers)
s.skills = normalizeSkillConfigs(snapshot.Skills)
}
func (s *Service) saveConfig() error {
return NewProviderConfigStore(s.configDir, s.secretStore).Save(ProviderConfigStoreSnapshot{
Providers: s.providers,
ActiveProvider: s.activeProvider,
SafetyLevel: s.safetyLevel,
ContextLevel: s.contextLevel,
Providers: s.providers,
ActiveProvider: s.activeProvider,
SafetyLevel: s.safetyLevel,
ContextLevel: s.contextLevel,
UserPromptSettings: s.userPromptSettings,
MCPServers: s.mcpServers,
Skills: s.skills,
})
}
const maxUserPromptChars = 16000
func normalizeUserPromptSettings(settings ai.UserPromptSettings) ai.UserPromptSettings {
return ai.UserPromptSettings{
Global: normalizeUserPromptText(settings.Global),
Database: normalizeUserPromptText(settings.Database),
JVM: normalizeUserPromptText(settings.JVM),
JVMDiagnostic: normalizeUserPromptText(settings.JVMDiagnostic),
}
}
func normalizeUserPromptText(value string) string {
normalized := strings.ReplaceAll(value, "\r\n", "\n")
normalized = strings.TrimSpace(normalized)
if len(normalized) > maxUserPromptChars {
return normalized[:maxUserPromptChars]
}
return normalized
}
// --- 会话文件持久化 ---
// sessionFileData 会话文件的 JSON 结构

View File

@@ -86,6 +86,77 @@ type ProviderConfig struct {
Temperature float64 `json:"temperature"`
}
// UserPromptSettings 表示用户级自定义提示词配置
type UserPromptSettings struct {
Global string `json:"global"`
Database string `json:"database"`
JVM string `json:"jvm"`
JVMDiagnostic string `json:"jvmDiagnostic"`
}
// MCPTransport 表示 MCP 服务的传输方式
type MCPTransport string
const (
MCPTransportStdio MCPTransport = "stdio"
)
// MCPServerConfig 表示一个可配置的 MCP 服务
type MCPServerConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Transport MCPTransport `json:"transport"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
Enabled bool `json:"enabled"`
TimeoutSeconds int `json:"timeoutSeconds"`
}
// MCPToolDescriptor 表示暴露给模型和前端的 MCP 工具描述
type MCPToolDescriptor struct {
Alias string `json:"alias"`
ServerID string `json:"serverId"`
ServerName string `json:"serverName"`
OriginalName string `json:"originalName"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
InputSchema map[string]any `json:"inputSchema,omitempty"`
}
// MCPToolCallResult 表示一次 MCP 工具调用的结果
type MCPToolCallResult struct {
Alias string `json:"alias"`
ServerID string `json:"serverId"`
ServerName string `json:"serverName"`
OriginalName string `json:"originalName"`
Title string `json:"title,omitempty"`
Content string `json:"content"`
StructuredContent any `json:"structuredContent,omitempty"`
IsError bool `json:"isError"`
}
// SkillScope 表示 Skill 的适用场景
type SkillScope string
const (
SkillScopeGlobal SkillScope = "global"
SkillScopeDatabase SkillScope = "database"
SkillScopeJVM SkillScope = "jvm"
SkillScopeJVMDiagnostic SkillScope = "jvmDiagnostic"
)
// SkillConfig 表示一个可配置的 Skill
type SkillConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
SystemPrompt string `json:"systemPrompt"`
Enabled bool `json:"enabled"`
Scopes []string `json:"scopes,omitempty"`
RequiredTools []string `json:"requiredTools,omitempty"`
}
// SQLPermissionLevel AI SQL 执行权限级别
type SQLPermissionLevel string