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:
@@ -38,7 +38,12 @@ export function buildOpenAICompatibleMessages(
|
|||||||
const nonSystemMessages = request.messages
|
const nonSystemMessages = request.messages
|
||||||
.filter((m) => m.role !== 'system')
|
.filter((m) => m.role !== 'system')
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
const msg: Record<string, unknown> = { role: m.role, content: m.content };
|
// v0.3.0 修复: assistant 消息有 tool_calls 但 content 为空时,content 设为 null
|
||||||
|
// DeepSeek/OpenAI API 要求有 tool_calls 的 assistant 消息 content 必须为 null 而非空字符串
|
||||||
|
const msg: Record<string, unknown> = {
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
};
|
||||||
|
|
||||||
// === Assistant 消息 ===
|
// === Assistant 消息 ===
|
||||||
if (m.role === 'assistant') {
|
if (m.role === 'assistant') {
|
||||||
@@ -49,6 +54,8 @@ export function buildOpenAICompatibleMessages(
|
|||||||
type: 'function',
|
type: 'function',
|
||||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||||
}));
|
}));
|
||||||
|
// 有 tool_calls 时 content 必须为 null(API 规范)
|
||||||
|
if (!m.content) msg.content = null;
|
||||||
}
|
}
|
||||||
// 推理内容(无论是否有工具调用,都保留 reasoning_content)
|
// 推理内容(无论是否有工具调用,都保留 reasoning_content)
|
||||||
if (m.reasoningContent) {
|
if (m.reasoningContent) {
|
||||||
|
|||||||
@@ -7,9 +7,12 @@
|
|||||||
* @see docs/Agent网络工具通用设计-v2.md — 第 4 章 browser 浏览器设计
|
* @see docs/Agent网络工具通用设计-v2.md — 第 4 章 browser 浏览器设计
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BrowserWindow } from 'electron';
|
import { BrowserWindow, session } from 'electron';
|
||||||
import log from 'electron-log';
|
import log from 'electron-log';
|
||||||
|
|
||||||
|
/** Agent 浏览器专用 session partition — 与主应用 default session 完全隔离 */
|
||||||
|
const AGENT_PARTITION = 'persist:metona-agent-browser';
|
||||||
|
|
||||||
export interface BrowserOpenOptions {
|
export interface BrowserOpenOptions {
|
||||||
url: string;
|
url: string;
|
||||||
waitSelector?: string;
|
waitSelector?: string;
|
||||||
@@ -49,6 +52,8 @@ export interface WaitOptions {
|
|||||||
export class BrowserWindowManager {
|
export class BrowserWindowManager {
|
||||||
private win: BrowserWindow | null = null;
|
private win: BrowserWindow | null = null;
|
||||||
private currentUrl: string | null = null;
|
private currentUrl: string | null = null;
|
||||||
|
// v0.3.0 修复: 并发互斥锁 — 串行化所有 open 调用,避免竞态导致窗口状态混乱
|
||||||
|
private openChain: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
/** 窗口是否就绪 */
|
/** 窗口是否就绪 */
|
||||||
get ready(): boolean {
|
get ready(): boolean {
|
||||||
@@ -58,35 +63,63 @@ export class BrowserWindowManager {
|
|||||||
// ===== browserOpen =====
|
// ===== browserOpen =====
|
||||||
|
|
||||||
async open(options: BrowserOpenOptions): Promise<BrowserOpenResult> {
|
async open(options: BrowserOpenOptions): Promise<BrowserOpenResult> {
|
||||||
// 若已有窗口加载了不同 URL → 先关闭重建
|
// v0.3.0 修复: 并发互斥锁 — 串行化所有 open 调用,避免竞态导致窗口状态混乱
|
||||||
if (this.win && this.currentUrl !== options.url) {
|
const run = async (): Promise<BrowserOpenResult> => {
|
||||||
this.destroy();
|
// 若已有窗口加载了不同 URL → 先关闭重建
|
||||||
}
|
if (this.win && this.currentUrl !== options.url) {
|
||||||
|
this.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.win) {
|
if (!this.win) {
|
||||||
this.win = new BrowserWindow({
|
this.win = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 800,
|
height: 800,
|
||||||
show: false,
|
show: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
webSecurity: false,
|
// v0.3.0 修复: 独立 partition,与主应用 default session 完全隔离
|
||||||
},
|
// 防止 Agent 浏览产生的 Cookie/缓存/存储污染主应用
|
||||||
});
|
partition: AGENT_PARTITION,
|
||||||
}
|
// v0.3.0 修复: 恢复 webSecurity,CORS 需求通过 session.webRequest 处理
|
||||||
|
webSecurity: true,
|
||||||
|
plugins: false,
|
||||||
|
webviewTag: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await this.loadURLWithTimeout(this.win, options.url, 30_000);
|
// v0.3.0 修复: 拦截 window.open,Agent 浏览的页面不允许再开新窗口
|
||||||
this.currentUrl = options.url;
|
this.win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
||||||
|
|
||||||
// 可选等待选择器
|
// v0.3.0 修复: 使用 CORS 放行替代 webSecurity: false
|
||||||
if (options.waitSelector) {
|
// 仅对 agent session 放行 CORS,不影响主应用
|
||||||
await this.waitForSelector(options.waitSelector, 10_000);
|
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');
|
await this.loadURLWithTimeout(this.win, options.url, 30_000);
|
||||||
return { title: String(title ?? ''), url: options.url };
|
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 =====
|
// ===== browserScreenshot =====
|
||||||
@@ -361,14 +394,19 @@ export class BrowserWindowManager {
|
|||||||
|
|
||||||
private destroy(): void {
|
private destroy(): void {
|
||||||
if (this.win && !this.win.isDestroyed()) {
|
if (this.win && !this.win.isDestroyed()) {
|
||||||
try {
|
try { this.win.webContents.stop(); } catch { /* ignore */ }
|
||||||
this.win.close();
|
try { this.win.destroy(); } catch { /* ignore */ }
|
||||||
} catch {
|
|
||||||
// 忽略关闭错误
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.win = null;
|
this.win = null;
|
||||||
this.currentUrl = 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> {
|
private sleep(ms: number): Promise<void> {
|
||||||
|
|||||||
@@ -267,6 +267,24 @@ export function registerAllIPCHandlers(
|
|||||||
iteration: step.iteration,
|
iteration: step.iteration,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.3.0 修复: 保存 tool 结果消息到数据库
|
||||||
|
// DeepSeek/OpenAI API 要求 assistant 消息有 tool_calls 时,后续必须有对应的 tool 结果消息
|
||||||
|
// 缺失会导致第二次发消息时 API 返回 400 Bad Request
|
||||||
|
if (step.toolResults) {
|
||||||
|
for (const result of step.toolResults) {
|
||||||
|
const resultContent = typeof result.result === 'string'
|
||||||
|
? result.result
|
||||||
|
: JSON.stringify(result.result);
|
||||||
|
sessionService.saveMessage({
|
||||||
|
sessionId,
|
||||||
|
role: 'tool',
|
||||||
|
content: result.error ?? resultContent,
|
||||||
|
toolResult: result,
|
||||||
|
iteration: step.iteration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新 Token 统计
|
// 更新 Token 统计
|
||||||
|
|||||||
+29
-6
@@ -262,14 +262,36 @@ async function initialize(): Promise<void> {
|
|||||||
confirmationHook.setToolDefs(toolRegistry.listAllTools());
|
confirmationHook.setToolDefs(toolRegistry.listAllTools());
|
||||||
|
|
||||||
// ===== 热重载 Adapter 回调(设置变更时触发)=====
|
// ===== 热重载 Adapter 回调(设置变更时触发)=====
|
||||||
|
// 记录上次创建 adapter 时的配置快照,用于检测配置是否真的变化
|
||||||
let lastProvider = configService.get<string>('llm.provider') ?? '';
|
let lastProvider = configService.get<string>('llm.provider') ?? '';
|
||||||
|
let lastConfigSig = JSON.stringify({
|
||||||
|
provider: configService.get<string>('llm.provider') ?? '',
|
||||||
|
model: configService.get<string>('llm.model') ?? '',
|
||||||
|
apiKey: configService.get<string>('llm.apiKey') ?? '',
|
||||||
|
baseURL: configService.get<string>('llm.baseURL') ?? '',
|
||||||
|
});
|
||||||
const reloadAdapter = (): boolean => {
|
const reloadAdapter = (): boolean => {
|
||||||
try {
|
try {
|
||||||
|
// 检测配置是否真的变化(避免每次发消息都重建 adapter 和弹 toast)
|
||||||
|
const currentConfig = {
|
||||||
|
provider: configService.get<string>('llm.provider') ?? '',
|
||||||
|
model: configService.get<string>('llm.model') ?? '',
|
||||||
|
apiKey: configService.get<string>('llm.apiKey') ?? '',
|
||||||
|
baseURL: configService.get<string>('llm.baseURL') ?? '',
|
||||||
|
};
|
||||||
|
const currentSig = JSON.stringify(currentConfig);
|
||||||
|
|
||||||
|
// 配置未变化 — 幂等返回成功,不重建 adapter,不发通知
|
||||||
|
if (currentSig === lastConfigSig) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置变化 — 重建 adapter
|
||||||
const newAdapter = createAdapter();
|
const newAdapter = createAdapter();
|
||||||
agentLoop.setAdapter(newAdapter);
|
agentLoop.setAdapter(newAdapter);
|
||||||
memoryConsolidator.setAdapter(newAdapter);
|
memoryConsolidator.setAdapter(newAdapter);
|
||||||
// Provider 切换时同步 contextLength:仅 Ollama 使用 numCtx 作为有效上下文窗口
|
// Provider 切换时同步 contextLength:仅 Ollama 使用 numCtx 作为有效上下文窗口
|
||||||
const provider = configService.get<string>('llm.provider') ?? '';
|
const provider = currentConfig.provider;
|
||||||
if (provider === 'ollama') {
|
if (provider === 'ollama') {
|
||||||
const numCtx = configService.get<number>('ollama.numCtx');
|
const numCtx = configService.get<number>('ollama.numCtx');
|
||||||
agentLoop.updateConfig({ contextLength: numCtx ?? undefined });
|
agentLoop.updateConfig({ contextLength: numCtx ?? undefined });
|
||||||
@@ -277,7 +299,7 @@ async function initialize(): Promise<void> {
|
|||||||
agentLoop.updateConfig({ contextLength: undefined });
|
agentLoop.updateConfig({ contextLength: undefined });
|
||||||
}
|
}
|
||||||
log.info(`[CONFIG] Adapter reloaded: provider=${provider}`);
|
log.info(`[CONFIG] Adapter reloaded: provider=${provider}`);
|
||||||
// 通知渲染进程 Provider 已切换(UI 显示 Toast + 系统消息)
|
// 仅在 Provider 真正变化时通知渲染进程
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
if (lastProvider && lastProvider !== provider) {
|
if (lastProvider && lastProvider !== provider) {
|
||||||
mainWindow.webContents.send('agent:providerSwitched', {
|
mainWindow.webContents.send('agent:providerSwitched', {
|
||||||
@@ -286,13 +308,14 @@ async function initialize(): Promise<void> {
|
|||||||
reason: 'config_changed',
|
reason: 'config_changed',
|
||||||
sessionId: '',
|
sessionId: '',
|
||||||
});
|
});
|
||||||
|
mainWindow.webContents.send('toast:show', {
|
||||||
|
type: 'success',
|
||||||
|
message: `Provider 已切换: ${lastProvider} → ${provider}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
mainWindow.webContents.send('toast:show', {
|
|
||||||
type: 'success',
|
|
||||||
message: `Provider 已切换: ${lastProvider || '未知'} → ${provider}`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
lastProvider = provider;
|
lastProvider = provider;
|
||||||
|
lastConfigSig = currentSig;
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error(`[CONFIG] Failed to reload adapter: ${(err as Error).message}`);
|
log.error(`[CONFIG] Failed to reload adapter: ${(err as Error).message}`);
|
||||||
|
|||||||
Generated
+3
-3
@@ -9341,9 +9341,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/postcss/node_modules/nanoid": {
|
"node_modules/postcss/node_modules/nanoid": {
|
||||||
"version": "3.3.15",
|
"version": "3.3.16",
|
||||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz",
|
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz",
|
||||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|||||||
+14
-10
@@ -153,16 +153,20 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>;
|
attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>;
|
||||||
iteration?: number;
|
iteration?: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}>).map((m) => ({
|
}>)
|
||||||
id: m.id,
|
// v0.3.0 修复: 过滤掉 tool 消息 — tool 结果已包含在 assistant 消息的 toolCalls 中
|
||||||
role: m.role as ChatMessage['role'],
|
// 独立的 tool 消息只用于 LLM API 上下文,不需要在前端显示为独立卡片
|
||||||
content: m.content,
|
.filter((m) => m.role !== 'tool')
|
||||||
reasoningContent: m.reasoningContent,
|
.map((m) => ({
|
||||||
toolCalls: m.toolCalls as ToolCallInfo[] | undefined,
|
id: m.id,
|
||||||
attachments: m.attachments as AttachmentInfo[] | undefined,
|
role: m.role as ChatMessage['role'],
|
||||||
iteration: m.iteration,
|
content: m.content,
|
||||||
timestamp: m.timestamp,
|
reasoningContent: m.reasoningContent,
|
||||||
}));
|
toolCalls: m.toolCalls as ToolCallInfo[] | undefined,
|
||||||
|
attachments: m.attachments as AttachmentInfo[] | undefined,
|
||||||
|
iteration: m.iteration,
|
||||||
|
timestamp: m.timestamp,
|
||||||
|
}));
|
||||||
set({ messages });
|
set({ messages });
|
||||||
}).catch((err) => { console.error('[AgentStore]', err); });
|
}).catch((err) => { console.error('[AgentStore]', err); });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user