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:
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Browser - Agent 浏览器控制 (v5.0)
|
||||||
|
* 通过隐藏 BrowserWindow 实现网页加载、截图、JS 执行
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BrowserWindow } from 'electron';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
let agentBrowser: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
/** 获取或创建 Agent 浏览器窗口 */
|
||||||
|
function getAgentBrowser(): BrowserWindow {
|
||||||
|
if (agentBrowser && !agentBrowser.isDestroyed()) return agentBrowser;
|
||||||
|
|
||||||
|
agentBrowser = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
show: false, // 隐藏窗口
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
|
webSecurity: false, // 允许跨域(Agent 需要)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
agentBrowser.on('closed', () => { agentBrowser = null; });
|
||||||
|
return agentBrowser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开 URL */
|
||||||
|
export async function browserOpen(url: string): Promise<{ success: boolean; title?: string; url?: string; error?: string }> {
|
||||||
|
try {
|
||||||
|
const win = getAgentBrowser();
|
||||||
|
await win.loadURL(url, { timeout: 15000 });
|
||||||
|
await new Promise(r => setTimeout(r, 1000)); // 等待页面渲染
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
title: win.webContents.getTitle(),
|
||||||
|
url: win.webContents.getURL()
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 截图 */
|
||||||
|
export async function browserScreenshot(): Promise<{ success: boolean; png?: string; error?: string }> {
|
||||||
|
try {
|
||||||
|
const win = getAgentBrowser();
|
||||||
|
if (win.webContents.getURL() === 'about:blank') {
|
||||||
|
return { success: false, error: '浏览器未加载任何页面' };
|
||||||
|
}
|
||||||
|
const image = await win.webContents.capturePage();
|
||||||
|
const buffer = image.toPNG();
|
||||||
|
return { success: true, png: buffer.toString('base64') };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行 JavaScript */
|
||||||
|
export async function browserEvaluate(js: string): Promise<{ success: boolean; result?: string; error?: string }> {
|
||||||
|
try {
|
||||||
|
const win = getAgentBrowser();
|
||||||
|
if (win.webContents.getURL() === 'about:blank') {
|
||||||
|
return { success: false, error: '浏览器未加载任何页面' };
|
||||||
|
}
|
||||||
|
const result = await win.webContents.executeJavaScript(js, true);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: typeof result === 'string' ? result : JSON.stringify(result, null, 2)
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取页面文本和链接 */
|
||||||
|
export async function browserExtract(): Promise<{ success: boolean; title?: string; text?: string; links?: Array<{text: string; href: string}>; error?: string }> {
|
||||||
|
try {
|
||||||
|
const win = getAgentBrowser();
|
||||||
|
if (win.webContents.getURL() === 'about:blank') {
|
||||||
|
return { success: false, error: '浏览器未加载任何页面' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await win.webContents.executeJavaScript(`
|
||||||
|
(() => {
|
||||||
|
const title = document.title;
|
||||||
|
// 提取正文文本
|
||||||
|
const getText = (el) => {
|
||||||
|
if (!el) return '';
|
||||||
|
const clone = el.cloneNode(true);
|
||||||
|
clone.querySelectorAll('script,style,noscript,iframe').forEach(e => e.remove());
|
||||||
|
return clone.innerText?.trim() || '';
|
||||||
|
};
|
||||||
|
const body = getText(document.body);
|
||||||
|
const text = body.length > 15000 ? body.slice(0, 15000) + '\\n... (已截断)' : body;
|
||||||
|
|
||||||
|
// 提取链接
|
||||||
|
const links = [];
|
||||||
|
document.querySelectorAll('a[href]').forEach(a => {
|
||||||
|
const href = a.href;
|
||||||
|
const text = a.textContent?.trim();
|
||||||
|
if (href && text && href.startsWith('http')) {
|
||||||
|
links.push({ text: text.slice(0, 100), href });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { title, text, links: links.slice(0, 50) };
|
||||||
|
})()
|
||||||
|
`, true);
|
||||||
|
|
||||||
|
return { success: true, ...result };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击元素 */
|
||||||
|
export async function browserClick(selector: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const win = getAgentBrowser();
|
||||||
|
await win.webContents.executeJavaScript(`document.querySelector('${selector.replace(/'/g, "\\'")}').click()`, true);
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 输入文本 */
|
||||||
|
export async function browserType(selector: string, text: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const win = getAgentBrowser();
|
||||||
|
const escapedSelector = selector.replace(/'/g, "\\'");
|
||||||
|
const escapedText = text.replace(/'/g, "\\'").replace(/\n/g, "\\n");
|
||||||
|
await win.webContents.executeJavaScript(`
|
||||||
|
(() => {
|
||||||
|
const el = document.querySelector('${escapedSelector}');
|
||||||
|
if (!el) throw new Error('元素未找到: ${escapedSelector}');
|
||||||
|
el.focus();
|
||||||
|
el.value = '${escapedText}';
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
})()
|
||||||
|
`, true);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 滚动页面 */
|
||||||
|
export async function browserScroll(direction: 'down' | 'up' | 'top' | 'bottom'): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const win = getAgentBrowser();
|
||||||
|
const js = direction === 'down' ? 'window.scrollBy(0, 500)'
|
||||||
|
: direction === 'up' ? 'window.scrollBy(0, -500)'
|
||||||
|
: direction === 'top' ? 'window.scrollTo(0, 0)'
|
||||||
|
: 'window.scrollTo(0, document.body.scrollHeight)';
|
||||||
|
await win.webContents.executeJavaScript(js, true);
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭浏览器 */
|
||||||
|
export function browserClose(): void {
|
||||||
|
if (agentBrowser && !agentBrowser.isDestroyed()) {
|
||||||
|
agentBrowser.close();
|
||||||
|
}
|
||||||
|
agentBrowser = null;
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
handleGit,
|
handleGit,
|
||||||
handleCompress
|
handleCompress
|
||||||
} from './tool-handlers.js';
|
} from './tool-handlers.js';
|
||||||
|
import { browserOpen, browserScreenshot, browserEvaluate, browserExtract, browserClick, browserType, browserScroll, browserClose } from './browser.js';
|
||||||
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js';
|
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js';
|
||||||
import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js';
|
import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js';
|
||||||
|
|
||||||
@@ -175,6 +176,15 @@ export async function setupIPC(): Promise<void> {
|
|||||||
case 'read_multiple_files':result = await handleReadMultipleFiles(args as { paths: string[]; max_chars_per_file?: number }); break;
|
case 'read_multiple_files':result = await handleReadMultipleFiles(args as { paths: string[]; max_chars_per_file?: number }); break;
|
||||||
case 'git': result = await handleGit(args as any); break;
|
case 'git': result = await handleGit(args as any); break;
|
||||||
case 'compress': result = await handleCompress(args as { action: string; path: string; destination?: string; format?: string }); break;
|
case 'compress': result = await handleCompress(args as { action: string; path: string; destination?: string; format?: string }); break;
|
||||||
|
// v5.0 Browser 控制
|
||||||
|
case 'browser_open': result = await browserOpen(args.url as string); break;
|
||||||
|
case 'browser_screenshot': result = await browserScreenshot(); break;
|
||||||
|
case 'browser_evaluate': result = await browserEvaluate(args.js as string); break;
|
||||||
|
case 'browser_extract': result = await browserExtract(); break;
|
||||||
|
case 'browser_click': result = await browserClick(args.selector as string); break;
|
||||||
|
case 'browser_type': result = await browserType(args.selector as string, args.text as string); break;
|
||||||
|
case 'browser_scroll': result = await browserScroll((args.direction as string) || 'down'); break;
|
||||||
|
case 'browser_close': browserClose(); result = { success: true }; break;
|
||||||
default: return { success: false, error: `未知工具: ${toolName}` };
|
default: return { success: false, error: `未知工具: ${toolName}` };
|
||||||
}
|
}
|
||||||
// 结果日志
|
// 结果日志
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { createTray } from './tray.js';
|
|||||||
import { createMenu } from './menu.js';
|
import { createMenu } from './menu.js';
|
||||||
import { showNotification } from './utils.js';
|
import { showNotification } from './utils.js';
|
||||||
import { ensureWorkspaceDir, killAllProcesses } from './workspace.js';
|
import { ensureWorkspaceDir, killAllProcesses } from './workspace.js';
|
||||||
|
import { browserClose } from './browser.js';
|
||||||
|
|
||||||
// ── 全局错误处理:写入文件 + 弹窗提示 ──
|
// ── 全局错误处理:写入文件 + 弹窗提示 ──
|
||||||
const ERROR_LOG = path.join(app.getPath('userData'), 'startup-error.log');
|
const ERROR_LOG = path.join(app.getPath('userData'), 'startup-error.log');
|
||||||
@@ -194,6 +195,8 @@ app.on('window-all-closed', () => {
|
|||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
isQuitting = true;
|
isQuitting = true;
|
||||||
|
// 清理浏览器
|
||||||
|
browserClose();
|
||||||
// 清理所有工作空间进程
|
// 清理所有工作空间进程
|
||||||
killAllProcesses();
|
killAllProcesses();
|
||||||
// 通知渲染进程释放显存
|
// 通知渲染进程释放显存
|
||||||
|
|||||||
@@ -151,6 +151,42 @@ export function initSettingsModal(): void {
|
|||||||
logSetting('检查间隔', `${ms / 60000} 分钟`);
|
logSetting('检查间隔', `${ms / 60000} 分钟`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── v5.0 训练数据导出 ──
|
||||||
|
document.querySelector('#btnExportTraining')!.addEventListener('click', async () => {
|
||||||
|
const { saveTrainingData } = await import('../services/training-export.js');
|
||||||
|
showToast('正在导出训练数据...', 'info');
|
||||||
|
const ok = await saveTrainingData();
|
||||||
|
if (ok) showToast('训练数据导出成功', 'success');
|
||||||
|
else showToast('导出取消或无数据', 'warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── v5.0 Cron 定时任务 ──
|
||||||
|
document.querySelector('#btnAddCronTask')!.addEventListener('click', async () => {
|
||||||
|
const name = (document.querySelector('#inputCronName') as HTMLInputElement).value.trim();
|
||||||
|
const type = (document.querySelector('#selectCronType') as HTMLSelectElement).value as 'oneshot' | 'recurring';
|
||||||
|
const schedule = (document.querySelector('#inputCronSchedule') as HTMLInputElement).value.trim();
|
||||||
|
const message = (document.querySelector('#inputCronMessage') as HTMLInputElement).value.trim();
|
||||||
|
|
||||||
|
if (!name || !schedule || !message) {
|
||||||
|
showToast('请填写任务名称、时间和描述', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { addCronTask } = await import('../services/cron-manager.js');
|
||||||
|
await addCronTask({ name, type, schedule, message, enabled: true });
|
||||||
|
|
||||||
|
// 清空输入框
|
||||||
|
(document.querySelector('#inputCronName') as HTMLInputElement).value = '';
|
||||||
|
(document.querySelector('#inputCronSchedule') as HTMLInputElement).value = '';
|
||||||
|
(document.querySelector('#inputCronMessage') as HTMLInputElement).value = '';
|
||||||
|
|
||||||
|
showToast(`定时任务已添加: ${name}`, 'success');
|
||||||
|
renderCronTaskList();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化 cron 列表
|
||||||
|
renderCronTaskList();
|
||||||
|
|
||||||
document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => {
|
document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => {
|
||||||
const api = state.get<OllamaAPI>(KEYS.API);
|
const api = state.get<OllamaAPI>(KEYS.API);
|
||||||
const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value;
|
const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value;
|
||||||
@@ -582,3 +618,39 @@ export function restoreHeartbeat(): void {
|
|||||||
if (select) select.value = String(interval);
|
if (select) select.value = String(interval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 渲染定时任务列表 */
|
||||||
|
async function renderCronTaskList(): Promise<void> {
|
||||||
|
const container = document.querySelector('#cronTaskList');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const { getCronTasks, removeCronTask, toggleCronTask } = await import('../services/cron-manager.js');
|
||||||
|
const tasks = await getCronTasks();
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-muted">无定时任务</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = tasks.map(t => `
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;padding:4px 0;border-bottom:1px solid var(--border-subtle);">
|
||||||
|
<span>${t.enabled ? '✅' : '⏸️'}</span>
|
||||||
|
<span style="flex:1;font-weight:500;">${t.name}</span>
|
||||||
|
<span class="text-muted" style="font-size:11px;">${t.type === 'oneshot' ? '一次性' : '周期'}</span>
|
||||||
|
<button class="btn btn-sm" onclick="window.__toggleCron('${t.id}')" style="padding:2px 6px;font-size:11px;">${t.enabled ? '禁用' : '启用'}</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="window.__deleteCron('${t.id}')" style="padding:2px 6px;font-size:11px;">删除</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局方法供内联 onclick 调用
|
||||||
|
(window as any).__toggleCron = async (id: string) => {
|
||||||
|
const { toggleCronTask } = await import('../services/cron-manager.js');
|
||||||
|
await toggleCronTask(id);
|
||||||
|
renderCronTaskList();
|
||||||
|
};
|
||||||
|
(window as any).__deleteCron = async (id: string) => {
|
||||||
|
const { removeCronTask } = await import('../services/cron-manager.js');
|
||||||
|
await removeCronTask(id);
|
||||||
|
renderCronTaskList();
|
||||||
|
};
|
||||||
|
|||||||
@@ -347,6 +347,27 @@
|
|||||||
<button class="btn btn-outline" id="btnDoctor" style="margin-bottom:8px;">🩺 一键诊断</button>
|
<button class="btn btn-outline" id="btnDoctor" style="margin-bottom:8px;">🩺 一键诊断</button>
|
||||||
<div id="doctorResults" style="display:none;margin-top:8px;padding:10px;background:var(--bg-layer);border-radius:var(--radius-control);font-size:12px;max-height:200px;overflow-y:auto;"></div>
|
<div id="doctorResults" style="display:none;margin-top:8px;padding:10px;background:var(--bg-layer);border-radius:var(--radius-control);font-size:12px;max-height:200px;overflow-y:auto;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-group">
|
||||||
|
<label class="setting-label">📚 训练数据导出</label>
|
||||||
|
<button class="btn btn-outline" id="btnExportTraining" style="margin-bottom:4px;">📥 导出训练数据 (JSONL)</button>
|
||||||
|
<p class="text-muted" style="margin-top:4px;font-size:11px;">将 Agent 执行轨迹导出为 SFT 格式,可用于模型微调训练。包含成功的工具调用链和对话记录。</p>
|
||||||
|
</div>
|
||||||
|
<div class="setting-group">
|
||||||
|
<label class="setting-label">⏰ 定时任务 (Cron)</label>
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:8px;">
|
||||||
|
<input class="setting-input" id="inputCronName" type="text" placeholder="任务名称" style="margin-bottom:0;flex:1;">
|
||||||
|
<select id="selectCronType" class="setting-input" style="width:auto;margin-bottom:0;padding:4px 8px;">
|
||||||
|
<option value="oneshot">一次性</option>
|
||||||
|
<option value="recurring">周期性</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:8px;">
|
||||||
|
<input class="setting-input" id="inputCronSchedule" type="text" placeholder="时间 (如: 2026-04-18T10:00 或 every 30m)" style="margin-bottom:0;flex:1;">
|
||||||
|
<button class="btn btn-sm btn-outline" id="btnAddCronTask">➕ 添加</button>
|
||||||
|
</div>
|
||||||
|
<input class="setting-input" id="inputCronMessage" type="text" placeholder="任务描述(将作为 Agent 指令)" style="margin-bottom:8px;">
|
||||||
|
<div id="cronTaskList" style="font-size:12px;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { initMemoryModal } from './components/memory-modal.js';
|
|||||||
import { initToolsModal, closeToolsModal } from './components/tools-modal.js';
|
import { initToolsModal, closeToolsModal } from './components/tools-modal.js';
|
||||||
import { initMemoryManager } from './services/memory-manager.js';
|
import { initMemoryManager } from './services/memory-manager.js';
|
||||||
import { restoreHeartbeat } from './components/settings-modal.js';
|
import { restoreHeartbeat } from './components/settings-modal.js';
|
||||||
|
import { restoreCronTasks } from './services/cron-manager.js';
|
||||||
import { setToolEnabled, setRunCommandMode } from './services/tool-registry.js';
|
import { setToolEnabled, setRunCommandMode } from './services/tool-registry.js';
|
||||||
import { initToolConfirmModal } from './components/tool-confirm-modal.js';
|
import { initToolConfirmModal } from './components/tool-confirm-modal.js';
|
||||||
import { initWorkspacePanel } from './components/workspace-panel.js';
|
import { initWorkspacePanel } from './components/workspace-panel.js';
|
||||||
@@ -392,6 +393,8 @@ async function init(): Promise<void> {
|
|||||||
|
|
||||||
// Heartbeat 在 UI 就绪后启动
|
// Heartbeat 在 UI 就绪后启动
|
||||||
restoreHeartbeat();
|
restoreHeartbeat();
|
||||||
|
// Cron 定时任务恢复
|
||||||
|
restoreCronTasks();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError('初始化失败', (err as Error).message);
|
logError('初始化失败', (err as Error).message);
|
||||||
if (!api!) {
|
if (!api!) {
|
||||||
@@ -454,6 +457,21 @@ function bindGlobalEvents(): void {
|
|||||||
const msg = e.reason?.message || String(e.reason);
|
const msg = e.reason?.message || String(e.reason);
|
||||||
showToast(`操作失败: ${msg}`, 'error', 5000);
|
showToast(`操作失败: ${msg}`, 'error', 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v5.0 Cron 定时任务事件
|
||||||
|
window.addEventListener('metona:cron-task', ((e: CustomEvent) => {
|
||||||
|
const task = e.detail;
|
||||||
|
if (task?.message) {
|
||||||
|
showToast(`⏰ 定时任务: ${task.name}`, 'info', 5000);
|
||||||
|
logInfo(`定时任务触发: ${task.name}`, task.message.slice(0, 60));
|
||||||
|
// 将任务描述填入输入框
|
||||||
|
const chatInput = document.querySelector('#chatInput') as HTMLTextAreaElement;
|
||||||
|
if (chatInput) {
|
||||||
|
chatInput.value = task.message;
|
||||||
|
chatInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) as EventListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
'move_file', 'copy_file', 'web_fetch', 'web_search', 'append_file', 'edit_file',
|
||||||
'get_file_info', 'tree', 'download_file', 'diff_files', 'replace_in_files',
|
'get_file_info', 'tree', 'download_file', 'diff_files', 'replace_in_files',
|
||||||
'read_multiple_files', 'git', 'compress',
|
'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 {
|
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: '⬇️',
|
edit_file: '✂️', get_file_info: 'ℹ️', tree: '🌳', download_file: '⬇️',
|
||||||
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
|
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
|
||||||
git: '🔖', compress: '🗜️', web_search: '🔍',
|
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] || '🔧';
|
return icons[name] || '🔧';
|
||||||
}
|
}
|
||||||
@@ -625,7 +726,9 @@ export function formatToolName(name: string): string {
|
|||||||
get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
|
get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
|
||||||
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
|
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
|
||||||
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
|
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;
|
return names[name] || name;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user