feat: v5.0 全面进化 — 浏览器控制 + 训练数据导出 + Cron 定时任务

新增功能:

1. 🌐 浏览器控制(8 个工具)
   - 主进程 browser.ts: 基于隐藏 BrowserWindow 的浏览器引擎
   - browser_open: 加载 URL,返回标题和最终 URL
   - browser_screenshot: 页面截图(base64 PNG)
   - browser_evaluate: 执行任意 JS,支持 DOM 操作
   - browser_extract: 提取页面文本 + 链接(最多 50 条)
   - browser_click: CSS 选择器点击
   - browser_type: 输入框文本输入
   - browser_scroll: 上下滚动/顶部/底部
   - browser_close: 关闭浏览器释放资源
   - 应用退出时自动清理 (before-quit)
   - 工具总数:34 个

2. 📚 训练数据导出
   - training-export.ts: 从 traces + messages 提取 SFT 格式训练数据
   - 输出 JSONL 格式(instruction/input/output/tools/observations/metadata)
   - 设置面板一键导出,Electron 原生文件对话框选择保存位置

3.  Cron 定时任务
   - cron-manager.ts: one-shot(指定时间)+ recurring(周期性)
   - 任务配置持久化到 settings
   - 设置面板添加/删除/启用/禁用
   - 任务触发时自动填入聊天输入框 + toast 通知
   - 应用启动时自动恢复所有已启用任务

