From f7d71c6c5c9ba128c42a59f0b425b97c94338c35 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 7 Jun 2026 17:56:45 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ai):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=8F=90=E7=A4=BA=E8=AF=8D=E4=B8=8E=20MCP/Sk?= =?UTF-8?q?ills=20=E6=89=A9=E5=B1=95=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持用户级自定义提示词加载保存与聊天会话注入 - 新增 MCP 服务配置、工具发现与按次调用链路 - 增加 Skills 配置、作用域注入与前后端类型同步 - 补充配置存储与前端行为测试并更新依赖 --- AI_EXTENSIONS_ROADMAP.md | 106 ++++ .../AIChatPanel.message-boundary.test.tsx | 15 + frontend/src/components/AIChatPanel.tsx | 259 +++++--- .../AISettingsModal.edit-password.test.tsx | 15 + frontend/src/components/AISettingsModal.tsx | 492 +++++++++++++- frontend/src/main.tsx | 73 +++ frontend/src/types.ts | 53 ++ frontend/src/utils/aiToolRegistry.ts | 185 ++++++ frontend/wailsjs/go/aiservice/Service.d.ts | 22 + frontend/wailsjs/go/aiservice/Service.js | 44 ++ frontend/wailsjs/go/models.ts | 119 ++++ go.mod | 23 +- go.sum | 46 +- internal/ai/service/config_store.go | 44 +- internal/ai/service/config_store_test.go | 127 ++++ internal/ai/service/extensions_service.go | 599 ++++++++++++++++++ internal/ai/service/service.go | 75 ++- internal/ai/types.go | 71 +++ 18 files changed, 2195 insertions(+), 173 deletions(-) create mode 100644 AI_EXTENSIONS_ROADMAP.md create mode 100644 frontend/src/utils/aiToolRegistry.ts create mode 100644 internal/ai/service/extensions_service.go diff --git a/AI_EXTENSIONS_ROADMAP.md b/AI_EXTENSIONS_ROADMAP.md new file mode 100644 index 0000000..dd196e6 --- /dev/null +++ b/AI_EXTENSIONS_ROADMAP.md @@ -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 包”的分发能力。 diff --git a/frontend/src/components/AIChatPanel.message-boundary.test.tsx b/frontend/src/components/AIChatPanel.message-boundary.test.tsx index 52c792b..e557fd0 100644 --- a/frontend/src/components/AIChatPanel.message-boundary.test.tsx +++ b/frontend/src/components/AIChatPanel.message-boundary.test.tsx @@ -12,4 +12,19 @@ describe('AIChatPanel message render isolation', () => { expect(source).toContain(' { + 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'); + }); }); diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 5f06522..75c6588 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -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 = ({ width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme @@ -328,6 +251,9 @@ export const AIChatPanel: React.FC = ({ const [draftImages, setDraftImages] = useState([]); const [sending, setSending] = useState(false); const [activeProvider, setActiveProvider] = useState(null); + const [userPromptSettings, setUserPromptSettings] = useState(EMPTY_AI_USER_PROMPT_SETTINGS); + const [mcpTools, setMcpTools] = useState([]); + const [skills, setSkills] = useState([]); const [dynamicModels, setDynamicModels] = useState([]); const [showScrollBottom, setShowScrollBottom] = useState(false); const [loadingModels, setLoadingModels] = useState(false); @@ -375,6 +301,10 @@ export const AIChatPanel: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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(['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 = ({ 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>(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, diff --git a/frontend/src/components/AISettingsModal.edit-password.test.tsx b/frontend/src/components/AISettingsModal.edit-password.test.tsx index 8bf7074..2db53a3 100644 --- a/frontend/src/components/AISettingsModal.edit-password.test.tsx +++ b/frontend/src/components/AISettingsModal.edit-password.test.tsx @@ -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,'); diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 4632e27..be8b8db 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -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 => { + const result: Record = {}; + 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 => + Object.entries(env || {}) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + const AISettingsModal: React.FC = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => { const [providers, setProviders] = useState([]); const [activeProviderId, setActiveProviderId] = useState(''); const [safetyLevel, setSafetyLevel] = useState('readonly'); const [contextLevel, setContextLevel] = useState('schema_only'); + const [mcpServers, setMCPServers] = useState([]); + const [mcpTools, setMCPTools] = useState([]); + const [skills, setSkills] = useState([]); const [editingProvider, setEditingProvider] = useState(null); const [isEditing, setIsEditing] = useState(false); const [loading, setLoading] = useState(false); const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [builtinPrompts, setBuiltinPrompts] = useState>({}); - const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'prompts' | 'tools'>('providers'); + const [userPromptSettings, setUserPromptSettings] = useState(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(null); @@ -107,16 +168,41 @@ const AISettingsModal: React.FC = ({ 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 (loader: (() => Promise) | undefined, fallback: T): Promise => { + 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(() => Service.AIGetSafetyLevel?.(), 'readonly'), + callOrFallback(() => 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 = ({ 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 = ({ 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) => { + 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) => { + 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 = ({ open, onClose, darkMo ); - const renderBuiltinPrompts = () => ( + const renderPromptSettings = () => (
+
+
+ 用户级自定义提示词 +
+
+ 这里的内容会在系统内置提示词之后,以 system message 的形式追加注入。 + 适合放你的个人风格偏好、输出约束、团队规范。涉及安全红线时,系统规则仍然优先。 +
+ + {[ + { + 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) => ( +
+
+ {item.title} +
+
+ {item.desc} +
+ 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', + }} + /> +
+ ))} + +
+ +
+
+
- 以下为当前版本 GoNavi 预设的底层 AI 提示词(只读)。它们会被动态注入到对应场景的请求上下文中。 + 以下为当前版本 GoNavi 预设的底层 AI 提示词(只读)。它们会先于上面的用户级提示词注入到对应场景的请求上下文中。
{Object.entries(builtinPrompts).map(([title, promptText]) => (
= ({ open, onClose, darkMo
); - 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 = () => ( +
+
+ MCP 会作为外部工具源接入 AI。当前阶段先支持 `stdio` 型服务,不需要为 GoNavi 的 MCP client 单独新建仓库;只有你准备发布独立的 MCP Server 时,才值得拆独立仓库。 +
+
+
支持命令、参数、环境变量和超时,保存后会自动进入 AI 工具列表。
+ +
+ {mcpServers.length === 0 && ( +
+ 还没有 MCP 服务。常见形式是 `node server.js`、`uvx some-mcp-server`、`python -m server`。 +
+ )} + {mcpServers.map((server) => { + const serverTools = mcpTools.filter((tool) => tool.serverId === server.id); + return ( +
+
+ updateMCPServerDraft(server.id, { name: event.target.value })} + placeholder="服务名称,例如:Filesystem / Browser / GitHub" + style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} + /> + updateMCPServerDraft(server.id, { transport: value as AIMCPServerConfig['transport'] })} + options={[{ label: 'stdio', value: 'stdio' }]} + /> + updateMCPServerDraft(server.id, { command: event.target.value })} + placeholder="启动命令,例如:node / uvx / python" + style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} + /> + updateMCPServerDraft(server.id, { timeoutSeconds: Number(event.target.value) || 20 })} + placeholder="超时(秒)" + style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} + /> +
+ updateSkillDraft(skill.id, { name: event.target.value })} + placeholder="Skill 名称,例如:SQL 审查 / JVM 诊断计划" + style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} + /> + updateSkillDraft(skill.id, { description: event.target.value })} + placeholder="给自己看的说明,例如:输出 SQL 前必须先确认字段名和风险" + style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} + /> + updateSkillDraft(skill.id, { requiredTools: value })} + options={skillRequiredToolOptions} + placeholder="可选:声明这个 Skill 依赖哪些工具" + style={{ width: '100%' }} + /> + 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' }} + /> +
+ + handleDeleteSkill(skill.id)}> + + +
+
+ ))} +
+ ); const renderBuiltinTools = () => (
@@ -702,7 +1150,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo
💡 工作流程:get_connections → get_databases → get_tables → get_columns → 生成 SQL
- {BUILTIN_TOOLS_INFO.map(tool => ( + {BUILTIN_AI_TOOL_INFO.map(tool => (
= ({ open, onClose, darkMo { key: 'providers', title: '模型供应商', description: '配置大模型接口与秘钥', icon: }, { key: 'safety', title: '安全控制', description: '限制 AI 操作风险级别', icon: }, { key: 'context', title: '上下文', description: '配置携带的数据架构信息', icon: }, + { key: 'mcp', title: 'MCP 服务', description: '接入外部工具源', icon: }, + { key: 'skills', title: 'Skills', description: '配置可复用提示模块', icon: }, { key: 'tools', title: '内置工具', description: '查看 AI 可调用的数据探针', icon: }, { key: 'prompts', title: '内置提示词', description: '查看系统预设的底层要求', icon: }, ].map((item) => { @@ -815,8 +1265,10 @@ const AISettingsModal: React.FC = ({ 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()}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index dad0db1..f94f734 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -28,6 +28,14 @@ if (typeof window !== 'undefined' && !(window as any).go) { const mockProviders: any[] = []; const mockProviderSecrets = new Map(); 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', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2dcebfe..dfd0968 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; + enabled: boolean; + timeoutSeconds: number; +} + +export interface AIMCPToolDescriptor { + alias: string; + serverId: string; + serverName: string; + originalName: string; + title?: string; + description?: string; + inputSchema?: Record; +} + +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; diff --git a/frontend/src/utils/aiToolRegistry.ts b/frontend/src/utils/aiToolRegistry.ts new file mode 100644 index 0000000..5e36e1c --- /dev/null +++ b/frontend/src/utils/aiToolRegistry.ts @@ -0,0 +1,185 @@ +import type { AIMCPToolDescriptor } from "../types"; + +export interface AIChatToolDefinition { + type: "function"; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +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( + 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)]; diff --git a/frontend/wailsjs/go/aiservice/Service.d.ts b/frontend/wailsjs/go/aiservice/Service.d.ts index 4b6342c..93450a1 100755 --- a/frontend/wailsjs/go/aiservice/Service.d.ts +++ b/frontend/wailsjs/go/aiservice/Service.d.ts @@ -2,6 +2,8 @@ // This file is automatically generated. DO NOT EDIT import {ai} from '../models'; +export function AICallMCPTool(arg1:string,arg2:string):Promise; + export function AIChatCancel(arg1:string):Promise; export function AIChatSend(arg1:Array,arg2:Array):Promise>; @@ -10,10 +12,14 @@ export function AIChatStream(arg1:string,arg2:Array,arg3:Array; +export function AIDeleteMCPServer(arg1:string):Promise; + export function AIDeleteProvider(arg1:string):Promise; export function AIDeleteSession(arg1:string):Promise; +export function AIDeleteSkill(arg1:string):Promise; + export function AIGetActiveProvider():Promise; export function AIGetBuiltinPrompts():Promise>; @@ -22,24 +28,40 @@ export function AIGetContextLevel():Promise; export function AIGetEditableProvider(arg1:string):Promise; +export function AIGetMCPServers():Promise>; + export function AIGetProviders():Promise>; export function AIGetSafetyLevel():Promise; export function AIGetSessions():Promise>>; +export function AIGetSkills():Promise>; + +export function AIGetUserPromptSettings():Promise; + +export function AIListMCPTools():Promise>; + export function AIListModels():Promise>; export function AILoadSession(arg1:string):Promise>; +export function AISaveMCPServer(arg1:ai.MCPServerConfig):Promise; + export function AISaveProvider(arg1:ai.ProviderConfig):Promise; export function AISaveSession(arg1:string,arg2:string,arg3:number,arg4:string):Promise; +export function AISaveSkill(arg1:ai.SkillConfig):Promise; + +export function AISaveUserPromptSettings(arg1:ai.UserPromptSettings):Promise; + export function AISetActiveProvider(arg1:string):Promise; export function AISetContextLevel(arg1:string):Promise; export function AISetSafetyLevel(arg1:string):Promise; +export function AITestMCPServer(arg1:ai.MCPServerConfig):Promise>; + export function AITestProvider(arg1:ai.ProviderConfig):Promise>; diff --git a/frontend/wailsjs/go/aiservice/Service.js b/frontend/wailsjs/go/aiservice/Service.js index c8e0dce..0ee39dc 100755 --- a/frontend/wailsjs/go/aiservice/Service.js +++ b/frontend/wailsjs/go/aiservice/Service.js @@ -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); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index b76630b..4d5126e 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,5 +1,81 @@ export namespace ai { + export class MCPServerConfig { + id: string; + name: string; + transport: string; + command: string; + args?: string[]; + env?: Record; + 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; + + 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"]; + } + } } diff --git a/go.mod b/go.mod index 62fdf54..bd99ab9 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index b15bbc0..64b9d88 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/ai/service/config_store.go b/internal/ai/service/config_store.go index 6f43a41..1118c46 100644 --- a/internal/ai/service/config_store.go +++ b/internal/ai/service/config_store.go @@ -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 { diff --git a/internal/ai/service/config_store_test.go b/internal/ai/service/config_store_test.go index df9b6a6..4ac9688 100644 --- a/internal/ai/service/config_store_test.go +++ b/internal/ai/service/config_store_test.go @@ -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)) + } +} diff --git a/internal/ai/service/extensions_service.go b/internal/ai/service/extensions_service.go new file mode 100644 index 0000000..9e02264 --- /dev/null +++ b/internal/ai/service/extensions_service.go @@ -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) +} diff --git a/internal/ai/service/service.go b/internal/ai/service/service.go index 599eedf..558888d 100644 --- a/internal/ai/service/service.go +++ b/internal/ai/service/service.go @@ -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 结构 diff --git a/internal/ai/types.go b/internal/ai/types.go index 9a58684..0783a77 100644 --- a/internal/ai/types.go +++ b/internal/ai/types.go @@ -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