
1. 为什么我要把 AI 助手塞进浏览器侧边栏浏览器侧边栏这个位置说实话被大多数人低估了。我们每天开着 Chrome 工作标签页动辄二三十个查文档、翻资料、写代码、回消息注意力被切得稀碎。每次想问 AI 一个问题要么切到另一个标签页要么打开独立客户端要么复制粘贴到某个网页对话框里——这个切换成本累积起来非常可观。我统计过自己一天的操作光是复制问题→切窗口→粘贴→等回答→复制答案→切回来这套动作重复了不下四十次。所以我动了念头能不能让 AI 就待在我浏览器右边那条窄窄的侧边栏里我选中什么它就能看到什么我问什么它就能顺手去查查完直接把结论给我这个想法落地之后就有了这个开源项目——一个跑在 Chrome 侧边栏里的、会自己查资料的 AI 助手。它解决的核心问题有三个。第一是上下文获取侧边栏能直接读取当前页面的内容你不用再手动复制粘贴。第二是主动检索它不只是一个你问我答的模型壳子而是带了一套工具调用逻辑遇到需要实时信息或者需要查证的问题它会自己去搜索、抓取网页、整理后再回答。第三是零打扰它不抢你的主视口你该干嘛干嘛需要的时候瞄一眼右边就行。适合谁来参考这篇内容如果你是会写一点 JavaScript、装过 Node.js、对 Chrome 扩展开发好奇的开发者那这篇基本可以照着复现。如果你只是想理解一个会查资料的 AI Agent 到底是怎么搭起来的那原理部分同样值得看。我会把选型理由、踩过的坑、关键代码逻辑都摊开讲尽量做到你读完能自己动手改出一个版本。先说清楚技术底座Chrome Manifest V3 扩展 Node.js 后端服务 大模型 API 搜索工具链。前端是侧边栏的 UI 和页面内容提取后端负责 Agent 的推理循环和工具调用。这个拆分不是拍脑袋定的后面会详细讲为什么不能全塞进扩展里。2. 侧边栏扩展的骨架怎么搭才不别扭2.1 Manifest V3 下 sidePanel 的启用姿势Chrome 从 114 版本开始正式提供了sidePanelAPI这是官方给侧边栏场景的正经入口不用再去搞那些 hack 的 iframe 注入方案。在manifest.json里声明非常直接{ manifest_version: 3, name: Sidebar AI Assistant, version: 1.0.0, permissions: [sidePanel, activeTab, scripting, storage], background: { service_worker: background.js }, side_panel: { default_path: sidepanel.html }, action: { default_title: 打开 AI 助手 } }这里有几个点必须说清楚不然你会卡很久。sidePanel权限是必须的activeTab和scripting是为了读取当前页面内容storage用来存对话历史和配置。background.js在 MV3 里是 service worker它会在空闲时被浏览器杀掉所以任何需要长期保持的状态都不能放在 background 的全局变量里必须落到chrome.storage或者后端。点击扩展图标打开侧边栏的逻辑写在 background 里chrome.sidePanel .setPanelBehavior({ openPanelOnActionClick: true }) .catch((error) console.error(error));这一行setPanelBehavior是关键它让用户点扩展图标时直接展开侧边栏而不是弹一个 popup。我一开始没加这行结果点了图标啥也不发生排查了半天才发现是行为没设置。2.2 页面内容提取别一上来就抓整个 DOM侧边栏要看到当前页面最直觉的做法是document.body.innerText全抓下来。我试过结果是灾难——一个新闻页面能抓出上万字塞给模型既费 token 又稀释重点模型反而抓不住关键信息。正确的思路是分层提取。第一层优先取用户选中的文本这是最强的意图信号function getSelectedText() { const selection window.getSelection(); return selection ? selection.toString().trim() : ; }第二层如果没选中就取页面的主体内容。这里不要用innerText一把梭而是优先找语义化标签function getMainContent() { const candidates [ document.querySelector(article), document.querySelector(main), document.querySelector([rolemain]), document.body, ]; for (const el of candidates) { if (el el.innerText el.innerText.length 200) { return el.innerText.slice(0, 8000); } } return ; }注意那个slice(0, 8000)这是硬性截断。我实测下来超过八千字的上下文对回答质量的边际贡献很低但成本和延迟是线性上涨的。宁可截断也不要无脑全塞。第三层是元信息比如页面标题和 URL这些对模型判断这是什么页面很有帮助const meta { title: document.title, url: location.href, selected: getSelectedText(), content: getMainContent(), };内容提取的代码通过chrome.scripting.executeScript注入到当前标签页执行拿回结果后再传给后端。这里有个坑注入脚本拿不到跨域 iframe 里的内容如果目标页面主体在 iframe 里你只能拿到空字符串。这种情况我目前的处理是提示用户当前页面内容无法读取请手动选中文本不硬刚。2.3 侧边栏 UI 的最小可用设计侧边栏宽度有限通常三百多像素UI 设计要克制。我的布局就三块顶部一个输入框中间是对话流底部一个状态条显示正在检索/正在生成。对话流用最朴素的 DOM 操作就行没必要上框架。每条消息一个 div用户消息右对齐助手消息左对齐。助手消息里如果触发了检索我会在气泡里插一个可折叠的检索来源区块列出它查了哪些网页。这个设计很重要——让用户看到 AI 查了什么是建立信任的关键不然你没法判断它的回答是不是在瞎编。function appendMessage(role, text, sources []) { const bubble document.createElement(div); bubble.className msg ${role}; bubble.textContent text; if (sources.length) { const srcBox document.createElement(details); srcBox.innerHTML summary检索来源 (${sources.length})/summary; sources.forEach((s) { const a document.createElement(a); a.href s.url; a.textContent s.title; a.target _blank; srcBox.appendChild(a); }); bubble.appendChild(srcBox); } chatContainer.appendChild(bubble); chatContainer.scrollTop chatContainer.scrollHeight; }状态条的作用被很多人忽略。Agent 干活是有过程的——它可能先思考、再搜索、再读网页、再总结这个过程可能持续十几秒。如果不给用户反馈用户会以为卡死了然后反复点击。一个简单的正在检索第 2 个来源...就能极大改善体验。3. 后端 Agent 的推理循环让 AI 真的会查3.1 为什么后端必须独立于扩展这是整个项目最关键的架构决策我要重点讲。很多人第一反应是全塞进扩展里不就行了但 MV3 的 service worker 有几个致命限制生命周期不可控空闲几十秒就被杀、无法长时间保持网络连接、没有完整的 Node.js 运行时。而 Agent 的推理循环可能要跑十几秒甚至更久中间还要发起多次网络请求放在 service worker 里随时可能被中断。所以我把 Agent 逻辑放在一个独立的 Node.js 服务里扩展通过fetch跟它通信。这个服务跑在本地用 Express 起一个简单的 HTTP 接口import express from express; import cors from cors; const app express(); app.use(cors()); app.use(express.json()); app.post(/chat, async (req, res) { const { question, pageContext, history } req.body; const answer await runAgent(question, pageContext, history); res.json(answer); }); app.listen(3000, () console.log(Agent service on :3000));Node.js 版本建议 18 以上因为要用到原生的fetch。如果你还在用更老的版本得自己装node-fetch而且可能遇到模块导出相关的报错升级到 18 能省掉一堆麻烦。安装依赖就三个express、cors加上你选的大模型 SDK。3.2 Agent 循环的核心思考-行动-观察一个会查资料的 AI本质上是把大模型的推理能力和外部工具串成一个循环。这个循环的经典结构是ReActReasoning Acting模型先输出一段思考决定要不要调用工具如果要就输出一个工具调用请求后端执行工具把结果喂回给模型模型看到结果后继续思考直到它认为可以给出最终答案。用伪代码表达这个循环async function runAgent(question, pageContext, history) { const messages buildInitialMessages(question, pageContext, history); const sources []; for (let step 0; step MAX_STEPS; step) { const response await callLLM(messages, TOOLS); if (response.type final_answer) { return { answer: response.content, sources }; } if (response.type tool_call) { const result await executeTool(response.tool, response.args); if (response.tool web_search) { sources.push(...result.items); } messages.push({ role: assistant, content: response.raw }); messages.push({ role: tool, content: JSON.stringify(result) }); } } return { answer: 抱歉我没能在限定步数内完成检索。, sources }; }MAX_STEPS这个上限非常重要我设的是 5。为什么因为模型有时候会陷入搜了不满意→再搜→还不满意→再搜的死循环没有上限的话它会一直烧你的 API 额度。5 步是个经验值绝大多数问题 2 到 3 步就能解决5 步足够兜底。3.3 工具定义搜索和网页抓取工具的定义要遵循大模型的 function calling 规范。我定义了两个工具一个搜索、一个抓取const TOOLS [ { type: function, function: { name: web_search, description: 当需要实时信息、事实核查或页面上下文不足时搜索互联网获取相关资料, parameters: { type: object, properties: { query: { type: string, description: 搜索关键词 }, }, required: [query], }, }, }, { type: function, function: { name: fetch_page, description: 抓取指定 URL 的正文内容用于深入阅读某个搜索结果, parameters: { type: object, properties: { url: { type: string, description: 要抓取的网页地址 }, }, required: [url], }, }, }, ];工具描述description的措辞直接影响模型会不会正确调用。我一开始写得很简略结果模型经常在该搜索的时候不搜索直接凭记忆瞎答。后来我把描述改成明确的触发条件——当需要实时信息、事实核查或页面上下文不足时——调用率明显上来了。给模型写工具描述本质上是在写 prompt要把它当成给新员工的说明书来写。搜索工具的实现可以接任意搜索 API抓取工具则用fetch加一个简单的正文提取async function fetchPage(url) { const res await fetch(url, { headers: { User-Agent: Mozilla/5.0 (compatible; SidebarAI/1.0) }, }); const html await res.text(); const text html .replace(/script[\s\S]*?\/script/gi, ) .replace(/style[\s\S]*?\/style/gi, ) .replace(/[^]/g, ) .replace(/\s/g, ) .trim(); return { url, content: text.slice(0, 6000) }; }这个正则版的正文提取很粗糙但胜在零依赖、够快。如果你追求质量可以上mozilla/readability它能更精准地剥离导航和广告。我目前的取舍是先用粗糙版跑通等有精力再换。4. 那些让我熬夜的坑以及怎么爬出来的4.1 扩展和后端通信的跨域与超时第一个大坑是跨域。扩展的 sidepanel 页面发起fetch到localhost:3000浏览器会拦。解决办法是在后端开 CORS允许扩展的来源。但扩展的来源是chrome-extension://你的扩展ID这个 ID 在开发模式下每次重载可能变所以开发阶段我直接app.use(cors())全放开上线前再收紧到具体来源。第二个坑是超时。Agent 跑一次可能十几秒而某些环境下fetch的默认超时比较短或者用户网络抖动就断了。我在扩展侧加了 AbortController 控制超时设成 60 秒同时后端在开始处理时就返回一个流式的进度让前端知道它还活着。const controller new AbortController(); const timeout setTimeout(() controller.abort(), 60000); const res await fetch(http://localhost:3000/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload), signal: controller.signal, }); clearTimeout(timeout);4.2 模型假装查了的幻觉问题这是最隐蔽也最危险的坑。有段时间我发现模型回答里带着根据我搜索到的资料这种话但sources数组是空的——它根本没调用搜索工具只是嘴上说说。这种幻觉比直接编答案更可怕因为它伪装成了有依据的样子。我的应对有三招。第一在系统提示里强制要求如果回答依赖了外部信息必须在工具调用里体现不允许口头声称。第二前端做校验如果模型回答里出现了搜索资料显示等词但sources为空就在气泡上打一个未经验证的标记。第三降低温度参数把temperature设到 0.3 左右减少它自由发挥的倾向。系统提示我是这么写的你是一个严谨的助手。当问题涉及实时信息、具体数据或你不确定的事实时 你必须调用 web_search 工具进行核实不得凭记忆直接回答。 回答中引用外部信息时必须基于工具返回的真实结果。 如果工具没有返回有效结果如实告知用户不要编造。4.3 上下文窗口的取舍与截断策略页面内容 对话历史 工具返回结果这三样加起来很容易撑爆上下文窗口。我的策略是分级预算页面内容最多 8000 字符对话历史只保留最近 6 轮工具返回每个结果最多 6000 字符且最多保留 3 个结果。超出预算的部分要主动丢弃而不是等 API 报错。我在组装 messages 之前会先算一遍总长度超了就从头砍历史function trimHistory(history, maxChars 4000) { let total 0; const kept []; for (let i history.length - 1; i 0; i--) { const len JSON.stringify(history[i]).length; if (total len maxChars) break; total len; kept.unshift(history[i]); } return kept; }这里有个反直觉的点对话历史不是越多越好。我实测发现保留太多历史反而会让模型被早期话题带偏回答当前问题时扯到之前的内容上。精简历史之后回答的聚焦度明显提升。4.4 侧边栏状态丢失的排查过程有个 bug 折磨了我整整一个晚上用户切到别的标签页再切回来侧边栏里的对话全没了。我一开始以为是 service worker 被杀导致的查了半天 background 日志发现 service worker 确实会被杀但对话数据我明明存在chrome.storage.session里了。后来才定位到真正原因sidepanel 页面本身在标签切换时会被销毁重建。也就是说不是数据丢了是 UI 重新渲染时没有从 storage 里恢复。修复方法是在 sidepanel 的DOMContentLoaded里主动读一次 storagedocument.addEventListener(DOMContentLoaded, async () { const { history [] } await chrome.storage.session.get(history); history.forEach((m) appendMessage(m.role, m.text, m.sources)); });这个坑的教训是MV3 里任何 UI 状态都要假设它会随时被销毁必须持久化。别指望内存里的变量能活过标签切换。5. 从能跑到好用几个提升体验的细节5.1 流式输出让等待不再煎熬Agent 跑十几秒如果最后才一次性吐出答案用户体感很差。改成流式输出后答案一个字一个字往外蹦用户能感觉到它在干活耐心度完全不一样。实现上后端用 SSEServer-Sent Events把模型的流式响应转发给前端app.post(/chat/stream, async (req, res) { res.setHeader(Content-Type, text/event-stream); res.setHeader(Cache-Control, no-cache); const stream await runAgentStream(req.body); for await (const chunk of stream) { res.write(data: ${JSON.stringify(chunk)}\n\n); } res.write(data: [DONE]\n\n); res.end(); });前端用EventSource或者fetch的 ReadableStream 接收。注意 SSE 在扩展环境里偶尔会有缓冲问题如果发现字不是实时蹦出来而是攒一批才出检查一下有没有中间层做了缓冲。5.2 快捷键与划词即问侧边栏最大的价值是随手可用所以快捷键不能少。我注册了一个commands按CtrlShiftK直接把选中的文本发进侧边栏提问{ commands: { ask-selection: { suggested_key: { default: CtrlShiftK }, description: 用选中的文本提问 } } }配合前面讲的getSelectedText用户在任何页面选中一段话按快捷键侧边栏自动打开并把这段话作为问题发出去。这个功能用起来非常顺手是我自己日常用得最多的入口。5.3 检索来源的可信度标注前面提到要展示检索来源但光列 URL 还不够。我加了一个简单的可信度分层优先展示域名权威的站点把明显是内容农场的结果排在后面。这个排序不需要多复杂一个域名白名单加一个黑名单就能过滤掉大部分噪音。用户看到来源列表时心里对答案的可靠性就有数了。5.4 本地配置与密钥管理大模型 API 的密钥绝对不能硬编码在扩展里因为扩展代码是明文可读的。我的做法是密钥存在后端的.env文件里扩展只跟本地后端通信从不接触密钥。后端用dotenv读取import dotenv/config; const API_KEY process.env.LLM_API_KEY;如果你要把这个项目分享给别人记得在.gitignore里加上.env并且提供一个.env.example模板。我见过太多开源项目因为把密钥提交上去被人盗刷的案例这个习惯必须养成。6. 我实际用下来的一些体会这个侧边栏助手我用了几个月最大的感受是它改变了我查资料的习惯。以前遇到不确定的东西我会开新标签页搜然后在搜索结果里翻来翻去经常翻着翻着就跑偏了。现在选中文本按个快捷键它帮我把搜索、阅读、总结一条龙做完我只需要判断结论对不对。省下来的不只是时间更是注意力。但我也要说清楚它的边界。它不是一个可以完全信任的信息源它只是一个效率工具。凡是涉及关键决策的信息我依然会点开它给的来源自己核对一遍。工具的价值在于帮你快速缩小范围而不是替你做判断。这个心态摆正了用起来才踏实。另外Agent 的检索质量高度依赖搜索 API 的质量和工具描述的措辞。如果你复现之后发现它老是搜不到点子上先别怀疑模型去检查你的工具描述是不是写得太含糊。我调这个描述调了好几轮每次微调都能感觉到调用准确率的变化。代码我已经整理开源了结构不复杂核心就是扩展负责交互和上下文后端负责推理和工具调用这一条主线。你可以基于它换成任意大模型接任意搜索服务改造成适合自己工作流的形态。真正花时间的从来不是把功能跑通而是把那些边角体验磨顺——流式输出、来源展示、状态反馈、快捷键这些才是决定你会不会一直用下去的东西。