新增文件:browser.ts, cron-manager.ts, training-export.ts
修改文件:ipc.ts, main.ts, settings-modal.ts, index.html, main.ts(渲染), tool-registry.ts
This commit is contained in:
thzxx
2026-04-18 09:29:29 +08:00
parent 374d86086e
commit 889d34b484
9 changed files with 730 additions and 3 deletions
+194
View File
@@ -0,0 +1,194 @@
/**
* CronManager - 定时任务系统 (v5.0)
* 支持 one-shot 和周期性定时任务
*/
import { state, KEYS } from '../state/state.js';
import { generateId } from '../utils/utils.js';
import { logInfo, logWarn } from './log-service.js';
export interface CronTask {
id: string;
name: string;
type: 'oneshot' | 'recurring';
/** ISO-8601 时间戳(one-shot)或 cron 表达式(recurring */
schedule: string;
/** 任务描述(注入给 Agent */
message: string;
/** 是否启用 */
enabled: boolean;
/** 上次执行时间 */
lastRunAt: number | null;
/** 创建时间 */
createdAt: number;
}
/** 活跃的定时器 */
const activeTimers = new Map<string, ReturnType<typeof setTimeout>>();
/**
* 获取所有定时任务
*/
export async function getCronTasks(): Promise<CronTask[]> {
const db = state.get<any>(KEYS.DB);
if (!db) return [];
try {
return await db.getSetting('cron_tasks', []);
} catch {
return [];
}
}
/**
* 保存定时任务
*/
async function saveCronTasks(tasks: CronTask[]): Promise<void> {
const db = state.get<any>(KEYS.DB);
if (db) await db.saveSetting('cron_tasks', tasks);
}
/**
* 添加定时任务
*/
export async function addCronTask(task: Omit<CronTask, 'id' | 'createdAt' | 'lastRunAt'>): Promise<CronTask> {
const tasks = await getCronTasks();
const newTask: CronTask = {
...task,
id: `cron_${generateId()}`,
createdAt: Date.now(),
lastRunAt: null
};
tasks.push(newTask);
await saveCronTasks(tasks);
if (newTask.enabled) {
scheduleTask(newTask);
}
logInfo(`定时任务已添加: ${newTask.name}`, newTask.schedule);
return newTask;
}
/**
* 删除定时任务
*/
export async function removeCronTask(id: string): Promise<void> {
const tasks = await getCronTasks();
await saveCronTasks(tasks.filter(t => t.id !== id));
clearTimer(id);
logInfo(`定时任务已删除: ${id}`);
}
/**
* 切换定时任务启用状态
*/
export async function toggleCronTask(id: string): Promise<boolean> {
const tasks = await getCronTasks();
const task = tasks.find(t => t.id === id);
if (!task) return false;
task.enabled = !task.enabled;
await saveCronTasks(tasks);
if (task.enabled) {
scheduleTask(task);
} else {
clearTimer(id);
}
logInfo(`定时任务 ${task.enabled ? '已启用' : '已禁用'}: ${task.name}`);
return task.enabled;
}
/**
* 调度单个任务
*/
function scheduleTask(task: CronTask): void {
clearTimer(task.id);
if (task.type === 'oneshot') {
const targetTime = new Date(task.schedule).getTime();
const delay = targetTime - Date.now();
if (delay <= 0) {
logWarn(`定时任务已过期: ${task.name}`);
return;
}
const timer = setTimeout(() => executeTask(task), delay);
activeTimers.set(task.id, timer);
logInfo(`One-shot 任务已调度: ${task.name}`, `${Math.round(delay / 1000)}s 后执行`);
} else {
// Recurring: 简化实现,使用 setInterval
// cron 表达式暂简化为 "every Nm" 或 "every Nh" 格式
const match = task.schedule.match(/every\s+(\d+)(m|h)/i);
if (match) {
const value = parseInt(match[1]);
const unit = match[2].toLowerCase();
const ms = unit === 'h' ? value * 3600000 : value * 60000;
const timer = setInterval(() => executeTask(task), ms);
activeTimers.set(task.id, timer);
logInfo(`Recurring 任务已调度: ${task.name}`, `${value}${unit} 执行`);
}
}
}
/**
* 执行任务
*/
async function executeTask(task: CronTask): Promise<void> {
logInfo(`执行定时任务: ${task.name}`);
// 更新 lastRunAt
const tasks = await getCronTasks();
const t = tasks.find(tt => tt.id === task.id);
if (t) {
t.lastRunAt = Date.now();
await saveCronTasks(tasks);
}
// 注入 systemEvent 到主会话
// 实际执行需要触发 Agent Loop,这里通过事件通知
window.dispatchEvent(new CustomEvent('metona:cron-task', { detail: task }));
// One-shot 执行后自动删除
if (task.type === 'oneshot') {
await removeCronTask(task.id);
}
}
/**
* 清除定时器
*/
function clearTimer(id: string): void {
const timer = activeTimers.get(id);
if (timer) {
clearTimeout(timer);
clearInterval(timer);
activeTimers.delete(id);
}
}
/**
* 恢复所有定时任务(应用启动时调用)
*/
export async function restoreCronTasks(): Promise<void> {
const tasks = await getCronTasks();
let scheduled = 0;
for (const task of tasks) {
if (task.enabled) {
scheduleTask(task);
scheduled++;
}
}
if (scheduled > 0) {
logInfo(`定时任务已恢复: ${scheduled} 个任务`);
}
}
/**
* 清理所有定时器(应用退出时调用)
*/
export function clearAllTimers(): void {
for (const [id] of activeTimers) {
clearTimer(id);
}
}
+106 -3
View File
@@ -433,6 +433,103 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
}
}
}
},
// ══════════════════════════════════════════════
// v5.0 新增工具:浏览器控制
// ══════════════════════════════════════════════
{
type: 'function',
function: {
name: 'browser_open',
description: 'Open a URL in the agent browser. Loads the page and waits for rendering. Returns the page title and final URL.',
parameters: {
type: 'object',
required: ['url'],
properties: {
url: { type: 'string', description: 'The URL to open (http/https).' }
}
}
}
},
{
type: 'function',
function: {
name: 'browser_screenshot',
description: 'Take a screenshot of the current browser page. Returns the image as base64 PNG. Use this to see what the page looks like.',
parameters: { type: 'object', properties: {} }
}
},
{
type: 'function',
function: {
name: 'browser_evaluate',
description: 'Execute JavaScript code in the browser page context. Use to interact with the page, read DOM elements, fill forms, click buttons, etc. Returns the result of the last expression.',
parameters: {
type: 'object',
required: ['js'],
properties: {
js: { type: 'string', description: 'JavaScript code to execute in the page context.' }
}
}
}
},
{
type: 'function',
function: {
name: 'browser_extract',
description: 'Extract all text content and links from the current browser page. Returns title, full text, and up to 50 links.',
parameters: { type: 'object', properties: {} }
}
},
{
type: 'function',
function: {
name: 'browser_click',
description: 'Click an element on the page using a CSS selector.',
parameters: {
type: 'object',
required: ['selector'],
properties: {
selector: { type: 'string', description: 'CSS selector for the element to click.' }
}
}
}
},
{
type: 'function',
function: {
name: 'browser_type',
description: 'Type text into an input field on the page using a CSS selector.',
parameters: {
type: 'object',
required: ['selector', 'text'],
properties: {
selector: { type: 'string', description: 'CSS selector for the input element.' },
text: { type: 'string', description: 'Text to type into the input.' }
}
}
}
},
{
type: 'function',
function: {
name: 'browser_scroll',
description: 'Scroll the browser page.',
parameters: {
type: 'object',
properties: {
direction: { type: 'string', enum: ['down', 'up', 'top', 'bottom'], description: 'Scroll direction. Default: down.' }
}
}
}
},
{
type: 'function',
function: {
name: 'browser_close',
description: 'Close the agent browser and release resources.',
parameters: { type: 'object', properties: {} }
}
}
];
@@ -464,7 +561,9 @@ 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', 'spawn_task'
'memory_search', 'memory_add', 'session_list', 'session_read', 'spawn_task',
'browser_open', 'browser_screenshot', 'browser_evaluate', 'browser_extract',
'browser_click', 'browser_type', 'browser_scroll', 'browser_close'
]);
export function setToolEnabled(toolName: string, enabled: boolean): void {
@@ -611,7 +710,9 @@ 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: '📖', spawn_task: '🤖'
memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖', spawn_task: '🤖',
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
};
return icons[name] || '🔧';
}
@@ -625,7 +726,9 @@ 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: '读取会话', spawn_task: '子代理委派'
memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派',
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
};
return names[name] || name;
}
+130
View File
@@ -0,0 +1,130 @@
/**
* TrainingExport - 训练数据导出 (v5.0)
* 将 Agent 执行轨迹转换为 SFT 训练数据格式
*/
import { state, KEYS } from '../state/state.js';
import { logInfo, logSuccess, logError } from './log-service.js';
interface SFTRecord {
instruction: string;
input: string;
output: string;
tools?: Array<{ name: string; arguments: Record<string, unknown> }>;
observations?: string[];
metadata?: {
session_id: string;
session_title: string;
timestamp: number;
model: string;
eval_count?: number;
total_duration?: number;
};
}
/**
* 导出所有会话的训练数据
* 从 traces + messages 中提取成功的工具调用链
*/
export async function exportTrainingData(): Promise<string | null> {
const bridge = (window as any).metonaDesktop;
if (!bridge?.db) return null;
try {
const sessions = await bridge.db.getAllSessions();
const records: SFTRecord[] = [];
for (const session of sessions) {
const messages = await bridge.db.getMessages(session.id);
const traces = await bridge.db.getTraces(session.id);
// 遍历消息对(user → assistant
for (let i = 0; i < messages.length - 1; i++) {
const userMsg = messages[i];
const asstMsg = messages[i + 1];
if (userMsg.role !== 'user' || asstMsg.role !== 'assistant') continue;
if (!userMsg.content || !asstMsg.content) continue;
// 解析工具调用
let tools: Array<{ name: string; arguments: Record<string, unknown> }> | undefined;
let observations: string[] | undefined;
if (asstMsg.tool_calls) {
try {
const toolCalls = JSON.parse(asstMsg.tool_calls);
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
tools = toolCalls.map((tc: any) => ({
name: tc.name || tc.function?.name || 'unknown',
arguments: tc.arguments || tc.function?.arguments || {}
}));
}
} catch { /* ignore */ }
}
// 从 traces 中提取 observation
const sessionTraces = traces.filter(t =>
t.created_at >= userMsg.created_at && t.created_at <= asstMsg.created_at
);
if (sessionTraces.length > 0) {
observations = sessionTraces.map(t => t.observation || '').filter(Boolean);
}
records.push({
instruction: userMsg.content,
input: '',
output: asstMsg.content,
...(tools && { tools }),
...(observations?.length && { observations }),
metadata: {
session_id: session.id,
session_title: session.title,
timestamp: userMsg.created_at,
model: session.model,
...(asstMsg.eval_count && { eval_count: asstMsg.eval_count }),
...(asstMsg.total_duration && { total_duration: asstMsg.total_duration })
}
});
i++; // 跳过 assistant 消息
}
}
if (records.length === 0) {
logInfo('训练数据导出: 无可导出数据');
return null;
}
logSuccess(`训练数据导出完成: ${records.length} 条记录`);
return records.map(r => JSON.stringify(r)).join('\n');
} catch (err) {
logError('训练数据导出失败', (err as Error).message);
return null;
}
}
/**
* 保存训练数据到文件
*/
export async function saveTrainingData(): Promise<boolean> {
const bridge = (window as any).metonaDesktop;
if (!bridge?.dialog?.saveFile) return false;
const data = await exportTrainingData();
if (!data) return false;
try {
const filePath = await bridge.dialog.saveFile({
defaultPath: `metona-training-data-${new Date().toISOString().slice(0, 10)}.jsonl`,
filters: [{ name: 'JSONL', extensions: ['jsonl'] }, { name: 'JSON', extensions: ['json'] }]
});
if (!filePath) return false;
await bridge.fs.writeFile(filePath, data);
logSuccess(`训练数据已保存: ${filePath}`);
return true;
} catch (err) {
logError('保存训练数据失败', (err as Error).message);
return false;
}
}