fix: 修复多轮对话400/Provider通知反复弹出/浏览器环境隔离三大缺陷

1. 多轮对话第二次发消息 AI 不响应 (CRITICAL)

handlers.ts: 保存 tool 结果消息到数据库,DeepSeek API 要求 assistant 有 tool_calls 时后续必须有对应 tool 结果消息

openai-format.ts: assistant 有 tool_calls 时 content 设为 null (API 规范,空字符串会导致 400)

agent-store.ts: 前端加载历史时过滤 tool 消息,避免冗余卡片

2. Provider 切换通知每次发消息都弹出

main.ts: reloadAdapter 添加配置签名比较 (lastConfigSig),配置未变化时幂等返回 true,不再重建 adapter 或发 toast

3. 浏览器工具无环境隔离

browser-window-manager.ts: 独立 partition 隔离 Cookie/存储; 恢复 webSecurity,CORS 通过 webRequest 处理; setWindowOpenHandler 拦截 window.open; 并发互斥锁串行化 open 调用; destroy() 改用 win.destroy() + clearStorageData 清理残留
This commit is contained in:
thzxx
2026-07-12 21:21:55 +08:00
parent d39f390be4
commit 4f5f570ac8
6 changed files with 141 additions and 51 deletions
@@ -7,9 +7,12 @@
* @see docs/Agent网络工具通用设计-v2.md — 第 4 章 browser 浏览器设计
*/
import { BrowserWindow } from 'electron';
import { BrowserWindow, session } from 'electron';
import log from 'electron-log';
/** Agent 浏览器专用 session partition — 与主应用 default session 完全隔离 */
const AGENT_PARTITION = 'persist:metona-agent-browser';
export interface BrowserOpenOptions {
url: string;
waitSelector?: string;
@@ -49,6 +52,8 @@ export interface WaitOptions {
export class BrowserWindowManager {
private win: BrowserWindow | null = null;
private currentUrl: string | null = null;
// v0.3.0 修复: 并发互斥锁 — 串行化所有 open 调用,避免竞态导致窗口状态混乱
private openChain: Promise<unknown> = Promise.resolve();
/** 窗口是否就绪 */
get ready(): boolean {
@@ -58,35 +63,63 @@ export class BrowserWindowManager {
// ===== browserOpen =====
async open(options: BrowserOpenOptions): Promise<BrowserOpenResult> {
// 若已有窗口加载了不同 URL → 先关闭重建
if (this.win && this.currentUrl !== options.url) {
this.destroy();
}
// v0.3.0 修复: 并发互斥锁 — 串行化所有 open 调用,避免竞态导致窗口状态混乱
const run = async (): 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,
},
});
}
if (!this.win) {
this.win = new BrowserWindow({
width: 1280,
height: 800,
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
// v0.3.0 修复: 独立 partition,与主应用 default session 完全隔离
// 防止 Agent 浏览产生的 Cookie/缓存/存储污染主应用
partition: AGENT_PARTITION,
// v0.3.0 修复: 恢复 webSecurityCORS 需求通过 session.webRequest 处理
webSecurity: true,
plugins: false,
webviewTag: false,
},
});
await this.loadURLWithTimeout(this.win, options.url, 30_000);
this.currentUrl = options.url;
// v0.3.0 修复: 拦截 window.open,Agent 浏览的页面不允许再开新窗口
this.win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
// 可选等待选择器
if (options.waitSelector) {
await this.waitForSelector(options.waitSelector, 10_000);
}
// v0.3.0 修复: 使用 CORS 放行替代 webSecurity: false
// 仅对 agent session 放行 CORS,不影响主应用
const agentSession = session.fromPartition(AGENT_PARTITION);
agentSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Access-Control-Allow-Origin': ['*'],
},
});
});
}
const title = await this.evaluate('document.title');
return { title: String(title ?? ''), url: options.url };
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 };
};
// 串行化:等待前一个 open 完成
this.openChain = this.openChain.then(run, run);
return this.openChain as Promise<BrowserOpenResult>;
}
// ===== browserScreenshot =====
@@ -361,14 +394,19 @@ export class BrowserWindowManager {
private destroy(): void {
if (this.win && !this.win.isDestroyed()) {
try {
this.win.close();
} catch {
// 忽略关闭错误
}
try { this.win.webContents.stop(); } catch { /* ignore */ }
try { this.win.destroy(); } catch { /* ignore */ }
}
this.win = null;
this.currentUrl = null;
// v0.3.0 修复: 清理 agent session 存储,防止下一次浏览残留上一次的 Cookie/缓存/localStorage
try {
const ses = session.fromPartition(AGENT_PARTITION);
ses.clearStorageData({
storages: ['cookies', 'localstorage', 'indexdb', 'shadercache', 'serviceworkers', 'cachestorage'],
}).catch(() => { /* ignore */ });
ses.clearCache().catch(() => { /* ignore */ });
} catch { /* ignore */ }
}
private sleep(ms: number): Promise<void> {