feat: MEMORY.md 访问保护、格式简化及预存问题修复

MEMORY.md 保护: 新建 file-guard.ts 共享守卫模块; read_file/write_file/search_files/run_command 禁止访问根目录 MEMORY.md; permissions.ts 增加 deniedPatterns 深度防御。格式简化: 元数据从 # 注释改为 > 引用语法; 校验规则从 6 条简化为 3 条; context-builder extractContent 适配。预存问题: 修复路径遍历前缀碰撞漏洞; 移除 browser_extract 内容截断; SearXNG 配置实时读取; web_search/web_fetch 移除截断; 侧边栏动态获取工具列表; 版本号 0.1.1
This commit is contained in:
thzxx
2026-07-05 21:24:22 +08:00
parent f4532a2bb2
commit 8cdb93f8bf
29 changed files with 2472 additions and 353 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ export class OllamaAdapter extends BaseAdapter {
throw new Error(`Ollama API error: ${response.status}`);
}
const data = await response.json();
const data = await response.json() as Record<string, unknown>;
return this.toMetonaResponse(data, request.meta.requestId);
}
+9
View File
@@ -104,6 +104,15 @@ export class AgentLoopEngine extends EventEmitter {
this.adapter = adapter;
}
/**
* 热更新 Engine 配置(设置变更时调用)
*
* 支持 maxIterations, totalTimeoutMs, thinkingEnabled, thinkingEffort, contextLength
*/
updateConfig(partial: Partial<AgentLoopConfig>): void {
this.config = { ...this.config, ...partial };
}
/**
* 执行完整的 ReAct 循环(流式模式)
*
+9 -3
View File
@@ -209,7 +209,7 @@ Use tools when needed to gather information or perform actions. Think step by st
}
/**
* 提取 Markdown 文件内容(跳过标题行和元数据注释
* 提取 Markdown 文件内容(跳过标题行和 > 引用元数据)
*/
private extractContent(fileContent: string): string {
if (!fileContent) return '';
@@ -219,10 +219,16 @@ Use tools when needed to gather information or perform actions. Think step by st
let skipMeta = true;
for (const line of lines) {
// 跳过元数据注释行(以 # 开头且不跟另一个 # 的行,即只跳过一级标题)
if (skipMeta && line.match(/^#[^#]/)) {
// 跳过文件头部的标题行(# 开头)和 > 引用元数据行
if (skipMeta) {
if (line.match(/^#[^#]/) || line.match(/^>/)) {
continue;
}
// 空行在元数据区域中也跳过
if (line.trim() === '') {
continue;
}
}
skipMeta = false;
contentLines.push(line);
}
+14 -4
View File
@@ -22,15 +22,25 @@ export interface PermissionPolicy {
}
export const DEFAULT_POLICIES: PermissionPolicy[] = [
{ toolName: 'read_file', requiredLevel: PermissionLevel.READ, deniedPatterns: [/\/etc\//, /\/proc\//, /C:\\Windows\\/i, /C:\\System32\\/i] },
{ toolName: 'read_file', requiredLevel: PermissionLevel.READ, deniedPatterns: [/\/etc\//, /\/proc\//, /C:\\Windows\\/i, /C:\\System32\\/i, /MEMORY\.md/i] },
{ toolName: 'web_search', requiredLevel: PermissionLevel.READ, maxFrequency: 10 },
{ toolName: 'list_directory', requiredLevel: PermissionLevel.READ },
{ toolName: 'search_files', requiredLevel: PermissionLevel.READ },
{ toolName: 'memory_search', requiredLevel: PermissionLevel.READ },
{ toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc\//, /\/proc\//, /\/System\//, /C:\\Windows\\/i, /C:\\System32\\/i], requireConfirmation: true, maxFrequency: 5 },
{ toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc\//, /\/proc\//, /\/System\//, /C:\\Windows\\/i, /C:\\System32\\/i, /MEMORY\.md/i], requireConfirmation: true, maxFrequency: 5 },
{ toolName: 'memory_store', requiredLevel: PermissionLevel.WRITE },
{ toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 3 },
{ toolName: 'web_extract', requiredLevel: PermissionLevel.READ },
{ toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 3 },
{ toolName: 'web_fetch', requiredLevel: PermissionLevel.READ },
// Browser 浏览器工具
{ toolName: 'browser_open', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 10 },
{ toolName: 'browser_screenshot', requiredLevel: PermissionLevel.READ, maxFrequency: 20 },
{ toolName: 'browser_evaluate', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 10 },
{ toolName: 'browser_extract', requiredLevel: PermissionLevel.READ },
{ toolName: 'browser_click', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 15 },
{ toolName: 'browser_type', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 15 },
{ toolName: 'browser_scroll', requiredLevel: PermissionLevel.READ },
{ toolName: 'browser_wait', requiredLevel: PermissionLevel.READ },
{ toolName: 'browser_close', requiredLevel: PermissionLevel.READ },
];
export class PolicyEngine {
@@ -0,0 +1,377 @@
/**
* BrowserWindowManager — 单例浏览器窗口管理器
*
* 提供网页加载、截图、JS 执行、内容提取、交互操作能力。
* 单例懒加载,首次调用时创建,browserClose() 显式销毁。
*
* @see docs/Agent网络工具通用设计-v2.md — 第 4 章 browser 浏览器设计
*/
import { BrowserWindow } from 'electron';
import log from 'electron-log';
export interface BrowserOpenOptions {
url: string;
waitSelector?: string;
}
export interface BrowserOpenResult {
title: string;
url: string;
}
export interface ScreenshotOptions {
fullPage?: boolean;
selector?: string;
}
export interface ScreenshotResult {
data: string; // Base64 PNG
width: number;
height: number;
}
export interface ExtractResult {
text: string;
links: Array<{ text: string; url: string }>;
}
export interface ScrollOptions {
direction?: 'down' | 'up' | 'top' | 'bottom';
selector?: string;
}
export interface WaitOptions {
selector?: string;
timeMs?: number;
}
export class BrowserWindowManager {
private win: BrowserWindow | null = null;
private currentUrl: string | null = null;
/** 窗口是否就绪 */
get ready(): boolean {
return this.win !== null && !this.win.isDestroyed();
}
// ===== browserOpen =====
async open(options: BrowserOpenOptions): Promise<BrowserOpenResult> {
// 若已有窗口加载了不同 URL → 先关闭重建
if (this.win && this.currentUrl !== options.url) {
this.destroy();
}
if (!this.win) {
this.win = new BrowserWindow({
width: 1280,
height: 800,
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: false,
},
});
}
await this.loadURLWithTimeout(this.win, options.url, 30_000);
this.currentUrl = options.url;
// 可选等待选择器
if (options.waitSelector) {
await this.waitForSelector(options.waitSelector, 10_000);
}
const title = await this.evaluate('document.title');
return { title: String(title ?? ''), url: options.url };
}
// ===== browserScreenshot =====
async screenshot(options: ScreenshotOptions = {}): Promise<ScreenshotResult> {
this.ensureReady();
const win = this.win!;
// 元素截图
if (options.selector) {
const rect = await win.webContents.executeJavaScript(
`(() => {
const el = document.querySelector(${JSON.stringify(options.selector)});
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
})()`,
true,
) as { x: number; y: number; width: number; height: number } | null;
if (!rect) throw new Error(`Element not found: ${options.selector}`);
const image = await win.webContents.capturePage({
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
});
return { data: image.toPNG().toString('base64'), width: rect.width, height: rect.height };
}
// 全页截图
if (options.fullPage) {
const dims = await win.webContents.executeJavaScript(
`({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight })`,
true,
) as { width: number; height: number };
// 先滚动到底部触发懒加载
await win.webContents.executeJavaScript(
`window.scrollTo(0, document.body.scrollHeight)`,
true,
);
await this.sleep(500);
await win.webContents.executeJavaScript(
`window.scrollTo(0, 0)`,
true,
);
await this.sleep(300);
const image = await win.webContents.capturePage({
x: 0, y: 0,
width: dims.width,
height: dims.height,
});
return { data: image.toPNG().toString('base64'), width: dims.width, height: dims.height };
}
// 视口截图
const image = await win.webContents.capturePage();
const size = win.getContentSize();
return { data: image.toPNG().toString('base64'), width: size[0], height: size[1] };
}
// ===== browserEvaluate =====
async evaluate(js: string): Promise<unknown> {
this.ensureReady();
const result = await this.win!.webContents.executeJavaScript(js, true);
if (typeof result === 'string') return result;
return result;
}
// ===== browserExtract =====
async extract(selector?: string): Promise<ExtractResult> {
this.ensureReady();
const result = await this.win!.webContents.executeJavaScript(
`(() => {
const root = ${selector ? `document.querySelector(${JSON.stringify(selector)})` : 'document.body'};
if (!root) return null;
const clone = root.cloneNode(true);
clone.querySelectorAll('script, style, noscript, iframe, svg, nav, header, footer, aside').forEach(el => el.remove());
const text = (clone.innerText || '').trim();
const links = [];
const anchors = (selector ? root : document).querySelectorAll('a[href^="http"]');
for (const a of anchors) {
if (links.length >= 50) break;
const text = (a.textContent || '').trim();
if (text && a.href) links.push({ text, url: a.href });
}
return { text, links };
})()`,
true,
) as { text: string; links: Array<{ text: string; url: string }> } | null;
if (!result) throw new Error(selector ? `Element not found: ${selector}` : 'No body content');
return { text: result.text, links: result.links };
}
// ===== browserClick =====
async click(selector: string, wait = false): Promise<void> {
this.ensureReady();
if (wait) {
await this.waitForSelector(selector, 10_000);
}
const found = await this.win!.webContents.executeJavaScript(
`(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return false;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
return true;
})()`,
true,
);
if (!found) throw new Error(`Element not found: ${selector}`);
await this.sleep(300);
await this.win!.webContents.executeJavaScript(
`(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return;
el.click();
})()`,
true,
);
await this.sleep(500);
}
// ===== browserType =====
async type(selector: string, text: string, options: { clear?: boolean; submit?: boolean } = {}): Promise<void> {
this.ensureReady();
const found = await this.win!.webContents.executeJavaScript(
`(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return false;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.focus();
return true;
})()`,
true,
);
if (!found) throw new Error(`Element not found: ${selector}`);
await this.sleep(300);
// 清空 + 赋值 + 触发事件(兼容 React/Vue
await this.win!.webContents.executeJavaScript(
`(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return;
${options.clear ? 'el.value = "";' : ''}
el.value += ${JSON.stringify(text)};
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
})()`,
true,
);
if (options.submit) {
await this.win!.webContents.executeJavaScript(
`(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return;
if (el.form) el.form.submit();
else el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
})()`,
true,
);
await this.sleep(1_000);
} else {
await this.sleep(300);
}
}
// ===== browserScroll =====
async scroll(options: ScrollOptions = {}): Promise<void> {
this.ensureReady();
if (options.selector) {
await this.win!.webContents.executeJavaScript(
`(() => {
const el = document.querySelector(${JSON.stringify(options.selector)});
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
})()`,
true,
);
return;
}
const direction = options.direction ?? 'down';
const js = {
down: 'window.scrollBy(0, 500)',
up: 'window.scrollBy(0, -500)',
top: 'window.scrollTo(0, 0)',
bottom: 'window.scrollTo(0, document.body.scrollHeight)',
}[direction] ?? 'window.scrollBy(0, 500)';
await this.win!.webContents.executeJavaScript(js, true);
await this.sleep(300);
}
// ===== browserWait =====
async wait(options: WaitOptions = {}): Promise<void> {
if (options.selector) {
await this.waitForSelector(options.selector, options.timeMs ?? 10_000);
} else {
await this.sleep(options.timeMs ?? 1_000);
}
}
// ===== browserClose =====
close(): void {
this.destroy();
}
// ===== 内部辅助 =====
private ensureReady(): void {
if (!this.ready) {
throw new Error('Browser window not ready. Call browser_open first.');
}
}
/** 带超时的 loadURLElectron 原生不支持 timeout 选项) */
private async loadURLWithTimeout(win: BrowserWindow, url: string, timeoutMs: number): Promise<void> {
let timer: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`Page load timeout after ${timeoutMs}ms: ${url}`)), timeoutMs);
});
try {
await Promise.race([
win.loadURL(url),
timeoutPromise,
]);
} finally {
if (timer) clearTimeout(timer);
}
}
private async waitForSelector(selector: string, timeoutMs: number): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const found = await this.win!.webContents.executeJavaScript(
`!!document.querySelector(${JSON.stringify(selector)})`,
true,
);
if (found) return;
await this.sleep(300);
}
throw new Error(`Timeout waiting for selector: ${selector}`);
}
private destroy(): void {
if (this.win && !this.win.isDestroyed()) {
try {
this.win.close();
} catch {
// 忽略关闭错误
}
}
this.win = null;
this.currentUrl = null;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** 应用退出时清理 */
static cleanup(manager: BrowserWindowManager | null): void {
if (manager) manager.close();
log.info('[BrowserWindowManager] Cleaned up');
}
}
+388
View File
@@ -0,0 +1,388 @@
/**
* browser — 浏览器交互工具集(9 个函数)
*
* 基于单例 BrowserWindowManager 实现网页加载、截图、JS 执行、
* 内容提取、点击、输入、滚动、等待、关闭等操作。
*
* 工具列表:
* 1. browser_open — 打开 URL
* 2. browser_screenshot — 截图(视口/元素/全页三模式)
* 3. browser_evaluate — 执行 JavaScript
* 4. browser_extract — 提取页面文本与链接
* 5. browser_click — 点击元素
* 6. browser_type — 输入文本
* 7. browser_scroll — 滚动页面
* 8. browser_wait — 等待条件
* 9. browser_close — 关闭窗口
*
* @see docs/Agent网络工具通用设计-v2.md — 第 4 章 browser 浏览器设计
*/
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import { BrowserWindowManager } from './browser-window-manager';
import { logTool } from './network-utils';
// ===== 单例 Manager =====
let managerInstance: BrowserWindowManager | null = null;
function getManager(): BrowserWindowManager {
if (!managerInstance) {
managerInstance = new BrowserWindowManager();
}
return managerInstance;
}
/** 应用退出时清理(由 main.ts 调用) */
export function cleanupBrowser(): void {
BrowserWindowManager.cleanup(managerInstance);
managerInstance = null;
}
// ===== 1. browser_open =====
export class BrowserOpenTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_open',
description: 'Open a URL in a hidden browser window. Loads the page and optionally waits for a selector to appear. Returns the page title and URL.',
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: 'Target URL (http/https only)' },
wait_selector: { type: 'string', description: 'CSS selector to wait for before returning (optional)' },
},
required: ['url'],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.MEDIUM,
requiresPermission: true,
timeoutMs: 60_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const url = args.url as string;
if (!url || !/^https?:\/\//i.test(url)) {
return { success: false, error: 'URL must start with http:// or https://' };
}
const waitSelector = args.wait_selector as string | undefined;
logTool('browser_open', `Opening: ${url}`);
try {
const result = await getManager().open({ url, waitSelector });
return { success: true, ...result };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}
// ===== 2. browser_screenshot =====
export class BrowserScreenshotTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_screenshot',
description: 'Capture a screenshot of the current browser window. Three modes: viewport (default), full page, or specific element by CSS selector. Returns base64-encoded PNG image.',
parameters: {
type: 'object',
properties: {
full_page: { type: 'boolean', description: 'Capture entire scrollable page (default false)' },
selector: { type: 'string', description: 'CSS selector to capture a specific element (overrides full_page)' },
},
required: [],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const fullPage = (args.full_page as boolean) ?? false;
const selector = args.selector as string | undefined;
logTool('browser_screenshot', `full_page=${fullPage}, selector=${selector ?? 'none'}`);
try {
const result = await getManager().screenshot({ fullPage, selector });
return {
success: true,
image: result.data,
width: result.width,
height: result.height,
mime_type: 'image/png',
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}
// ===== 3. browser_evaluate =====
export class BrowserEvaluateTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_evaluate',
description: 'Execute JavaScript code in the current browser page context. Returns the result of the last expression. Use for data extraction, DOM queries, or triggering page actions.',
parameters: {
type: 'object',
properties: {
script: { type: 'string', description: 'JavaScript code to execute (must return a value)' },
},
required: ['script'],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.HIGH,
requiresPermission: true,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const script = args.script as string;
if (!script) {
return { success: false, error: 'No script provided' };
}
logTool('browser_evaluate', `Executing ${script.length} chars of JS`);
try {
const result = await getManager().evaluate(script);
return { success: true, result };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}
// ===== 4. browser_extract =====
export class BrowserExtractTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_extract',
description: 'Extract text content and links from the current browser page. Optionally target a specific element by CSS selector. Strips scripts, styles, and navigation elements. Returns clean text and up to 50 links.',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: 'CSS selector to extract from (default: entire body)' },
},
required: [],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const selector = args.selector as string | undefined;
logTool('browser_extract', `selector=${selector ?? 'body'}`);
try {
const result = await getManager().extract(selector);
return {
success: true,
text: result.text,
links: result.links,
link_count: result.links.length,
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}
// ===== 5. browser_click =====
export class BrowserClickTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_click',
description: 'Click an element on the page by CSS selector. Scrolls the element into view before clicking. Optionally wait for the selector to appear first.',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: 'CSS selector of the element to click' },
wait: { type: 'boolean', description: 'Wait for selector to appear before clicking (default false)' },
},
required: ['selector'],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.MEDIUM,
requiresPermission: true,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const selector = args.selector as string;
const wait = (args.wait as boolean) ?? false;
if (!selector) {
return { success: false, error: 'No selector provided' };
}
logTool('browser_click', `selector=${selector}, wait=${wait}`);
try {
await getManager().click(selector, wait);
return { success: true, selector, clicked: true };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}
// ===== 6. browser_type =====
export class BrowserTypeTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_type',
description: 'Type text into an input element by CSS selector. Focuses the element, optionally clears it first, and dispatches input/change events for React/Vue compatibility. Can optionally submit the form.',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: 'CSS selector of the input element' },
text: { type: 'string', description: 'Text to type into the element' },
clear: { type: 'boolean', description: 'Clear the field before typing (default true)' },
submit: { type: 'boolean', description: 'Submit the form after typing (default false)' },
},
required: ['selector', 'text'],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.MEDIUM,
requiresPermission: true,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const selector = args.selector as string;
const text = args.text as string;
const clear = (args.clear as boolean) ?? true;
const submit = (args.submit as boolean) ?? false;
if (!selector) {
return { success: false, error: 'No selector provided' };
}
if (text === undefined || text === null) {
return { success: false, error: 'No text provided' };
}
logTool('browser_type', `selector=${selector}, len=${text.length}, submit=${submit}`);
try {
await getManager().type(selector, text, { clear, submit });
return { success: true, selector, typed: text.length };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}
// ===== 7. browser_scroll =====
export class BrowserScrollTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_scroll',
description: 'Scroll the browser page. Modes: down/up (500px increment), top/bottom (absolute), or scroll to a specific element by CSS selector.',
parameters: {
type: 'object',
properties: {
direction: { type: 'string', description: 'Scroll direction: down, up, top, bottom (default down)' },
selector: { type: 'string', description: 'CSS selector to scroll to (overrides direction)' },
},
required: [],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 15_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const direction = (args.direction as string) ?? 'down';
const selector = args.selector as string | undefined;
logTool('browser_scroll', `direction=${direction}, selector=${selector ?? 'none'}`);
try {
await getManager().scroll({ direction: direction as 'down' | 'up' | 'top' | 'bottom', selector });
return { success: true, direction, selector };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}
// ===== 8. browser_wait =====
export class BrowserWaitTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_wait',
description: 'Wait for a condition on the browser page. Either wait for a CSS selector to appear (with timeout) or wait for a fixed duration.',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: 'CSS selector to wait for (mutually exclusive with time_ms)' },
time_ms: { type: 'number', description: 'Fixed wait time in milliseconds (default 1000)' },
},
required: [],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const selector = args.selector as string | undefined;
const timeMs = (args.time_ms as number) ?? 1_000;
logTool('browser_wait', `selector=${selector ?? 'none'}, time_ms=${timeMs}`);
try {
await getManager().wait({ selector, timeMs });
return { success: true, waited_for: selector ?? `${timeMs}ms` };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}
// ===== 9. browser_close =====
export class BrowserCloseTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_close',
description: 'Close the current browser window and release resources. Call this when browser interaction is complete to free memory.',
parameters: {
type: 'object',
properties: {},
required: [],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 10_000,
};
async execute(_args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
logTool('browser_close', 'Closing browser window');
getManager().close();
return { success: true, closed: true };
}
}
// ===== 导出所有 Browser 工具 =====
export const browserTools: IMetonaTool[] = [
new BrowserOpenTool(),
new BrowserScreenshotTool(),
new BrowserEvaluateTool(),
new BrowserExtractTool(),
new BrowserClickTool(),
new BrowserTypeTool(),
new BrowserScrollTool(),
new BrowserWaitTool(),
new BrowserCloseTool(),
];
@@ -12,6 +12,7 @@ import { promisify } from 'util';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import { commandTouchesProtectedFile } from './file-guard';
const execAsync = promisify(exec);
@@ -83,6 +84,11 @@ export class RunCommandTool implements IMetonaTool {
private validateCommand(command: string): { allowed: boolean; reason?: string } {
const cmd = command.trim().toLowerCase();
// 受保护文件检查:禁止通过命令行读写工作空间根目录的 MEMORY.md
if (commandTouchesProtectedFile(command)) {
return { allowed: false, reason: 'Access denied: MEMORY.md is managed by the memory system and cannot be accessed via command execution' };
}
// 硬阻止列表(绝对禁止执行)
const hardBlocks = [
{ pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' },
@@ -0,0 +1,76 @@
/**
* File Guard — 受保护文件守卫
*
* 确保工作空间根目录的 MEMORY.md 只能由系统内部(WorkspaceService)管理,
* 任何工具(read_file / write_file / search_files / run_command 等)均禁止直接读写。
*
* 注意:仅保护工作空间根目录的 MEMORY.md,
* 子目录或其他位置的同名文件不受限制。
*/
import { resolve, sep } from 'path';
/**
* 受保护文件名列表(工作空间根目录)
*/
const PROTECTED_FILES = ['MEMORY.md'];
/**
* 检查目标路径是否为工作空间根目录的受保护文件
*
* @param filePath 用户传入的文件路径(绝对或相对)
* @param workspacePath 当前工作空间根路径
* @returns true 如果路径指向受保护文件
*/
export function isProtectedWorkspaceFile(
filePath: string,
workspacePath: string,
): boolean {
const resolved = resolve(workspacePath, filePath);
const workspaceRoot = resolve(workspacePath);
for (const protectedName of PROTECTED_FILES) {
const protectedPath = resolve(workspaceRoot, protectedName);
if (resolved === protectedPath) {
return true;
}
}
return false;
}
/**
* 检查路径是否在工作空间内(防止路径遍历攻击)
*
* 修复前缀碰撞漏洞:`/home/user/app-evil` 不应被误判为在 `/home/user/app` 内。
*
* @param filePath 用户传入的文件路径
* @param workspacePath 当前工作空间根路径
* @returns true 如果路径在工作空间内
*/
export function isPathWithinWorkspace(
filePath: string,
workspacePath: string,
): boolean {
const resolved = resolve(workspacePath, filePath);
const workspaceRoot = resolve(workspacePath);
return resolved === workspaceRoot || resolved.startsWith(workspaceRoot + sep);
}
/**
* 检查命令字符串是否尝试访问工作空间根目录的受保护文件
*
* 用于 run_command 工具的命令校验。
* 采用大小写不敏感匹配,覆盖 cat/type/Get-Content 等常见读取命令。
*
* @param command Shell 命令字符串
* @returns true 如果命令包含受保护文件名
*/
export function commandTouchesProtectedFile(command: string): boolean {
const lowerCmd = command.toLowerCase();
for (const protectedName of PROTECTED_FILES) {
if (lowerCmd.includes(protectedName.toLowerCase())) {
return true;
}
}
return false;
}
+33 -33
View File
@@ -3,6 +3,11 @@
*
* read_file, write_file, list_directory, search_files
*
* 安全策略:
* 1. 路径遍历防护 — 所有路径必须解析到工作空间内(修复前缀碰撞漏洞)
* 2. 受保护文件拦截 — MEMORY.md 仅由系统内部 WorkspaceService 管理,
* 禁止任何文件工具直接读写(仅限工作空间根目录,子目录不受限)
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
* @see standard/开发规范.md — 使用 fs/path 内置模块
*/
@@ -13,6 +18,21 @@ import { existsSync } from 'fs';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import { isProtectedWorkspaceFile, isPathWithinWorkspace } from './file-guard';
/** 共享路径解析 + 安全校验 */
function safeResolvePath(filePath: string, workspacePath: string): string {
const resolved = resolve(workspacePath, filePath);
// 安全检查:路径遍历防护(修复前缀碰撞漏洞)
if (!isPathWithinWorkspace(filePath, workspacePath)) {
throw new Error(`Path traversal detected: ${filePath}`);
}
// 受保护文件检查:MEMORY.md 仅由系统内部管理
if (isProtectedWorkspaceFile(filePath, workspacePath)) {
throw new Error('Access denied: MEMORY.md is managed by the memory system and cannot be accessed via file tools');
}
return resolved;
}
// ===== 1. read_file =====
@@ -36,7 +56,7 @@ export class ReadFileTool implements IMetonaTool {
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const filePath = this.resolvePath(args.file_path as string, context.workspacePath);
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
const offset = Math.max(1, (args.offset as number) ?? 1);
const limit = Math.min(2000, Math.max(1, (args.limit as number) ?? 500));
@@ -52,15 +72,6 @@ export class ReadFileTool implements IMetonaTool {
file_size: content.length,
};
}
private resolvePath(filePath: string, workspacePath: string): string {
const resolved = resolve(workspacePath, filePath);
// 安全检查:路径遍历防护
if (!resolved.startsWith(resolve(workspacePath))) {
throw new Error(`Path traversal detected: ${filePath}`);
}
return resolved;
}
}
// ===== 2. write_file =====
@@ -85,7 +96,7 @@ export class WriteFileTool implements IMetonaTool {
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const filePath = this.resolvePath(args.file_path as string, context.workspacePath);
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
const content = args.content as string;
const mode = (args.mode as string) ?? 'overwrite';
@@ -97,14 +108,6 @@ export class WriteFileTool implements IMetonaTool {
return { bytes_written: Buffer.byteLength(content, 'utf-8'), path: filePath };
}
private resolvePath(filePath: string, workspacePath: string): string {
const resolved = resolve(workspacePath, filePath);
if (!resolved.startsWith(resolve(workspacePath))) {
throw new Error(`Path traversal detected: ${filePath}`);
}
return resolved;
}
}
// ===== 3. list_directory =====
@@ -129,7 +132,7 @@ export class ListDirectoryTool implements IMetonaTool {
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const dirPath = args.dir_path
? this.resolvePath(args.dir_path as string, context.workspacePath)
? safeResolvePath(args.dir_path as string, context.workspacePath)
: context.workspacePath;
const depth = Math.min(5, Math.max(1, (args.depth as number) ?? 1));
const glob = args.glob as string | undefined;
@@ -176,14 +179,6 @@ export class ListDirectoryTool implements IMetonaTool {
const pattern = glob.replace(/\*/g, '.*').replace(/\?/g, '.');
return new RegExp(`^${pattern}$`, 'i').test(name);
}
private resolvePath(filePath: string, workspacePath: string): string {
const resolved = resolve(workspacePath, filePath);
if (!resolved.startsWith(resolve(workspacePath))) {
throw new Error(`Path traversal detected: ${filePath}`);
}
return resolved;
}
}
// ===== 4. search_files =====
@@ -213,7 +208,7 @@ export class SearchFilesTool implements IMetonaTool {
const pattern = args.pattern as string;
const target = (args.target as string) ?? 'content';
const searchPath = args.path
? this.resolvePath(args.path as string, context.workspacePath)
? this.resolveSearchPath(args.path as string, context.workspacePath)
: context.workspacePath;
const fileGlob = args.file_glob as string | undefined;
const limit = Math.min(200, Math.max(1, (args.limit as number) ?? 50));
@@ -221,7 +216,7 @@ export class SearchFilesTool implements IMetonaTool {
if (target === 'files') {
return this.searchByFilename(searchPath, pattern, fileGlob, limit);
}
return this.searchByContent(searchPath, pattern, fileGlob, limit);
return this.searchByContent(searchPath, pattern, fileGlob, limit, context.workspacePath);
}
private async searchByFilename(dirPath: string, pattern: string, fileGlob: string | undefined, limit: number): Promise<unknown> {
@@ -239,12 +234,16 @@ export class SearchFilesTool implements IMetonaTool {
return { results, count: results.length };
}
private async searchByContent(dirPath: string, pattern: string, fileGlob: string | undefined, limit: number): Promise<unknown> {
private async searchByContent(dirPath: string, pattern: string, fileGlob: string | undefined, limit: number, workspacePath: string): Promise<unknown> {
const results: Array<{ path: string; line: number; match: string }> = [];
const regex = new RegExp(pattern, 'gi');
await this.walkDir(dirPath, async (filePath) => {
if (results.length >= limit) return;
// 跳过工作空间根目录的 MEMORY.md(受保护文件,使用完整路径精确匹配)
if (isProtectedWorkspaceFile(filePath, workspacePath)) {
return;
}
try {
const content = await readFile(filePath, 'utf-8');
const lines = content.split('\n');
@@ -295,9 +294,10 @@ export class SearchFilesTool implements IMetonaTool {
return new RegExp(`^${pattern}$`, 'i').test(name);
}
private resolvePath(filePath: string, workspacePath: string): string {
/** search_files 的路径解析:仅需遍历防护,不需要 MEMORY.md 拦截(搜索时单独跳过) */
private resolveSearchPath(filePath: string, workspacePath: string): string {
const resolved = resolve(workspacePath, filePath);
if (!resolved.startsWith(resolve(workspacePath))) {
if (!isPathWithinWorkspace(filePath, workspacePath)) {
throw new Error(`Path traversal detected: ${filePath}`);
}
return resolved;
+14 -1
View File
@@ -5,6 +5,19 @@
*/
export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool } from './filesystem';
export { WebSearchTool, WebExtractTool } from './network';
export { WebSearchTool, WebFetchTool } from './network';
export { MemoryStoreTool, MemorySearchTool } from './memory';
export { RunCommandTool } from './command';
export {
BrowserOpenTool,
BrowserScreenshotTool,
BrowserEvaluateTool,
BrowserExtractTool,
BrowserClickTool,
BrowserTypeTool,
BrowserScrollTool,
BrowserWaitTool,
BrowserCloseTool,
browserTools,
cleanupBrowser,
} from './browser';
@@ -0,0 +1,222 @@
/**
* 网络工具共享模块
*
* 提供 UA 轮换池、反爬请求头、HTML 转文本、拦截检测、超时 fetch、LRU 缓存等通用能力。
* web_search 和 web_fetch 共享此模块。
*
* @see docs/Agent网络工具通用设计-v2.md — 第 3 章 web_fetch 抓取设计
*/
import { LRUCache } from 'lru-cache';
import log from 'electron-log';
// ===== LRU 缓存 =====
/** 搜索结果缓存(200 条,5 分钟 TTL) */
export const searchCache = new LRUCache<string, Record<string, unknown>>({
max: 200,
ttl: 300_000,
});
/** 浏览器回退缓存(100 条,10 分钟 TTL) */
export const fetchCache = new LRUCache<string, string>({
max: 100,
ttl: 600_000,
});
// ===== UA 轮换池 =====
export const UA_POOL = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Edg/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0',
];
export const MOBILE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
export const ACCEPT_LANGUAGE_POOL = [
'zh-CN,zh;q=0.9,en;q=0.8',
'zh-CN,zh;q=0.9',
'en-US,en;q=0.9,zh-CN;q=0.8',
];
// ===== 反爬请求头构建 =====
export function buildAntiCrawlHeaders(
url: string,
attempt: number,
mobileUA = false,
): Record<string, string> {
const uaIdx = attempt % UA_POOL.length;
const langIdx = attempt % ACCEPT_LANGUAGE_POOL.length;
const userAgent = mobileUA ? MOBILE_UA : UA_POOL[uaIdx];
let origin = '';
try { origin = new URL(url).origin; } catch { /* ignore */ }
return {
'User-Agent': userAgent,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': ACCEPT_LANGUAGE_POOL[langIdx],
'Accept-Encoding': 'gzip, deflate, br',
'Cache-Control': 'no-cache',
'DNT': '1',
'Referer': origin || '',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Pragma': 'no-cache',
};
}
// ===== 超时 fetch =====
export async function fetchWithTimeout(
url: string,
options: RequestInit = {},
timeoutMs = 20_000,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
// ===== URL 标准化(去重用) =====
export function normalizeUrl(url: string): string {
try {
const u = new URL(url);
// 去除追踪参数
const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid'];
for (const p of trackingParams) u.searchParams.delete(p);
// 去除尾部斜杠(根路径除外)
let path = u.pathname;
if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
// 强制小写 host
return `${u.protocol}//${u.host.toLowerCase()}${path}${u.search}${u.hash}`;
} catch {
return url;
}
}
// ===== 拦截页面检测 =====
const INTERCEPTION_PATTERNS = [
/just a moment/i,
/attention required/i,
/challenge-platform/i,
/\.cf-challenge-/i,
/access denied/i,
/403 forbidden/i,
/请启用\s*javascript/i,
/please enable javascript/i,
/checking your browser/i,
/ddos protection/i,
];
export function isInterceptedPage(html: string): boolean {
if (html.length < 80) return true;
const lower = html.toLowerCase();
return INTERCEPTION_PATTERNS.some((p) => p.test(lower));
}
// ===== HTML → 纯文本转换 =====
const HTML_ENTITY_MAP: Record<string, string> = {
'&nbsp;': ' ', '&lt;': '<', '&gt;': '>', '&amp;': '&', '&quot;': '"',
'&apos;': "'", '&hellip;': '…', '&mdash;': '—', '&ndash;': '',
'&laquo;': '«', '&raquo;': '»', '&times;': '×', '&divide;': '÷',
'&copy;': '©', '&reg;': '®', '&trade;': '™', '&euro;': '€',
'&pound;': '£', '&yen;': '¥', '&cent;': '¢', '&deg;': '°',
};
export function htmlToText(html: string): string {
return html
// 移除噪声标签及内容
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<noscript[^>]*>[\s\S]*?<\/noscript>/gi, '')
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '')
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
.replace(/<aside[^>]*>[\s\S]*?<\/aside>/gi, '')
.replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, '')
.replace(/<svg[^>]*>[\s\S]*?<\/svg>/gi, '')
// 移除 HTML 注释
.replace(/<!--[\s\S]*?-->/g, '')
// 块级标签转换行
.replace(/<\/?(p|div|h[1-6]|li|tr|blockquote|section|article|pre|br|hr)[^>]*>/gi, '\n')
// 表格单元格转制表符
.replace(/<\/?(td|th)[^>]*>/gi, '\t')
// 移除剩余标签
.replace(/<[^>]+>/g, '')
// 解码 HTML 实体
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16)))
.replace(/&[a-z]+;/gi, (m) => HTML_ENTITY_MAP[m.toLowerCase()] ?? m)
// 清理空白
.replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]+/g, ' ')
.replace(/^[ \t]+/gm, '')
.trim();
}
// ===== 流式读取(大文件保护,10MB 上限) =====
export async function readBodyWithLimit(response: Response, maxBytes = 10 * 1024 * 1024): Promise<string> {
const contentLength = response.headers.get('content-length');
if (contentLength && parseInt(contentLength) > maxBytes) {
throw new Error(`Response too large: ${contentLength} bytes (limit: ${maxBytes})`);
}
const reader = response.body?.getReader();
if (!reader) return '';
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
throw new Error(`Response exceeded ${maxBytes} bytes limit`);
}
chunks.push(value);
}
}
} finally {
reader.releaseLock();
}
const decoder = new TextDecoder('utf-8', { fatal: false });
return decoder.decode(Buffer.concat(chunks));
}
// ===== SearXNG 认证头构建 =====
export function buildSearXNGAuthHeaders(authKey: string, authType: string): Record<string, string> {
if (!authKey) return {};
if (authType === 'bearer') {
return { Authorization: `Bearer ${authKey}` };
}
if (authType === 'basic') {
return { Authorization: `Basic ${Buffer.from(authKey).toString('base64')}` };
}
return {};
}
// ===== 日志辅助 =====
export function logTool(toolName: string, message: string): void {
log.info(`[Tool:${toolName}] ${message}`);
}
+6 -146
View File
@@ -1,151 +1,11 @@
/**
* 网络工具2 个)
* 网络工具导出
*
* web_search, web_extract
* web_search — 双模式搜索(SearXNG / 内置四引擎)
* web_fetch — 三阶段回退抓取(HTTP / SPA 升级 / 浏览器渲染)
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
* @see standard/开发规范.md — 使用原生 fetch(允许在工具层使用)
* @see docs/Agent网络工具通用设计-v2.md
*/
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
// ===== 5. web_search =====
export class WebSearchTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'web_search',
description: 'Search the web for information. Returns titles, snippets, and URLs. Supports search operators (site:, filetype:, etc.).',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query. Supports operators like site:domain filetype:pdf' },
limit: { type: 'number', description: 'Number of results (default 5, max 100)' },
},
required: ['query'],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const query = args.query as string;
const limit = Math.min(100, Math.max(1, (args.limit as number) ?? 5));
// 使用 DuckDuckGo Instant Answer API(免费,无需 API Key
try {
const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1`;
const response = await fetch(url, {
signal: AbortSignal.timeout(15_000),
headers: { 'User-Agent': 'MetonaAI/1.0' },
});
if (!response.ok) {
throw new Error(`Search API error: ${response.status}`);
}
const data = await response.json() as Record<string, unknown>;
const results: Array<{ title: string; snippet: string; url: string }> = [];
// 提取 AbstractText
if (data.AbstractText) {
results.push({
title: (data.Heading as string) ?? query,
snippet: (data.AbstractText as string).slice(0, 300),
url: (data.AbstractURL as string) ?? '',
});
}
// 提取 RelatedTopics
const related = data.RelatedTopics as Array<Record<string, unknown>> | undefined;
if (related) {
for (const topic of related.slice(0, limit - results.length)) {
if (topic.Text && topic.FirstURL) {
results.push({
title: ((topic.Text as string) ?? '').slice(0, 100),
snippet: (topic.Text as string) ?? '',
url: (topic.FirstURL as string) ?? '',
});
}
}
}
return { query, results, count: results.length };
} catch (error) {
return { query, results: [], count: 0, error: (error as Error).message };
}
}
}
// ===== 6. web_extract =====
export class WebExtractTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'web_extract',
description: 'Fetch web page content and convert to plain text. Supports HTML pages. Content over 5000 chars is auto-summarized.',
parameters: {
type: 'object',
properties: {
urls: { type: 'array', description: 'URL list to fetch (max 5)', items: { type: 'string', description: 'URL to fetch' } },
},
required: ['urls'],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const urls = (args.urls as string[]).slice(0, 5);
const results: Array<{ url: string; content: string; success: boolean; error?: string }> = [];
for (const url of urls) {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(15_000),
headers: {
'User-Agent': 'MetonaAI/1.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
});
if (!response.ok) {
results.push({ url, content: '', success: false, error: `HTTP ${response.status}` });
continue;
}
const html = await response.text();
const text = this.htmlToText(html);
const truncated = text.length > 5000 ? text.slice(0, 5000) + '\n\n[Content truncated at 5000 characters]' : text;
results.push({ url, content: truncated, success: true });
} catch (error) {
results.push({ url, content: '', success: false, error: (error as Error).message });
}
}
return { results, count: results.length };
}
/**
* 简单 HTML → 文本转换
* @see standard/开发规范.md — < 20 行纯函数,允许自写
*/
private htmlToText(html: string): string {
return html
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/\s+/g, ' ')
.trim();
}
}
export { WebSearchTool } from './web-search';
export { WebFetchTool } from './web-fetch';
@@ -0,0 +1,240 @@
/**
* web_fetch — 网页抓取工具
*
* 三阶段回退策略:
* Phase 1: HTTP 抓取(UA 轮换 + 反爬请求头 + 指数退避重试 + 拦截检测)
* Phase 2: 内容过短自动升级(< 200 字符 → 浏览器渲染)
* Phase 3: 浏览器回退(隐藏 BrowserWindow + JS 渲染 + 内容提取)
*
* @see docs/Agent网络工具通用设计-v2.md — 第 3 章 web_fetch 抓取设计
*/
import { BrowserWindow } from 'electron';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import {
fetchCache,
buildAntiCrawlHeaders,
fetchWithTimeout,
htmlToText,
isInterceptedPage,
readBodyWithLimit,
logTool,
} from './network-utils';
// ===== 跳过重试的状态码 =====
const SKIP_RETRY_STATUS = new Set([403, 429, 502, 503]);
// ===== WebFetchTool =====
export class WebFetchTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'web_fetch',
description: 'Fetch a web page and convert to plain text. Uses a three-phase fallback strategy: HTTP fetch with anti-crawl headers → SPA auto-upgrade → browser rendering. Handles Cloudflare interception, JavaScript-rendered pages, and large files (10MB limit).',
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: 'Target URL (http/https only)' },
mobile_ua: { type: 'boolean', description: 'Use mobile User-Agent (default false)' },
retry: { type: 'boolean', description: 'Enable retry with exponential backoff (default true)' },
},
required: ['url'],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 120_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const url = args.url as string;
const mobileUA = (args.mobile_ua as boolean) ?? false;
const enableRetry = (args.retry as boolean) ?? true;
if (!url || !/^https?:\/\//i.test(url)) {
return { url, content: '', success: false, error: 'URL must start with http:// or https://' };
}
logTool('web_fetch', `Fetching: ${url}`);
// ===== Phase 1: HTTP 抓取 =====
const phase1Result = await this.httpFetch(url, mobileUA, enableRetry);
if (phase1Result.success && !phase1Result.intercepted) {
// 内容过短检测 → Phase 2 升级
if (phase1Result.text.length < 200) {
logTool('web_fetch', `Phase 2: Content too short (${phase1Result.text.length} chars), upgrading to browser`);
const browserResult = await this.browserFetch(url);
if (browserResult) {
return this.buildSuccess(url, browserResult, 'browser');
}
}
return this.buildSuccess(url, phase1Result.text, 'http');
}
// ===== Phase 3: 浏览器回退 =====
logTool('web_fetch', `Phase 3: Falling back to browser (${phase1Result.reason})`);
const browserResult = await this.browserFetch(url);
if (browserResult) {
return this.buildSuccess(url, browserResult, 'browser');
}
// 全部失败
return {
url,
content: '',
success: false,
error: `All phases failed. HTTP: ${phase1Result.reason}. Browser fallback also failed.`,
};
}
// ===== Phase 1: HTTP 抓取 =====
private async httpFetch(
url: string,
mobileUA: boolean,
enableRetry: boolean,
): Promise<{ success: boolean; text: string; intercepted: boolean; reason: string }> {
const maxRetries = enableRetry ? 3 : 1;
const backoffBase = 2_000;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const headers = buildAntiCrawlHeaders(url, attempt, mobileUA);
const response = await fetchWithTimeout(url, { headers, redirect: 'follow' }, 20_000);
// 跳过重试的状态码 → 直接进入浏览器回退
if (SKIP_RETRY_STATUS.has(response.status)) {
return { success: false, text: '', intercepted: true, reason: `HTTP ${response.status}` };
}
if (!response.ok) {
// 5xx 可重试
if (response.status >= 500 && attempt < maxRetries - 1) {
await this.sleep(backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6);
continue;
}
return { success: false, text: '', intercepted: false, reason: `HTTP ${response.status} ${response.statusText}` };
}
// 读取正文(10MB 限制)
const html = await readBodyWithLimit(response, 10 * 1024 * 1024);
// 拦截检测
if (isInterceptedPage(html)) {
return { success: false, text: '', intercepted: true, reason: 'Intercepted page detected' };
}
// HTML → 纯文本
const text = htmlToText(html);
return { success: true, text, intercepted: false, reason: '' };
} catch (err) {
const errorMsg = (err as Error).message;
if (attempt < maxRetries - 1) {
logTool('web_fetch', `Attempt ${attempt + 1} failed: ${errorMsg}, retrying...`);
await this.sleep(backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6);
continue;
}
return { success: false, text: '', intercepted: false, reason: errorMsg };
}
}
return { success: false, text: '', intercepted: false, reason: 'All retries exhausted' };
}
// ===== Phase 2/3: 浏览器回退 =====
private async browserFetch(url: string): Promise<string | null> {
// 查缓存
const cached = fetchCache.get(url);
if (cached) {
logTool('web_fetch', 'Browser cache hit');
return cached;
}
let win: BrowserWindow | null = null;
let cleanup: (() => void) | null = null;
try {
win = new BrowserWindow({
width: 1280,
height: 800,
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: false, // 允许跨域(截图需要)
},
});
cleanup = () => {
try {
if (win && !win.isDestroyed()) win.close();
} catch {
// 忽略关闭错误
}
};
await this.loadURLWithTimeout(win, url, 30_000);
// 等待 JS 渲染
await this.sleep(2_500);
// 提取页面正文
const text = await win.webContents.executeJavaScript(`
(function() {
var clone = document.body.cloneNode(true);
var noise = clone.querySelectorAll('script, style, noscript, nav, header, footer, aside, iframe, svg');
noise.forEach(function(el) { el.remove(); });
return clone.innerText || '';
})();
`, true);
if (text && text.trim().length >= 80) {
// 写缓存
fetchCache.set(url, text);
logTool('web_fetch', `Browser fetch success: ${text.length} chars`);
return text;
}
return null;
} catch (err) {
logTool('web_fetch', `Browser fetch failed: ${(err as Error).message}`);
return null;
} finally {
if (cleanup) cleanup();
}
}
// ===== 辅助方法 =====
private buildSuccess(url: string, text: string, method: string): unknown {
return {
url,
content: text,
success: true,
method,
length: text.length,
};
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** 带超时的 loadURLElectron 原生不支持 timeout 选项) */
private async loadURLWithTimeout(win: BrowserWindow, url: string, timeoutMs: number): Promise<void> {
let timer: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`Page load timeout after ${timeoutMs}ms: ${url}`)), timeoutMs);
});
try {
await Promise.race([win.loadURL(url), timeoutPromise]);
} finally {
if (timer) clearTimeout(timer);
}
}
}
@@ -0,0 +1,600 @@
/**
* web_search — 网络搜索工具
*
* 双模式搜索:SearXNG 元搜索 / 内置四引擎并行搜索
* 内置引擎:Bing + 百度 + 搜狗 + 360 搜索(Promise.allSettled 容错并发)
* 智能排序:引擎权重(50%) + 可达性(30%) + 摘要质量(20%)
* 自动抓取:对前 N 条结果调用 web_fetch 获取完整正文
*
* @see docs/Agent网络工具通用设计-v2.md — 第 2 章 web_search 搜索设计
*/
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import type { ConfigService } from '../../../services/config.service';
import {
searchCache,
normalizeUrl,
fetchWithTimeout,
buildSearXNGAuthHeaders,
logTool,
} from './network-utils';
// ===== 类型定义 =====
interface SearchResult {
title: string;
url: string;
snippet: string;
engine: string;
weight: number;
reachable?: boolean;
_score?: number;
_enhanced?: boolean;
}
interface SearXNGConfig {
enabled: boolean;
url: string;
engines: string;
language: string;
safesearch: number;
time_range: string;
max_results: number;
auth_key: string;
auth_type: string;
format: string;
fetch_count: number;
fetch_mode: string;
}
// ===== 引擎定义 =====
interface EngineDef {
name: string;
weight: number;
searchUrl: (query: string, timeRange?: string) => string;
parse: (html: string) => SearchResult[];
}
const ENGINES: EngineDef[] = [
{
name: 'bing',
weight: 90,
searchUrl: (q, tr) => {
const freshness = tr ? `&filters=ex1:"ez${tr === 'day' ? '1' : tr === 'week' ? '2' : tr === 'month' ? '3' : '4'}"` : '';
return `https://www.bing.com/search?q=${encodeURIComponent(q)}${freshness}&count=20`;
},
parse: parseBing,
},
{
name: '百度',
weight: 80,
searchUrl: (q) => `https://www.baidu.com/s?wd=${encodeURIComponent(q)}&rn=20`,
parse: parseBaidu,
},
{
name: '搜狗',
weight: 75,
searchUrl: (q) => `https://www.sogou.com/web?query=${encodeURIComponent(q)}&num=20`,
parse: parseSogou,
},
{
name: '360搜索',
weight: 75,
searchUrl: (q) => `https://www.so.com/s?q=${encodeURIComponent(q)}&pn=20`,
parse: parse360,
},
];
// ===== HTML 解析器(正则实现,后续可迁移至 cheerio) =====
function parseBing(html: string): SearchResult[] {
const results: SearchResult[] = [];
const blocks = html.split(/<li[^>]*class="b_algo"/i).slice(1);
for (const block of blocks) {
const titleMatch = block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
if (!titleMatch) continue;
const url = titleMatch[1];
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
const snippetMatch = block.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || block.match(/class="b_caption"[^>]*>([\s\S]*?)<\/div>/i);
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
if (title && url && !url.includes('bing.com')) {
results.push({ title, url, snippet, engine: 'bing', weight: 90 });
}
}
return results;
}
function parseBaidu(html: string): SearchResult[] {
const results: SearchResult[] = [];
const blocks = html.split(/<div[^>]*class="result[^"]*"/i).slice(1);
for (const block of blocks) {
const titleMatch = block.match(/<a[^>]*data-url="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i)
|| block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
if (!titleMatch) continue;
const url = titleMatch[1].startsWith('http') ? titleMatch[1] : `https://${titleMatch[1]}`;
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
const snippetMatch = block.match(/class="c-abstract[^"]*"[^>]*>([\s\S]*?)<\/span>/i)
|| block.match(/class="content-right[^"]*"[^>]*>([\s\S]*?)<\/div>/i);
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
if (title && url && !url.includes('baidu.com/link')) {
results.push({ title, url, snippet, engine: '百度', weight: 80 });
}
}
return results;
}
function parseSogou(html: string): SearchResult[] {
const results: SearchResult[] = [];
const blocks = html.split(/<div[^>]*class="vrwrap"/i).slice(1)
.concat(html.split(/<div[^>]*class="rb"/i).slice(1));
for (const block of blocks) {
const titleMatch = block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
if (!titleMatch) continue;
const url = titleMatch[1].startsWith('http') ? titleMatch[1] : `https://www.sogou.com${titleMatch[1]}`;
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
const snippetMatch = block.match(/class="star-wiki[^"]*"[^>]*>([\s\S]*?)<\/div>/i)
|| block.match(/class="space-txt[^"]*"[^>]*>([\s\S]*?)<\/p>/i)
|| block.match(/class="str_info[^"]*"[^>]*>([\s\S]*?)<\/p>/i);
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
if (title && url && !url.includes('sogou.com')) {
results.push({ title, url, snippet, engine: '搜狗', weight: 75 });
}
}
return results;
}
function parse360(html: string): SearchResult[] {
const results: SearchResult[] = [];
const blocks = html.split(/<li[^>]*class="res-list"/i).slice(1)
.concat(html.split(/<div[^>]*class="result"/i).slice(1));
for (const block of blocks) {
const titleMatch = block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
if (!titleMatch) continue;
const url = titleMatch[1];
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
const snippetMatch = block.match(/class="res-desc[^"]*"[^>]*>([\s\S]*?)<\/p>/i)
|| block.match(/class="res-rich[^"]*"[^>]*>([\s\S]*?)<\/div>/i)
|| block.match(/class="res-summary[^"]*"[^>]*>([\s\S]*?)<\/p>/i)
|| block.match(/<dd[^>]*>([\s\S]*?)<\/dd>/i);
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
if (title && url && !url.includes('so.com')) {
results.push({ title, url, snippet, engine: '360搜索', weight: 75 });
}
}
return results;
}
// ===== 可达性预检 =====
async function checkReachability(urls: string[], concurrency = 5): Promise<Map<string, boolean>> {
const result = new Map<string, boolean>();
for (let i = 0; i < urls.length; i += concurrency) {
const batch = urls.slice(i, i + concurrency);
const checks = batch.map(async (url) => {
try {
const resp = await fetchWithTimeout(url, { method: 'HEAD', redirect: 'follow' }, 3_000);
result.set(url, resp.ok);
} catch {
result.set(url, false);
}
});
await Promise.allSettled(checks);
}
return result;
}
// ===== 智能排序 =====
function smartSort(results: SearchResult[], reachabilityMap: Map<string, boolean>): SearchResult[] {
for (const r of results) {
const reachability = r.reachable ? 30 : -20;
const snippetQuality = Math.min(r.snippet.length, 100) / 100 * 20;
const weightScore = (r.weight / 100) * 50;
r._score = weightScore + reachability + snippetQuality;
}
return results.sort((a, b) => (b._score ?? 0) - (a._score ?? 0));
}
// ===== 相关性过滤(用于自动抓取) =====
function computeRelevance(query: string, result: SearchResult): number {
const terms: string[] = [];
// 中文双字/三字片段
const chinese = query.match(/[\u4e00-\u9fa5]{2,3}/g);
if (chinese) terms.push(...chinese);
// 英文单词
const english = query.match(/[a-zA-Z]{2,}/g);
if (english) terms.push(...english);
let score = 0;
const text = `${result.title} ${result.snippet}`.toLowerCase();
for (const term of terms) {
if (text.includes(term.toLowerCase())) score += 10;
}
return Math.min(score, 100);
}
// ===== WebSearchTool =====
export class WebSearchTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'web_search',
description: 'Search the web for information. Returns titles, snippets, and URLs. When SearXNG is enabled, uses the configured SearXNG instance; otherwise uses built-in engines (Bing, Baidu, Sogou, 360). Automatically fetches full content for top results.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query keywords' },
max_results: { type: 'number', description: 'Maximum results (default 30, max 30)' },
time_range: { type: 'string', description: 'Time filter: day, week, month, year (optional)' },
enhance_snippets: { type: 'boolean', description: 'Auto-enhance short snippets (default true)' },
fetch_top: { type: 'number', description: 'Auto-fetch full content for top N results (3-8, default 5)' },
},
required: ['query'],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 300_000,
};
constructor(private configService: ConfigService) {}
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const query = args.query as string;
// 读取 SearXNG 配置(提前读取,使工具参数默认值与配置一致)
const searxngConfig = this.readSearXNGConfig();
// max_results: 工具参数 > 配置面板 > 默认 30。取较大值保证配置面板的下限约束
const argMaxResults = (args.max_results as number) ?? undefined;
const cfgMaxResults = searxngConfig.max_results > 0 ? searxngConfig.max_results : undefined;
const maxResults = Math.min(30, Math.max(1, argMaxResults ?? cfgMaxResults ?? 30));
const timeRange = (args.time_range as string) ?? '';
const enhanceSnippets = (args.enhance_snippets as boolean) ?? true;
// fetch_top: 工具参数 > 配置面板 > 默认 5。取较大值保证配置面板的下限约束
const argFetchTop = (args.fetch_top as number) ?? undefined;
const cfgFetchCount = searxngConfig.fetch_count > 0 ? searxngConfig.fetch_count : undefined;
const fetchTop = Math.min(8, Math.max(3, argFetchTop ?? cfgFetchCount ?? 5));
// 缓存检查(缓存 key 含 SearXNG 模式,避免切换后返回旧缓存)
const cacheKey = `${searxngConfig.enabled ? 'searxng' : 'builtin'}:${normalizeUrl(query).toLowerCase()}`;
const cached = searchCache.get(cacheKey);
if (cached) {
logTool('web_search', `Cache hit: "${query}"`);
return { ...(cached as Record<string, unknown>), from_cache: true };
}
let results: SearchResult[];
let mode: string;
let engineStats: Record<string, string>;
if (searxngConfig.enabled && searxngConfig.url) {
const searxResult = await this.searchSearXNG(query, searxngConfig, maxResults, timeRange);
results = searxResult.results;
mode = 'searxng';
engineStats = searxResult.engineStats;
} else {
const builtinResult = await this.searchBuiltinEngines(query, maxResults, timeRange);
results = builtinResult.results;
mode = 'builtin';
engineStats = builtinResult.engineStats;
}
// 合并去重
const deduped = this.deduplicate(results);
// 可达性预检
const topUrls = deduped.slice(0, Math.min(20, deduped.length)).map((r) => r.url);
const reachabilityMap = await checkReachability(topUrls);
for (const r of deduped) {
r.reachable = reachabilityMap.get(r.url) ?? false;
}
// 智能排序
const sorted = smartSort(deduped, reachabilityMap).slice(0, maxResults);
// 摘要增强
if (enhanceSnippets) {
await this.enhanceSnippets(sorted, 3);
}
// 自动抓取完整内容
const fetchedContent = await this.autoFetch(query, sorted, searxngConfig.fetch_mode, fetchTop);
// 格式化输出
const formatted = this.formatResults(query, sorted);
const output = {
success: true,
query,
results: sorted.map((r) => ({
title: r.title,
url: r.url,
snippet: r.snippet,
engine: r.engine,
reachable: r.reachable,
_score: r._score ? Math.round(r._score * 10) / 10 : undefined,
_enhanced: r._enhanced,
})),
total: sorted.length,
formatted,
from_cache: false,
engine_stats: engineStats,
_mode: mode,
_fetched: fetchedContent.map((f) => ({ url: f.url, title: f.title, content: f.content })),
_fetched_count: fetchedContent.length,
};
// 写入缓存
searchCache.set(cacheKey, output);
logTool('web_search', `Completed: ${sorted.length} results, ${fetchedContent.length} fetched, mode=${mode}`);
return output;
}
// ===== SearXNG 搜索 =====
private async searchSearXNG(
query: string,
config: SearXNGConfig,
maxResults: number,
timeRange: string,
): Promise<{ results: SearchResult[]; engineStats: Record<string, string> }> {
const params = new URLSearchParams();
params.set('q', query);
params.set('format', config.format || 'json');
if (config.engines) params.set('engines', config.engines);
if (config.language && config.language !== 'auto') params.set('language', config.language);
params.set('safesearch', String(config.safesearch));
const tr = timeRange || config.time_range;
if (tr) params.set('time_range', tr);
const limit = Math.max(maxResults, config.max_results);
if (limit > 0) params.set('results_count', String(limit));
const searchUrl = `${config.url.replace(/\/$/, '')}/search?${params.toString()}`;
const headers = buildSearXNGAuthHeaders(config.auth_key, config.auth_type);
logTool('web_search', `[SearXNG] Fetching: ${searchUrl}`);
const response = await fetchWithTimeout(searchUrl, { headers }, 15_000);
if (!response.ok) {
throw new Error(`SearXNG API error: ${response.status} ${response.statusText}`);
}
const results: SearchResult[] = [];
const engineStats: Record<string, string> = {};
const engineCounts = new Map<string, number>();
if (config.format === 'html') {
const html = await response.text();
// HTML 模式:简单提取链接和标题
const matches = html.matchAll(/<a[^>]*href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/gi);
for (const m of matches) {
const url = m[1];
const title = m[2].replace(/<[^>]+>/g, '').trim();
if (title && url && !url.includes(config.url)) {
results.push({ title, url, snippet: '', engine: 'searxng', weight: 85 });
}
}
} else {
const data = await response.json() as { results?: Array<Record<string, unknown>> };
for (const item of data.results ?? []) {
const url = item.url as string;
const title = item.title as string;
const snippet = item.content as string;
const engine = (item.engine as string) ?? 'searxng';
results.push({ title, url, snippet: snippet ?? '', engine, weight: 85 });
engineCounts.set(engine, (engineCounts.get(engine) ?? 0) + 1);
}
}
for (const [engine, count] of engineCounts) {
engineStats[engine] = `${count}`;
}
return { results, engineStats };
}
// ===== 内置四引擎并行搜索 =====
private async searchBuiltinEngines(
query: string,
maxResults: number,
timeRange: string,
): Promise<{ results: SearchResult[]; engineStats: Record<string, string> }> {
const searchPromises = ENGINES.map(async (engine) => {
try {
const url = engine.searchUrl(query, timeRange);
const response = await fetchWithTimeout(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
},
}, 8_000);
if (!response.ok) {
logTool('web_search', `[内置] ${engine.name} HTTP ${response.status}`);
return [];
}
const html = await response.text();
const parsed = engine.parse(html);
logTool('web_search', `[内置] ${engine.name}: ${parsed.length} results`);
return parsed;
} catch (err) {
logTool('web_search', `[内置] ${engine.name} error: ${(err as Error).message}`);
return [];
}
});
const settled = await Promise.allSettled(searchPromises);
const allResults: SearchResult[] = [];
const engineStats: Record<string, string> = {};
for (let i = 0; i < ENGINES.length; i++) {
const result = settled[i];
const engineName = ENGINES[i].name;
if (result.status === 'fulfilled') {
allResults.push(...result.value);
engineStats[engineName] = `${result.value.length}`;
} else {
engineStats[engineName] = '失败';
}
}
return { results: allResults, engineStats };
}
// ===== 合并去重 =====
private deduplicate(results: SearchResult[]): SearchResult[] {
const seen = new Map<string, SearchResult>();
for (const r of results) {
const normalized = normalizeUrl(r.url);
if (!seen.has(normalized)) {
seen.set(normalized, r);
}
}
return Array.from(seen.values());
}
// ===== 摘要增强 =====
private async enhanceSnippets(results: SearchResult[], maxEnhance: number): Promise<void> {
let enhanced = 0;
for (const r of results) {
if (enhanced >= maxEnhance) break;
if (r.snippet.length < 30 && r.reachable) {
try {
const resp = await fetchWithTimeout(r.url, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36' },
}, 5_000);
if (resp.ok) {
const html = await resp.text();
// 延迟导入 htmlToText 以避免循环依赖
const { htmlToText } = await import('./network-utils');
const text = htmlToText(html).slice(0, 200);
if (text.length > r.snippet.length) {
r.snippet = text;
r._enhanced = true;
enhanced++;
}
}
} catch {
// 忽略增强失败
}
}
}
}
// ===== 自动抓取完整内容 =====
private async autoFetch(
query: string,
results: SearchResult[],
fetchMode: string,
fetchTop: number,
): Promise<Array<{ url: string; title: string; content: string }>> {
// 相关性评分(不过滤,relevance=0 的结果也参与抓取候选)
const withRelevance = results.map((r) => ({ result: r, relevance: computeRelevance(query, r) }));
const filtered = withRelevance.length > 0 ? withRelevance : [];
// 确定抓取数量:fetchTop 已在 execute() 中综合了配置面板和工具参数
const topN = Math.min(fetchTop, 8, filtered.length);
// 构建抓取列表
let toFetch: typeof filtered;
if (fetchMode === 'random') {
// Fisher-Yates 洗牌
const shuffled = [...filtered];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
toFetch = shuffled.slice(0, topN);
} else {
toFetch = filtered
.sort((a, b) => b.relevance - a.relevance)
.slice(0, topN);
}
const fetched: Array<{ url: string; title: string; content: string }> = [];
const { htmlToText } = await import('./network-utils');
for (const item of toFetch) {
try {
const resp = await fetchWithTimeout(item.result.url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36',
'Accept-Language': 'zh-CN,zh;q=0.9',
},
}, 15_000);
if (resp.ok) {
const html = await resp.text();
const content = htmlToText(html);
fetched.push({ url: item.result.url, title: item.result.title, content });
}
} catch (err) {
logTool('web_search', `Auto-fetch failed for ${item.result.url}: ${(err as Error).message}`);
// 从剩余结果中随机补充
const remaining = withRelevance.filter((x) => !toFetch.includes(x) && !fetched.some((f) => f.url === x.result.url));
if (remaining.length > 0) {
const randomPick = remaining[Math.floor(Math.random() * remaining.length)];
try {
const resp2 = await fetchWithTimeout(randomPick.result.url, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36' },
}, 10_000);
if (resp2.ok) {
const html2 = await resp2.text();
fetched.push({ url: randomPick.result.url, title: randomPick.result.title, content: htmlToText(html2) });
}
} catch {
// 忽略补充失败
}
}
}
}
return fetched;
}
// ===== 格式化人类可读输出 =====
private formatResults(query: string, results: SearchResult[]): string {
if (results.length === 0) return `搜索 "${query}" 无结果。`;
const lines = [`搜索 "${query}" — ${results.length} 条结果:\n`];
results.forEach((r, i) => {
lines.push(`${i + 1}. ${r.title}`);
lines.push(` URL: ${r.url}`);
if (r.snippet) lines.push(` 摘要: ${r.snippet.slice(0, 150)}`);
lines.push(` 来源: ${r.engine}${r.reachable === false ? ' (不可达)' : ''}${r._enhanced ? ' [已增强]' : ''}\n`);
});
return lines.join('\n');
}
// ===== 读取 SearXNG 配置 =====
private readSearXNGConfig(): SearXNGConfig {
const cs = this.configService;
return {
enabled: cs.get<boolean>('searxng.enabled') ?? false,
url: cs.get<string>('searxng.url') ?? '',
engines: cs.get<string>('searxng.engines') ?? '',
language: cs.get<string>('searxng.language') ?? 'zh-CN',
safesearch: cs.get<number>('searxng.safesearch') ?? 1,
time_range: cs.get<string>('searxng.time_range') ?? '',
max_results: cs.get<number>('searxng.max_results') ?? 0,
auth_key: cs.get<string>('searxng.auth_key') ?? '',
auth_type: cs.get<string>('searxng.auth_type') ?? 'bearer',
format: cs.get<string>('searxng.format') ?? 'json',
fetch_count: cs.get<number>('searxng.fetch_count') ?? 0,
fetch_mode: cs.get<string>('searxng.fetch_mode') ?? 'sequential',
};
}
}
+8
View File
@@ -58,6 +58,14 @@ export class ToolRegistry {
.map((e) => e.tool.definition);
}
/** 列出所有工具定义(含已禁用的,供设置 UI 使用) */
listAllTools(): Array<MetonaToolDef & { enabled: boolean }> {
return Array.from(this.tools.values()).map((e) => ({
...e.tool.definition,
enabled: e.enabled && !this.disabledTools.has(e.tool.definition.name),
}));
}
/** 设置工具启用/禁用状态(供 IPC tools:toggle 调用) */
setToolEnabled(name: string, enabled: boolean): void {
if (enabled) {
+80 -1
View File
@@ -405,6 +405,44 @@ export function registerAllIPCHandlers(
log.info(`[CONFIG] LLM config changed (${key}), adapter reloaded`);
}
// Agent 配置变更时热更新 Engine
if (key === 'agent.maxIterations') {
agentLoop.updateConfig({ maxIterations: value as number });
log.info(`[CONFIG] Agent maxIterations updated to ${value}`);
} else if (key === 'agent.totalTimeoutMs') {
agentLoop.updateConfig({ totalTimeoutMs: value as number });
log.info(`[CONFIG] Agent totalTimeoutMs updated to ${value}`);
} else if (key === 'agent.enableThinking') {
agentLoop.updateConfig({ thinkingEnabled: value as boolean });
log.info(`[CONFIG] Agent thinkingEnabled updated to ${value}`);
} else if (key === 'agent.thinkingEffort') {
agentLoop.updateConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' });
log.info(`[CONFIG] Agent thinkingEffort updated to ${value}`);
} else if (key === 'ollama.numCtx') {
// numCtx 同时更新 Engine 的 contextLength
agentLoop.updateConfig({ contextLength: (value as number) || undefined });
log.info(`[CONFIG] Agent contextLength updated to ${value}`);
}
// 日志级别变更时即时应用
if (key === 'logging.level') {
const level = value as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly';
log.transports.file.level = level;
log.transports.console.level = level;
log.info(`[CONFIG] Log level updated to ${level}`);
}
// 工作空间路径变更时写入独立文件(下次启动生效)
if (key === 'workspace.path') {
try {
const { writeWorkspacePathToFile } = await import('../main');
if (value) writeWorkspacePathToFile(value as string);
log.info(`[CONFIG] Workspace path saved (restart required): ${value}`);
} catch (err) {
log.error('[CONFIG] Failed to save workspace path:', err);
}
}
return { success: true };
});
@@ -439,18 +477,23 @@ export function registerAllIPCHandlers(
// ===== 工具管理 =====
ipcMain.handle('tools:list', async () => {
return toolRegistry.listTools().map((t) => ({
return toolRegistry.listAllTools().map((t) => ({
name: t.name,
description: t.description,
category: t.category,
riskLevel: t.riskLevel,
requiresPermission: t.requiresPermission,
enabled: t.enabled,
}));
});
ipcMain.handle('tools:toggle', async (_event, toolName: string, enabled: boolean) => {
// 工具开关通过配置持久化
configService.set(`tools.${toolName}.enabled`, enabled);
// 同步到 ToolRegistry(立即生效)
toolRegistry.setToolEnabled(toolName, enabled);
// 同步到 AgentLoop 的工具列表
agentLoop.setTools(toolRegistry.listTools());
log.info(`Tool ${toolName} ${enabled ? 'enabled' : 'disabled'}`);
return { success: true };
});
@@ -528,5 +571,41 @@ export function registerAllIPCHandlers(
}
});
// ===== SearXNG =====
ipcMain.handle('searxng:testConnection', async (_event, url: string, authKey: string, authType: string) => {
try {
if (!url || !/^https?:\/\//.test(url)) {
return { success: false, error: 'URL 需以 http:// 或 https:// 开头' };
}
const startTime = Date.now();
const headers: Record<string, string> = {};
if (authKey) {
if (authType === 'bearer') {
headers['Authorization'] = `Bearer ${authKey}`;
} else if (authType === 'basic') {
headers['Authorization'] = `Basic ${Buffer.from(authKey).toString('base64')}`;
}
}
const testUrl = `${url.replace(/\/$/, '')}/search?q=test&format=json&pageno=1`;
const response = await fetch(testUrl, {
headers,
signal: AbortSignal.timeout(10_000),
});
const latencyMs = Date.now() - startTime;
if (response.ok) {
return { success: true, statusCode: response.status, latencyMs };
}
return { success: false, statusCode: response.status, latencyMs, error: `HTTP ${response.status} ${response.statusText}` };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
log.info('[SYS] All IPC handlers registered');
}
+64 -5
View File
@@ -16,6 +16,7 @@
import { app, shell, Menu } from 'electron';
import { join } from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { electronApp, optimizer } from '@electron-toolkit/utils';
import log from 'electron-log';
import { DatabaseService } from './services/database.service';
@@ -37,9 +38,10 @@ import { AgnesAdapter } from './harness/adapters/agnes-ai.adapter';
import { OllamaAdapter } from './harness/adapters/ollama.adapter';
import {
ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool,
WebSearchTool, WebExtractTool,
WebSearchTool, WebFetchTool,
MemoryStoreTool, MemorySearchTool,
RunCommandTool,
browserTools, cleanupBrowser,
} from './harness/tools/built-in';
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks';
import { PolicyEngine } from './harness/sandbox/permissions';
@@ -52,6 +54,31 @@ import { UpdateService } from './services/update.service';
log.transports.file.level = 'info';
log.transports.console.level = 'debug';
// ===== 工作空间路径独立存储(解决 DB 在 workspace 内的鸡生蛋问题)=====
const WORKSPACE_CONFIG_FILE = join(app.getPath('userData'), 'workspace-config.json');
function readWorkspacePathFromFile(): string | null {
try {
if (existsSync(WORKSPACE_CONFIG_FILE)) {
const data = JSON.parse(readFileSync(WORKSPACE_CONFIG_FILE, 'utf-8'));
return data.workspacePath ?? null;
}
} catch {
// 忽略读取错误
}
return null;
}
function writeWorkspacePathToFile(workspacePath: string): void {
try {
writeFileSync(WORKSPACE_CONFIG_FILE, JSON.stringify({ workspacePath }, null, 2), 'utf-8');
} catch (err) {
log.error('Failed to write workspace config file:', err);
}
}
export { readWorkspacePathFromFile, writeWorkspacePathToFile };
let databaseService: DatabaseService | null = null;
let trayManager: TrayManager | null = null;
let windowManager: WindowManager | null = null;
@@ -67,9 +94,14 @@ async function initialize(): Promise<void> {
optimizer.watchWindowShortcuts(window);
});
// ===== 步骤 2: 工作空间 =====
const workspaceService = new WorkspaceService();
// ===== 步骤 2: 工作空间(优先从独立配置文件读取路径)=====
const savedWorkspacePath = readWorkspacePathFromFile();
const workspaceService = new WorkspaceService(savedWorkspacePath ?? undefined);
const workspaceInfo = workspaceService.initialize();
// 持久化工作空间路径(供下次启动读取)
if (savedWorkspacePath !== workspaceInfo.path) {
writeWorkspacePathToFile(workspaceInfo.path);
}
log.info(`Workspace: ${workspaceInfo.path} (missing: ${workspaceInfo.missingFiles.join(', ') || 'none'})`);
// ===== 步骤 3: SQLite =====
@@ -122,11 +154,16 @@ async function initialize(): Promise<void> {
toolRegistry.registerBuiltin(new WriteFileTool());
toolRegistry.registerBuiltin(new ListDirectoryTool());
toolRegistry.registerBuiltin(new SearchFilesTool());
toolRegistry.registerBuiltin(new WebSearchTool());
toolRegistry.registerBuiltin(new WebExtractTool());
toolRegistry.registerBuiltin(new WebSearchTool(configService));
toolRegistry.registerBuiltin(new WebFetchTool());
toolRegistry.registerBuiltin(new MemoryStoreTool(memoryManager));
toolRegistry.registerBuiltin(new MemorySearchTool(memoryManager));
toolRegistry.registerBuiltin(new RunCommandTool());
// 注册 9 个 Browser 浏览器工具
for (const tool of browserTools) {
toolRegistry.registerBuiltin(tool);
}
log.info(`Registered ${toolRegistry.size} built-in tools`);
// ===== MCP Manager =====
@@ -159,11 +196,15 @@ async function initialize(): Promise<void> {
const ollamaNumCtx = configService.get<number>('ollama.numCtx');
const agentMaxIter = configService.get<number>('agent.maxIterations');
const agentTimeout = configService.get<number>('agent.totalTimeoutMs');
const agentThinkingEnabled = configService.get<boolean>('agent.enableThinking');
const agentThinkingEffort = configService.get<string>('agent.thinkingEffort') as 'low' | 'medium' | 'high' | 'max' | null;
const agentLoop = new AgentLoopEngine(
{
maxIterations: agentMaxIter ?? 20,
totalTimeoutMs: agentTimeout ?? 600_000,
contextLength: ollamaNumCtx ?? undefined,
thinkingEnabled: agentThinkingEnabled ?? true,
thinkingEffort: agentThinkingEffort ?? 'high',
},
adapter, toolRegistry, preToolHooks, postToolHooks,
);
@@ -245,9 +286,27 @@ async function initialize(): Promise<void> {
trayManager?.destroy();
windowManager?.closeAll();
try { await mcpManager.shutdown(); } catch (err) { log.error('[Shutdown] MCP shutdown failed:', err); }
cleanupBrowser();
if (databaseService) { databaseService.close(); databaseService = null; }
});
// ===== 应用日志级别配置 =====
const logLevel = configService.get<string>('logging.level') as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly' | null;
if (logLevel) {
log.transports.file.level = logLevel;
log.transports.console.level = logLevel;
log.info(`Log level applied: ${logLevel}`);
}
// ===== 恢复工具启用/禁用状态 =====
for (const toolDef of toolRegistry.listAllTools()) {
const stored = configService.get<boolean>(`tools.${toolDef.name}.enabled`);
if (stored === false) {
toolRegistry.setToolEnabled(toolDef.name, false);
log.info(`Tool restored as disabled: ${toolDef.name}`);
}
}
log.info(`MetonaAI Desktop initialized [Provider: ${configService.get<string>('llm.provider') ?? 'none'}, Model: ${configService.get<string>('llm.model') ?? 'none'}, Tools: ${toolRegistry.size}]`);
}
+8 -2
View File
@@ -94,6 +94,12 @@ const metonaAPI = {
clearAuditLogs: () => ipcRenderer.invoke('data:clearAuditLogs'),
},
// ===== SearXNG =====
searxng: {
testConnection: (url: string, authKey: string, authType: string) =>
ipcRenderer.invoke('searxng:testConnection', url, authKey, authType),
},
// ===== Toast 通知桥接 =====
toast: {
onShow: (callback: (data: { type: string; message: string; options?: unknown }) => void) => {
@@ -115,6 +121,6 @@ if (process.contextIsolated) {
}
} else {
// 降级方案(不推荐)
(window as unknown as Record<string, unknown>).electron = electronAPI;
(window as unknown as Record<string, unknown>).metona = metonaAPI;
(globalThis as unknown as Record<string, unknown>).electron = electronAPI;
(globalThis as unknown as Record<string, unknown>).metona = metonaAPI;
}
+28 -46
View File
@@ -38,34 +38,20 @@ const REQUIRED_FILES = ['SOUL.md', 'AGENTS.md', 'MEMORY.md', 'USERS.md'] as cons
const AUTO_CREATED_DIRS = ['logs', 'traces', '.metona'] as const;
const MEMORY_TEMPLATE = `# MEMORY.md — AI 持久记忆
#
# 格式版本: 1.0
# 创建时间: __CREATED_AT__
# 最后更新: __UPDATED_AT__
# 工作空间: __WORKSPACE_PATH__
#
# Metona Agent
# Agent
> 创建时间: __CREATED_AT__
> 最后更新: __UPDATED_AT__
> 工作空间: __WORKSPACE_PATH__
##
# : - []
# : - []
##
# : - []
# : - [MyApp] 技术栈: React + TypeScript
##
# : - YYYY-MM-DD: 决策内容
# : - 2026-06-25: 选择 sql.js
##
# : - [] (状态: pending/done/cancelled)
# : - [pending]
##
# : - | |
# : - | |
`;
// ===== 服务类 =====
@@ -161,10 +147,9 @@ export class WorkspaceService {
let content = readFileSync(memoryPath, 'utf-8');
const now = new Date().toISOString();
// 替换最后更新时间戳
content = content.replace(
/# 最后更新: .*/,
`# 最后更新: ${now}`,
/> 最后更新: .*/,
`> 最后更新: ${now}`,
);
writeFileSync(memoryPath, content, 'utf-8');
@@ -201,8 +186,8 @@ export class WorkspaceService {
// 更新时间戳
content = content.replace(
/# 最后更新: .*/,
`# 最后更新: ${new Date().toISOString()}`,
/> 最后更新: .*/,
`> 最后更新: ${new Date().toISOString()}`,
);
writeFileSync(memoryPath, content, 'utf-8');
@@ -211,17 +196,12 @@ export class WorkspaceService {
}
/**
* MEMORY.md 6
* MEMORY.md
*
*
* 1. # # # #
* 2. ## ## ##
* 3. - [/]
* 4. 使 YYYY-MM-DD
* 5. [pending/done/cancelled]
* 6. #
*
* @see docs/MetonaAI-Desktop .html MEMORY.md
*
* 1. >
* 2.
* 3.
*/
validateMemoryFormat(): boolean {
const memoryPath = join(this.workspacePath, 'MEMORY.md');
@@ -230,33 +210,35 @@ export class WorkspaceService {
let content = readFileSync(memoryPath, 'utf-8');
let modified = false;
// 规则 1: 元数据头
const requiredMeta = ['# 格式版本', '# 创建时间', '# 最后更新', '# 工作空间'];
// 规则 1: 元数据头> 引用语法)
const requiredMeta: Array<{ key: string; line: string }> = [
{ key: '> 创建时间', line: `> 创建时间: ${new Date().toISOString()}` },
{ key: '> 最后更新', line: `> 最后更新: ${new Date().toISOString()}` },
{ key: '> 工作空间', line: `> 工作空间: ${this.workspacePath}` },
];
for (const meta of requiredMeta) {
if (!content.includes(meta)) {
// 自动补充缺失的元数据
const metaLine = meta === '# 工作空间'
? `# 工作空间: ${this.workspacePath}`
: `${meta}: ${new Date().toISOString()}`;
content = content.replace(/^# MEMORY/m, `# MEMORY\n#\n# ${metaLine.replace('# ', '')}`);
if (!content.includes(meta.key)) {
// 在标题后插入元数据
const titleEnd = content.indexOf('\n', content.indexOf('# MEMORY')) + 1;
content = content.slice(0, titleEnd) + meta.line + '\n' + content.slice(titleEnd);
modified = true;
log.info(`MEMORY.md: auto-added missing metadata "${meta}"`);
log.info(`MEMORY.md: auto-added missing metadata "${meta.key}"`);
}
}
// 规则 2: 分区结构
// 规则 2: 核心分区
const requiredSections = ['## 用户偏好', '## 项目上下文', '## 重要决策'];
for (const section of requiredSections) {
if (!content.includes(section)) {
content += `\n${section}\n# 格式: - [类别] 内容描述\n`;
content += `\n${section}\n`;
modified = true;
log.info(`MEMORY.md: auto-added missing section "${section}"`);
}
}
// 规则 6: 时间戳更新
// 规则 3: 时间戳更新
const now = new Date().toISOString();
content = content.replace(/# 最后更新: .*/, `# 最后更新: ${now}`);
content = content.replace(/> 最后更新: .*/, `> 最后更新: ${now}`);
if (modified) {
writeFileSync(memoryPath, content, 'utf-8');
+27 -74
View File
@@ -1,12 +1,12 @@
{
"name": "metona-ai-desktop",
"version": "0.1.0",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ai-desktop",
"version": "0.1.0",
"version": "0.1.1",
"license": "MIT",
"dependencies": {
"@emotion/react": "^11.14.0",
@@ -701,9 +701,9 @@
}
},
"node_modules/@electron/universal/node_modules/fs-extra": {
"version": "11.3.5",
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.5.tgz",
"integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
"version": "11.3.6",
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.6.tgz",
"integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -777,9 +777,9 @@
}
},
"node_modules/@electron/windows-sign/node_modules/fs-extra": {
"version": "11.3.5",
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.5.tgz",
"integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
"version": "11.3.6",
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.6.tgz",
"integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -3567,22 +3567,6 @@
"semver": "bin/semver.js"
}
},
"node_modules/app-builder-lib/node_modules/ci-info": {
"version": "4.3.1",
"resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-4.3.1.tgz",
"integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/app-builder-lib/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz",
@@ -3933,9 +3917,9 @@
"optional": true
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.7",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4327,9 +4311,9 @@
"license": "MIT"
},
"node_modules/ci-info": {
"version": "4.4.0",
"resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-4.4.0.tgz",
"integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==",
"version": "4.3.1",
"resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-4.3.1.tgz",
"integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==",
"dev": true,
"funding": [
{
@@ -5035,23 +5019,6 @@
"util-deprecate": "~1.0.1"
}
},
"node_modules/duplexer2/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
},
"node_modules/duplexer2/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
@@ -8777,9 +8744,9 @@
}
},
"node_modules/node-abi": {
"version": "4.31.0",
"resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-4.31.0.tgz",
"integrity": "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw==",
"version": "4.33.0",
"resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-4.33.0.tgz",
"integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9449,9 +9416,9 @@
}
},
"node_modules/prebuild-install/node_modules/node-abi": {
"version": "3.92.0",
"resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.92.0.tgz",
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
"version": "3.94.0",
"resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.94.0.tgz",
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
@@ -10110,23 +10077,9 @@
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/safer-buffer": {
@@ -10528,12 +10481,12 @@
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
"safe-buffer": "~5.1.0"
}
},
"node_modules/string-width": {
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "metona-ai-desktop",
"version": "0.1.0",
"version": "0.1.1",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js",
"author": "Metona Team",
@@ -8,7 +8,8 @@
"type": "module",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build && electron-builder",
"build": "npm run prebuild && electron-vite build && electron-builder",
"prebuild": "npm dedupe && rimraf release",
"build:renderer": "electron-vite build --rendererOnly",
"build:electron": "tsc -p tsconfig.node.json",
"preview": "vite preview",
@@ -72,6 +73,7 @@
"allowScripts": {
"electron@35.7.5": true,
"better-sqlite3@11.10.0": true,
"esbuild@0.25.12": true
"esbuild@0.25.12": true,
"electron-winstaller@5.4.0": true
}
}
+17 -12
View File
@@ -135,28 +135,33 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
function ToolManagerPanel() {
const [expanded, setExpanded] = useState(false);
const tools = [
{ name: 'read_file', label: '读取文件', risk: 'SAFE' }, { name: 'write_file', label: '写入文件', risk: 'MEDIUM' },
{ name: 'list_directory', label: '列出目录', risk: 'SAFE' }, { name: 'search_files', label: '搜索文件', risk: 'SAFE' },
{ name: 'web_search', label: '网络搜索', risk: 'LOW' }, { name: 'web_extract', label: '网页抓取', risk: 'LOW' },
{ name: 'memory_store', label: '存储记忆', risk: 'MEDIUM' }, { name: 'memory_search', label: '搜索记忆', risk: 'SAFE' },
{ name: 'run_command', label: '执行命令', risk: 'HIGH' },
];
const riskColors: Record<string, string> = { SAFE: 'success.main', LOW: 'info.main', MEDIUM: 'warning.main', HIGH: 'error.main' };
const [tools, setTools] = useState<Array<{ name: string; description: string; category: string; riskLevel: string; requiresPermission: boolean; enabled: boolean }>>([]);
useEffect(() => {
if (window.metona?.tools?.list) {
window.metona.tools.list().then((list) => {
setTools(list as MetonaToolInfo[]);
}).catch((err) => { console.error('[Sidebar]', err); });
}
}, []);
const readyCount = tools.filter((t) => t.enabled).length;
const riskColors: Record<string, string> = { safe: 'success.main', low: 'info.main', medium: 'warning.main', high: 'error.main' };
const riskLabels: Record<string, string> = { safe: 'SAFE', low: 'LOW', medium: 'MEDIUM', high: 'HIGH' };
return (
<Box>
<ListItemButton onClick={() => setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}>
<ListItemIcon sx={{ minWidth: 24 }}>{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}<Wrench size={12} style={{ marginLeft: 4 }} /></ListItemIcon>
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12 }}></Typography>} />
<Typography variant="caption" sx={{ color: 'success.main', fontSize: 10 }}>9 </Typography>
<Typography variant="caption" sx={{ color: readyCount > 0 ? 'success.main' : 'text.disabled', fontSize: 10 }}>{readyCount} </Typography>
</ListItemButton>
<Collapse in={expanded}>
<List dense disablePadding sx={{ pl: 3 }}>
{tools.map((t) => (
<ListItemButton key={t.name} dense sx={{ py: 0.25, px: 1, borderRadius: 1 }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: riskColors[t.risk], mr: 1, flexShrink: 0 }} />
<Typography variant="caption" sx={{ flex: 1, fontSize: 11, color: 'text.secondary' }}>{t.label}</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: riskColors[t.risk] }}>{t.risk}</Typography>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: t.enabled ? riskColors[t.riskLevel] ?? 'text.disabled' : 'text.disabled', mr: 1, flexShrink: 0 }} />
<Typography variant="caption" sx={{ flex: 1, fontSize: 11, color: t.enabled ? 'text.secondary' : 'text.disabled' }}>{t.name}</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: t.enabled ? (riskColors[t.riskLevel] ?? 'text.disabled') : 'text.disabled' }}>{riskLabels[t.riskLevel] ?? t.riskLevel}</Typography>
</ListItemButton>
))}
</List>
+1 -1
View File
@@ -18,7 +18,7 @@ export function StatusBar(): React.JSX.Element {
const model = useAgentStore((s) => s.model);
const tokenUsage = useAgentStore((s) => s.tokenUsage);
const openSettings = useUIStore((s) => s.openSettings);
const [version, setVersion] = useState('v0.1.0');
const [version, setVersion] = useState('v0.1.1');
useEffect(() => {
if (window.metona?.app?.getVersion) {
+193 -4
View File
@@ -3,16 +3,17 @@
*/
import { useState, useEffect, useCallback } from 'react';
import { Dialog, DialogContent, DialogTitle, Button, TextField, Select, MenuItem, Tabs, Tab, Checkbox, Box, Typography, Stack, Divider, FormControlLabel, InputLabel, FormControl, IconButton, Chip } from '@mui/material';
import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen } from 'lucide-react';
import { Dialog, DialogContent, DialogTitle, Button, TextField, Select, MenuItem, Tabs, Tab, Checkbox, Box, Typography, Stack, Divider, FormControlLabel, InputLabel, FormControl, IconButton, Chip, Switch, Alert, CircularProgress } from '@mui/material';
import { X, Settings, Bot, Wrench, Server, Palette, FileText, Eye, EyeOff, FolderOpen, Globe } from 'lucide-react';
import { useUIStore, type ThemeMode } from '@renderer/stores/ui-store';
import { useAgentStore } from '@renderer/stores/agent-store';
type SettingsTab = 'workspace' | 'llm' | 'agent' | 'tools' | 'mcp' | 'appearance' | 'logs';
type SettingsTab = 'workspace' | 'llm' | 'agent' | 'tools' | 'mcp' | 'searxng' | 'appearance' | 'logs';
const TABS: { id: SettingsTab; label: string; icon: typeof Settings }[] = [
{ id: 'workspace', label: '工作空间', icon: FolderOpen },
{ id: 'llm', label: 'LLM 配置', icon: Bot }, { id: 'agent', label: 'Agent 配置', icon: Settings },
{ id: 'tools', label: '工具管理', icon: Wrench }, { id: 'mcp', label: 'MCP 服务', icon: Server },
{ id: 'searxng', label: 'SearXNG', icon: Globe },
{ id: 'appearance', label: '外观', icon: Palette }, { id: 'logs', label: '日志与数据', icon: FileText },
];
@@ -50,6 +51,7 @@ export function SettingsModal(): React.JSX.Element | null {
{tab === 'agent' && <AgentSettings />}
{tab === 'tools' && <ToolsSettings />}
{tab === 'mcp' && <MCPSettings />}
{tab === 'searxng' && <SearXNGSettings />}
{tab === 'appearance' && <AppearanceSettings theme={theme} setTheme={setTheme} />}
{tab === 'logs' && <LogsSettings />}
</Box>
@@ -193,7 +195,7 @@ function AgentSettings() {
function ToolsSettings() {
const [tools, setTools] = useState<Array<{ name: string; description: string; riskLevel: string; enabled: boolean }>>([]);
useEffect(() => { if (window.metona?.tools?.list) window.metona.tools.list().then((l) => setTools((l as MetonaToolInfo[]).map((t) => ({ ...t, enabled: true })))).catch((err) => { console.error('[SettingsModal]', err); }); }, []);
useEffect(() => { if (window.metona?.tools?.list) window.metona.tools.list().then((l) => setTools((l as MetonaToolInfo[]).map((t) => ({ name: t.name, description: t.description, riskLevel: t.riskLevel, enabled: t.enabled })))).catch((err) => { console.error('[SettingsModal]', err); }); }, []);
const handleToggle = (name: string, enabled: boolean) => { setTools((p) => p.map((t) => t.name === name ? { ...t, enabled } : t)); window.metona?.tools?.toggle(name, enabled).catch((err) => { console.error('[SettingsModal]', err); }); };
const riskColors: Record<string, 'success' | 'info' | 'warning' | 'error'> = { safe: 'success', low: 'info', medium: 'warning', high: 'error' };
@@ -259,6 +261,193 @@ function MCPSettings() {
);
}
function SearXNGSettings() {
// ===== 12 项配置(useConfig 实时持久化) =====
const [enabled, setEnabled] = useConfig('searxng.enabled', false);
const [url, setUrl] = useConfig('searxng.url', '');
const [engines, setEngines] = useConfig('searxng.engines', '');
const [language, setLanguage] = useConfig('searxng.language', 'zh-CN');
const [safesearch, setSafesearch] = useConfig('searxng.safesearch', 1);
const [timeRange, setTimeRange] = useConfig('searxng.time_range', '');
const [maxResults, setMaxResults] = useConfig('searxng.max_results', 0);
const [authKey, setAuthKey] = useConfig('searxng.auth_key', '');
const [authType, setAuthType] = useConfig('searxng.auth_type', 'bearer');
const [format, setFormat] = useConfig('searxng.format', 'json');
const [fetchCount, setFetchCount] = useConfig('searxng.fetch_count', 0);
const [fetchMode, setFetchMode] = useConfig('searxng.fetch_mode', 'sequential');
const [showKey, setShowKey] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const urlError = !!url && !/^https?:\/\//.test(url);
const handleTest = async () => {
if (!url.trim() || urlError) return;
setTesting(true);
setTestResult(null);
try {
const result = await window.metona?.searxng?.testConnection(url.trim(), authKey, authType);
if (result?.success) {
setTestResult({ success: true, message: `连接成功(${result.latencyMs}ms` });
} else {
setTestResult({ success: false, message: result?.error || `连接失败(HTTP ${result?.statusCode}` });
}
} catch (e) {
setTestResult({ success: false, message: (e as Error).message });
}
setTesting(false);
};
return (
<Stack spacing={2}>
{/* 标题 + 状态徽章 */}
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>SearXNG </Typography>
<Chip label={enabled ? '已启用' : '未启用'} size="small" color={enabled ? 'success' : 'default'} variant={enabled ? 'filled' : 'outlined'} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
SearXNG 70+ 退 Bing + + + 360
</Typography>
{/* 启用开关 */}
<FormControlLabel control={<Switch checked={enabled} onChange={(e) => setEnabled(e.target.checked)} size="small" />} label={<Typography variant="body2"> SearXNG</Typography>} />
<Divider />
{/* API 地址 + 连接测试 */}
<Stack direction="row" spacing={1} sx={{ alignItems: 'flex-start' }}>
<TextField
size="small"
label="API 地址"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="如 https://searxng.example.com"
error={urlError}
helperText={urlError ? '需以 http:// 或 https:// 开头' : '实例根地址(不含 /search 路径)'}
sx={{ flex: 1 }}
/>
<Button variant="outlined" size="small" onClick={handleTest} disabled={!url.trim() || urlError || testing} sx={{ mt: 0.5, minWidth: 90, height: 40 }}>
{testing ? <CircularProgress size={14} /> : '测试连接'}
</Button>
</Stack>
{/* 测试结果 */}
{testResult && (
<Alert severity={testResult.success ? 'success' : 'error'} sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
{testResult.message}
</Alert>
)}
{/* 搜索引擎 */}
<TextField
size="small"
label="搜索引擎(逗号分隔)"
value={engines}
onChange={(e) => setEngines(e.target.value)}
placeholder="如 google,bing,duckduckgo(留空使用实例默认)"
/>
{/* 语言 + 安全搜索 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<FormControl size="small"><InputLabel></InputLabel>
<Select value={language} label="语言" onChange={(e) => setLanguage(e.target.value)}>
<MenuItem value="zh-CN"></MenuItem>
<MenuItem value="zh-TW"></MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ja"></MenuItem>
<MenuItem value="ko"></MenuItem>
<MenuItem value="auto"></MenuItem>
</Select>
</FormControl>
<FormControl size="small"><InputLabel></InputLabel>
<Select value={safesearch} label="安全搜索" onChange={(e) => setSafesearch(e.target.value as number)}>
<MenuItem value={0}></MenuItem>
<MenuItem value={1}></MenuItem>
<MenuItem value={2}></MenuItem>
</Select>
</FormControl>
</Box>
{/* 时间范围 + 返回格式 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<FormControl size="small"><InputLabel></InputLabel>
<Select value={timeRange} label="时间范围" onChange={(e) => setTimeRange(e.target.value)}>
<MenuItem value=""></MenuItem>
<MenuItem value="day"></MenuItem>
<MenuItem value="week"></MenuItem>
<MenuItem value="month"></MenuItem>
<MenuItem value="year"></MenuItem>
</Select>
</FormControl>
<FormControl size="small"><InputLabel></InputLabel>
<Select value={format} label="返回格式" onChange={(e) => setFormat(e.target.value)}>
<MenuItem value="json">JSON</MenuItem>
<MenuItem value="html">HTML</MenuItem>
</Select>
</FormControl>
</Box>
{/* 最大结果数 + 自动抓取条数 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<TextField
size="small"
label="最大结果数"
type="number"
value={maxResults}
onChange={(e) => setMaxResults(Number(e.target.value))}
placeholder="0 表示使用默认"
slotProps={{ htmlInput: { min: 0, max: 50 } }}
/>
<TextField
size="small"
label="自动抓取条数"
type="number"
value={fetchCount}
onChange={(e) => setFetchCount(Number(e.target.value))}
placeholder="0 表示由 AI 决定"
slotProps={{ htmlInput: { min: 0, max: 8 } }}
/>
</Box>
{/* 抓取类型 */}
<FormControl size="small"><InputLabel></InputLabel>
<Select value={fetchMode} label="抓取类型" onChange={(e) => setFetchMode(e.target.value)}>
<MenuItem value="sequential"></MenuItem>
<MenuItem value="random"></MenuItem>
</Select>
</FormControl>
<Divider />
{/* 认证设置 */}
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}></Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
<FormControl size="small"><InputLabel></InputLabel>
<Select value={authType} label="认证类型" onChange={(e) => setAuthType(e.target.value)}>
<MenuItem value="bearer">Bearer Token</MenuItem>
<MenuItem value="basic">Basic Auth</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label={authType === 'bearer' ? 'Token' : '用户名:密码'}
type={showKey ? 'text' : 'password'}
value={authKey}
onChange={(e) => setAuthKey(e.target.value)}
placeholder={authType === 'bearer' ? '访问令牌原值' : 'username:password'}
slotProps={{ input: { endAdornment: <IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton> } }}
/>
</Box>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
{authType === 'bearer'
? 'Bearer: 直接填写令牌原值,原样透传到 Authorization 头。建议配合 HTTPS 使用。'
: 'Basic: 填写 username:password 明文串,系统自动 Base64 编码。必须配合 HTTPS 使用。'}
</Typography>
</Stack>
);
}
function AppearanceSettings({ theme, setTheme }: { theme: ThemeMode; setTheme: (t: ThemeMode) => void }) {
return (
<Stack spacing={2}>
+2 -2
View File
@@ -159,8 +159,8 @@ export const appAPI = {
return getBridge().app.openExternal(url);
},
showItemInFolder(path: string): void {
getBridge().app.showItemInFolder(path);
showItemInFolder(path: string): Promise<void> {
return getBridge().app.showItemInFolder(path);
},
};
+17 -1
View File
@@ -157,7 +157,7 @@ interface MetonaAppAPI {
getVersion: () => Promise<string>;
getAppDataPath: () => Promise<string>;
openExternal: (url: string) => Promise<void>;
showItemInFolder: (path: string) => void;
showItemInFolder: (path: string) => Promise<void>;
selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>;
}
@@ -179,6 +179,7 @@ interface MetonaToolInfo {
category: string;
riskLevel: string;
requiresPermission: boolean;
enabled: boolean;
}
interface MetonaToolsAPI {
@@ -186,6 +187,20 @@ interface MetonaToolsAPI {
toggle: (toolName: string, enabled: boolean) => Promise<{ success: boolean }>;
}
// ===== SearXNG API =====
interface MetonaSearXNGTestResult {
success: boolean;
statusCode?: number;
latencyMs?: number;
error?: string;
}
interface MetonaSearXNGAPI {
/** 测试 SearXNG 实例连接可达性与认证有效性 */
testConnection: (url: string, authKey: string, authType: string) => Promise<MetonaSearXNGTestResult>;
}
// ===== Data API =====
interface MetonaDataAPI {
@@ -207,6 +222,7 @@ interface MetonaBridge {
toast: MetonaToastAPI;
tools: MetonaToolsAPI;
data: MetonaDataAPI;
searxng: MetonaSearXNGAPI;
}
// ===== 全局 Window 增强 =====
+1 -5
View File
@@ -12,9 +12,6 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@renderer/*": ["src/*"],
@@ -22,8 +19,7 @@
"@shared/*": ["electron/harness/types/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "electron/**/*.ts"],
"exclude": ["node_modules", "dist", "dist-electron", "release"],
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
+3 -1
View File
@@ -1,6 +1,8 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
@@ -18,5 +20,5 @@
}
},
"include": ["electron/**/*.ts"],
"exclude": ["node_modules", "dist", "dist-electron"]
"exclude": ["node_modules", "dist", "dist-electron", "dist-web"]
}
+20 -5
View File
@@ -1,13 +1,28 @@
{
"compilerOptions": {
"composite": true,
"outDir": "./dist-web",
"rootDir": "./src",
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"outDir": "dist-web",
"rootDir": "src",
"declaration": true,
"sourceMap": true,
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@renderer/*": ["src/*"],
"@shared/*": ["electron/harness/types/*"]
}
},
"include": ["src/**/*"]
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist", "dist-electron", "dist-web"]
}