mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-21 14:04:01 +08:00
✨ feat(ai): 增加用户提示词与 MCP/Skills 扩展能力
- 支持用户级自定义提示词加载保存与聊天会话注入 - 新增 MCP 服务配置、工具发现与按次调用链路 - 增加 Skills 配置、作用域注入与前后端类型同步 - 补充配置存储与前端行为测试并更新依赖
This commit is contained in:
106
AI_EXTENSIONS_ROADMAP.md
Normal file
106
AI_EXTENSIONS_ROADMAP.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# AI 扩展能力路线
|
||||
|
||||
当前 GoNavi 的 AI 链路是:
|
||||
|
||||
1. 前端 `AIChatPanel` 组装 system messages。
|
||||
2. 前端声明本地固定工具 `LOCAL_TOOLS`。
|
||||
3. 后端 `aiservice.Service` 只负责 Provider 配置、安全级别与模型转发。
|
||||
|
||||
这套结构已经足够承接“用户级提示词”,但要继续承接 MCP 和 Skills,需要先把“提示词 / 工具 / 技能”三层职责拆开。
|
||||
|
||||
## 1. 用户级自定义提示词
|
||||
|
||||
已落地的方向:
|
||||
|
||||
- 配置存储在 `ai_config.json` 的 `userPromptSettings`。
|
||||
- 由 `AISettingsModal` 提供编辑入口。
|
||||
- 由 `AIChatPanel` 在运行时追加为 system message。
|
||||
|
||||
建议长期保持 4 个层级:
|
||||
|
||||
- `global`: 所有 AI 会话统一追加。
|
||||
- `database`: 数据库 / SQL 场景追加。
|
||||
- `jvm`: JVM 资源浏览与分析场景追加。
|
||||
- `jvmDiagnostic`: JVM 诊断命令规划场景追加。
|
||||
|
||||
这样既能满足“个人习惯”定制,也不会把所有场景揉成一条超长总提示词。
|
||||
|
||||
## 2. MCP 能力开放
|
||||
|
||||
目标不是把 MCP 做成新的聊天面板,而是把它变成“外部工具源”。
|
||||
|
||||
建议后续拆成三层:
|
||||
|
||||
1. `tool registry`
|
||||
- 统一收口内置工具、本地扩展工具、MCP 工具。
|
||||
- 对模型只暴露统一的 `tools[]`。
|
||||
2. `mcp server config`
|
||||
- 保存 server 名称、transport、启动命令或 URL、超时、启用状态。
|
||||
- 由后端维护生命周期与连通性。
|
||||
3. `mcp runtime bridge`
|
||||
- 负责 `list tools / call tool / errors / timeout / auth`。
|
||||
|
||||
### MCP 是否需要单独 GitHub 仓库
|
||||
|
||||
不需要把“GoNavi 对 MCP 的支持”单独拆仓库。
|
||||
|
||||
更合理的边界是:
|
||||
|
||||
- `GoNavi 主仓库`
|
||||
- 维护 MCP client、配置、UI、工具注册和运行时桥接。
|
||||
- `单独仓库(可选)`
|
||||
- 只在你要发布一个可复用的 MCP Server 时才有价值。
|
||||
- 例如 `gonavi-mcp-sql-tools`、`gonavi-mcp-jvm-agent` 这类独立 server。
|
||||
|
||||
结论:
|
||||
|
||||
- “客户端支持 MCP” 不需要新仓库。
|
||||
- “某个独立 MCP Server” 是否拆仓库,取决于它要不要单独发布、复用或部署。
|
||||
|
||||
## 3. Skills 设计
|
||||
|
||||
Skills 不建议直接等同于“另一种提示词”。
|
||||
|
||||
更合适的定义是:
|
||||
|
||||
- `skill manifest`
|
||||
- 名称、说明、适用场景、是否默认启用。
|
||||
- `skill prompt`
|
||||
- 该技能追加的 system prompt / few-shot / 输出约束。
|
||||
- `skill tool requirements`
|
||||
- 该技能依赖哪些内置工具或 MCP 工具。
|
||||
- `skill shortcuts`
|
||||
- 可选地给欢迎卡片、斜杠命令或快速动作提供入口。
|
||||
|
||||
一个 Skill 本质上应该是“提示词 + 工具依赖 + 使用入口”的组合,而不是单独一段文案。
|
||||
|
||||
### Skills 是否需要单独 GitHub 仓库
|
||||
|
||||
第一阶段不需要。
|
||||
|
||||
建议顺序:
|
||||
|
||||
1. 先在 GoNavi 主仓库内把 Skills manifest/runtime 跑通。
|
||||
2. 等格式稳定后,再考虑增加“本地目录导入”或“Git 仓库导入”。
|
||||
|
||||
只有当你明确要做下面两件事时,独立仓库才值得:
|
||||
|
||||
- 把 Skills 当作社区共享资产分发。
|
||||
- 让不同团队独立维护自己的 skill pack。
|
||||
|
||||
## 建议的下一步实现顺序
|
||||
|
||||
1. 抽出统一 `ToolRegistry`,让 `LOCAL_TOOLS` 不再硬编码在聊天面板内部。
|
||||
2. 在 AI 设置中新增 `MCP Servers` 配置页。
|
||||
3. 后端先支持最小 transport:
|
||||
- `stdio`
|
||||
- `http/sse`(如果后续确认需要)
|
||||
4. 在 AI 设置中新增 `Skills` 配置页。
|
||||
5. 让 Skill 以 manifest 形式声明:
|
||||
- `id`
|
||||
- `name`
|
||||
- `description`
|
||||
- `systemPrompt`
|
||||
- `requiredTools`
|
||||
- `scopes`
|
||||
6. 再决定是否增加“从 Git 仓库同步 MCP/Skills 包”的分发能力。
|
||||
@@ -12,4 +12,19 @@ describe('AIChatPanel message render isolation', () => {
|
||||
expect(source).toContain('<AIMessageRenderBoundary');
|
||||
expect(source).toContain('onDeleteMessage={handleDeleteMessage}');
|
||||
});
|
||||
|
||||
it('loads user prompt settings and appends them as system messages', () => {
|
||||
expect(source).toContain('AIGetUserPromptSettings');
|
||||
expect(source).toContain("window.addEventListener('gonavi:ai:config-changed'");
|
||||
expect(source).toContain('以下是当前用户的自定义补充提示词');
|
||||
expect(source).toContain("appendCustomPromptGroup(['database'])");
|
||||
});
|
||||
|
||||
it('loads MCP tools and skills into the runtime tool chain', () => {
|
||||
expect(source).toContain('AIListMCPTools');
|
||||
expect(source).toContain('AIGetSkills');
|
||||
expect(source).toContain('AICallMCPTool');
|
||||
expect(source).toContain('以下是当前启用的 Skill');
|
||||
expect(source).toContain('buildAvailableAIChatTools');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,9 @@ import { DBGetDatabases, DBGetTables } from '../../wailsjs/go/app/App';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import type {
|
||||
AIChatMessage,
|
||||
AIMCPToolDescriptor,
|
||||
AISkillConfig,
|
||||
AIUserPromptSettings,
|
||||
AIToolCall,
|
||||
JVMAIPlanContext,
|
||||
JVMDiagnosticPlanContext,
|
||||
@@ -31,6 +34,7 @@ import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut'
|
||||
import { toAIRequestMessage } from '../utils/aiMessagePayload';
|
||||
import { getShortcutPlatform, resolveShortcutBinding } from '../utils/shortcuts';
|
||||
import { isMacLikePlatform } from '../utils/appearance';
|
||||
import { buildAvailableAIChatTools } from '../utils/aiToolRegistry';
|
||||
|
||||
interface AIChatPanelProps {
|
||||
width?: number;
|
||||
@@ -233,93 +237,12 @@ const sanitizeErrorMsg = (raw: string): string => {
|
||||
return raw;
|
||||
};
|
||||
|
||||
const LOCAL_TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_connections',
|
||||
description: '当需要查询、操作数据库但用户没有选择任何连接上下文时,获取当前软件中可用的所有数据库连接信息。返回的数据包含连接ID(id)和名称(name)。',
|
||||
parameters: { type: 'object', properties: {} }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_databases',
|
||||
description: '获取指定连接(connectionId)下的所有数据库(Database/Schema)名。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
connectionId: { type: 'string', description: '连接ID (从 get_connections 获取)' }
|
||||
},
|
||||
required: ['connectionId']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_tables',
|
||||
description: '当已经确定了目标连接和数据库名后,如果用户询问或隐式提到了表但你不知道确切表名,调用此工具获取该数据库下的所有表名列表(只含表名,帮助你推断目标表)。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
connectionId: { type: 'string', description: '连接ID' },
|
||||
dbName: { type: 'string', description: '数据库名' },
|
||||
},
|
||||
required: ['connectionId', 'dbName']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_columns',
|
||||
description: '获取指定表的字段列表(字段名、类型、是否可空、默认值、注释等)。在生成 SQL 之前必须先调用此工具确认真实字段名,禁止猜测字段名。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
connectionId: { type: 'string', description: '连接ID' },
|
||||
dbName: { type: 'string', description: '数据库名' },
|
||||
tableName: { type: 'string', description: '表名' },
|
||||
},
|
||||
required: ['connectionId', 'dbName', 'tableName']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_table_ddl',
|
||||
description: '获取指定表的完整建表语句(CREATE TABLE DDL),包含字段、索引、约束等完整结构信息。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
connectionId: { type: 'string', description: '连接ID' },
|
||||
dbName: { type: 'string', description: '数据库名' },
|
||||
tableName: { type: 'string', description: '表名' },
|
||||
},
|
||||
required: ['connectionId', 'dbName', 'tableName']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'execute_sql',
|
||||
description: '在指定连接和数据库上执行 SQL 查询并返回结果。受安全级别控制,只读模式下只能执行 SELECT/SHOW/DESCRIBE 等查询操作。结果最多返回 50 行。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
connectionId: { type: 'string', description: '连接ID' },
|
||||
dbName: { type: 'string', description: '数据库名' },
|
||||
sql: { type: 'string', description: '要执行的 SQL 语句' },
|
||||
},
|
||||
required: ['connectionId', 'dbName', 'sql']
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
const EMPTY_AI_USER_PROMPT_SETTINGS: AIUserPromptSettings = {
|
||||
global: '',
|
||||
database: '',
|
||||
jvm: '',
|
||||
jvmDiagnostic: '',
|
||||
};
|
||||
|
||||
export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme
|
||||
@@ -328,6 +251,9 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const [draftImages, setDraftImages] = useState<string[]>([]);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [activeProvider, setActiveProvider] = useState<any>(null);
|
||||
const [userPromptSettings, setUserPromptSettings] = useState<AIUserPromptSettings>(EMPTY_AI_USER_PROMPT_SETTINGS);
|
||||
const [mcpTools, setMcpTools] = useState<AIMCPToolDescriptor[]>([]);
|
||||
const [skills, setSkills] = useState<AISkillConfig[]>([]);
|
||||
const [dynamicModels, setDynamicModels] = useState<string[]>([]);
|
||||
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
@@ -375,6 +301,10 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const aiPanelVisible = useStore(state => state.aiPanelVisible);
|
||||
const isV2Ui = appearance.uiVersion === 'v2';
|
||||
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
|
||||
const availableTools = useMemo(
|
||||
() => buildAvailableAIChatTools(mcpTools),
|
||||
[mcpTools],
|
||||
);
|
||||
const aiChatSendShortcutBinding = useStore(state => resolveShortcutBinding(
|
||||
state.shortcutOptions,
|
||||
'sendAIChatMessage',
|
||||
@@ -519,6 +449,69 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
|
||||
useEffect(() => { loadActiveProvider(); }, [loadActiveProvider]);
|
||||
|
||||
const loadUserPromptSettings = useCallback(async () => {
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (!Service?.AIGetUserPromptSettings) {
|
||||
setUserPromptSettings(EMPTY_AI_USER_PROMPT_SETTINGS);
|
||||
return;
|
||||
}
|
||||
const nextSettings = await Service.AIGetUserPromptSettings();
|
||||
setUserPromptSettings({
|
||||
...EMPTY_AI_USER_PROMPT_SETTINGS,
|
||||
...nextSettings,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to load user prompt settings', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMCPTools = useCallback(async () => {
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (!Service?.AIListMCPTools) {
|
||||
setMcpTools([]);
|
||||
return;
|
||||
}
|
||||
const nextTools = await Service.AIListMCPTools();
|
||||
setMcpTools(Array.isArray(nextTools) ? nextTools : []);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load MCP tools', e);
|
||||
setMcpTools([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSkills = useCallback(async () => {
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (!Service?.AIGetSkills) {
|
||||
setSkills([]);
|
||||
return;
|
||||
}
|
||||
const nextSkills = await Service.AIGetSkills();
|
||||
setSkills(Array.isArray(nextSkills) ? nextSkills : []);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load skills', e);
|
||||
setSkills([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUserPromptSettings();
|
||||
void loadMCPTools();
|
||||
void loadSkills();
|
||||
const handleAIConfigChanged = () => {
|
||||
void loadUserPromptSettings();
|
||||
void loadMCPTools();
|
||||
void loadSkills();
|
||||
void loadActiveProvider();
|
||||
};
|
||||
window.addEventListener('gonavi:ai:config-changed', handleAIConfigChanged as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('gonavi:ai:config-changed', handleAIConfigChanged as EventListener);
|
||||
};
|
||||
}, [loadActiveProvider, loadMCPTools, loadSkills, loadUserPromptSettings]);
|
||||
|
||||
// 监听供应商配置变更(来自设置面板的删除/新增/切换操作),重新加载 active provider 并清空已缓存的模型
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
@@ -817,7 +810,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
messagesPayload.push({ role: 'user', content: '请直接使用 function call 调用工具执行操作,不要只用文字描述计划。' });
|
||||
const allMsg = [...sysMessages, ...messagesPayload];
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (Service?.AIChatStream) await Service.AIChatStream(sid, allMsg, LOCAL_TOOLS);
|
||||
if (Service?.AIChatStream) await Service.AIChatStream(sid, allMsg, availableTools);
|
||||
} catch (e) {
|
||||
console.error('Nudge failed', e);
|
||||
setSending(false);
|
||||
@@ -942,9 +935,9 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (Service?.AIChatStream) {
|
||||
await Service.AIChatStream(sid, allMessages, LOCAL_TOOLS);
|
||||
await Service.AIChatStream(sid, allMessages, availableTools);
|
||||
} else if (Service?.AIChatSend) {
|
||||
const result = await Service.AIChatSend(allMessages, LOCAL_TOOLS);
|
||||
const result = await Service.AIChatSend(allMessages, availableTools);
|
||||
const errRaw = result?.error || '未知错误';
|
||||
const errClean = sanitizeErrorMsg(errRaw);
|
||||
addAIChatMessage(sid, {
|
||||
@@ -994,6 +987,57 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const connectionKey = ctx?.connectionId ? `${ctx.connectionId}:${ctx.dbName || ''}` : 'default';
|
||||
const activeContextItems = ctxMap[connectionKey] || [];
|
||||
const systemMessages: { role: string; content: string; images?: string[] }[] = [];
|
||||
const appendCustomPrompt = (label: string, content: string) => {
|
||||
const trimmed = String(content || '').trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
systemMessages.push({
|
||||
role: 'system',
|
||||
content: `以下是当前用户的自定义补充提示词(${label})。在不违反安全规则和事实约束的前提下,请优先遵循:\n${trimmed}`,
|
||||
});
|
||||
};
|
||||
const appendCustomPromptGroup = (prompts: string[]) => {
|
||||
appendCustomPrompt('全局', userPromptSettings.global);
|
||||
prompts.forEach((prompt) => {
|
||||
if (prompt === 'database') {
|
||||
appendCustomPrompt('数据库会话', userPromptSettings.database);
|
||||
} else if (prompt === 'jvm') {
|
||||
appendCustomPrompt('JVM 资源分析', userPromptSettings.jvm);
|
||||
} else if (prompt === 'jvmDiagnostic') {
|
||||
appendCustomPrompt('JVM 诊断', userPromptSettings.jvmDiagnostic);
|
||||
}
|
||||
});
|
||||
};
|
||||
const availableToolNameSet = new Set(availableTools.map((tool) => tool.function.name));
|
||||
const appendSkillPromptGroup = (scopes: string[]) => {
|
||||
const wantedScopes = new Set<string>(['global', ...scopes]);
|
||||
skills.forEach((skill) => {
|
||||
if (!skill?.enabled) {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(skill.scopes) || !skill.scopes.some((scope) => wantedScopes.has(scope))) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(skill.requiredTools) && skill.requiredTools.length > 0) {
|
||||
const hasAllRequiredTools = skill.requiredTools.every((toolName) => availableToolNameSet.has(toolName));
|
||||
if (!hasAllRequiredTools) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const promptText = String(skill.systemPrompt || '').trim();
|
||||
if (!promptText) {
|
||||
return;
|
||||
}
|
||||
const requiredToolText = Array.isArray(skill.requiredTools) && skill.requiredTools.length > 0
|
||||
? `\n依赖工具:${skill.requiredTools.join(', ')}`
|
||||
: '';
|
||||
systemMessages.push({
|
||||
role: 'system',
|
||||
content: `以下是当前启用的 Skill「${skill.name}」${skill.description ? `(${skill.description})` : ''}。请在本次回答中遵循它的约束和工作方式:${requiredToolText}\n${promptText}`,
|
||||
});
|
||||
});
|
||||
};
|
||||
const matchesDiagnosticContext = (tab: typeof allTabs[number]) => {
|
||||
if (!overrideJVMDiagnosticPlanContext || tab.type !== 'jvm-diagnostic') {
|
||||
return false;
|
||||
@@ -1055,6 +1099,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
6. expectedSignals 必须是字符串数组,描述执行后需要重点观察的信号。
|
||||
7. 如果命令权限不允许某类操作,就不要输出该类命令;无法满足时直接说明限制。`,
|
||||
});
|
||||
appendCustomPromptGroup(['jvmDiagnostic']);
|
||||
appendSkillPromptGroup(['jvmDiagnostic']);
|
||||
return systemMessages;
|
||||
}
|
||||
|
||||
@@ -1086,6 +1132,8 @@ ${resourcePath ? `当前资源路径:${resourcePath}` : '当前未选中具体
|
||||
5. payload 只能使用 {"format":"json","value":{...}} 或 {"format":"text","value":"..."} 这两种包装形式,不要输出脚本、命令或裸值。
|
||||
6. 不要输出脚本、命令或“已经执行成功”之类的表述。`
|
||||
});
|
||||
appendCustomPromptGroup(['jvm']);
|
||||
appendSkillPromptGroup(['jvm']);
|
||||
return systemMessages;
|
||||
}
|
||||
|
||||
@@ -1149,8 +1197,10 @@ SELECT * FROM users WHERE status = 1;
|
||||
当前存在的连接:[${connList || '无连接'}]`
|
||||
});
|
||||
}
|
||||
appendCustomPromptGroup(['database']);
|
||||
appendSkillPromptGroup(['database']);
|
||||
return systemMessages;
|
||||
}, []); // 零依赖:函数内部通过 useStore.getState() 实时读取
|
||||
}, [availableTools, skills, userPromptSettings]);
|
||||
|
||||
// 记录所有成功的 get_tables 调用结果,用于表级精确匹配
|
||||
const toolContextMapRef = useRef<Map<string, { connectionId: string; dbName: string; tables: string[] }>>(new Map());
|
||||
@@ -1180,12 +1230,14 @@ SELECT * FROM users WHERE status = 1;
|
||||
}
|
||||
|
||||
const results: AIChatMessage[] = [];
|
||||
const mcpToolMap = new Map(mcpTools.map((tool) => [tool.alias, tool]));
|
||||
// 【串行逐条执行 + 实时写入 store】
|
||||
for (const tc of toolCalls) {
|
||||
let resStr = '';
|
||||
let success = false;
|
||||
try {
|
||||
const args = JSON.parse(tc.function.arguments || '{}');
|
||||
const mcpToolDescriptor = mcpToolMap.get(tc.function.name);
|
||||
switch (tc.function.name) {
|
||||
case 'get_connections':
|
||||
const conns = useStore.getState().connections.map(c => ({
|
||||
@@ -1324,19 +1376,31 @@ SELECT * FROM users WHERE status = 1;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
resStr = `Unknown function: ${tc.function.name}`;
|
||||
if (mcpToolDescriptor) {
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
const toolResult = await Service?.AICallMCPTool?.(tc.function.name, tc.function.arguments || '{}');
|
||||
resStr = String(toolResult?.content || (toolResult?.isError ? 'MCP 工具调用失败' : ''));
|
||||
success = !!toolResult && !toolResult.isError;
|
||||
} catch (e: any) {
|
||||
resStr = `MCP 工具调用失败: ${e?.message || e}`;
|
||||
}
|
||||
} else {
|
||||
resStr = `Unknown function: ${tc.function.name}`;
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
resStr = e.message;
|
||||
}
|
||||
|
||||
const resolvedToolDescriptor = mcpToolMap.get(tc.function.name);
|
||||
const toolResultMsg: AIChatMessage = {
|
||||
id: genId(),
|
||||
role: 'tool',
|
||||
content: resStr,
|
||||
timestamp: Date.now(),
|
||||
tool_call_id: tc.id,
|
||||
tool_name: tc.function.name,
|
||||
tool_name: resolvedToolDescriptor?.title || resolvedToolDescriptor?.originalName || tc.function.name,
|
||||
success
|
||||
};
|
||||
results.push(toolResultMsg);
|
||||
@@ -1425,7 +1489,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
|
||||
// 【软收敛】超过 10 轮工具调用后,不再传递 tools 参数,从物理层面强制模型只能用文本回答
|
||||
const SOFT_LIMIT_ROUNDS = 10;
|
||||
const chainTools = totalToolRoundRef.current >= SOFT_LIMIT_ROUNDS ? [] : LOCAL_TOOLS;
|
||||
const chainTools = totalToolRoundRef.current >= SOFT_LIMIT_ROUNDS ? [] : availableTools;
|
||||
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (Service?.AIChatStream) {
|
||||
@@ -1450,7 +1514,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
console.error('Failed to chain tool call', e);
|
||||
setSending(false);
|
||||
}
|
||||
}, [sid, buildSystemContextMessages]);
|
||||
}, [availableTools, buildSystemContextMessages, mcpTools, sid]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
@@ -1534,9 +1598,9 @@ SELECT * FROM users WHERE status = 1;
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (Service?.AIChatStream) {
|
||||
await Service.AIChatStream(sid, allMessages, LOCAL_TOOLS);
|
||||
await Service.AIChatStream(sid, allMessages, availableTools);
|
||||
} else if (Service?.AIChatSend) {
|
||||
const result = await Service.AIChatSend(allMessages, LOCAL_TOOLS);
|
||||
const result = await Service.AIChatSend(allMessages, availableTools);
|
||||
const errR2 = result?.error || '未知错误';
|
||||
const errC2 = sanitizeErrorMsg(errR2);
|
||||
const assistantMsg: AIChatMessage = {
|
||||
@@ -1589,6 +1653,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
addAIChatMessage,
|
||||
sid,
|
||||
activeProvider,
|
||||
availableTools,
|
||||
buildSystemContextMessages,
|
||||
getCurrentJVMPlanContext,
|
||||
getCurrentJVMDiagnosticPlanContext,
|
||||
|
||||
@@ -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,');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons';
|
||||
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types';
|
||||
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AISkillConfig, AISkillScope } from '../types';
|
||||
import {
|
||||
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
|
||||
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
|
||||
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry';
|
||||
interface AISettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -78,17 +79,77 @@ const CONTEXT_OPTIONS: { label: string; value: AIContextLevel; desc: string; ico
|
||||
{ label: '含查询结果', value: 'with_results', desc: '传递最近的查询结果作为上下文', icon: '📑' },
|
||||
];
|
||||
|
||||
const EMPTY_AI_USER_PROMPT_SETTINGS: AIUserPromptSettings = {
|
||||
global: '',
|
||||
database: '',
|
||||
jvm: '',
|
||||
jvmDiagnostic: '',
|
||||
};
|
||||
|
||||
const EMPTY_MCP_SERVER = (): AIMCPServerConfig => ({
|
||||
id: `mcp-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: '',
|
||||
transport: 'stdio',
|
||||
command: '',
|
||||
args: [],
|
||||
env: {},
|
||||
enabled: true,
|
||||
timeoutSeconds: 20,
|
||||
});
|
||||
|
||||
const EMPTY_SKILL = (): AISkillConfig => ({
|
||||
id: `skill-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: '',
|
||||
description: '',
|
||||
systemPrompt: '',
|
||||
enabled: true,
|
||||
scopes: ['global'],
|
||||
requiredTools: [],
|
||||
});
|
||||
|
||||
const SKILL_SCOPE_OPTIONS: Array<{ value: AISkillScope; label: string; desc: string }> = [
|
||||
{ value: 'global', label: '全局', desc: '所有 AI 会话都启用' },
|
||||
{ value: 'database', label: '数据库', desc: '仅 SQL / 数据库场景启用' },
|
||||
{ value: 'jvm', label: 'JVM 资源', desc: '仅 JVM 资源分析场景启用' },
|
||||
{ value: 'jvmDiagnostic', label: 'JVM 诊断', desc: '仅 JVM 诊断工作台启用' },
|
||||
];
|
||||
|
||||
const parseMCPEnvText = (text: string): Record<string, string> => {
|
||||
const result: Record<string, string> = {};
|
||||
String(text || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
const index = line.indexOf('=');
|
||||
if (index <= 0) return;
|
||||
const key = line.slice(0, index).trim();
|
||||
if (!key) return;
|
||||
result[key] = line.slice(index + 1);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const stringifyMCPEnv = (env?: Record<string, string>): string =>
|
||||
Object.entries(env || {})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n');
|
||||
|
||||
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
|
||||
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
|
||||
const [activeProviderId, setActiveProviderId] = useState<string>('');
|
||||
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
|
||||
const [contextLevel, setContextLevel] = useState<AIContextLevel>('schema_only');
|
||||
const [mcpServers, setMCPServers] = useState<AIMCPServerConfig[]>([]);
|
||||
const [mcpTools, setMCPTools] = useState<AIMCPToolDescriptor[]>([]);
|
||||
const [skills, setSkills] = useState<AISkillConfig[]>([]);
|
||||
const [editingProvider, setEditingProvider] = useState<AIProviderConfig | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [builtinPrompts, setBuiltinPrompts] = useState<Record<string, string>>({});
|
||||
const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'prompts' | 'tools'>('providers');
|
||||
const [userPromptSettings, setUserPromptSettings] = useState<AIUserPromptSettings>(EMPTY_AI_USER_PROMPT_SETTINGS);
|
||||
const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'mcp' | 'skills' | 'prompts' | 'tools'>('providers');
|
||||
const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const modalBodyRef = useRef<HTMLDivElement>(null);
|
||||
@@ -107,16 +168,41 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
const watchedType = Form.useWatch('type', form);
|
||||
const watchedPresetKey = Form.useWatch('presetKey', form);
|
||||
const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai';
|
||||
const skillRequiredToolOptions = useMemo(() => ([
|
||||
...BUILTIN_AI_TOOL_INFO.map((tool) => ({
|
||||
label: `${tool.name} · 内置工具`,
|
||||
value: tool.name,
|
||||
})),
|
||||
...mcpTools.map((tool) => ({
|
||||
label: `${tool.alias} · ${tool.serverName}`,
|
||||
value: tool.alias,
|
||||
})),
|
||||
]), [mcpTools]);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (!Service) { console.warn('[AI] Service not found on window.go'); return; }
|
||||
const [provRes, safeRes, ctxRes, promptsRes] = await Promise.all([
|
||||
Service.AIGetProviders?.() || [],
|
||||
Service.AIGetSafetyLevel?.() || 'readonly',
|
||||
Service.AIGetContextLevel?.() || 'schema_only',
|
||||
Service.AIGetBuiltinPrompts?.() || {},
|
||||
const callOrFallback = async <T,>(loader: (() => Promise<T>) | undefined, fallback: T): Promise<T> => {
|
||||
if (typeof loader !== 'function') {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return await loader();
|
||||
} catch (error) {
|
||||
console.warn('[AI] settings load fallback', error);
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
const [provRes, safeRes, ctxRes, promptsRes, userPromptsRes, mcpServersRes, mcpToolsRes, skillsRes] = await Promise.all([
|
||||
callOrFallback(() => Service.AIGetProviders?.(), []),
|
||||
callOrFallback<AISafetyLevel>(() => Service.AIGetSafetyLevel?.(), 'readonly'),
|
||||
callOrFallback<AIContextLevel>(() => Service.AIGetContextLevel?.(), 'schema_only'),
|
||||
callOrFallback(() => Service.AIGetBuiltinPrompts?.(), {}),
|
||||
callOrFallback(() => Service.AIGetUserPromptSettings?.(), EMPTY_AI_USER_PROMPT_SETTINGS),
|
||||
callOrFallback(() => Service.AIGetMCPServers?.(), []),
|
||||
callOrFallback(() => Service.AIListMCPTools?.(), []),
|
||||
callOrFallback(() => Service.AIGetSkills?.(), []),
|
||||
]);
|
||||
console.log('[AI] AIGetProviders result:', JSON.stringify(provRes), 'isArray:', Array.isArray(provRes));
|
||||
if (Array.isArray(provRes)) {
|
||||
@@ -128,6 +214,15 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
if (safeRes) setSafetyLevel(safeRes);
|
||||
if (ctxRes) setContextLevel(ctxRes);
|
||||
if (promptsRes) setBuiltinPrompts(promptsRes);
|
||||
if (userPromptsRes) {
|
||||
setUserPromptSettings({
|
||||
...EMPTY_AI_USER_PROMPT_SETTINGS,
|
||||
...userPromptsRes,
|
||||
});
|
||||
}
|
||||
if (Array.isArray(mcpServersRes)) setMCPServers(mcpServersRes);
|
||||
if (Array.isArray(mcpToolsRes)) setMCPTools(mcpToolsRes);
|
||||
if (Array.isArray(skillsRes)) setSkills(skillsRes);
|
||||
} catch (e) { console.warn('Failed to load AI config', e); }
|
||||
}, []);
|
||||
|
||||
@@ -310,6 +405,134 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleSaveUserPromptSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
const payload = {
|
||||
global: String(userPromptSettings.global || ''),
|
||||
database: String(userPromptSettings.database || ''),
|
||||
jvm: String(userPromptSettings.jvm || ''),
|
||||
jvmDiagnostic: String(userPromptSettings.jvmDiagnostic || ''),
|
||||
};
|
||||
await Service?.AISaveUserPromptSettings?.(payload);
|
||||
setUserPromptSettings(payload);
|
||||
void messageApi.success('自定义提示词已保存');
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '保存自定义提示词失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMCPServerDraft = (id: string, patch: Partial<AIMCPServerConfig>) => {
|
||||
setMCPServers((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item));
|
||||
};
|
||||
|
||||
const handleAddMCPServer = () => {
|
||||
setMCPServers((prev) => [...prev, EMPTY_MCP_SERVER()]);
|
||||
};
|
||||
|
||||
const handleSaveMCPServer = async (server: AIMCPServerConfig) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
await Service?.AISaveMCPServer?.(server);
|
||||
await loadConfig();
|
||||
void messageApi.success('MCP 服务已保存');
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '保存 MCP 服务失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMCPServer = async (id: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (typeof Service?.AIDeleteMCPServer === 'function' && !String(id).startsWith('mcp-draft-')) {
|
||||
await Service.AIDeleteMCPServer(id);
|
||||
await loadConfig();
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||||
} else {
|
||||
setMCPServers((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
void messageApi.success('MCP 服务已删除');
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '删除 MCP 服务失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestMCPServer = async (server: AIMCPServerConfig) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
const res = await Service?.AITestMCPServer?.(server);
|
||||
if (res?.success) {
|
||||
void messageApi.success(res?.message || 'MCP 服务连接成功');
|
||||
if (typeof Service?.AIListMCPTools === 'function') {
|
||||
const nextTools = await Service.AIListMCPTools();
|
||||
if (Array.isArray(nextTools)) setMCPTools(nextTools);
|
||||
} else if (Array.isArray(res?.tools)) {
|
||||
setMCPTools(res.tools);
|
||||
}
|
||||
} else {
|
||||
void messageApi.error(res?.message || 'MCP 服务测试失败');
|
||||
}
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '测试 MCP 服务失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSkillDraft = (id: string, patch: Partial<AISkillConfig>) => {
|
||||
setSkills((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item));
|
||||
};
|
||||
|
||||
const handleAddSkill = () => {
|
||||
setSkills((prev) => [...prev, EMPTY_SKILL()]);
|
||||
};
|
||||
|
||||
const handleSaveSkill = async (skill: AISkillConfig) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
await Service?.AISaveSkill?.(skill);
|
||||
await loadConfig();
|
||||
void messageApi.success('Skill 已保存');
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '保存 Skill 失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSkill = async (id: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (typeof Service?.AIDeleteSkill === 'function' && !String(id).startsWith('skill-draft-')) {
|
||||
await Service.AIDeleteSkill(id);
|
||||
await loadConfig();
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||||
} else {
|
||||
setSkills((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
void messageApi.success('Skill 已删除');
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '删除 Skill 失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestProvider = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
@@ -660,10 +883,83 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderBuiltinPrompts = () => (
|
||||
const renderPromptSettings = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: 14,
|
||||
border: `1px solid ${cardBorder}`,
|
||||
background: cardBg,
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, marginBottom: 6 }}>
|
||||
用户级自定义提示词
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 14 }}>
|
||||
这里的内容会在系统内置提示词之后,以 system message 的形式追加注入。
|
||||
适合放你的个人风格偏好、输出约束、团队规范。涉及安全红线时,系统规则仍然优先。
|
||||
</div>
|
||||
|
||||
{[
|
||||
{
|
||||
key: 'global',
|
||||
title: '全局补充提示词',
|
||||
desc: '对所有 AI 会话生效,例如“先给结论”“回答保持简洁”。',
|
||||
rows: 4,
|
||||
},
|
||||
{
|
||||
key: 'database',
|
||||
title: '数据库会话补充提示词',
|
||||
desc: '仅数据库/SQL 场景生效,例如“生成 SQL 前必须先确认字段名”。',
|
||||
rows: 5,
|
||||
},
|
||||
{
|
||||
key: 'jvm',
|
||||
title: 'JVM 资源分析补充提示词',
|
||||
desc: '仅 JVM 资源浏览/分析场景生效。',
|
||||
rows: 4,
|
||||
},
|
||||
{
|
||||
key: 'jvmDiagnostic',
|
||||
title: 'JVM 诊断补充提示词',
|
||||
desc: '仅 JVM 诊断工作台生效,例如“先给计划,再给命令”。',
|
||||
rows: 4,
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.key} style={{ marginTop: 14 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: overlayTheme.titleText, marginBottom: 4 }}>
|
||||
{item.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
|
||||
{item.desc}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={item.rows}
|
||||
value={userPromptSettings[item.key as keyof AIUserPromptSettings]}
|
||||
onChange={(event) => setUserPromptSettings((prev) => ({
|
||||
...prev,
|
||||
[item.key]: event.target.value,
|
||||
}))}
|
||||
placeholder="留空表示不额外追加"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: inputBg,
|
||||
border: `1px solid ${cardBorder}`,
|
||||
fontFamily: 'var(--gn-font-mono)',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
||||
<Button type="primary" onClick={handleSaveUserPromptSettings} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}>
|
||||
保存自定义提示词
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
|
||||
以下为当前版本 GoNavi 预设的底层 AI 提示词(只读)。它们会被动态注入到对应场景的请求上下文中。
|
||||
以下为当前版本 GoNavi 预设的底层 AI 提示词(只读)。它们会先于上面的用户级提示词注入到对应场景的请求上下文中。
|
||||
</div>
|
||||
{Object.entries(builtinPrompts).map(([title, promptText]) => (
|
||||
<div key={title} style={{
|
||||
@@ -685,14 +981,166 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
</div>
|
||||
);
|
||||
|
||||
const BUILTIN_TOOLS_INFO = [
|
||||
{ name: 'get_connections', icon: '🔗', desc: '获取所有可用的数据库连接', detail: '返回连接 ID、名称、类型 (MySQL/PostgreSQL 等) 和 Host 地址。AI 根据返回信息决定优先探索哪个连接。', params: '无参数' },
|
||||
{ name: 'get_databases', icon: '🗄️', desc: '获取指定连接下的所有数据库', detail: '传入 connectionId,返回该连接下的数据库/Schema 名称列表。', params: 'connectionId: 连接 ID' },
|
||||
{ name: 'get_tables', icon: '📋', desc: '获取指定数据库下的所有表名', detail: '传入 connectionId 和 dbName,返回表名列表。AI 用它来定位用户提到的目标表。', params: 'connectionId, dbName' },
|
||||
{ name: 'get_columns', icon: '🔍', desc: '获取指定表的字段结构', detail: '传入 connectionId、dbName 和 tableName,返回每个字段的名称、类型、是否可空、默认值和注释。AI 在生成 SQL 前必须调用此工具确认真实字段名。', params: 'connectionId, dbName, tableName' },
|
||||
{ name: 'get_table_ddl', icon: '📝', desc: '获取表的建表语句 (DDL)', detail: '传入 connectionId、dbName 和 tableName,返回完整的 CREATE TABLE 语句,包含字段定义、索引、约束等信息。', params: 'connectionId, dbName, tableName' },
|
||||
{ name: 'execute_sql', icon: '▶️', desc: '执行 SQL 查询并返回结果', detail: '传入 connectionId、dbName 和 sql,在目标数据库上执行 SQL 并返回结果(最多 50 行)。受安全级别控制,只读模式下仅允许 SELECT/SHOW/DESCRIBE。', params: 'connectionId, dbName, sql' },
|
||||
];
|
||||
const renderMCPSettings = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
|
||||
MCP 会作为外部工具源接入 AI。当前阶段先支持 `stdio` 型服务,不需要为 GoNavi 的 MCP client 单独新建仓库;只有你准备发布独立的 MCP Server 时,才值得拆独立仓库。
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}>支持命令、参数、环境变量和超时,保存后会自动进入 AI 工具列表。</div>
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddMCPServer} style={{ borderRadius: 10 }}>新增 MCP 服务</Button>
|
||||
</div>
|
||||
{mcpServers.length === 0 && (
|
||||
<div style={{ padding: '18px 16px', borderRadius: 14, border: `1px dashed ${cardBorder}`, background: cardBg, color: overlayTheme.mutedText }}>
|
||||
还没有 MCP 服务。常见形式是 `node server.js`、`uvx some-mcp-server`、`python -m server`。
|
||||
</div>
|
||||
)}
|
||||
{mcpServers.map((server) => {
|
||||
const serverTools = mcpTools.filter((tool) => tool.serverId === server.id);
|
||||
return (
|
||||
<div key={server.id} style={{ padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 132px', gap: 12 }}>
|
||||
<Input
|
||||
value={server.name}
|
||||
onChange={(event) => updateMCPServerDraft(server.id, { name: event.target.value })}
|
||||
placeholder="服务名称,例如:Filesystem / Browser / GitHub"
|
||||
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
|
||||
/>
|
||||
<Select
|
||||
value={server.enabled ? 'enabled' : 'disabled'}
|
||||
onChange={(value) => updateMCPServerDraft(server.id, { enabled: value === 'enabled' })}
|
||||
options={[{ label: '已启用', value: 'enabled' }, { label: '已禁用', value: 'disabled' }]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '132px minmax(0,1fr) 132px', gap: 12 }}>
|
||||
<Select
|
||||
value={server.transport}
|
||||
onChange={(value) => updateMCPServerDraft(server.id, { transport: value as AIMCPServerConfig['transport'] })}
|
||||
options={[{ label: 'stdio', value: 'stdio' }]}
|
||||
/>
|
||||
<Input
|
||||
value={server.command}
|
||||
onChange={(event) => updateMCPServerDraft(server.id, { command: event.target.value })}
|
||||
placeholder="启动命令,例如:node / uvx / python"
|
||||
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={3}
|
||||
max={120}
|
||||
value={server.timeoutSeconds}
|
||||
onChange={(event) => updateMCPServerDraft(server.id, { timeoutSeconds: Number(event.target.value) || 20 })}
|
||||
placeholder="超时(秒)"
|
||||
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
mode="tags"
|
||||
value={server.args || []}
|
||||
onChange={(value) => updateMCPServerDraft(server.id, { args: value })}
|
||||
placeholder="命令参数,回车录入,例如:server.js、--stdio"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={stringifyMCPEnv(server.env)}
|
||||
onChange={(event) => updateMCPServerDraft(server.id, { env: parseMCPEnvText(event.target.value) })}
|
||||
placeholder={"环境变量,每行一个 KEY=VALUE,例如:\nOPENAI_API_KEY=...\nGITHUB_TOKEN=..."}
|
||||
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)' }}
|
||||
/>
|
||||
{serverTools.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: overlayTheme.titleText }}>已发现工具</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{serverTools.map((tool) => (
|
||||
<span key={tool.alias} style={{ padding: '4px 8px', borderRadius: 999, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', fontSize: 12, color: overlayTheme.mutedText }}>
|
||||
{tool.alias}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={() => handleTestMCPServer(server)} loading={loading} style={{ borderRadius: 10 }}>测试工具发现</Button>
|
||||
<Button type="primary" onClick={() => handleSaveMCPServer(server)} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}>保存</Button>
|
||||
<Popconfirm title="删除这个 MCP 服务?" okText="删除" cancelText="取消" onConfirm={() => handleDeleteMCPServer(server.id)}>
|
||||
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 10 }}>删除</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSkillSettings = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
|
||||
Skill 不是另一条大提示词,而是“命名的提示模块 + 作用域 + 工具依赖”。当前阶段仍建议保留在主仓库内,不需要单独新建 GitHub 仓库;只有未来要做共享 skill pack 分发时,再考虑拆仓。
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}>启用后会按 scope 注入对应会话;如果依赖的工具不存在,该 Skill 会被自动跳过。</div>
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddSkill} style={{ borderRadius: 10 }}>新增 Skill</Button>
|
||||
</div>
|
||||
{skills.length === 0 && (
|
||||
<div style={{ padding: '18px 16px', borderRadius: 14, border: `1px dashed ${cardBorder}`, background: cardBg, color: overlayTheme.mutedText }}>
|
||||
还没有 Skill。你可以给数据库、JVM、诊断场景分别定义专用的 system prompt。
|
||||
</div>
|
||||
)}
|
||||
{skills.map((skill) => (
|
||||
<div key={skill.id} style={{ padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 132px', gap: 12 }}>
|
||||
<Input
|
||||
value={skill.name}
|
||||
onChange={(event) => updateSkillDraft(skill.id, { name: event.target.value })}
|
||||
placeholder="Skill 名称,例如:SQL 审查 / JVM 诊断计划"
|
||||
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
|
||||
/>
|
||||
<Select
|
||||
value={skill.enabled ? 'enabled' : 'disabled'}
|
||||
onChange={(value) => updateSkillDraft(skill.id, { enabled: value === 'enabled' })}
|
||||
options={[{ label: '已启用', value: 'enabled' }, { label: '已禁用', value: 'disabled' }]}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={skill.description || ''}
|
||||
onChange={(event) => updateSkillDraft(skill.id, { description: event.target.value })}
|
||||
placeholder="给自己看的说明,例如:输出 SQL 前必须先确认字段名和风险"
|
||||
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={skill.scopes || []}
|
||||
onChange={(value) => updateSkillDraft(skill.id, { scopes: value as AISkillScope[] })}
|
||||
options={SKILL_SCOPE_OPTIONS.map((option) => ({ label: `${option.label} · ${option.desc}`, value: option.value }))}
|
||||
placeholder="选择这个 Skill 要作用到哪些场景"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={skill.requiredTools || []}
|
||||
onChange={(value) => updateSkillDraft(skill.id, { requiredTools: value })}
|
||||
options={skillRequiredToolOptions}
|
||||
placeholder="可选:声明这个 Skill 依赖哪些工具"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
value={skill.systemPrompt}
|
||||
onChange={(event) => updateSkillDraft(skill.id, { systemPrompt: event.target.value })}
|
||||
placeholder="输入这条 Skill 要追加的 system prompt。建议聚焦一个明确能力,不要和全局提示词重复。"
|
||||
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)', resize: 'vertical' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button type="primary" onClick={() => handleSaveSkill(skill)} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}>保存</Button>
|
||||
<Popconfirm title="删除这个 Skill?" okText="删除" cancelText="取消" onConfirm={() => handleDeleteSkill(skill.id)}>
|
||||
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 10 }}>删除</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderBuiltinTools = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
@@ -702,7 +1150,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, padding: '8px 12px', borderRadius: 8, background: cardBg, border: `1px solid ${cardBorder}` }}>
|
||||
💡 工作流程:get_connections → get_databases → get_tables → get_columns → 生成 SQL
|
||||
</div>
|
||||
{BUILTIN_TOOLS_INFO.map(tool => (
|
||||
{BUILTIN_AI_TOOL_INFO.map(tool => (
|
||||
<div key={tool.name} style={{
|
||||
padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg,
|
||||
transition: 'all 0.2s ease',
|
||||
@@ -776,6 +1224,8 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
{ key: 'providers', title: '模型供应商', description: '配置大模型接口与秘钥', icon: <ApiOutlined /> },
|
||||
{ key: 'safety', title: '安全控制', description: '限制 AI 操作风险级别', icon: <SafetyCertificateOutlined /> },
|
||||
{ key: 'context', title: '上下文', description: '配置携带的数据架构信息', icon: <RobotOutlined /> },
|
||||
{ key: 'mcp', title: 'MCP 服务', description: '接入外部工具源', icon: <AppstoreOutlined /> },
|
||||
{ key: 'skills', title: 'Skills', description: '配置可复用提示模块', icon: <ExperimentOutlined /> },
|
||||
{ key: 'tools', title: '内置工具', description: '查看 AI 可调用的数据探针', icon: <ToolOutlined /> },
|
||||
{ key: 'prompts', title: '内置提示词', description: '查看系统预设的底层要求', icon: <ExperimentOutlined /> },
|
||||
].map((item) => {
|
||||
@@ -815,8 +1265,10 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
{activeSection === 'providers' && (isEditing ? renderProviderForm() : renderProviderList())}
|
||||
{activeSection === 'safety' && renderSafetySettings()}
|
||||
{activeSection === 'context' && renderContextSettings()}
|
||||
{activeSection === 'mcp' && renderMCPSettings()}
|
||||
{activeSection === 'skills' && renderSkillSettings()}
|
||||
{activeSection === 'tools' && renderBuiltinTools()}
|
||||
{activeSection === 'prompts' && renderBuiltinPrompts()}
|
||||
{activeSection === 'prompts' && renderPromptSettings()}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -28,6 +28,14 @@ if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
const mockProviders: any[] = [];
|
||||
const mockProviderSecrets = new Map<string, string>();
|
||||
let mockActiveProviderId = '';
|
||||
let mockAIUserPromptSettings: any = {
|
||||
global: '',
|
||||
database: '',
|
||||
jvm: '',
|
||||
jvmDiagnostic: '',
|
||||
};
|
||||
let mockMCPServers: any[] = [];
|
||||
let mockSkills: any[] = [];
|
||||
let mockGlobalProxy: any = { enabled: false, type: 'socks5', host: '', port: 1080, user: '', password: '', hasPassword: false };
|
||||
let mockDataRootInfo: any = {
|
||||
path: 'C:/mock/.gonavi',
|
||||
@@ -286,6 +294,71 @@ if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
AIGetSafetyLevel: async () => 'readonly',
|
||||
AIGetContextLevel: async () => 'schema_only',
|
||||
AIGetBuiltinPrompts: async () => ({}),
|
||||
AIGetUserPromptSettings: async () => cloneBrowserMockValue(mockAIUserPromptSettings),
|
||||
AISaveUserPromptSettings: async (input: any) => {
|
||||
mockAIUserPromptSettings = {
|
||||
global: String(input?.global || ''),
|
||||
database: String(input?.database || ''),
|
||||
jvm: String(input?.jvm || ''),
|
||||
jvmDiagnostic: String(input?.jvmDiagnostic || ''),
|
||||
};
|
||||
return null;
|
||||
},
|
||||
AIGetMCPServers: async () => cloneBrowserMockValue(mockMCPServers),
|
||||
AISaveMCPServer: async (input: any) => {
|
||||
const next = {
|
||||
id: String(input?.id || `mcp-${Date.now()}`),
|
||||
name: String(input?.name || ''),
|
||||
transport: 'stdio',
|
||||
command: String(input?.command || ''),
|
||||
args: Array.isArray(input?.args) ? [...input.args] : [],
|
||||
env: { ...(input?.env || {}) },
|
||||
enabled: input?.enabled !== false,
|
||||
timeoutSeconds: Number(input?.timeoutSeconds) || 20,
|
||||
};
|
||||
const index = mockMCPServers.findIndex((item) => item.id === next.id);
|
||||
if (index >= 0) mockMCPServers[index] = next;
|
||||
else mockMCPServers.push(next);
|
||||
return null;
|
||||
},
|
||||
AIDeleteMCPServer: async (id: string) => {
|
||||
mockMCPServers = mockMCPServers.filter((item) => item.id !== id);
|
||||
return null;
|
||||
},
|
||||
AITestMCPServer: async (input: any) => ({
|
||||
success: String(input?.command || '').trim() !== '',
|
||||
message: String(input?.command || '').trim() !== '' ? 'MCP mock 测试成功' : 'MCP 命令不能为空',
|
||||
tools: [],
|
||||
}),
|
||||
AIListMCPTools: async () => [],
|
||||
AICallMCPTool: async (_alias: string, _argumentsJSON: string) => ({
|
||||
alias: _alias,
|
||||
serverId: '',
|
||||
serverName: '',
|
||||
originalName: _alias,
|
||||
content: '浏览器 mock 未接入真实 MCP 服务',
|
||||
isError: true,
|
||||
}),
|
||||
AIGetSkills: async () => cloneBrowserMockValue(mockSkills),
|
||||
AISaveSkill: async (input: any) => {
|
||||
const next = {
|
||||
id: String(input?.id || `skill-${Date.now()}`),
|
||||
name: String(input?.name || ''),
|
||||
description: String(input?.description || ''),
|
||||
systemPrompt: String(input?.systemPrompt || ''),
|
||||
enabled: input?.enabled !== false,
|
||||
scopes: Array.isArray(input?.scopes) ? [...input.scopes] : ['global'],
|
||||
requiredTools: Array.isArray(input?.requiredTools) ? [...input.requiredTools] : [],
|
||||
};
|
||||
const index = mockSkills.findIndex((item) => item.id === next.id);
|
||||
if (index >= 0) mockSkills[index] = next;
|
||||
else mockSkills.push(next);
|
||||
return null;
|
||||
},
|
||||
AIDeleteSkill: async (id: string) => {
|
||||
mockSkills = mockSkills.filter((item) => item.id !== id);
|
||||
return null;
|
||||
},
|
||||
AITestProvider: async (input: any) => ({
|
||||
success: String(input?.apiKey || '').trim() !== '',
|
||||
message: String(input?.apiKey || '').trim() !== '' ? '端点连通性测试成功!' : '连接测试失败: missing api key',
|
||||
|
||||
@@ -551,6 +551,59 @@ export interface AIProviderConfig {
|
||||
temperature: number;
|
||||
}
|
||||
|
||||
export interface AIUserPromptSettings {
|
||||
global: string;
|
||||
database: string;
|
||||
jvm: string;
|
||||
jvmDiagnostic: string;
|
||||
}
|
||||
|
||||
export type AIMCPTransport = "stdio";
|
||||
|
||||
export interface AIMCPServerConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
transport: AIMCPTransport;
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
timeoutSeconds: number;
|
||||
}
|
||||
|
||||
export interface AIMCPToolDescriptor {
|
||||
alias: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
originalName: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AIMCPToolCallResult {
|
||||
alias: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
originalName: string;
|
||||
title?: string;
|
||||
content: string;
|
||||
structuredContent?: any;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export type AISkillScope = "global" | "database" | "jvm" | "jvmDiagnostic";
|
||||
|
||||
export interface AISkillConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
systemPrompt: string;
|
||||
enabled: boolean;
|
||||
scopes: AISkillScope[];
|
||||
requiredTools?: string[];
|
||||
}
|
||||
|
||||
export interface AIToolCall {
|
||||
id: string;
|
||||
type: string;
|
||||
|
||||
185
frontend/src/utils/aiToolRegistry.ts
Normal file
185
frontend/src/utils/aiToolRegistry.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { AIMCPToolDescriptor } from "../types";
|
||||
|
||||
export interface AIChatToolDefinition {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AIBuiltinToolInfo {
|
||||
name: string;
|
||||
icon: string;
|
||||
desc: string;
|
||||
detail: string;
|
||||
params: string;
|
||||
tool: AIChatToolDefinition;
|
||||
}
|
||||
|
||||
export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [
|
||||
{
|
||||
name: "get_connections",
|
||||
icon: "🔗",
|
||||
desc: "获取所有可用的数据库连接",
|
||||
detail:
|
||||
"返回连接 ID、名称、类型 (MySQL/PostgreSQL 等) 和 Host 地址。AI 根据返回信息决定优先探索哪个连接。",
|
||||
params: "无参数",
|
||||
tool: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_connections",
|
||||
description:
|
||||
"当需要查询、操作数据库但用户没有选择任何连接上下文时,获取当前软件中可用的所有数据库连接信息。返回的数据包含连接ID(id)和名称(name)。",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_databases",
|
||||
icon: "🗄️",
|
||||
desc: "获取指定连接下的所有数据库",
|
||||
detail: "传入 connectionId,返回该连接下的数据库/Schema 名称列表。",
|
||||
params: "connectionId: 连接 ID",
|
||||
tool: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_databases",
|
||||
description: "获取指定连接(connectionId)下的所有数据库(Database/Schema)名。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
connectionId: { type: "string", description: "连接ID (从 get_connections 获取)" },
|
||||
},
|
||||
required: ["connectionId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_tables",
|
||||
icon: "📋",
|
||||
desc: "获取指定数据库下的所有表名",
|
||||
detail:
|
||||
"传入 connectionId 和 dbName,返回表名列表。AI 用它来定位用户提到的目标表。",
|
||||
params: "connectionId, dbName",
|
||||
tool: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_tables",
|
||||
description:
|
||||
"当已经确定了目标连接和数据库名后,如果用户询问或隐式提到了表但你不知道确切表名,调用此工具获取该数据库下的所有表名列表(只含表名,帮助你推断目标表)。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
connectionId: { type: "string", description: "连接ID" },
|
||||
dbName: { type: "string", description: "数据库名" },
|
||||
},
|
||||
required: ["connectionId", "dbName"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_columns",
|
||||
icon: "🔍",
|
||||
desc: "获取指定表的字段结构",
|
||||
detail:
|
||||
"传入 connectionId、dbName 和 tableName,返回每个字段的名称、类型、是否可空、默认值和注释。AI 在生成 SQL 前必须调用此工具确认真实字段名。",
|
||||
params: "connectionId, dbName, tableName",
|
||||
tool: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_columns",
|
||||
description:
|
||||
"获取指定表的字段列表(字段名、类型、是否可空、默认值、注释等)。在生成 SQL 之前必须先调用此工具确认真实字段名,禁止猜测字段名。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
connectionId: { type: "string", description: "连接ID" },
|
||||
dbName: { type: "string", description: "数据库名" },
|
||||
tableName: { type: "string", description: "表名" },
|
||||
},
|
||||
required: ["connectionId", "dbName", "tableName"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_table_ddl",
|
||||
icon: "📝",
|
||||
desc: "获取表的建表语句 (DDL)",
|
||||
detail:
|
||||
"传入 connectionId、dbName 和 tableName,返回完整的 CREATE TABLE 语句,包含字段定义、索引、约束等信息。",
|
||||
params: "connectionId, dbName, tableName",
|
||||
tool: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_table_ddl",
|
||||
description: "获取指定表的完整建表语句(CREATE TABLE DDL),包含字段、索引、约束等完整结构信息。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
connectionId: { type: "string", description: "连接ID" },
|
||||
dbName: { type: "string", description: "数据库名" },
|
||||
tableName: { type: "string", description: "表名" },
|
||||
},
|
||||
required: ["connectionId", "dbName", "tableName"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "execute_sql",
|
||||
icon: "▶️",
|
||||
desc: "执行 SQL 查询并返回结果",
|
||||
detail:
|
||||
"传入 connectionId、dbName 和 sql,在目标数据库上执行 SQL 并返回结果(最多 50 行)。受安全级别控制,只读模式下仅允许 SELECT/SHOW/DESCRIBE。",
|
||||
params: "connectionId, dbName, sql",
|
||||
tool: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "execute_sql",
|
||||
description:
|
||||
"在指定连接和数据库上执行 SQL 查询并返回结果。受安全级别控制,只读模式下只能执行 SELECT/SHOW/DESCRIBE 等查询操作。结果最多返回 50 行。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
connectionId: { type: "string", description: "连接ID" },
|
||||
dbName: { type: "string", description: "数据库名" },
|
||||
sql: { type: "string", description: "要执行的 SQL 语句" },
|
||||
},
|
||||
required: ["connectionId", "dbName", "sql"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const BUILTIN_AI_TOOLS: AIChatToolDefinition[] = BUILTIN_AI_TOOL_INFO.map((item) => item.tool);
|
||||
|
||||
export const BUILTIN_AI_TOOL_NAME_SET = new Set<string>(
|
||||
BUILTIN_AI_TOOL_INFO.map((item) => item.name),
|
||||
);
|
||||
|
||||
export const buildMCPAIChatTools = (
|
||||
tools: AIMCPToolDescriptor[],
|
||||
): AIChatToolDefinition[] =>
|
||||
(tools || []).map((tool) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.alias,
|
||||
description:
|
||||
tool.description ||
|
||||
`${tool.serverName} 提供的 MCP 工具 ${tool.title || tool.originalName}`,
|
||||
parameters:
|
||||
tool.inputSchema && Object.keys(tool.inputSchema).length > 0
|
||||
? tool.inputSchema
|
||||
: { type: "object", properties: {} },
|
||||
},
|
||||
}));
|
||||
|
||||
export const buildAvailableAIChatTools = (
|
||||
tools: AIMCPToolDescriptor[],
|
||||
): AIChatToolDefinition[] => [...BUILTIN_AI_TOOLS, ...buildMCPAIChatTools(tools)];
|
||||
22
frontend/wailsjs/go/aiservice/Service.d.ts
vendored
22
frontend/wailsjs/go/aiservice/Service.d.ts
vendored
@@ -2,6 +2,8 @@
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {ai} from '../models';
|
||||
|
||||
export function AICallMCPTool(arg1:string,arg2:string):Promise<ai.MCPToolCallResult>;
|
||||
|
||||
export function AIChatCancel(arg1:string):Promise<void>;
|
||||
|
||||
export function AIChatSend(arg1:Array<ai.Message>,arg2:Array<ai.Tool>):Promise<Record<string, any>>;
|
||||
@@ -10,10 +12,14 @@ export function AIChatStream(arg1:string,arg2:Array<ai.Message>,arg3:Array<ai.To
|
||||
|
||||
export function AICheckSQL(arg1:string):Promise<ai.SafetyResult>;
|
||||
|
||||
export function AIDeleteMCPServer(arg1:string):Promise<void>;
|
||||
|
||||
export function AIDeleteProvider(arg1:string):Promise<void>;
|
||||
|
||||
export function AIDeleteSession(arg1:string):Promise<void>;
|
||||
|
||||
export function AIDeleteSkill(arg1:string):Promise<void>;
|
||||
|
||||
export function AIGetActiveProvider():Promise<string>;
|
||||
|
||||
export function AIGetBuiltinPrompts():Promise<Record<string, string>>;
|
||||
@@ -22,24 +28,40 @@ export function AIGetContextLevel():Promise<string>;
|
||||
|
||||
export function AIGetEditableProvider(arg1:string):Promise<ai.ProviderConfig>;
|
||||
|
||||
export function AIGetMCPServers():Promise<Array<ai.MCPServerConfig>>;
|
||||
|
||||
export function AIGetProviders():Promise<Array<ai.ProviderConfig>>;
|
||||
|
||||
export function AIGetSafetyLevel():Promise<string>;
|
||||
|
||||
export function AIGetSessions():Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function AIGetSkills():Promise<Array<ai.SkillConfig>>;
|
||||
|
||||
export function AIGetUserPromptSettings():Promise<ai.UserPromptSettings>;
|
||||
|
||||
export function AIListMCPTools():Promise<Array<ai.MCPToolDescriptor>>;
|
||||
|
||||
export function AIListModels():Promise<Record<string, any>>;
|
||||
|
||||
export function AILoadSession(arg1:string):Promise<Record<string, any>>;
|
||||
|
||||
export function AISaveMCPServer(arg1:ai.MCPServerConfig):Promise<void>;
|
||||
|
||||
export function AISaveProvider(arg1:ai.ProviderConfig):Promise<void>;
|
||||
|
||||
export function AISaveSession(arg1:string,arg2:string,arg3:number,arg4:string):Promise<void>;
|
||||
|
||||
export function AISaveSkill(arg1:ai.SkillConfig):Promise<void>;
|
||||
|
||||
export function AISaveUserPromptSettings(arg1:ai.UserPromptSettings):Promise<void>;
|
||||
|
||||
export function AISetActiveProvider(arg1:string):Promise<void>;
|
||||
|
||||
export function AISetContextLevel(arg1:string):Promise<void>;
|
||||
|
||||
export function AISetSafetyLevel(arg1:string):Promise<void>;
|
||||
|
||||
export function AITestMCPServer(arg1:ai.MCPServerConfig):Promise<Record<string, any>>;
|
||||
|
||||
export function AITestProvider(arg1:ai.ProviderConfig):Promise<Record<string, any>>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
export namespace ai {
|
||||
|
||||
export class MCPServerConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
transport: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
timeoutSeconds: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new MCPServerConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.transport = source["transport"];
|
||||
this.command = source["command"];
|
||||
this.args = source["args"];
|
||||
this.env = source["env"];
|
||||
this.enabled = source["enabled"];
|
||||
this.timeoutSeconds = source["timeoutSeconds"];
|
||||
}
|
||||
}
|
||||
export class MCPToolCallResult {
|
||||
alias: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
originalName: string;
|
||||
title?: string;
|
||||
content: string;
|
||||
structuredContent?: any;
|
||||
isError: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new MCPToolCallResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.alias = source["alias"];
|
||||
this.serverId = source["serverId"];
|
||||
this.serverName = source["serverName"];
|
||||
this.originalName = source["originalName"];
|
||||
this.title = source["title"];
|
||||
this.content = source["content"];
|
||||
this.structuredContent = source["structuredContent"];
|
||||
this.isError = source["isError"];
|
||||
}
|
||||
}
|
||||
export class MCPToolDescriptor {
|
||||
alias: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
originalName: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, any>;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new MCPToolDescriptor(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.alias = source["alias"];
|
||||
this.serverId = source["serverId"];
|
||||
this.serverName = source["serverName"];
|
||||
this.originalName = source["originalName"];
|
||||
this.title = source["title"];
|
||||
this.description = source["description"];
|
||||
this.inputSchema = source["inputSchema"];
|
||||
}
|
||||
}
|
||||
export class ToolCallFunction {
|
||||
name: string;
|
||||
arguments: string;
|
||||
@@ -142,6 +218,30 @@ export namespace ai {
|
||||
this.warningMessage = source["warningMessage"];
|
||||
}
|
||||
}
|
||||
export class SkillConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
systemPrompt: string;
|
||||
enabled: boolean;
|
||||
scopes?: string[];
|
||||
requiredTools?: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SkillConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.description = source["description"];
|
||||
this.systemPrompt = source["systemPrompt"];
|
||||
this.enabled = source["enabled"];
|
||||
this.scopes = source["scopes"];
|
||||
this.requiredTools = source["requiredTools"];
|
||||
}
|
||||
}
|
||||
export class ToolFunction {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -192,6 +292,25 @@ export namespace ai {
|
||||
}
|
||||
|
||||
|
||||
|
||||
export class UserPromptSettings {
|
||||
global: string;
|
||||
database: string;
|
||||
jvm: string;
|
||||
jvmDiagnostic: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new UserPromptSettings(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.global = source["global"];
|
||||
this.database = source["database"];
|
||||
this.jvm = source["jvm"];
|
||||
this.jvmDiagnostic = source["jvmDiagnostic"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
23
go.mod
23
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
|
||||
|
||||
46
go.sum
46
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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
599
internal/ai/service/extensions_service.go
Normal file
599
internal/ai/service/extensions_service.go
Normal file
@@ -0,0 +1,599 @@
|
||||
package aiservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMCPServerTimeoutSeconds = 20
|
||||
minMCPServerTimeoutSeconds = 3
|
||||
maxMCPServerTimeoutSeconds = 120
|
||||
mcpToolAliasPrefix = "mcp__"
|
||||
)
|
||||
|
||||
// AIGetMCPServers 获取 MCP 服务配置
|
||||
func (s *Service) AIGetMCPServers() []ai.MCPServerConfig {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return cloneMCPServerConfigs(s.mcpServers)
|
||||
}
|
||||
|
||||
// AISaveMCPServer 保存/更新 MCP 服务配置
|
||||
func (s *Service) AISaveMCPServer(config ai.MCPServerConfig) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
normalized := normalizeMCPServerConfig(config)
|
||||
if normalized.Enabled && strings.TrimSpace(normalized.Command) == "" {
|
||||
return fmt.Errorf("MCP 服务命令不能为空")
|
||||
}
|
||||
|
||||
for i := range s.mcpServers {
|
||||
if s.mcpServers[i].ID == normalized.ID {
|
||||
s.mcpServers[i] = normalized
|
||||
return s.saveConfig()
|
||||
}
|
||||
}
|
||||
s.mcpServers = append(s.mcpServers, normalized)
|
||||
return s.saveConfig()
|
||||
}
|
||||
|
||||
// AIDeleteMCPServer 删除 MCP 服务配置
|
||||
func (s *Service) AIDeleteMCPServer(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
filtered := s.mcpServers[:0]
|
||||
for _, serverConfig := range s.mcpServers {
|
||||
if serverConfig.ID == id {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, serverConfig)
|
||||
}
|
||||
s.mcpServers = append([]ai.MCPServerConfig(nil), filtered...)
|
||||
return s.saveConfig()
|
||||
}
|
||||
|
||||
// AITestMCPServer 测试 MCP 服务连通性
|
||||
func (s *Service) AITestMCPServer(config ai.MCPServerConfig) map[string]any {
|
||||
normalized := normalizeMCPServerConfig(config)
|
||||
if strings.TrimSpace(normalized.Command) == "" {
|
||||
return map[string]any{"success": false, "message": "MCP 服务命令不能为空", "tools": []ai.MCPToolDescriptor{}}
|
||||
}
|
||||
|
||||
tools, err := s.listMCPToolsForServer(normalized)
|
||||
if err != nil {
|
||||
return map[string]any{"success": false, "message": err.Error(), "tools": []ai.MCPToolDescriptor{}}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("MCP 服务连接成功,发现 %d 个工具", len(tools)),
|
||||
"toolCount": len(tools),
|
||||
"tools": tools,
|
||||
}
|
||||
}
|
||||
|
||||
// AIListMCPTools 聚合所有启用的 MCP 工具
|
||||
func (s *Service) AIListMCPTools() []ai.MCPToolDescriptor {
|
||||
s.mu.RLock()
|
||||
servers := cloneMCPServerConfigs(s.mcpServers)
|
||||
s.mu.RUnlock()
|
||||
|
||||
descriptors := make([]ai.MCPToolDescriptor, 0)
|
||||
for _, serverConfig := range servers {
|
||||
if !serverConfig.Enabled {
|
||||
continue
|
||||
}
|
||||
tools, err := s.listMCPToolsForServer(serverConfig)
|
||||
if err != nil {
|
||||
logger.Warnf("列出 MCP 工具失败(server=%s): %v", serverConfig.Name, err)
|
||||
continue
|
||||
}
|
||||
descriptors = append(descriptors, tools...)
|
||||
}
|
||||
return descriptors
|
||||
}
|
||||
|
||||
// AICallMCPTool 调用指定的 MCP 工具
|
||||
func (s *Service) AICallMCPTool(alias string, argumentsJSON string) (ai.MCPToolCallResult, error) {
|
||||
serverID, originalName, err := parseMCPToolAlias(alias)
|
||||
if err != nil {
|
||||
return ai.MCPToolCallResult{}, err
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
serverConfig, ok := findMCPServerConfigByID(s.mcpServers, serverID)
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return ai.MCPToolCallResult{}, fmt.Errorf("未找到 MCP 服务: %s", serverID)
|
||||
}
|
||||
if !serverConfig.Enabled {
|
||||
return ai.MCPToolCallResult{}, fmt.Errorf("MCP 服务未启用: %s", serverConfig.Name)
|
||||
}
|
||||
|
||||
var arguments any = map[string]any{}
|
||||
trimmedArguments := strings.TrimSpace(argumentsJSON)
|
||||
if trimmedArguments != "" {
|
||||
if err := json.Unmarshal([]byte(trimmedArguments), &arguments); err != nil {
|
||||
return ai.MCPToolCallResult{}, fmt.Errorf("解析 MCP 工具参数失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var callResult *mcp.CallToolResult
|
||||
err = s.withMCPClientSession(serverConfig, func(ctx context.Context, session *mcp.ClientSession) error {
|
||||
result, callErr := session.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: originalName,
|
||||
Arguments: arguments,
|
||||
})
|
||||
if callErr != nil {
|
||||
return callErr
|
||||
}
|
||||
callResult = result
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ai.MCPToolCallResult{}, fmt.Errorf("调用 MCP 工具失败: %w", err)
|
||||
}
|
||||
|
||||
return ai.MCPToolCallResult{
|
||||
Alias: alias,
|
||||
ServerID: serverConfig.ID,
|
||||
ServerName: serverConfig.Name,
|
||||
OriginalName: originalName,
|
||||
Title: originalName,
|
||||
Content: formatMCPToolCallContent(callResult),
|
||||
StructuredContent: callResult.StructuredContent,
|
||||
IsError: callResult.IsError,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AIGetSkills 获取 Skill 配置
|
||||
func (s *Service) AIGetSkills() []ai.SkillConfig {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return cloneSkillConfigs(s.skills)
|
||||
}
|
||||
|
||||
// AISaveSkill 保存/更新 Skill 配置
|
||||
func (s *Service) AISaveSkill(config ai.SkillConfig) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
normalized := normalizeSkillConfig(config)
|
||||
for i := range s.skills {
|
||||
if s.skills[i].ID == normalized.ID {
|
||||
s.skills[i] = normalized
|
||||
return s.saveConfig()
|
||||
}
|
||||
}
|
||||
s.skills = append(s.skills, normalized)
|
||||
return s.saveConfig()
|
||||
}
|
||||
|
||||
// AIDeleteSkill 删除 Skill 配置
|
||||
func (s *Service) AIDeleteSkill(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
filtered := s.skills[:0]
|
||||
for _, skillConfig := range s.skills {
|
||||
if skillConfig.ID == id {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, skillConfig)
|
||||
}
|
||||
s.skills = append([]ai.SkillConfig(nil), filtered...)
|
||||
return s.saveConfig()
|
||||
}
|
||||
|
||||
func (s *Service) listMCPToolsForServer(serverConfig ai.MCPServerConfig) ([]ai.MCPToolDescriptor, error) {
|
||||
descriptors := make([]ai.MCPToolDescriptor, 0)
|
||||
err := s.withMCPClientSession(serverConfig, func(ctx context.Context, session *mcp.ClientSession) error {
|
||||
cursor := ""
|
||||
for {
|
||||
result, err := session.ListTools(ctx, &mcp.ListToolsParams{Cursor: cursor})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, tool := range result.Tools {
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
descriptors = append(descriptors, ai.MCPToolDescriptor{
|
||||
Alias: buildMCPToolAlias(serverConfig.ID, tool.Name),
|
||||
ServerID: serverConfig.ID,
|
||||
ServerName: serverConfig.Name,
|
||||
OriginalName: tool.Name,
|
||||
Title: firstNonEmpty(tool.Title, toolAnnotationsTitle(tool), tool.Name),
|
||||
Description: strings.TrimSpace(tool.Description),
|
||||
InputSchema: normalizeToolSchema(tool.InputSchema),
|
||||
})
|
||||
}
|
||||
if strings.TrimSpace(result.NextCursor) == "" {
|
||||
break
|
||||
}
|
||||
cursor = result.NextCursor
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return descriptors, err
|
||||
}
|
||||
|
||||
func (s *Service) withMCPClientSession(serverConfig ai.MCPServerConfig, fn func(context.Context, *mcp.ClientSession) error) error {
|
||||
serverConfig = normalizeMCPServerConfig(serverConfig)
|
||||
if serverConfig.Transport != ai.MCPTransportStdio {
|
||||
return fmt.Errorf("暂不支持的 MCP transport: %s", serverConfig.Transport)
|
||||
}
|
||||
if strings.TrimSpace(serverConfig.Command) == "" {
|
||||
return fmt.Errorf("MCP 服务命令不能为空")
|
||||
}
|
||||
|
||||
timeout := time.Duration(serverConfig.TimeoutSeconds) * time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
command := exec.CommandContext(ctx, serverConfig.Command, serverConfig.Args...)
|
||||
command.Env = append(os.Environ(), formatMCPEnv(serverConfig.Env)...)
|
||||
|
||||
client := mcp.NewClient(&mcp.Implementation{
|
||||
Name: "GoNavi",
|
||||
Version: "dev",
|
||||
}, nil)
|
||||
|
||||
session, err := client.Connect(ctx, &mcp.CommandTransport{Command: command}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
return fn(ctx, session)
|
||||
}
|
||||
|
||||
func normalizeMCPServerConfigs(configs []ai.MCPServerConfig) []ai.MCPServerConfig {
|
||||
normalized := make([]ai.MCPServerConfig, 0, len(configs))
|
||||
for _, config := range configs {
|
||||
normalized = append(normalized, normalizeMCPServerConfig(config))
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeMCPServerConfig(config ai.MCPServerConfig) ai.MCPServerConfig {
|
||||
id := sanitizeExtensionID(strings.TrimSpace(config.ID), "mcp")
|
||||
if id == "" {
|
||||
id = "mcp-" + uuid.New().String()[:8]
|
||||
}
|
||||
|
||||
transport := config.Transport
|
||||
if transport != ai.MCPTransportStdio {
|
||||
transport = ai.MCPTransportStdio
|
||||
}
|
||||
|
||||
args := make([]string, 0, len(config.Args))
|
||||
for _, arg := range config.Args {
|
||||
trimmed := strings.TrimSpace(arg)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
args = append(args, trimmed)
|
||||
}
|
||||
|
||||
env := make(map[string]string, len(config.Env))
|
||||
for key, value := range config.Env {
|
||||
trimmedKey := strings.TrimSpace(key)
|
||||
if trimmedKey == "" {
|
||||
continue
|
||||
}
|
||||
env[trimmedKey] = value
|
||||
}
|
||||
|
||||
timeout := config.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = defaultMCPServerTimeoutSeconds
|
||||
}
|
||||
if timeout < minMCPServerTimeoutSeconds {
|
||||
timeout = minMCPServerTimeoutSeconds
|
||||
}
|
||||
if timeout > maxMCPServerTimeoutSeconds {
|
||||
timeout = maxMCPServerTimeoutSeconds
|
||||
}
|
||||
|
||||
return ai.MCPServerConfig{
|
||||
ID: id,
|
||||
Name: firstNonEmpty(strings.TrimSpace(config.Name), strings.TrimSpace(config.Command), "MCP Server"),
|
||||
Transport: transport,
|
||||
Command: strings.TrimSpace(config.Command),
|
||||
Args: args,
|
||||
Env: env,
|
||||
Enabled: config.Enabled,
|
||||
TimeoutSeconds: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMCPServerConfigs(configs []ai.MCPServerConfig) []ai.MCPServerConfig {
|
||||
cloned := make([]ai.MCPServerConfig, 0, len(configs))
|
||||
for _, config := range configs {
|
||||
next := config
|
||||
next.Args = append([]string(nil), config.Args...)
|
||||
if len(config.Env) > 0 {
|
||||
next.Env = make(map[string]string, len(config.Env))
|
||||
for key, value := range config.Env {
|
||||
next.Env[key] = value
|
||||
}
|
||||
} else {
|
||||
next.Env = map[string]string{}
|
||||
}
|
||||
cloned = append(cloned, next)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func buildMCPToolAlias(serverID string, originalName string) string {
|
||||
return mcpToolAliasPrefix + sanitizeAliasPart(serverID) + "__" + sanitizeAliasPart(originalName)
|
||||
}
|
||||
|
||||
func parseMCPToolAlias(alias string) (string, string, error) {
|
||||
trimmed := strings.TrimSpace(alias)
|
||||
if !strings.HasPrefix(trimmed, mcpToolAliasPrefix) {
|
||||
return "", "", fmt.Errorf("无效的 MCP 工具别名: %s", alias)
|
||||
}
|
||||
|
||||
parts := strings.SplitN(strings.TrimPrefix(trimmed, mcpToolAliasPrefix), "__", 2)
|
||||
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
|
||||
return "", "", fmt.Errorf("无效的 MCP 工具别名: %s", alias)
|
||||
}
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
|
||||
func formatMCPEnv(env map[string]string) []string {
|
||||
if len(env) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(env))
|
||||
for key, value := range env {
|
||||
lines = append(lines, key+"="+value)
|
||||
}
|
||||
slices.Sort(lines)
|
||||
return lines
|
||||
}
|
||||
|
||||
func normalizeToolSchema(schema any) map[string]any {
|
||||
if schema == nil {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
if typed, ok := schema.(map[string]any); ok {
|
||||
return typed
|
||||
}
|
||||
|
||||
data, err := json.Marshal(schema)
|
||||
if err != nil {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(data, &result); err != nil || result == nil {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func formatMCPToolCallContent(result *mcp.CallToolResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := make([]string, 0, len(result.Content))
|
||||
for _, item := range result.Content {
|
||||
switch typed := item.(type) {
|
||||
case *mcp.TextContent:
|
||||
if strings.TrimSpace(typed.Text) != "" {
|
||||
parts = append(parts, typed.Text)
|
||||
}
|
||||
default:
|
||||
data, err := json.Marshal(typed)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(string(data)) != "" {
|
||||
parts = append(parts, string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 && result.StructuredContent != nil {
|
||||
if data, err := json.Marshal(result.StructuredContent); err == nil {
|
||||
parts = append(parts, string(data))
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 && result.IsError {
|
||||
return "MCP 工具调用失败"
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
func findMCPServerConfigByID(configs []ai.MCPServerConfig, id string) (ai.MCPServerConfig, bool) {
|
||||
for _, config := range configs {
|
||||
if config.ID == id {
|
||||
return cloneMCPServerConfigs([]ai.MCPServerConfig{config})[0], true
|
||||
}
|
||||
}
|
||||
return ai.MCPServerConfig{}, false
|
||||
}
|
||||
|
||||
func normalizeSkillConfigs(configs []ai.SkillConfig) []ai.SkillConfig {
|
||||
normalized := make([]ai.SkillConfig, 0, len(configs))
|
||||
for _, config := range configs {
|
||||
normalized = append(normalized, normalizeSkillConfig(config))
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeSkillConfig(config ai.SkillConfig) ai.SkillConfig {
|
||||
id := sanitizeExtensionID(strings.TrimSpace(config.ID), "skill")
|
||||
if id == "" {
|
||||
id = "skill-" + uuid.New().String()[:8]
|
||||
}
|
||||
|
||||
requiredTools := make([]string, 0, len(config.RequiredTools))
|
||||
seenRequiredTools := make(map[string]struct{}, len(config.RequiredTools))
|
||||
for _, toolName := range config.RequiredTools {
|
||||
trimmed := strings.TrimSpace(toolName)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenRequiredTools[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seenRequiredTools[trimmed] = struct{}{}
|
||||
requiredTools = append(requiredTools, trimmed)
|
||||
}
|
||||
|
||||
return ai.SkillConfig{
|
||||
ID: id,
|
||||
Name: firstNonEmpty(strings.TrimSpace(config.Name), "未命名 Skill"),
|
||||
Description: strings.TrimSpace(config.Description),
|
||||
SystemPrompt: normalizeUserPromptText(config.SystemPrompt),
|
||||
Enabled: config.Enabled,
|
||||
Scopes: normalizeSkillScopes(config.Scopes),
|
||||
RequiredTools: requiredTools,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneSkillConfigs(configs []ai.SkillConfig) []ai.SkillConfig {
|
||||
cloned := make([]ai.SkillConfig, 0, len(configs))
|
||||
for _, config := range configs {
|
||||
next := config
|
||||
next.Scopes = append([]string(nil), config.Scopes...)
|
||||
next.RequiredTools = append([]string(nil), config.RequiredTools...)
|
||||
cloned = append(cloned, next)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func normalizeSkillScopes(scopes []string) []string {
|
||||
if len(scopes) == 0 {
|
||||
return []string{string(ai.SkillScopeGlobal)}
|
||||
}
|
||||
|
||||
allowed := map[string]struct{}{
|
||||
string(ai.SkillScopeGlobal): {},
|
||||
string(ai.SkillScopeDatabase): {},
|
||||
string(ai.SkillScopeJVM): {},
|
||||
string(ai.SkillScopeJVMDiagnostic): {},
|
||||
}
|
||||
seen := make(map[string]struct{}, len(scopes))
|
||||
normalized := make([]string, 0, len(scopes))
|
||||
for _, scope := range scopes {
|
||||
trimmed := strings.TrimSpace(scope)
|
||||
if _, ok := allowed[trimmed]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
normalized = append(normalized, trimmed)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return []string{string(ai.SkillScopeGlobal)}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func sanitizeExtensionID(raw string, prefix string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
builder.Grow(len(raw))
|
||||
lastWasDash := false
|
||||
for _, r := range strings.ToLower(raw) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
builder.WriteRune(r)
|
||||
lastWasDash = false
|
||||
case r >= '0' && r <= '9':
|
||||
builder.WriteRune(r)
|
||||
lastWasDash = false
|
||||
case r == '-' || r == '_':
|
||||
if builder.Len() == 0 || lastWasDash {
|
||||
continue
|
||||
}
|
||||
builder.WriteByte('-')
|
||||
lastWasDash = true
|
||||
default:
|
||||
if builder.Len() == 0 || lastWasDash {
|
||||
continue
|
||||
}
|
||||
builder.WriteByte('-')
|
||||
lastWasDash = true
|
||||
}
|
||||
}
|
||||
|
||||
sanitized := strings.Trim(builder.String(), "-")
|
||||
if sanitized == "" {
|
||||
return ""
|
||||
}
|
||||
if prefix != "" && !strings.HasPrefix(sanitized, prefix+"-") && sanitized != prefix {
|
||||
return prefix + "-" + sanitized
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func sanitizeAliasPart(raw string) string {
|
||||
var builder strings.Builder
|
||||
builder.Grow(len(raw))
|
||||
for _, r := range strings.TrimSpace(raw) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
builder.WriteRune(r)
|
||||
case r == '_', r == '-', r == '.':
|
||||
builder.WriteRune(r)
|
||||
default:
|
||||
builder.WriteByte('_')
|
||||
}
|
||||
}
|
||||
return strings.Trim(builder.String(), "_")
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func toolAnnotationsTitle(tool *mcp.Tool) string {
|
||||
if tool == nil || tool.Annotations == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(tool.Annotations.Title)
|
||||
}
|
||||
@@ -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 结构
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user