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:
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user