From 374d86086eae2fab0d624d2eeb424030721f19a3 Mon Sep 17 00:00:00 2001 From: thzxx Date: Sat, 18 Apr 2026 09:25:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v4.3=20=E7=94=9F=E6=80=81=E6=89=A9?= =?UTF-8?q?=E5=B1=95=20=E2=80=94=20=E8=AF=8A=E6=96=AD=E7=B3=BB=E7=BB=9F=20?= =?UTF-8?q?+=20Heartbeat=20+=20=E5=AD=90=E4=BB=A3=E7=90=86=20+=20MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: 1. 🩺 一键诊断系统 - 设置面板添加「一键诊断」按钮 - 检查项:Ollama 连接、模型可用性、模型能力(Tools/Vision/Think)、 SQLite 数据库、技能系统、应用版本信息 - 实时渲染诊断结果,✅/⚠️/❌ 状态标识 2. 💓 Heartbeat 后台检查 - 设置面板添加开关 + 间隔选择(10分/30分/1小时/2小时) - 定时检查 Ollama 连接,异常时系统通知 - 配置持久化到 settings,启动时自动恢复 - startHeartbeat/stopHeartbeat/restoreHeartbeat 三件套 3. 🤖 子代理委派(spawn_task) - 新增 spawn_task 工具(26 个工具) - 子 Agent 独立运行(独立消息上下文,5 轮上限,2 分钟超时) - 不支持工具调用,专注于文本推理任务 - 主 Agent 可传入附加上下文 - 结果返回主 Agent Loop 4. 🔌 MCP Client 框架 - mcp-client.ts: MCP 服务器配置管理(增删改查、启用禁用) - 配置存储在 settings.mcp_servers - 工具注册框架就绪,待主进程 MCP bridge 实现协议通信 新增文件:mcp-client.ts, sub-agent.ts 修改文件:settings-modal.ts, index.html, main.ts, tool-registry.ts --- src/renderer/components/settings-modal.ts | 180 ++++++++++++++++++++++ src/renderer/index.html | 25 +++ src/renderer/main.ts | 10 ++ src/renderer/services/mcp-client.ts | 90 +++++++++++ src/renderer/services/sub-agent.ts | 112 ++++++++++++++ src/renderer/services/tool-registry.ts | 36 ++++- 6 files changed, 450 insertions(+), 3 deletions(-) create mode 100644 src/renderer/services/mcp-client.ts create mode 100644 src/renderer/services/sub-agent.ts diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index b0e7e1e..8905010 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -117,6 +117,40 @@ export function initSettingsModal(): void { }); }); + // ── v4.3 诊断系统 ── + document.querySelector('#btnDoctor')!.addEventListener('click', runDoctor); + + // ── v4.3 Heartbeat 后台检查 ── + const toggleHB = document.querySelector('#toggleHeartbeat')!; + const selectHBInterval = document.querySelector('#selectHeartbeatInterval')!; + + toggleHB.addEventListener('change', async () => { + const enabled = toggleHB.checked; + state.set('heartbeatEnabled', enabled); + const db = state.get(KEYS.DB); + if (db) await db.saveSetting('heartbeatEnabled', enabled); + if (enabled) { + startHeartbeat(parseInt(selectHBInterval.value)); + showToast('后台检查已启用', 'success'); + } else { + stopHeartbeat(); + showToast('后台检查已关闭', 'info'); + } + logSetting('后台检查', enabled ? '已启用' : '已关闭'); + }); + + selectHBInterval.addEventListener('change', async () => { + const ms = parseInt(selectHBInterval.value); + state.set('heartbeatInterval', ms); + const db = state.get(KEYS.DB); + if (db) await db.saveSetting('heartbeatInterval', ms); + if (state.get('heartbeatEnabled', false)) { + stopHeartbeat(); + startHeartbeat(ms); + } + logSetting('检查间隔', `${ms / 60000} 分钟`); + }); + document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => { const api = state.get(KEYS.API); const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value; @@ -402,3 +436,149 @@ async function importSessions(filePath: string): Promise { logError('导入失败', (err as Error).message); } } + +// ══════════════════════════════════════════════ +// v4.3 诊断系统 +// ══════════════════════════════════════════════ + +async function runDoctor(): Promise { + const resultsEl = document.querySelector('#doctorResults')!; + resultsEl.style.display = ''; + resultsEl.innerHTML = '
🩺 正在诊断...
'; + + const checks: Array<{ name: string; status: 'ok' | 'warn' | 'error'; detail: string }> = []; + + // 1. Ollama 连接 + const api = state.get(KEYS.API); + try { + const version = await api!.getVersion(); + checks.push({ name: 'Ollama 连接', status: 'ok', detail: `v${version.version}` }); + } catch (err) { + checks.push({ name: 'Ollama 连接', status: 'error', detail: `无法连接: ${(err as Error).message}` }); + } + + // 2. 模型可用性 + try { + const models = await api!.listModels(); + const count = models.models?.length || 0; + checks.push({ name: '已安装模型', status: count > 0 ? 'ok' : 'warn', detail: `${count} 个模型` }); + } catch { + checks.push({ name: '已安装模型', status: 'error', detail: '无法获取模型列表' }); + } + + // 3. 当前模型能力 + const model = (document.querySelector('#modelSelect') as HTMLSelectElement)?.value; + if (model) { + try { + const detail = await api!.showModel(model); + const caps = detail.capabilities || []; + const hasTools = caps.includes('tools'); + const hasVision = caps.includes('vision'); + const hasThink = caps.includes('thinking'); + checks.push({ + name: `模型能力 (${model})`, + status: hasTools ? 'ok' : 'warn', + detail: `Tool Calling: ${hasTools ? '✅' : '❌'} | Vision: ${hasVision ? '✅' : '❌'} | Think: ${hasThink ? '✅' : '❌'}` + }); + } catch { + checks.push({ name: '模型能力', status: 'warn', detail: '无法检测模型能力' }); + } + } else { + checks.push({ name: '当前模型', status: 'warn', detail: '未选择模型' }); + } + + // 4. SQLite 数据库 + const db = state.get(KEYS.DB); + if (db) { + try { + const sessions = await db.getAllSessions(); + checks.push({ name: 'SQLite 数据库', status: 'ok', detail: `${sessions.length} 个会话` }); + } catch (err) { + checks.push({ name: 'SQLite 数据库', status: 'error', detail: `错误: ${(err as Error).message}` }); + } + } else { + checks.push({ name: 'SQLite 数据库', status: 'error', detail: '未初始化' }); + } + + // 5. 技能系统 + try { + if (db && (db as any).getAllSkills) { + const skills = await (db as any).getAllSkills(); + checks.push({ name: '技能系统', status: 'ok', detail: `${skills.length} 个技能` }); + } else { + checks.push({ name: '技能系统', status: 'ok', detail: '已就绪' }); + } + } catch { + checks.push({ name: '技能系统', status: 'warn', detail: '无法获取技能列表' }); + } + + // 6. 版本信息 + const bridge = (window as any).metonaDesktop; + if (bridge?.info) { + try { + const info = await bridge.info(); + checks.push({ name: '应用信息', status: 'ok', detail: `v${info.version} | Electron ${info.electronVersion} | ${info.platform} ${info.arch}` }); + } catch { + checks.push({ name: '应用信息', status: 'warn', detail: '无法获取' }); + } + } + + // 渲染结果 + const statusIcon = (s: string) => s === 'ok' ? '✅' : s === 'warn' ? '⚠️' : '❌'; + const allOk = checks.every(c => c.status === 'ok'); + + resultsEl.innerHTML = ` +
${allOk ? '✅ 所有检查通过' : '⚠️ 发现问题'}
+ ${checks.map(c => `
${statusIcon(c.status)} ${c.name}: ${c.detail}
`).join('')} + `; + + logInfo('系统诊断完成', `${checks.filter(c => c.status === 'ok').length}/${checks.length} 项通过`); +} + +// ══════════════════════════════════════════════ +// v4.3 Heartbeat 后台检查 +// ══════════════════════════════════════════════ + +let heartbeatTimer: ReturnType | null = null; + +export function startHeartbeat(intervalMs: number): void { + stopHeartbeat(); + heartbeatTimer = setInterval(async () => { + const api = state.get(KEYS.API); + if (!api) return; + + try { + await api.getVersion(); + // 连接正常,静默 + } catch { + // 连接异常,通知用户 + const bridge = (window as any).metonaDesktop; + if (bridge?.notify) { + bridge.notify('Metona Ollama', '⚠️ Ollama 服务连接异常,请检查是否正在运行'); + } + logWarn('Heartbeat: Ollama 连接异常'); + } + }, intervalMs); + logInfo(`Heartbeat 已启动`, `间隔: ${intervalMs / 60000} 分钟`); +} + +export function stopHeartbeat(): void { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } +} + +/** 应用启动时恢复 heartbeat */ +export function restoreHeartbeat(): void { + const enabled = state.get('heartbeatEnabled', false); + const interval = state.get('heartbeatInterval', 3600000); + if (enabled) { + startHeartbeat(interval); + // 同步 UI 状态 + const toggle = document.querySelector('#toggleHeartbeat'); + if (toggle) toggle.checked = true; + const select = document.querySelector('#selectHeartbeatInterval'); + if (select) select.value = String(interval); + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html index b660eb8..5a0159e 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -322,6 +322,31 @@

导出格式为 JSON,可备份后在其他设备导入恢复。

+
+ +
+ + 启用后台定时检查 +
+
+ + +
+

定期检查 Ollama 连接状态和模型可用性,异常时通过系统通知提醒。

+
+
+ + + +
diff --git a/src/renderer/main.ts b/src/renderer/main.ts index eff2700..e036174 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -22,6 +22,7 @@ import { initHistoryModal, closeHistoryModal } from './components/history-modal. import { initMemoryModal } from './components/memory-modal.js'; import { initToolsModal, closeToolsModal } from './components/tools-modal.js'; import { initMemoryManager } from './services/memory-manager.js'; +import { restoreHeartbeat } from './components/settings-modal.js'; import { setToolEnabled, setRunCommandMode } from './services/tool-registry.js'; import { initToolConfirmModal } from './components/tool-confirm-modal.js'; import { initWorkspacePanel } from './components/workspace-panel.js'; @@ -378,10 +379,19 @@ async function init(): Promise { } }); + // ── v4.3 Heartbeat 恢复 ── + const heartbeatEnabled = await db.getSetting('heartbeatEnabled', false); + const heartbeatInterval = await db.getSetting('heartbeatInterval', 3600000); + state.set('heartbeatEnabled', heartbeatEnabled); + state.set('heartbeatInterval', heartbeatInterval); + (document.querySelector('#chatInput') as HTMLElement)?.focus(); updateTotalTokens(); logSuccess('应用初始化完成'); + + // Heartbeat 在 UI 就绪后启动 + restoreHeartbeat(); } catch (err) { logError('初始化失败', (err as Error).message); if (!api!) { diff --git a/src/renderer/services/mcp-client.ts b/src/renderer/services/mcp-client.ts new file mode 100644 index 0000000..8d98c66 --- /dev/null +++ b/src/renderer/services/mcp-client.ts @@ -0,0 +1,90 @@ +/** + * MCP Client - Model Context Protocol 客户端 (v4.3) + * 轻量级实现:管理 MCP Server 配置,提供工具注册框架 + * + * MCP Server 配置存储在 settings.mcp_servers 中,格式: + * [ + * { "name": "filesystem", "command": "npx", "args": ["-y", "@anthropic/mcp-filesystem"], "enabled": true }, + * { "name": "sqlite", "command": "npx", "args": ["-y", "@anthropic/mcp-sqlite", "./data.db"], "enabled": false } + * ] + */ + +import { state, KEYS } from '../state/state.js'; +import { logInfo, logWarn } from './log-service.js'; +import type { ToolDefinition } from '../types.js'; + +export interface MCPServerConfig { + name: string; + command: string; + args: string[]; + enabled: boolean; + description?: string; +} + +/** 获取所有 MCP 服务器配置 */ +export async function getMCPServers(): Promise { + const db = state.get(KEYS.DB); + if (!db) return []; + try { + return await db.getSetting('mcp_servers', []); + } catch { + return []; + } +} + +/** 保存 MCP 服务器配置 */ +export async function saveMCPServers(servers: MCPServerConfig[]): Promise { + const db = state.get(KEYS.DB); + if (db) await db.saveSetting('mcp_servers', servers); +} + +/** 添加 MCP 服务器 */ +export async function addMCPServer(config: MCPServerConfig): Promise { + const servers = await getMCPServers(); + const existing = servers.findIndex(s => s.name === config.name); + if (existing >= 0) { + servers[existing] = config; + } else { + servers.push(config); + } + await saveMCPServers(servers); + logInfo(`MCP Server 已${existing >= 0 ? '更新' : '添加'}: ${config.name}`); +} + +/** 删除 MCP 服务器 */ +export async function removeMCPServer(name: string): Promise { + const servers = await getMCPServers(); + await saveMCPServers(servers.filter(s => s.name !== name)); + logInfo(`MCP Server 已删除: ${name}`); +} + +/** 切换 MCP 服务器启用状态 */ +export async function toggleMCPServer(name: string): Promise { + const servers = await getMCPServers(); + const server = servers.find(s => s.name === name); + if (!server) return false; + server.enabled = !server.enabled; + await saveMCPServers(servers); + logInfo(`MCP Server ${server.enabled ? '已启用' : '已禁用'}: ${name}`); + return server.enabled; +} + +/** 获取已启用的 MCP 服务器 */ +export async function getEnabledMCPServers(): Promise { + const servers = await getMCPServers(); + return servers.filter(s => s.enabled); +} + +/** + * 获取 MCP 工具定义(供 Agent 使用) + * 目前返回占位工具定义,实际 MCP 协议通信需通过主进程实现 + */ +export async function getMCPToolDefinitions(): Promise { + const enabled = await getEnabledMCPServers(); + // MCP 工具通过主进程的 MCP bridge 动态注册 + // 这里返回空数组,工具定义由 MCP bridge 在 IPC 层注入 + if (enabled.length > 0) { + logInfo(`MCP: ${enabled.length} 个服务器已启用`, enabled.map(s => s.name).join(', ')); + } + return []; +} diff --git a/src/renderer/services/sub-agent.ts b/src/renderer/services/sub-agent.ts new file mode 100644 index 0000000..cc4694a --- /dev/null +++ b/src/renderer/services/sub-agent.ts @@ -0,0 +1,112 @@ +/** + * SubAgent - 子代理委派系统 (v4.3) + * 允许主 Agent 将独立任务委派给子 Agent 执行 + * 子 Agent 独立运行,完成后将结果返回给主 Agent + */ + +import { state, KEYS } from '../state/state.js'; +import { OllamaAPI } from '../api/ollama.js'; +import { logInfo, logWarn, logError, logAgentLoop } from './log-service.js'; +import type { ToolResult } from '../types.js'; + +const SUB_AGENT_TIMEOUT = 120000; // 2 分钟超时 +const SUB_AGENT_MAX_LOOPS = 5; // 子 Agent 最多 5 轮 + +/** + * 执行子 Agent 任务 + * @param task 子任务描述 + * @param context 附加上下文(主 Agent 提供的参考信息) + * @returns 子 Agent 的执行结果 + */ +export async function executeSubAgent(task: string, context?: string): Promise { + const api = state.get(KEYS.API); + const model = state.get('_defaultModel', ''); + + if (!api || !model) { + return { success: false, error: '未选择模型,无法执行子任务' }; + } + + logInfo(`子 Agent 启动`, `任务: ${task.slice(0, 80)}`); + + const systemPrompt = `你是一个子任务执行 Agent。你的任务是高效完成指定的单一任务,然后给出简洁的结果报告。 +不要闲聊,不要说废话。直接执行任务,完成后给出结果。 +${context ? `\n附加上下文:\n${context}` : ''}`; + + const messages: Array<{ role: string; content: string }> = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: task } + ]; + + let resultContent = ''; + let loopCount = 0; + const startTime = Date.now(); + + try { + while (loopCount < SUB_AGENT_MAX_LOOPS) { + loopCount++; + + if (Date.now() - startTime > SUB_AGENT_TIMEOUT) { + logWarn('子 Agent 超时', `${loopCount} 轮`); + break; + } + + let content = ''; + let toolCalls: Array<{ name: string; arguments: Record }> = []; + + await api.chatStream({ + model, + messages, + stream: true, + think: false, + options: { num_ctx: 8192, temperature: 0.3 } + } as any, (chunk: any) => { + if (chunk.message?.content) content += chunk.message.content; + if (chunk.message?.tool_calls?.length) { + for (const tc of chunk.message.tool_calls) { + if (tc.function?.name) { + toolCalls.push({ name: tc.function.name, arguments: tc.function.arguments || {} }); + } + } + } + }); + + resultContent = content; + + if (toolCalls.length === 0) { + // 子 Agent 完成 + logInfo(`子 Agent 完成`, `${loopCount} 轮, ${resultContent.length} 字`); + return { + success: true, + content: resultContent, + loops: loopCount, + duration: Date.now() - startTime + }; + } + + // 子 Agent 调用了工具(不支持,提示直接回答) + messages.push({ role: 'assistant', content }); + messages.push({ + role: 'user', + content: '你无法调用工具。请基于已有信息直接给出回答。如果需要工具才能完成,请说明你无法完成此任务及原因。' + }); + } + + // 达到最大轮次 + logWarn('子 Agent 达到最大轮次', `${loopCount} 轮`); + return { + success: true, + content: resultContent || '(子任务执行超时)', + loops: loopCount, + duration: Date.now() - startTime, + partial: true + }; + } catch (err) { + logError('子 Agent 错误', (err as Error).message); + return { + success: false, + error: (err as Error).message, + loops: loopCount, + duration: Date.now() - startTime + }; + } +} diff --git a/src/renderer/services/tool-registry.ts b/src/renderer/services/tool-registry.ts index 50788cf..3e6ade6 100644 --- a/src/renderer/services/tool-registry.ts +++ b/src/renderer/services/tool-registry.ts @@ -415,6 +415,24 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ } } } + }, + // ══════════════════════════════════════════════ + // v4.3 新增工具:子代理委派 + // ══════════════════════════════════════════════ + { + type: 'function', + function: { + name: 'spawn_task', + description: 'Spawn a sub-agent to independently execute a task. The sub-agent runs in isolation without tool access and returns a result. Use this to parallelize independent sub-tasks or delegate focused work.', + parameters: { + type: 'object', + required: ['task'], + properties: { + task: { type: 'string', description: 'The task description for the sub-agent to execute.' }, + context: { type: 'string', description: 'Optional additional context or reference data for the sub-agent.' } + } + } + } } ]; @@ -446,7 +464,7 @@ let enabledTools: Set = new Set([ 'move_file', 'copy_file', 'web_fetch', 'web_search', 'append_file', 'edit_file', 'get_file_info', 'tree', 'download_file', 'diff_files', 'replace_in_files', 'read_multiple_files', 'git', 'compress', - 'memory_search', 'memory_add', 'session_list', 'session_read' + 'memory_search', 'memory_add', 'session_list', 'session_read', 'spawn_task' ]); export function setToolEnabled(toolName: string, enabled: boolean): void { @@ -539,6 +557,18 @@ export async function executeTool(toolName: string, args: Record