From 889d34b4843130451e42cb89309cf1dd6e7b2157 Mon Sep 17 00:00:00 2001 From: thzxx Date: Sat, 18 Apr 2026 09:29:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v5.0=20=E5=85=A8=E9=9D=A2=E8=BF=9B?= =?UTF-8?q?=E5=8C=96=20=E2=80=94=20=E6=B5=8F=E8=A7=88=E5=99=A8=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=20+=20=E8=AE=AD=E7=BB=83=E6=95=B0=E6=8D=AE=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=20+=20Cron=20=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: 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 --- src/main/browser.ts | 176 ++++++++++++++++++++ src/main/ipc.ts | 10 ++ src/main/main.ts | 3 + src/renderer/components/settings-modal.ts | 72 ++++++++ src/renderer/index.html | 21 +++ src/renderer/main.ts | 18 ++ src/renderer/services/cron-manager.ts | 194 ++++++++++++++++++++++ src/renderer/services/tool-registry.ts | 109 +++++++++++- src/renderer/services/training-export.ts | 130 +++++++++++++++ 9 files changed, 730 insertions(+), 3 deletions(-) create mode 100644 src/main/browser.ts create mode 100644 src/renderer/services/cron-manager.ts create mode 100644 src/renderer/services/training-export.ts diff --git a/src/main/browser.ts b/src/main/browser.ts new file mode 100644 index 0000000..1a0e2e5 --- /dev/null +++ b/src/main/browser.ts @@ -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; +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 42cc578..6250743 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -47,6 +47,7 @@ import { handleGit, handleCompress } 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 { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js'; @@ -175,6 +176,15 @@ export async function setupIPC(): Promise { 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 '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}` }; } // 结果日志 diff --git a/src/main/main.ts b/src/main/main.ts index 1a92432..17febba 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -10,6 +10,7 @@ import { createTray } from './tray.js'; import { createMenu } from './menu.js'; import { showNotification } from './utils.js'; import { ensureWorkspaceDir, killAllProcesses } from './workspace.js'; +import { browserClose } from './browser.js'; // ── 全局错误处理:写入文件 + 弹窗提示 ── const ERROR_LOG = path.join(app.getPath('userData'), 'startup-error.log'); @@ -194,6 +195,8 @@ app.on('window-all-closed', () => { app.on('before-quit', () => { isQuitting = true; + // 清理浏览器 + browserClose(); // 清理所有工作空间进程 killAllProcesses(); // 通知渲染进程释放显存 diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index 8905010..f2236d1 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -151,6 +151,42 @@ export function initSettingsModal(): void { 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 () => { const api = state.get(KEYS.API); const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value; @@ -582,3 +618,39 @@ export function restoreHeartbeat(): void { if (select) select.value = String(interval); } } + +/** 渲染定时任务列表 */ +async function renderCronTaskList(): Promise { + 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 = '

无定时任务

'; + return; + } + + container.innerHTML = tasks.map(t => ` +
+ ${t.enabled ? '✅' : '⏸️'} + ${t.name} + ${t.type === 'oneshot' ? '一次性' : '周期'} + + +
+ `).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(); +}; diff --git a/src/renderer/index.html b/src/renderer/index.html index 5a0159e..37d4a51 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -347,6 +347,27 @@ +
+ + +

将 Agent 执行轨迹导出为 SFT 格式,可用于模型微调训练。包含成功的工具调用链和对话记录。

+
+
+ +
+ + +
+
+ + +
+ +
+
diff --git a/src/renderer/main.ts b/src/renderer/main.ts index e036174..d5de0ba 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -23,6 +23,7 @@ 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 { restoreCronTasks } from './services/cron-manager.js'; import { setToolEnabled, setRunCommandMode } from './services/tool-registry.js'; import { initToolConfirmModal } from './components/tool-confirm-modal.js'; import { initWorkspacePanel } from './components/workspace-panel.js'; @@ -392,6 +393,8 @@ async function init(): Promise { // Heartbeat 在 UI 就绪后启动 restoreHeartbeat(); + // Cron 定时任务恢复 + restoreCronTasks(); } catch (err) { logError('初始化失败', (err as Error).message); if (!api!) { @@ -454,6 +457,21 @@ function bindGlobalEvents(): void { const msg = e.reason?.message || String(e.reason); 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(); diff --git a/src/renderer/services/cron-manager.ts b/src/renderer/services/cron-manager.ts new file mode 100644 index 0000000..897121c --- /dev/null +++ b/src/renderer/services/cron-manager.ts @@ -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>(); + +/** + * 获取所有定时任务 + */ +export async function getCronTasks(): Promise { + const db = state.get(KEYS.DB); + if (!db) return []; + try { + return await db.getSetting('cron_tasks', []); + } catch { + return []; + } +} + +/** + * 保存定时任务 + */ +async function saveCronTasks(tasks: CronTask[]): Promise { + const db = state.get(KEYS.DB); + if (db) await db.saveSetting('cron_tasks', tasks); +} + +/** + * 添加定时任务 + */ +export async function addCronTask(task: Omit): Promise { + 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 { + const tasks = await getCronTasks(); + await saveCronTasks(tasks.filter(t => t.id !== id)); + clearTimer(id); + logInfo(`定时任务已删除: ${id}`); +} + +/** + * 切换定时任务启用状态 + */ +export async function toggleCronTask(id: string): Promise { + 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 { + 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 { + 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); + } +} diff --git a/src/renderer/services/tool-registry.ts b/src/renderer/services/tool-registry.ts index 3e6ade6..84c3eb0 100644 --- a/src/renderer/services/tool-registry.ts +++ b/src/renderer/services/tool-registry.ts @@ -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 = 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; } diff --git a/src/renderer/services/training-export.ts b/src/renderer/services/training-export.ts new file mode 100644 index 0000000..571ec83 --- /dev/null +++ b/src/renderer/services/training-export.ts @@ -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 }>; + 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 { + 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 }> | 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 { + 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; + } +}