From 4f5f570ac86f6b2850d8cc71d4ed585773bd956a Mon Sep 17 00:00:00 2001 From: thzxx Date: Sun, 12 Jul 2026 21:21:55 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A=E8=BD=AE?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D400/Provider=E9=80=9A=E7=9F=A5=E5=8F=8D?= =?UTF-8?q?=E5=A4=8D=E5=BC=B9=E5=87=BA/=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E9=9A=94=E7=A6=BB=E4=B8=89=E5=A4=A7=E7=BC=BA?= =?UTF-8?q?=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 清理残留 --- .../harness/adapters/shared/openai-format.ts | 9 +- .../tools/built-in/browser-window-manager.ts | 100 ++++++++++++------ electron/ipc/handlers.ts | 18 ++++ electron/main.ts | 35 ++++-- package-lock.json | 6 +- src/stores/agent-store.ts | 24 +++-- 6 files changed, 141 insertions(+), 51 deletions(-) diff --git a/electron/harness/adapters/shared/openai-format.ts b/electron/harness/adapters/shared/openai-format.ts index daac6af..0450081 100644 --- a/electron/harness/adapters/shared/openai-format.ts +++ b/electron/harness/adapters/shared/openai-format.ts @@ -38,7 +38,12 @@ export function buildOpenAICompatibleMessages( const nonSystemMessages = request.messages .filter((m) => m.role !== 'system') .map((m) => { - const msg: Record = { 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 = { + role: m.role, + content: m.content, + }; // === Assistant 消息 === if (m.role === 'assistant') { @@ -49,6 +54,8 @@ export function buildOpenAICompatibleMessages( type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.args) }, })); + // 有 tool_calls 时 content 必须为 null(API 规范) + if (!m.content) msg.content = null; } // 推理内容(无论是否有工具调用,都保留 reasoning_content) if (m.reasoningContent) { diff --git a/electron/harness/tools/built-in/browser-window-manager.ts b/electron/harness/tools/built-in/browser-window-manager.ts index fe399f3..35af50f 100644 --- a/electron/harness/tools/built-in/browser-window-manager.ts +++ b/electron/harness/tools/built-in/browser-window-manager.ts @@ -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 = Promise.resolve(); /** 窗口是否就绪 */ get ready(): boolean { @@ -58,35 +63,63 @@ export class BrowserWindowManager { // ===== browserOpen ===== async open(options: BrowserOpenOptions): Promise { - // 若已有窗口加载了不同 URL → 先关闭重建 - if (this.win && this.currentUrl !== options.url) { - this.destroy(); - } + // v0.3.0 修复: 并发互斥锁 — 串行化所有 open 调用,避免竞态导致窗口状态混乱 + const run = async (): Promise => { + // 若已有窗口加载了不同 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 修复: 恢复 webSecurity,CORS 需求通过 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; } // ===== 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 { diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 96460ca..34d2631 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -267,6 +267,24 @@ export function registerAllIPCHandlers( 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 统计 diff --git a/electron/main.ts b/electron/main.ts index 67db3bd..57a2375 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -262,14 +262,36 @@ async function initialize(): Promise { confirmationHook.setToolDefs(toolRegistry.listAllTools()); // ===== 热重载 Adapter 回调(设置变更时触发)===== + // 记录上次创建 adapter 时的配置快照,用于检测配置是否真的变化 let lastProvider = configService.get('llm.provider') ?? ''; + let lastConfigSig = JSON.stringify({ + provider: configService.get('llm.provider') ?? '', + model: configService.get('llm.model') ?? '', + apiKey: configService.get('llm.apiKey') ?? '', + baseURL: configService.get('llm.baseURL') ?? '', + }); const reloadAdapter = (): boolean => { try { + // 检测配置是否真的变化(避免每次发消息都重建 adapter 和弹 toast) + const currentConfig = { + provider: configService.get('llm.provider') ?? '', + model: configService.get('llm.model') ?? '', + apiKey: configService.get('llm.apiKey') ?? '', + baseURL: configService.get('llm.baseURL') ?? '', + }; + const currentSig = JSON.stringify(currentConfig); + + // 配置未变化 — 幂等返回成功,不重建 adapter,不发通知 + if (currentSig === lastConfigSig) { + return true; + } + + // 配置变化 — 重建 adapter const newAdapter = createAdapter(); agentLoop.setAdapter(newAdapter); memoryConsolidator.setAdapter(newAdapter); // Provider 切换时同步 contextLength:仅 Ollama 使用 numCtx 作为有效上下文窗口 - const provider = configService.get('llm.provider') ?? ''; + const provider = currentConfig.provider; if (provider === 'ollama') { const numCtx = configService.get('ollama.numCtx'); agentLoop.updateConfig({ contextLength: numCtx ?? undefined }); @@ -277,7 +299,7 @@ async function initialize(): Promise { agentLoop.updateConfig({ contextLength: undefined }); } log.info(`[CONFIG] Adapter reloaded: provider=${provider}`); - // 通知渲染进程 Provider 已切换(UI 显示 Toast + 系统消息) + // 仅在 Provider 真正变化时通知渲染进程 if (mainWindow && !mainWindow.isDestroyed()) { if (lastProvider && lastProvider !== provider) { mainWindow.webContents.send('agent:providerSwitched', { @@ -286,13 +308,14 @@ async function initialize(): Promise { reason: 'config_changed', 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; + lastConfigSig = currentSig; return true; } catch (err) { log.error(`[CONFIG] Failed to reload adapter: ${(err as Error).message}`); diff --git a/package-lock.json b/package-lock.json index be7f95e..d5e41eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9341,9 +9341,9 @@ "license": "MIT" }, "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { diff --git a/src/stores/agent-store.ts b/src/stores/agent-store.ts index d8e14c7..c89ec5c 100644 --- a/src/stores/agent-store.ts +++ b/src/stores/agent-store.ts @@ -153,16 +153,20 @@ export const useAgentStore = create((set, get) => ({ attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>; iteration?: number; timestamp: number; - }>).map((m) => ({ - id: m.id, - role: m.role as ChatMessage['role'], - content: m.content, - reasoningContent: m.reasoningContent, - toolCalls: m.toolCalls as ToolCallInfo[] | undefined, - attachments: m.attachments as AttachmentInfo[] | undefined, - iteration: m.iteration, - timestamp: m.timestamp, - })); + }>) + // v0.3.0 修复: 过滤掉 tool 消息 — tool 结果已包含在 assistant 消息的 toolCalls 中 + // 独立的 tool 消息只用于 LLM API 上下文,不需要在前端显示为独立卡片 + .filter((m) => m.role !== 'tool') + .map((m) => ({ + id: m.id, + role: m.role as ChatMessage['role'], + content: m.content, + reasoningContent: m.reasoningContent, + toolCalls: m.toolCalls as ToolCallInfo[] | undefined, + attachments: m.attachments as AttachmentInfo[] | undefined, + iteration: m.iteration, + timestamp: m.timestamp, + })); set({ messages }); }).catch((err) => { console.error('[AgentStore]', err); }); }