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:
@@ -55,7 +55,7 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
throw new Error(`Ollama API error: ${response.status}`);
|
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);
|
return this.toMetonaResponse(data, request.meta.requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,8 +262,8 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
body: JSON.stringify({ model }),
|
body: JSON.stringify({ model }),
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const data = await response.json() as { parameters?: string; template?: string; capabilities?: string[] };
|
const data = await response.json() as { parameters?: string; template?: string; capabilities?: string[] };
|
||||||
return {
|
return {
|
||||||
parameters: data.parameters ?? '',
|
parameters: data.parameters ?? '',
|
||||||
template: data.template ?? '',
|
template: data.template ?? '',
|
||||||
|
|||||||
@@ -104,6 +104,15 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
this.adapter = adapter;
|
this.adapter = adapter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 热更新 Engine 配置(设置变更时调用)
|
||||||
|
*
|
||||||
|
* 支持 maxIterations, totalTimeoutMs, thinkingEnabled, thinkingEffort, contextLength
|
||||||
|
*/
|
||||||
|
updateConfig(partial: Partial<AgentLoopConfig>): void {
|
||||||
|
this.config = { ...this.config, ...partial };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行完整的 ReAct 循环(流式模式)
|
* 执行完整的 ReAct 循环(流式模式)
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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 {
|
private extractContent(fileContent: string): string {
|
||||||
if (!fileContent) return '';
|
if (!fileContent) return '';
|
||||||
@@ -219,9 +219,15 @@ Use tools when needed to gather information or perform actions. Think step by st
|
|||||||
let skipMeta = true;
|
let skipMeta = true;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
// 跳过元数据注释行(以 # 开头且不跟另一个 # 的行,即只跳过一级标题)
|
// 跳过文件头部的标题行(# 开头)和 > 引用元数据行
|
||||||
if (skipMeta && line.match(/^#[^#]/)) {
|
if (skipMeta) {
|
||||||
continue;
|
if (line.match(/^#[^#]/) || line.match(/^>/)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 空行在元数据区域中也跳过
|
||||||
|
if (line.trim() === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
skipMeta = false;
|
skipMeta = false;
|
||||||
contentLines.push(line);
|
contentLines.push(line);
|
||||||
|
|||||||
@@ -22,15 +22,25 @@ export interface PermissionPolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_POLICIES: 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: 'web_search', requiredLevel: PermissionLevel.READ, maxFrequency: 10 },
|
||||||
{ toolName: 'list_directory', requiredLevel: PermissionLevel.READ },
|
{ toolName: 'list_directory', requiredLevel: PermissionLevel.READ },
|
||||||
{ toolName: 'search_files', requiredLevel: PermissionLevel.READ },
|
{ toolName: 'search_files', requiredLevel: PermissionLevel.READ },
|
||||||
{ toolName: 'memory_search', 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: 'memory_store', requiredLevel: PermissionLevel.WRITE },
|
||||||
{ toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 3 },
|
{ toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 3 },
|
||||||
{ toolName: 'web_extract', requiredLevel: PermissionLevel.READ },
|
{ 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 {
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 带超时的 loadURL(Electron 原生不支持 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
import type { MetonaToolDef } from '../../../harness/types';
|
import type { MetonaToolDef } from '../../../harness/types';
|
||||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||||
|
import { commandTouchesProtectedFile } from './file-guard';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -83,6 +84,11 @@ export class RunCommandTool implements IMetonaTool {
|
|||||||
private validateCommand(command: string): { allowed: boolean; reason?: string } {
|
private validateCommand(command: string): { allowed: boolean; reason?: string } {
|
||||||
const cmd = command.trim().toLowerCase();
|
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 = [
|
const hardBlocks = [
|
||||||
{ pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' },
|
{ 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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
*
|
*
|
||||||
* read_file, write_file, list_directory, search_files
|
* read_file, write_file, list_directory, search_files
|
||||||
*
|
*
|
||||||
|
* 安全策略:
|
||||||
|
* 1. 路径遍历防护 — 所有路径必须解析到工作空间内(修复前缀碰撞漏洞)
|
||||||
|
* 2. 受保护文件拦截 — MEMORY.md 仅由系统内部 WorkspaceService 管理,
|
||||||
|
* 禁止任何文件工具直接读写(仅限工作空间根目录,子目录不受限)
|
||||||
|
*
|
||||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
|
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
|
||||||
* @see standard/开发规范.md — 使用 fs/path 内置模块
|
* @see standard/开发规范.md — 使用 fs/path 内置模块
|
||||||
*/
|
*/
|
||||||
@@ -13,6 +18,21 @@ import { existsSync } from 'fs';
|
|||||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
import type { MetonaToolDef } from '../../../harness/types';
|
import type { MetonaToolDef } from '../../../harness/types';
|
||||||
import { MetonaToolCategory, MetonaRiskLevel } 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 =====
|
// ===== 1. read_file =====
|
||||||
|
|
||||||
@@ -36,7 +56,7 @@ export class ReadFileTool implements IMetonaTool {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
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 offset = Math.max(1, (args.offset as number) ?? 1);
|
||||||
const limit = Math.min(2000, Math.max(1, (args.limit as number) ?? 500));
|
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,
|
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 =====
|
// ===== 2. write_file =====
|
||||||
@@ -85,7 +96,7 @@ export class WriteFileTool implements IMetonaTool {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
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 content = args.content as string;
|
||||||
const mode = (args.mode as string) ?? 'overwrite';
|
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 };
|
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 =====
|
// ===== 3. list_directory =====
|
||||||
@@ -129,7 +132,7 @@ export class ListDirectoryTool implements IMetonaTool {
|
|||||||
|
|
||||||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
const dirPath = args.dir_path
|
const dirPath = args.dir_path
|
||||||
? this.resolvePath(args.dir_path as string, context.workspacePath)
|
? safeResolvePath(args.dir_path as string, context.workspacePath)
|
||||||
: context.workspacePath;
|
: context.workspacePath;
|
||||||
const depth = Math.min(5, Math.max(1, (args.depth as number) ?? 1));
|
const depth = Math.min(5, Math.max(1, (args.depth as number) ?? 1));
|
||||||
const glob = args.glob as string | undefined;
|
const glob = args.glob as string | undefined;
|
||||||
@@ -176,14 +179,6 @@ export class ListDirectoryTool implements IMetonaTool {
|
|||||||
const pattern = glob.replace(/\*/g, '.*').replace(/\?/g, '.');
|
const pattern = glob.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||||
return new RegExp(`^${pattern}$`, 'i').test(name);
|
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 =====
|
// ===== 4. search_files =====
|
||||||
@@ -213,7 +208,7 @@ export class SearchFilesTool implements IMetonaTool {
|
|||||||
const pattern = args.pattern as string;
|
const pattern = args.pattern as string;
|
||||||
const target = (args.target as string) ?? 'content';
|
const target = (args.target as string) ?? 'content';
|
||||||
const searchPath = args.path
|
const searchPath = args.path
|
||||||
? this.resolvePath(args.path as string, context.workspacePath)
|
? this.resolveSearchPath(args.path as string, context.workspacePath)
|
||||||
: context.workspacePath;
|
: context.workspacePath;
|
||||||
const fileGlob = args.file_glob as string | undefined;
|
const fileGlob = args.file_glob as string | undefined;
|
||||||
const limit = Math.min(200, Math.max(1, (args.limit as number) ?? 50));
|
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') {
|
if (target === 'files') {
|
||||||
return this.searchByFilename(searchPath, pattern, fileGlob, limit);
|
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> {
|
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 };
|
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 results: Array<{ path: string; line: number; match: string }> = [];
|
||||||
const regex = new RegExp(pattern, 'gi');
|
const regex = new RegExp(pattern, 'gi');
|
||||||
|
|
||||||
await this.walkDir(dirPath, async (filePath) => {
|
await this.walkDir(dirPath, async (filePath) => {
|
||||||
if (results.length >= limit) return;
|
if (results.length >= limit) return;
|
||||||
|
// 跳过工作空间根目录的 MEMORY.md(受保护文件,使用完整路径精确匹配)
|
||||||
|
if (isProtectedWorkspaceFile(filePath, workspacePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const content = await readFile(filePath, 'utf-8');
|
const content = await readFile(filePath, 'utf-8');
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
@@ -295,9 +294,10 @@ export class SearchFilesTool implements IMetonaTool {
|
|||||||
return new RegExp(`^${pattern}$`, 'i').test(name);
|
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);
|
const resolved = resolve(workspacePath, filePath);
|
||||||
if (!resolved.startsWith(resolve(workspacePath))) {
|
if (!isPathWithinWorkspace(filePath, workspacePath)) {
|
||||||
throw new Error(`Path traversal detected: ${filePath}`);
|
throw new Error(`Path traversal detected: ${filePath}`);
|
||||||
}
|
}
|
||||||
return resolved;
|
return resolved;
|
||||||
|
|||||||
@@ -5,6 +5,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool } from './filesystem';
|
export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool } from './filesystem';
|
||||||
export { WebSearchTool, WebExtractTool } from './network';
|
export { WebSearchTool, WebFetchTool } from './network';
|
||||||
export { MemoryStoreTool, MemorySearchTool } from './memory';
|
export { MemoryStoreTool, MemorySearchTool } from './memory';
|
||||||
export { RunCommandTool } from './command';
|
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> = {
|
||||||
|
' ': ' ', '<': '<', '>': '>', '&': '&', '"': '"',
|
||||||
|
''': "'", '…': '…', '—': '—', '–': '–',
|
||||||
|
'«': '«', '»': '»', '×': '×', '÷': '÷',
|
||||||
|
'©': '©', '®': '®', '™': '™', '€': '€',
|
||||||
|
'£': '£', '¥': '¥', '¢': '¢', '°': '°',
|
||||||
|
};
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
@@ -1,151 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* 网络工具(2 个)
|
* 网络工具导出
|
||||||
*
|
*
|
||||||
* web_search, web_extract
|
* web_search — 双模式搜索(SearXNG / 内置四引擎)
|
||||||
|
* web_fetch — 三阶段回退抓取(HTTP / SPA 升级 / 浏览器渲染)
|
||||||
*
|
*
|
||||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
|
* @see docs/Agent网络工具通用设计-v2.md
|
||||||
* @see standard/开发规范.md — 使用原生 fetch(允许在工具层使用)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
export { WebSearchTool } from './web-search';
|
||||||
import type { MetonaToolDef } from '../../../harness/types';
|
export { WebFetchTool } from './web-fetch';
|
||||||
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(/ /g, ' ')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 带超时的 loadURL(Electron 原生不支持 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,14 @@ export class ToolRegistry {
|
|||||||
.map((e) => e.tool.definition);
|
.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 调用) */
|
/** 设置工具启用/禁用状态(供 IPC tools:toggle 调用) */
|
||||||
setToolEnabled(name: string, enabled: boolean): void {
|
setToolEnabled(name: string, enabled: boolean): void {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
|
|||||||
@@ -405,6 +405,44 @@ export function registerAllIPCHandlers(
|
|||||||
log.info(`[CONFIG] LLM config changed (${key}), adapter reloaded`);
|
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 };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -439,18 +477,23 @@ export function registerAllIPCHandlers(
|
|||||||
// ===== 工具管理 =====
|
// ===== 工具管理 =====
|
||||||
|
|
||||||
ipcMain.handle('tools:list', async () => {
|
ipcMain.handle('tools:list', async () => {
|
||||||
return toolRegistry.listTools().map((t) => ({
|
return toolRegistry.listAllTools().map((t) => ({
|
||||||
name: t.name,
|
name: t.name,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
category: t.category,
|
category: t.category,
|
||||||
riskLevel: t.riskLevel,
|
riskLevel: t.riskLevel,
|
||||||
requiresPermission: t.requiresPermission,
|
requiresPermission: t.requiresPermission,
|
||||||
|
enabled: t.enabled,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('tools:toggle', async (_event, toolName: string, enabled: boolean) => {
|
ipcMain.handle('tools:toggle', async (_event, toolName: string, enabled: boolean) => {
|
||||||
// 工具开关通过配置持久化
|
// 工具开关通过配置持久化
|
||||||
configService.set(`tools.${toolName}.enabled`, enabled);
|
configService.set(`tools.${toolName}.enabled`, enabled);
|
||||||
|
// 同步到 ToolRegistry(立即生效)
|
||||||
|
toolRegistry.setToolEnabled(toolName, enabled);
|
||||||
|
// 同步到 AgentLoop 的工具列表
|
||||||
|
agentLoop.setTools(toolRegistry.listTools());
|
||||||
log.info(`Tool ${toolName} ${enabled ? 'enabled' : 'disabled'}`);
|
log.info(`Tool ${toolName} ${enabled ? 'enabled' : 'disabled'}`);
|
||||||
return { success: true };
|
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');
|
log.info('[SYS] All IPC handlers registered');
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-5
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
import { app, shell, Menu } from 'electron';
|
import { app, shell, Menu } from 'electron';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||||
import { electronApp, optimizer } from '@electron-toolkit/utils';
|
import { electronApp, optimizer } from '@electron-toolkit/utils';
|
||||||
import log from 'electron-log';
|
import log from 'electron-log';
|
||||||
import { DatabaseService } from './services/database.service';
|
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 { OllamaAdapter } from './harness/adapters/ollama.adapter';
|
||||||
import {
|
import {
|
||||||
ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool,
|
ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool,
|
||||||
WebSearchTool, WebExtractTool,
|
WebSearchTool, WebFetchTool,
|
||||||
MemoryStoreTool, MemorySearchTool,
|
MemoryStoreTool, MemorySearchTool,
|
||||||
RunCommandTool,
|
RunCommandTool,
|
||||||
|
browserTools, cleanupBrowser,
|
||||||
} from './harness/tools/built-in';
|
} from './harness/tools/built-in';
|
||||||
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks';
|
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks';
|
||||||
import { PolicyEngine } from './harness/sandbox/permissions';
|
import { PolicyEngine } from './harness/sandbox/permissions';
|
||||||
@@ -52,6 +54,31 @@ import { UpdateService } from './services/update.service';
|
|||||||
log.transports.file.level = 'info';
|
log.transports.file.level = 'info';
|
||||||
log.transports.console.level = 'debug';
|
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 databaseService: DatabaseService | null = null;
|
||||||
let trayManager: TrayManager | null = null;
|
let trayManager: TrayManager | null = null;
|
||||||
let windowManager: WindowManager | null = null;
|
let windowManager: WindowManager | null = null;
|
||||||
@@ -67,9 +94,14 @@ async function initialize(): Promise<void> {
|
|||||||
optimizer.watchWindowShortcuts(window);
|
optimizer.watchWindowShortcuts(window);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== 步骤 2: 工作空间 =====
|
// ===== 步骤 2: 工作空间(优先从独立配置文件读取路径)=====
|
||||||
const workspaceService = new WorkspaceService();
|
const savedWorkspacePath = readWorkspacePathFromFile();
|
||||||
|
const workspaceService = new WorkspaceService(savedWorkspacePath ?? undefined);
|
||||||
const workspaceInfo = workspaceService.initialize();
|
const workspaceInfo = workspaceService.initialize();
|
||||||
|
// 持久化工作空间路径(供下次启动读取)
|
||||||
|
if (savedWorkspacePath !== workspaceInfo.path) {
|
||||||
|
writeWorkspacePathToFile(workspaceInfo.path);
|
||||||
|
}
|
||||||
log.info(`Workspace: ${workspaceInfo.path} (missing: ${workspaceInfo.missingFiles.join(', ') || 'none'})`);
|
log.info(`Workspace: ${workspaceInfo.path} (missing: ${workspaceInfo.missingFiles.join(', ') || 'none'})`);
|
||||||
|
|
||||||
// ===== 步骤 3: SQLite =====
|
// ===== 步骤 3: SQLite =====
|
||||||
@@ -122,11 +154,16 @@ async function initialize(): Promise<void> {
|
|||||||
toolRegistry.registerBuiltin(new WriteFileTool());
|
toolRegistry.registerBuiltin(new WriteFileTool());
|
||||||
toolRegistry.registerBuiltin(new ListDirectoryTool());
|
toolRegistry.registerBuiltin(new ListDirectoryTool());
|
||||||
toolRegistry.registerBuiltin(new SearchFilesTool());
|
toolRegistry.registerBuiltin(new SearchFilesTool());
|
||||||
toolRegistry.registerBuiltin(new WebSearchTool());
|
toolRegistry.registerBuiltin(new WebSearchTool(configService));
|
||||||
toolRegistry.registerBuiltin(new WebExtractTool());
|
toolRegistry.registerBuiltin(new WebFetchTool());
|
||||||
toolRegistry.registerBuiltin(new MemoryStoreTool(memoryManager));
|
toolRegistry.registerBuiltin(new MemoryStoreTool(memoryManager));
|
||||||
toolRegistry.registerBuiltin(new MemorySearchTool(memoryManager));
|
toolRegistry.registerBuiltin(new MemorySearchTool(memoryManager));
|
||||||
toolRegistry.registerBuiltin(new RunCommandTool());
|
toolRegistry.registerBuiltin(new RunCommandTool());
|
||||||
|
|
||||||
|
// 注册 9 个 Browser 浏览器工具
|
||||||
|
for (const tool of browserTools) {
|
||||||
|
toolRegistry.registerBuiltin(tool);
|
||||||
|
}
|
||||||
log.info(`Registered ${toolRegistry.size} built-in tools`);
|
log.info(`Registered ${toolRegistry.size} built-in tools`);
|
||||||
|
|
||||||
// ===== MCP Manager =====
|
// ===== MCP Manager =====
|
||||||
@@ -159,11 +196,15 @@ async function initialize(): Promise<void> {
|
|||||||
const ollamaNumCtx = configService.get<number>('ollama.numCtx');
|
const ollamaNumCtx = configService.get<number>('ollama.numCtx');
|
||||||
const agentMaxIter = configService.get<number>('agent.maxIterations');
|
const agentMaxIter = configService.get<number>('agent.maxIterations');
|
||||||
const agentTimeout = configService.get<number>('agent.totalTimeoutMs');
|
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(
|
const agentLoop = new AgentLoopEngine(
|
||||||
{
|
{
|
||||||
maxIterations: agentMaxIter ?? 20,
|
maxIterations: agentMaxIter ?? 20,
|
||||||
totalTimeoutMs: agentTimeout ?? 600_000,
|
totalTimeoutMs: agentTimeout ?? 600_000,
|
||||||
contextLength: ollamaNumCtx ?? undefined,
|
contextLength: ollamaNumCtx ?? undefined,
|
||||||
|
thinkingEnabled: agentThinkingEnabled ?? true,
|
||||||
|
thinkingEffort: agentThinkingEffort ?? 'high',
|
||||||
},
|
},
|
||||||
adapter, toolRegistry, preToolHooks, postToolHooks,
|
adapter, toolRegistry, preToolHooks, postToolHooks,
|
||||||
);
|
);
|
||||||
@@ -245,9 +286,27 @@ async function initialize(): Promise<void> {
|
|||||||
trayManager?.destroy();
|
trayManager?.destroy();
|
||||||
windowManager?.closeAll();
|
windowManager?.closeAll();
|
||||||
try { await mcpManager.shutdown(); } catch (err) { log.error('[Shutdown] MCP shutdown failed:', err); }
|
try { await mcpManager.shutdown(); } catch (err) { log.error('[Shutdown] MCP shutdown failed:', err); }
|
||||||
|
cleanupBrowser();
|
||||||
if (databaseService) { databaseService.close(); databaseService = null; }
|
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}]`);
|
log.info(`MetonaAI Desktop initialized [Provider: ${configService.get<string>('llm.provider') ?? 'none'}, Model: ${configService.get<string>('llm.model') ?? 'none'}, Tools: ${toolRegistry.size}]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -94,6 +94,12 @@ const metonaAPI = {
|
|||||||
clearAuditLogs: () => ipcRenderer.invoke('data:clearAuditLogs'),
|
clearAuditLogs: () => ipcRenderer.invoke('data:clearAuditLogs'),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ===== SearXNG =====
|
||||||
|
searxng: {
|
||||||
|
testConnection: (url: string, authKey: string, authType: string) =>
|
||||||
|
ipcRenderer.invoke('searxng:testConnection', url, authKey, authType),
|
||||||
|
},
|
||||||
|
|
||||||
// ===== Toast 通知桥接 =====
|
// ===== Toast 通知桥接 =====
|
||||||
toast: {
|
toast: {
|
||||||
onShow: (callback: (data: { type: string; message: string; options?: unknown }) => void) => {
|
onShow: (callback: (data: { type: string; message: string; options?: unknown }) => void) => {
|
||||||
@@ -115,6 +121,6 @@ if (process.contextIsolated) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 降级方案(不推荐)
|
// 降级方案(不推荐)
|
||||||
(window as unknown as Record<string, unknown>).electron = electronAPI;
|
(globalThis as unknown as Record<string, unknown>).electron = electronAPI;
|
||||||
(window as unknown as Record<string, unknown>).metona = metonaAPI;
|
(globalThis as unknown as Record<string, unknown>).metona = metonaAPI;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 AUTO_CREATED_DIRS = ['logs', 'traces', '.metona'] as const;
|
||||||
|
|
||||||
const MEMORY_TEMPLATE = `# MEMORY.md — AI 持久记忆
|
const MEMORY_TEMPLATE = `# MEMORY.md — AI 持久记忆
|
||||||
#
|
|
||||||
# 格式版本: 1.0
|
> 创建时间: __CREATED_AT__
|
||||||
# 创建时间: __CREATED_AT__
|
> 最后更新: __UPDATED_AT__
|
||||||
# 最后更新: __UPDATED_AT__
|
> 工作空间: __WORKSPACE_PATH__
|
||||||
# 工作空间: __WORKSPACE_PATH__
|
|
||||||
#
|
|
||||||
# 此文件由 Metona Agent 自动维护,用户可手动编辑。
|
|
||||||
# 格式规范详见文档,Agent 写入时会自动校验格式。
|
|
||||||
|
|
||||||
## 用户偏好
|
## 用户偏好
|
||||||
# 格式: - [类别] 内容描述
|
|
||||||
# 示例: - [沟通风格] 用户喜欢简洁的回答
|
|
||||||
|
|
||||||
## 项目上下文
|
## 项目上下文
|
||||||
# 格式: - [项目名] 关键信息
|
|
||||||
# 示例: - [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');
|
let content = readFileSync(memoryPath, 'utf-8');
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
// 替换最后更新时间戳
|
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/# 最后更新: .*/,
|
/> 最后更新: .*/,
|
||||||
`# 最后更新: ${now}`,
|
`> 最后更新: ${now}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
writeFileSync(memoryPath, content, 'utf-8');
|
writeFileSync(memoryPath, content, 'utf-8');
|
||||||
@@ -201,8 +186,8 @@ export class WorkspaceService {
|
|||||||
|
|
||||||
// 更新时间戳
|
// 更新时间戳
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/# 最后更新: .*/,
|
/> 最后更新: .*/,
|
||||||
`# 最后更新: ${new Date().toISOString()}`,
|
`> 最后更新: ${new Date().toISOString()}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
writeFileSync(memoryPath, content, 'utf-8');
|
writeFileSync(memoryPath, content, 'utf-8');
|
||||||
@@ -211,17 +196,12 @@ export class WorkspaceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验 MEMORY.md 格式(6 条规则)
|
* 校验 MEMORY.md 格式
|
||||||
*
|
*
|
||||||
* 规则:
|
* 简化规则:
|
||||||
* 1. 元数据头 — 必须包含 # 格式版本、# 创建时间、# 最后更新、# 工作空间
|
* 1. 元数据头 — 用 > 引用语法,包含创建时间、最后更新、工作空间
|
||||||
* 2. 分区结构 — 必须包含 ## 用户偏好、## 项目上下文、## 重要决策
|
* 2. 分区结构 — 包含用户偏好、项目上下文、重要决策(其余可选)
|
||||||
* 3. 条目前缀 — 每个条目必须以 - 开头,后跟 [类别/标签]
|
* 3. 时间戳自动更新
|
||||||
* 4. 日期格式 — 决策条目必须使用 YYYY-MM-DD
|
|
||||||
* 5. 状态标记 — 待办事项必须包含 [pending/done/cancelled]
|
|
||||||
* 6. 时间戳更新 — 每次写入时自动更新 # 最后更新 时间戳
|
|
||||||
*
|
|
||||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — MEMORY.md 格式校验规则
|
|
||||||
*/
|
*/
|
||||||
validateMemoryFormat(): boolean {
|
validateMemoryFormat(): boolean {
|
||||||
const memoryPath = join(this.workspacePath, 'MEMORY.md');
|
const memoryPath = join(this.workspacePath, 'MEMORY.md');
|
||||||
@@ -230,33 +210,35 @@ export class WorkspaceService {
|
|||||||
let content = readFileSync(memoryPath, 'utf-8');
|
let content = readFileSync(memoryPath, 'utf-8');
|
||||||
let modified = false;
|
let modified = false;
|
||||||
|
|
||||||
// 规则 1: 元数据头
|
// 规则 1: 元数据头(> 引用语法)
|
||||||
const requiredMeta = ['# 格式版本', '# 创建时间', '# 最后更新', '# 工作空间'];
|
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) {
|
for (const meta of requiredMeta) {
|
||||||
if (!content.includes(meta)) {
|
if (!content.includes(meta.key)) {
|
||||||
// 自动补充缺失的元数据
|
// 在标题后插入元数据
|
||||||
const metaLine = meta === '# 工作空间'
|
const titleEnd = content.indexOf('\n', content.indexOf('# MEMORY')) + 1;
|
||||||
? `# 工作空间: ${this.workspacePath}`
|
content = content.slice(0, titleEnd) + meta.line + '\n' + content.slice(titleEnd);
|
||||||
: `${meta}: ${new Date().toISOString()}`;
|
|
||||||
content = content.replace(/^# MEMORY/m, `# MEMORY\n#\n# ${metaLine.replace('# ', '')}`);
|
|
||||||
modified = true;
|
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 = ['## 用户偏好', '## 项目上下文', '## 重要决策'];
|
const requiredSections = ['## 用户偏好', '## 项目上下文', '## 重要决策'];
|
||||||
for (const section of requiredSections) {
|
for (const section of requiredSections) {
|
||||||
if (!content.includes(section)) {
|
if (!content.includes(section)) {
|
||||||
content += `\n${section}\n# 格式: - [类别] 内容描述\n`;
|
content += `\n${section}\n`;
|
||||||
modified = true;
|
modified = true;
|
||||||
log.info(`MEMORY.md: auto-added missing section "${section}"`);
|
log.info(`MEMORY.md: auto-added missing section "${section}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 规则 6: 时间戳更新
|
// 规则 3: 时间戳更新
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
content = content.replace(/# 最后更新: .*/, `# 最后更新: ${now}`);
|
content = content.replace(/> 最后更新: .*/, `> 最后更新: ${now}`);
|
||||||
|
|
||||||
if (modified) {
|
if (modified) {
|
||||||
writeFileSync(memoryPath, content, 'utf-8');
|
writeFileSync(memoryPath, content, 'utf-8');
|
||||||
|
|||||||
Generated
+27
-74
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
@@ -701,9 +701,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@electron/universal/node_modules/fs-extra": {
|
"node_modules/@electron/universal/node_modules/fs-extra": {
|
||||||
"version": "11.3.5",
|
"version": "11.3.6",
|
||||||
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.5.tgz",
|
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.6.tgz",
|
||||||
"integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
|
"integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -777,9 +777,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@electron/windows-sign/node_modules/fs-extra": {
|
"node_modules/@electron/windows-sign/node_modules/fs-extra": {
|
||||||
"version": "11.3.5",
|
"version": "11.3.6",
|
||||||
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.5.tgz",
|
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.6.tgz",
|
||||||
"integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
|
"integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -3567,22 +3567,6 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/app-builder-lib/node_modules/fs-extra": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||||
@@ -3933,9 +3917,9 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.6",
|
"version": "5.0.7",
|
||||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4327,9 +4311,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ci-info": {
|
"node_modules/ci-info": {
|
||||||
"version": "4.4.0",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-4.4.0.tgz",
|
"resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-4.3.1.tgz",
|
||||||
"integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==",
|
"integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -5035,23 +5019,6 @@
|
|||||||
"util-deprecate": "~1.0.1"
|
"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": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -8777,9 +8744,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-abi": {
|
"node_modules/node-abi": {
|
||||||
"version": "4.31.0",
|
"version": "4.33.0",
|
||||||
"resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-4.31.0.tgz",
|
"resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-4.33.0.tgz",
|
||||||
"integrity": "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw==",
|
"integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -9449,9 +9416,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prebuild-install/node_modules/node-abi": {
|
"node_modules/prebuild-install/node_modules/node-abi": {
|
||||||
"version": "3.92.0",
|
"version": "3.94.0",
|
||||||
"resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.92.0.tgz",
|
"resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.94.0.tgz",
|
||||||
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
|
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "^7.3.5"
|
"semver": "^7.3.5"
|
||||||
@@ -10110,23 +10077,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
@@ -10528,12 +10481,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
|
|||||||
+5
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
||||||
"main": "dist-electron/main/main.js",
|
"main": "dist-electron/main/main.js",
|
||||||
"author": "Metona Team",
|
"author": "Metona Team",
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "electron-vite dev",
|
"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:renderer": "electron-vite build --rendererOnly",
|
||||||
"build:electron": "tsc -p tsconfig.node.json",
|
"build:electron": "tsc -p tsconfig.node.json",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -72,6 +73,7 @@
|
|||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
"electron@35.7.5": true,
|
"electron@35.7.5": true,
|
||||||
"better-sqlite3@11.10.0": true,
|
"better-sqlite3@11.10.0": true,
|
||||||
"esbuild@0.25.12": true
|
"esbuild@0.25.12": true,
|
||||||
|
"electron-winstaller@5.4.0": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,28 +135,33 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
|
|||||||
|
|
||||||
function ToolManagerPanel() {
|
function ToolManagerPanel() {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const tools = [
|
const [tools, setTools] = useState<Array<{ name: string; description: string; category: string; riskLevel: string; requiresPermission: boolean; enabled: boolean }>>([]);
|
||||||
{ name: 'read_file', label: '读取文件', risk: 'SAFE' }, { name: 'write_file', label: '写入文件', risk: 'MEDIUM' },
|
|
||||||
{ name: 'list_directory', label: '列出目录', risk: 'SAFE' }, { name: 'search_files', label: '搜索文件', risk: 'SAFE' },
|
useEffect(() => {
|
||||||
{ name: 'web_search', label: '网络搜索', risk: 'LOW' }, { name: 'web_extract', label: '网页抓取', risk: 'LOW' },
|
if (window.metona?.tools?.list) {
|
||||||
{ name: 'memory_store', label: '存储记忆', risk: 'MEDIUM' }, { name: 'memory_search', label: '搜索记忆', risk: 'SAFE' },
|
window.metona.tools.list().then((list) => {
|
||||||
{ name: 'run_command', label: '执行命令', risk: 'HIGH' },
|
setTools(list as MetonaToolInfo[]);
|
||||||
];
|
}).catch((err) => { console.error('[Sidebar]', err); });
|
||||||
const riskColors: Record<string, string> = { SAFE: 'success.main', LOW: 'info.main', MEDIUM: 'warning.main', HIGH: 'error.main' };
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<ListItemButton onClick={() => setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}>
|
<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>
|
<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>} />
|
<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>
|
</ListItemButton>
|
||||||
<Collapse in={expanded}>
|
<Collapse in={expanded}>
|
||||||
<List dense disablePadding sx={{ pl: 3 }}>
|
<List dense disablePadding sx={{ pl: 3 }}>
|
||||||
{tools.map((t) => (
|
{tools.map((t) => (
|
||||||
<ListItemButton key={t.name} dense sx={{ py: 0.25, px: 1, borderRadius: 1 }}>
|
<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 }} />
|
<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: 'text.secondary' }}>{t.label}</Typography>
|
<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: riskColors[t.risk] }}>{t.risk}</Typography>
|
<Typography variant="caption" sx={{ fontSize: 9, color: t.enabled ? (riskColors[t.riskLevel] ?? 'text.disabled') : 'text.disabled' }}>{riskLabels[t.riskLevel] ?? t.riskLevel}</Typography>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function StatusBar(): React.JSX.Element {
|
|||||||
const model = useAgentStore((s) => s.model);
|
const model = useAgentStore((s) => s.model);
|
||||||
const tokenUsage = useAgentStore((s) => s.tokenUsage);
|
const tokenUsage = useAgentStore((s) => s.tokenUsage);
|
||||||
const openSettings = useUIStore((s) => s.openSettings);
|
const openSettings = useUIStore((s) => s.openSettings);
|
||||||
const [version, setVersion] = useState('v0.1.0');
|
const [version, setVersion] = useState('v0.1.1');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.metona?.app?.getVersion) {
|
if (window.metona?.app?.getVersion) {
|
||||||
|
|||||||
@@ -3,16 +3,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
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 { 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 } from 'lucide-react';
|
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 { useUIStore, type ThemeMode } from '@renderer/stores/ui-store';
|
||||||
import { useAgentStore } from '@renderer/stores/agent-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 }[] = [
|
const TABS: { id: SettingsTab; label: string; icon: typeof Settings }[] = [
|
||||||
{ id: 'workspace', label: '工作空间', icon: FolderOpen },
|
{ id: 'workspace', label: '工作空间', icon: FolderOpen },
|
||||||
{ id: 'llm', label: 'LLM 配置', icon: Bot }, { id: 'agent', label: 'Agent 配置', icon: Settings },
|
{ id: 'llm', label: 'LLM 配置', icon: Bot }, { id: 'agent', label: 'Agent 配置', icon: Settings },
|
||||||
{ id: 'tools', label: '工具管理', icon: Wrench }, { id: 'mcp', label: 'MCP 服务', icon: Server },
|
{ 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 },
|
{ 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 === 'agent' && <AgentSettings />}
|
||||||
{tab === 'tools' && <ToolsSettings />}
|
{tab === 'tools' && <ToolsSettings />}
|
||||||
{tab === 'mcp' && <MCPSettings />}
|
{tab === 'mcp' && <MCPSettings />}
|
||||||
|
{tab === 'searxng' && <SearXNGSettings />}
|
||||||
{tab === 'appearance' && <AppearanceSettings theme={theme} setTheme={setTheme} />}
|
{tab === 'appearance' && <AppearanceSettings theme={theme} setTheme={setTheme} />}
|
||||||
{tab === 'logs' && <LogsSettings />}
|
{tab === 'logs' && <LogsSettings />}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -193,7 +195,7 @@ function AgentSettings() {
|
|||||||
|
|
||||||
function ToolsSettings() {
|
function ToolsSettings() {
|
||||||
const [tools, setTools] = useState<Array<{ name: string; description: string; riskLevel: string; enabled: boolean }>>([]);
|
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 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' };
|
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 }) {
|
function AppearanceSettings({ theme, setTheme }: { theme: ThemeMode; setTheme: (t: ThemeMode) => void }) {
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
|
|||||||
@@ -159,8 +159,8 @@ export const appAPI = {
|
|||||||
return getBridge().app.openExternal(url);
|
return getBridge().app.openExternal(url);
|
||||||
},
|
},
|
||||||
|
|
||||||
showItemInFolder(path: string): void {
|
showItemInFolder(path: string): Promise<void> {
|
||||||
getBridge().app.showItemInFolder(path);
|
return getBridge().app.showItemInFolder(path);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Vendored
+17
-1
@@ -157,7 +157,7 @@ interface MetonaAppAPI {
|
|||||||
getVersion: () => Promise<string>;
|
getVersion: () => Promise<string>;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
openExternal: (url: string) => Promise<void>;
|
openExternal: (url: string) => Promise<void>;
|
||||||
showItemInFolder: (path: string) => void;
|
showItemInFolder: (path: string) => Promise<void>;
|
||||||
selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>;
|
selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +179,7 @@ interface MetonaToolInfo {
|
|||||||
category: string;
|
category: string;
|
||||||
riskLevel: string;
|
riskLevel: string;
|
||||||
requiresPermission: boolean;
|
requiresPermission: boolean;
|
||||||
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MetonaToolsAPI {
|
interface MetonaToolsAPI {
|
||||||
@@ -186,6 +187,20 @@ interface MetonaToolsAPI {
|
|||||||
toggle: (toolName: string, enabled: boolean) => Promise<{ success: boolean }>;
|
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 =====
|
// ===== Data API =====
|
||||||
|
|
||||||
interface MetonaDataAPI {
|
interface MetonaDataAPI {
|
||||||
@@ -207,6 +222,7 @@ interface MetonaBridge {
|
|||||||
toast: MetonaToastAPI;
|
toast: MetonaToastAPI;
|
||||||
tools: MetonaToolsAPI;
|
tools: MetonaToolsAPI;
|
||||||
data: MetonaDataAPI;
|
data: MetonaDataAPI;
|
||||||
|
searxng: MetonaSearXNGAPI;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 全局 Window 增强 =====
|
// ===== 全局 Window 增强 =====
|
||||||
|
|||||||
+1
-5
@@ -12,9 +12,6 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@renderer/*": ["src/*"],
|
"@renderer/*": ["src/*"],
|
||||||
@@ -22,8 +19,7 @@
|
|||||||
"@shared/*": ["electron/harness/types/*"]
|
"@shared/*": ["electron/harness/types/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "electron/**/*.ts"],
|
"files": [],
|
||||||
"exclude": ["node_modules", "dist", "dist-electron", "release"],
|
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.node.json" },
|
{ "path": "./tsconfig.node.json" },
|
||||||
{ "path": "./tsconfig.web.json" }
|
{ "path": "./tsconfig.web.json" }
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -18,5 +20,5 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["electron/**/*.ts"],
|
"include": ["electron/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist", "dist-electron"]
|
"exclude": ["node_modules", "dist", "dist-electron", "dist-web"]
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-5
@@ -1,13 +1,28 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"outDir": "./dist-web",
|
"target": "ES2022",
|
||||||
"rootDir": "./src",
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"strict": true,
|
||||||
"types": ["vite/client"]
|
"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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user