feat: v4.3 生态扩展 — 诊断系统 + Heartbeat + 子代理 + MCP

新增功能:

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
This commit is contained in:
thzxx
2026-04-18 09:25:40 +08:00
parent f1625b4353
commit 374d86086e
6 changed files with 450 additions and 3 deletions
+90
View File
@@ -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<MCPServerConfig[]> {
const db = state.get<any>(KEYS.DB);
if (!db) return [];
try {
return await db.getSetting('mcp_servers', []);
} catch {
return [];
}
}
/** 保存 MCP 服务器配置 */
export async function saveMCPServers(servers: MCPServerConfig[]): Promise<void> {
const db = state.get<any>(KEYS.DB);
if (db) await db.saveSetting('mcp_servers', servers);
}
/** 添加 MCP 服务器 */
export async function addMCPServer(config: MCPServerConfig): Promise<void> {
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<void> {
const servers = await getMCPServers();
await saveMCPServers(servers.filter(s => s.name !== name));
logInfo(`MCP Server 已删除: ${name}`);
}
/** 切换 MCP 服务器启用状态 */
export async function toggleMCPServer(name: string): Promise<boolean> {
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<MCPServerConfig[]> {
const servers = await getMCPServers();
return servers.filter(s => s.enabled);
}
/**
* 获取 MCP 工具定义(供 Agent 使用)
* 目前返回占位工具定义,实际 MCP 协议通信需通过主进程实现
*/
export async function getMCPToolDefinitions(): Promise<ToolDefinition[]> {
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 [];
}
+112
View File
@@ -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<ToolResult> {
const api = state.get<OllamaAPI>(KEYS.API);
const model = state.get<string>('_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<string, unknown> }> = [];
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
};
}
}
+33 -3
View File
@@ -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<string> = 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<string, unknown
return { success: true, session: { id: session.id, title: session.title, model: session.model }, messages: msgs, total: messages.length };
}
// v4.3 spawn_task 走子代理
if (toolName === 'spawn_task') {
const { executeSubAgent } = await import('./sub-agent.js');
const task = args.task as string;
const context = args.context as string | undefined;
if (!task) return { success: false, error: '缺少 task 参数' };
logInfo(`子代理委派: ${task.slice(0, 80)}`);
const result = await executeSubAgent(task, context);
logToolResult('spawn_task', result.success, result.success ? `完成, ${(result as any).loops}` : result.error);
return result;
}
// run_command 走 workspace IPC
if (toolName === 'run_command') {
const command = args.command as string;
@@ -581,7 +611,7 @@ export function getToolIcon(name: string): string {
edit_file: '✂️', get_file_info: '️', tree: '🌳', download_file: '⬇️',
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
git: '🔖', compress: '🗜️', web_search: '🔍',
memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖'
memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖', spawn_task: '🤖'
};
return icons[name] || '🔧';
}
@@ -595,7 +625,7 @@ export function formatToolName(name: string): string {
get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话'
memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派'
};
return names[name] || name;
}