1. 项目概述一个桌面端的AI对话聚合器如果你和我一样每天需要在ChatGPT、Claude、DeepSeek、Kimi等五六个AI助手的网页之间来回切换只为对比同一个问题的不同答案那你一定理解这种“甜蜜的负担”有多烦人。浏览器标签页越开越多内存占用飙升登录状态还时不时掉线更别提想快速横向对比答案时得手动在几个窗口间复制粘贴效率低得让人抓狂。ChatAllAI这个项目就是冲着解决这个痛点来的。它本质上是一个用Electron打包的桌面应用核心思路非常直接既然每个AI助手都有自己的网页版那我就在一个应用里开多个“浏览器窗口”WebView把它们都装进来。然后我只需要在一个输入框里打字应用就能自动帮我把问题“广播”到所有选中的AI窗口里让它们同时回答。最后所有答案并排展示在一个界面里谁答得好、谁答得偏一目了然。听起来是不是有点像给浏览器开了个“多标签页同步操作”的外挂没错它的技术原理并不复杂但实现出来的产品形态却非常实用。它不生产AI它只是AI网页的搬运工和同步器。对于需要频繁进行多模型对比测试的开发者、研究者或者只是想看看不同AI“性格”的普通用户来说这工具能省下大量机械操作的时间。接下来我就结合自己折腾这个项目的经验从为什么做、怎么做、以及里面有哪些“坑”来给你彻底拆解一遍。2. 核心设计思路与架构选型2.1 为什么是Electron Vue3 TypeScript当初立项时技术栈的选择是第一个要权衡的问题。目标很明确做一个跨平台的桌面应用核心功能是网页嵌入和自动化操作。备选方案主要有三个原生开发如Swift for macOS, Win32 API for Windows、Qt等跨平台GUI框架以及Electron。为什么不选原生或Qt原生开发性能最好但需要维护多套代码成本太高。Qt功能强大但C的学习曲线和现代Web生态的丰富度相比有差距。我们的核心“网页操作”逻辑本质上就是一系列针对DOM的JavaScript脚本用Web技术来实现是最高效、最自然的。团队对Vue生态也更熟悉。为什么选Electron它允许我们使用Web技术HTML, CSS, JS来构建桌面应用一套代码能打包成Windows、macOS、Linux的应用。这对于一个需要快速迭代、功能基于Web交互的项目来说是性价比最高的选择。虽然它打包后的体积和内存占用常被诟病但考虑到我们的核心就是多个WebView这点开销在可接受范围内。为什么是Vue3 TypeScriptVue3的Composition API让复杂的状态管理比如管理十几个AI模型的状态变得更清晰。TypeScript则是大型项目维护的“安全带”它能提前在编译阶段发现很多潜在的类型错误尤其是在处理来自不同AI网站、结构各异的数据时定义清晰的接口Interface能极大减少运行时错误。所以最终的选型Electron主进程/窗口管理 Vue3 TS渲染进程/UI交互是一个务实且高效的决定。Electron负责创建窗口、管理生命周期和提供Node.js能力Vue3负责构建用户界面和交互逻辑TypeScript确保代码质量。2.2 整体架构拆解主进程与渲染进程的协作Electron应用遵循主进程-渲染进程的架构。理解这一点对后续开发至关重要。主进程Main Process这是应用的“后台管家”只有一个实例。它通过Node.js运行负责创建和管理应用窗口BrowserWindow、系统菜单、托盘图标以及处理一些需要系统权限的操作如文件访问。在ChatAllAI里主进程的核心职责是创建并管理主窗口。管理所有WebView所在的渲染进程。作为IPC进程间通信的中心枢纽转发渲染进程之间的消息。渲染进程Renderer Process每个Electron窗口包括每个WebView都运行在一个独立的渲染进程中。它们就像一个个独立的浏览器标签页负责渲染网页和运行JavaScript。我们的Vue3应用就运行在主窗口的渲染进程里。关键点来了每个WebView组件虽然内嵌在主窗口里但它本质上是一个独立的渲染进程拥有自己独立的JavaScript上下文、Cookie存储和localStorage。这种架构带来了ChatAllAI的核心优势也带来了核心挑战优势会话隔离每个AI网站的登录状态Cookie、本地存储完全隔离不会互相污染。你用同一个浏览器账号登录A网站和B网站在ChatAllAI里它们就是两个独立的会话非常干净。挑战通信与同步Vue应用主渲染进程如何控制那么多独立的WebView子渲染进程答案就是IPC和预加载脚本Preload Script。整个数据流大致是这样的你在Vue组件里的输入框按下发送 → Vue组件通过IPC通知主进程“我要发消息了” → 主进程将消息和目标任务列表通过另一条IPC通道分发到各个WebView所在的渲染进程 → 每个WebView的预加载脚本接收到消息执行一段注入的JavaScript代码模拟用户在那个AI网页里输入和点击发送。3. 核心模块实现细节3.1 WebView的管理与通信灵魂所在WebView组件是项目的绝对核心。它不是简单的iframe而是Electron提供的webview标签或新版本的BrowserView能力更强隔离性更好。在src/components/webview/WebView.vue组件中核心逻辑围绕这几个生命周期展开// 简化后的核心逻辑示意 onMounted(() { // 1. 获取webview的DOM引用 const webviewEl refElectron.WebviewTag(null); // 2. 监听webview加载完成事件 webviewEl.value.addEventListener(dom-ready, () { // 注入一些基础脚本比如屏蔽原网站的一些干扰元素 injectBaseScripts(); // 开始定期检查登录状态 startLoginStatusChecker(); }); // 3. 暴露方法给父组件调用 defineExpose({ async sendMessage(content: string) { // 这里调用一个工具函数获取针对该AI平台定制的发送脚本 const script getSendMessageScript(providerId, content); // 在webview中执行这段脚本 const success await webviewEl.value.executeJavaScript(script); return success; }, async checkLogin() { const script getLoginCheckScript(providerId); const isLoggedIn await webviewEl.value.executeJavaScript(script); updateLoginStatus(isLoggedIn); } }); });这里有个非常重要的实践细节executeJavaScript的调用时机。你不能在WebView的src刚设置好就去执行脚本那时页面可能还在加载DOM元素根本不存在。必须在dom-ready或did-finish-load事件触发后才能安全地注入和执行脚本。我们项目里为此封装了一个waitForElement的工具函数用于在脚本中轮询等待目标输入框出现提高了脚本的鲁棒性。3.2 消息分发器如何做到“一键群发”消息分发器Message Dispatcher是协调中枢。它的工作流程如下接收输入从统一的输入框组件获取用户输入的文本和当前选中的AI模型列表。并发发送这里不能使用简单的for...of循环因为那会是串行的一个AI卡住了后面的都得等。我们必须使用Promise.all或Promise.allSettled进行并发操作。// 在 dispatcher 模块中 async function broadcastMessage(message: string, providerIds: string[]) { const sendPromises providerIds.map(id { // 通过Vue的ref或Pinia store找到对应的WebView组件实例 const webviewInstance getWebviewInstance(id); return webviewInstance.sendMessage(message); }); // 使用allSettled即使某个AI发送失败也不影响其他AI const results await Promise.allSettled(sendPromises); // 处理结果更新UI状态如哪个AI发送成功哪个失败 results.forEach((result, index) { const providerId providerIds[index]; if (result.status fulfilled) { updateProviderStatus(providerId, sending); } else { console.error(发送到 ${providerId} 失败:, result.reason); updateProviderStatus(providerId, error); } }); }状态同步每个WebView发送消息后会进入“等待回答”状态。UI上对应的AI卡片会显示一个加载动画。这里又涉及另一个难点如何知道AI回答结束了网页上没有标准的“回答完成”事件。我们的做法是在注入的脚本里发送消息后同时启动一个“回答状态监控器”定期检查对话列表中最后一条消息是否来自AI、是否还在“打字中”如果有打字动画的话。一旦检测到回答完成就通过IPC通知主进程更新状态。3.3 状态管理用Pinia管理十几个AI的复杂状态随着支持的AI模型越来越多状态管理变得复杂。每个AI模型Provider至少有以下状态需要跟踪isEnabled: 是否被用户启用。isLoggedIn: 是否已登录。isLoading: WebView是否在加载页面。isResponding: 是否正在生成回答。lastError: 最新的错误信息。sessionData: 会话数据用于持久化。使用Vue3的响应式系统直接管理会非常混乱。我们引入了Pinia。在src/stores/chat.ts里我们定义了一个useChatStore。// 简化版的store定义 export const useChatStore defineStore(chat, { state: () ({ providers: [] as AIProvider[], // 所有AI提供商的数组 activeProviderIds: [] as string[], // 当前选中的提供商ID layout: grid as LayoutType, // 当前布局 // ... 其他全局状态 }), actions: { // 更新某个提供商的状态 updateProviderStatus(providerId: string, updates: PartialAIProvider) { const provider this.providers.find(p p.id providerId); if (provider) { Object.assign(provider, updates); } }, // 发送消息到所有选中的提供商 async sendMessageToAll(content: string) { const promises this.activeProviderIds.map(id { const provider this.getProviderById(id); // 这里会调用对应WebView组件暴露的sendMessage方法 return provider.webviewRef?.sendMessage(content); }); await Promise.allSettled(promises); } }, getters: { // 计算属性比如获取所有已登录的提供商 loggedInProviders: (state) state.providers.filter(p p.isLoggedIn), } });Pinia的好处是状态变更逻辑集中并且在任何Vue组件中都可以通过const store useChatStore()来获取和修改响应式自动更新UI非常清晰。3.4 会话持久化让登录状态“记住我”这是提升用户体验的关键。没人愿意每次打开应用都重新登录一遍所有AI网站。Electron的WebView在默认情况下关闭应用后会话数据Cookie、localStorage是会丢失的。我们的解决方案是拦截并持久化会话数据。Cookie持久化在WebView的dom-ready事件中我们可以读取其session对象中的Cookie。webviewEl.value.addEventListener(dom-ready, async () { const ses webviewEl.value.getWebContents().session; const cookies await ses.cookies.get({}); // 将cookies数组序列化后存储到本地文件或IndexedDB await saveCookiesToStorage(providerId, cookies); });恢复会话在WebView加载指定URL前先将之前保存的Cookie设置回去。async function restoreSession(providerId: string) { const cookies await loadCookiesFromStorage(providerId); const ses webviewEl.value.getWebContents().session; for (const cookie of cookies) { // 注意需要设置url参数且cookie格式要正确 await ses.cookies.set({ ...cookie, url: provider.baseUrl }); } } // 在设置webview的src之前调用restoreSessionLocalStorage的挑战Cookie相对容易但LocalStorage是每个源origin隔离的我们无法直接从主进程访问WebView的localStorage。一种变通方法是在注入到WebView的预加载脚本中监听localStorage的变化然后通过ipcRenderer.sendToHost将变化的数据传递出来由主进程保存。恢复时再将数据通过脚本注入回去。这部分实现起来更繁琐需要仔细处理序列化和安全边界。实操心得不是所有网站的登录状态都只靠Cookie。有些用了sessionStorage标签页关闭就失效有些用了内存状态或IndexedDB。因此我们的“会话持久化”无法保证100%成功但对于主流使用Cookie或localStorage的网站效果很好。在代码中我们为每个Provider的sessionData设计了一个isActive字段用来标记上次保存的会话是否还有效如果失效就提示用户重新登录。4. 新增AI模型接入实战指南这是项目最具扩展性的部分也是贡献者最容易参与的地方。接入一个新的AI网站本质上就是为这个网站编写三套“自动化脚本”。4.1 第一步在配置中心注册新模型首先在src/stores/chat.ts的providers数组中新增一项。这是该AI模型在应用内的“身份证”。{ id: my-new-ai, // 唯一ID建议小写连字符 name: 我的新AI, url: https://chat.new-ai.com, // 官网聊天页地址 icon: ./icons/my-new-ai.png, // 图标放public/icons/下 isLoggedIn: false, // 初始状态 webviewId: webview-my-new-ai, // 与id对应 isEnabled: true, // ... 其他默认状态 }4.2 第二步编写登录状态检查脚本脚本文件在src/utils/LoginCheckScripts.ts。你需要写一段JavaScript代码这段代码将在目标网站的页面上下文中执行并返回一个布尔值表示是否已登录。如何编写打开目标AI网站的聊天页面登录后用浏览器开发者工具观察。找登录后独有的元素比如用户头像、昵称显示、退出登录按钮。这是最可靠的。找登录按钮是否消失检查页面上的“登录/注册”按钮是否还存在。技巧优先使用有明确语义的>// 在getLoginCheckScript函数中添加 my-new-ai: // 检查“我的新AI”登录状态 // 方案1检查用户头像是否存在 const avatar document.querySelector([data-testiduser-avatar]); if (avatar) return true; // 方案2检查是否有“退出”按钮 const logoutBtn document.querySelector(button:contains(退出)); if (logoutBtn) return true; // 方案3检查登录按钮是否不存在作为后备 const loginBtn document.querySelector(a[href*login]); return !loginBtn; 4.3 第三步编写消息发送脚本这是最核心也最容易出错的脚本在src/utils/MessageScripts.ts。它的任务是在目标网页的输入框填入文本并触发发送。编写步骤与避坑指南定位输入框同样用开发者工具。现代AI聊天页的输入框可能是textarea也可能是div contenteditabletrue。你需要写出能覆盖多种情况的健壮选择器。function findInputElement() { // 尝试多种选择器按优先级 const selectors [ textarea[placeholder*输入], // 带提示的textarea div[contenteditabletrue], // 可编辑div textarea, // 任何textarea input[typetext] // 文本输入框较少见 ]; for (const selector of selectors) { const el document.querySelector(selector); if (el) return el; } return null; }填入文本并触发事件仅仅设置input.value或div.textContent是不够的很多网站依赖输入事件来触发按钮状态更新或字数统计。const input findInputElement(); if (!input) return false; // 找不到输入框返回失败 input.focus(); // 先聚焦 if (input.tagName TEXTAREA || input.tagName INPUT) { input.value ${escapedMessage}; // 注意消息内容需要转义 // 触发input和change事件 input.dispatchEvent(new Event(input, { bubbles: true })); input.dispatchEvent(new Event(change, { bubbles: true })); } else if (input.isContentEditable) { input.textContent ${escapedMessage}; // 对于可编辑div有时需要触发input事件 input.dispatchEvent(new InputEvent(input, { bubbles: true })); }定位并点击发送按钮找到按钮并触发点击。注意按钮可能有禁用状态。function findSendButton() { const buttonSelectors [ button[aria-label*发送], button:contains(发送), button[typesubmit], svg span:contains(发送), // 图标文字的组合 ]; for (const selector of buttonSelectors) { const btn document.querySelector(selector); // 关键检查按钮是否可见且未被禁用 if (btn btn.offsetParent ! null !btn.disabled) { return btn; } } return null; } const sendButton findSendButton(); if (sendButton) { sendButton.click(); return true; // 发送成功 } return false; // 找不到可用按钮踩坑实录最大的坑是动态内容加载。有些网站在页面初始加载时输入框或按钮并不存在而是通过JS动态生成的。我们的脚本可能在dom-ready时执行但目标元素还没出来。解决方案是在脚本内部实现一个简单的轮询或使用MutationObserver监听DOM变化等待目标元素出现后再操作。我们在工具函数里提供了waitForElement来辅助处理这种情况。4.4 第四步编写新建对话脚本很多AI聊天网页支持快捷键如Ctrl/Cmd N或点击按钮来新建一个对话。为了让ChatAllAI能一键清空所有AI的对话历史我们需要为每个网站实现这个功能。脚本在src/utils/NewChatScripts.ts。实现方式通常是模拟快捷键或点击“新建聊天”按钮。my-new-ai: (function() { // 方法1尝试点击新建按钮 const newChatBtn document.querySelector(button[aria-label新对话]); if (newChatBtn) { newChatBtn.click(); return true; } // 方法2模拟快捷键 Ctrl/Cmd N const isMac navigator.platform.toUpperCase().indexOf(MAC) 0; const eventInit { key: n, code: KeyN, ctrlKey: !isMac, metaKey: isMac, bubbles: true }; document.dispatchEvent(new KeyboardEvent(keydown, eventInit)); document.dispatchEvent(new KeyboardEvent(keyup, eventInit)); return true; // 假设发送了快捷键就算成功 })() 4.5 第五步调试与测试编写完脚本后需要在ChatAllAI里进行实际测试。在浏览器中模拟测试在目标AI网站的开发者工具Console里直接粘贴并执行你写的脚本函数去掉外层IIFE和消息变量看是否能正确找到元素并操作。这是最快的调试方式。在应用内测试将新配置的模型加入列表启用它。观察WebView加载后登录状态检测是否准确卡片上是否显示“已登录”。然后尝试发送一条消息看是否能成功。处理错误如果失败打开该WebView的开发者工具应用内每个卡片都有按钮可以打开查看Console是否有脚本执行错误或者检查Elements面板看看你的选择器是否真的找到了元素。一个重要的安全提醒在拼接用户消息到JavaScript字符串时必须转义否则如果消息里包含引号或反斜杠会破坏脚本结构甚至导致脚本注入漏洞。我们项目里应该有一个escapeJavaScriptString工具函数来处理这个问题。5. 性能优化与资源管理同时运行多个WebView是资源消耗大户。一个Chromium渲染进程轻松占用100MB内存开10个就是1GB以上。我们必须进行优化。5.1 懒加载Lazy Loading不是一开始就创建所有AI模型的WebView。我们的策略是初始状态只创建被用户“启用”isEnabled: true的AI模型的WebView。动态加载当用户在左侧栏勾选一个之前未启用的AI时才动态创建对应的WebView组件并加载其URL。动态卸载当用户取消勾选某个AI时不是直接销毁WebView因为重新创建成本高而是将其src设置为一个空白页或about:blank并将其DOM节点从视图中移除v-if或display: none但组件实例仍在内存中。如果内存紧张可以彻底销毁。5.2 页面生命周期管理对于非当前激活的AI卡片比如用户折叠了某个卡片或者切到了其他布局标签页我们可以让它的WebView进入“休眠”状态。暂停执行调用webviewEl.value.setWebContents相关API如果存在来降低其进程优先级或直接执行webviewEl.value.reload()重载到一个轻量级页面但这会丢失会话状态需权衡。限制活动在页面脚本中可以监听页面可见性当页面不可见时暂停一些动画或轮询任务。但这需要向目标网站注入脚本实现复杂且可能有风险。5.3 内存与缓存清理Electron应用本身会缓存数据。我们需要在应用关闭或定期清理时清理不必要的缓存。清理缓存在主进程中可以调用ses.clearCache()等方法。会话管理对于用户明确移除的AI提供商应将其持久化的会话数据Cookie文件等也从磁盘删除。5.4 给用户的实用建议在应用内或文档中我们应该给用户明确的性能指引“建议根据您电脑的内存大小同时启用3-6个AI模型以获得流畅体验。”“不使用的AI模型请及时在左侧栏取消勾选以释放资源。”“如果遇到卡顿可以尝试在‘设置’中清理应用缓存。”6. 常见问题排查与实战技巧在实际使用和开发中你会遇到各种各样的问题。这里列一些典型的和解决方法。6.1 问题消息发送了但某个AI没反应可能原因1网页未加载完成。脚本执行时输入框还没渲染出来。排查打开该AI卡片的开发者工具查看Console是否有脚本错误检查Elements里输入框是否存在。解决优化脚本加入waitForElement逻辑等待目标元素出现再操作。或者检查WebView的dom-ready事件是否真的在页面完全加载后触发有时需要监听did-finish-load。可能原因2选择器失效。目标网站更新了UI导致我们的CSS选择器找不到元素。排查在开发者工具里用document.querySelector(你的选择器)手动测试看是否返回null。解决更新MessageScripts.ts和LoginCheckScripts.ts中对该网站的选择器。优先寻找更稳定的选择器如>