浏览器扩展要接入 AI 助手,听起来像是个“加一个对话框”的小功能,真正发布后才发现,链路里可以断掉的地方远不止一处。下面复盘的项目是一款基于 Manifest V3 的浏览器扩展:用户在当前网页选中文字,点击扩展图标,扩展把选中文本发送给大模型接口,再把摘要、翻译或改写结果展示在侧边栏。这个扩展在本地反复验证都正常,发布到商店后,用户陆续反馈“点按钮没反应”“控制台提示请求被拦截”“侧边栏一直转圈”。回头定位问题时,真正值得记录的并不是 AI 接口本身,而是浏览器扩展的运行机制、消息链路、安全边界和浏览器兼容性。如果你正在做类似的 AI 助手类扩展,或者准备把本地 Demo 发布给真实用户,这篇文章能把容易踩的坑提前列出来。
1. 先理解浏览器扩展里一条 AI 请求到底经过哪几层
1.1 扩展的三种运行环境,对应三条容易断的链路
浏览器扩展至少有三种运行环境,它们分别承载 AI 助手的不同职责:
- Background Service Worker:无 DOM,事件驱动,负责监听消息、调用 AI API、读取存储。
- Content Script:注入到网页中,能操作 DOM,但运行在隔离环境里,拿不到页面自己的 JavaScript 变量。
- Extension Page:侧边栏、弹窗、设置页都是普通 HTML 页面,拥有较完整的扩展 API 权限,但没有注入到页面的能力。
AI 助手功能会同时用到这三种环境,所以只要有一条链路断掉,整个功能就表现为“点击没反应”。
| 运行环境 | 是否有 DOM | 能否读取页面 JS 变量 | 能否直接 fetch 外部 API | 生命周期 |
|---|---|---|---|---|
| Service Worker | 无 | 否 | 可以,配合 host_permissions | 事件驱动,空闲可能被回收 |
| Content Script | 有 | 否,页面 JS 变量不可见 | 受页面 CORS 限制,不建议 | 随页面生命周期 |
| Extension Page | 有 | 否,只能拿到页面 DOM 引用 | 可以,配合 host_permissions 和 CSP | 随页面/弹窗关闭 |
理解这张表,后面排查问题时才能快速判断“这个消息到底是哪一段没传过去”。
1.2 AI 助手的完整请求链路
一个完整的“选中文本 -> AI 返回结果”的请求链路如下:
用户选中文本 -> content_script 捕获 selection -> chrome.runtime.sendMessage 发送消息 -> background service worker 接收消息 -> fetch 调用 AI API -> 解析响应 -> chrome.runtime.sendMessage 回传结果 -> sidebar 页面渲染结果这条链路里有三个关键点需要提前知道:
第一,chrome.runtime.onMessage的监听器里如果调用了异步方法,必须return true,否则消息通道会被浏览器提前关闭。
第二,sendResponse只能调用一次,不能先回一个“正在处理”,再回一个最终结果。
第三,Content Script 的 fetch 请求使用的是页面源,会受网页 CORS 策略影响;外部 AI API 的请求应该统一放在 Service Worker 里发。
1.3 为什么本地开发正常,发布后就会出问题
本地开发时,扩展以“已解压的扩展”方式加载,使用的是同一台机器、同一个浏览器版本、自己可控的测试页面。发布后,用户的环境差异非常大:
- 用户的 Chrome/Edge 版本不同,部分 API 可能不存在或行为不同。
- 用户访问的网页不同,有的站点会禁掉扩展注入,有的页面结构特殊,
window.getSelection()拿不到有效文本。 - 用户所在的网络环境不同,AI API 可能超时、被代理拦截、返回 429 或 403。
- 商店审核过后,浏览器可能对权限声明、隐私政策有额外限制。
所以,本地能跑通只是第一步,真正稳定要靠权限最小化、功能降级和完整的日志埋点。
2. 环境准备和最小项目结构
2.1 环境要求和浏览器选型
用纯 JavaScript 写一个最小扩展,不需要安装 Node.js,也不需要构建工具。只要准备一个现代浏览器即可,Chrome 和 Edge 对 Manifest V3 的支持最完整,Firefox 对部分 MV3 API 的支持不完全一致,如果目标用户包含 Firefox,需要做能力检测。
| 项目 | 建议 | 说明 |
|---|---|---|
| 浏览器 | Chrome、Edge | MV3 支持最稳定 |
| 构建工具 | 可选 | 纯 JS 可不用,TypeScript 场景可引入 Vite |
| 包管理 | npm | 仅当需要依赖第三方库时使用 |
| 版本兼容 | 做能力检测 | 不要假定所有用户都是最新版 |
如果项目稍大,需要引入 UI 框架或 TypeScript,再用 Vite 或 Webpack 打包。但初始阶段建议保持纯 JS,缩小问题范围。
2.2 项目目录和文件清单
最小项目结构如下:
ai-assistant-extension/ ├── manifest.json ├── background.js ├── content.js ├── sidebar.html ├── sidebar.js ├── icons/ │ ├── icon16.png │ ├── icon48.png │ └── icon128.png └── README.md每个文件的职责:
manifest.json:声明扩展名称、权限、后台脚本、内容脚本和侧边栏入口。background.js:Service Worker,负责接收消息、调用 AI API、回传结果。content.js:注入到网页,负责读取用户选中文本并转发给后台。sidebar.html和sidebar.js:侧边栏界面,负责展示结果和发送请求。icons/:商店上架和扩展工具栏图标。
2.3 manifest.json 的关键字段和权限对照表
一个最小可运行的 manifest 如下:
{ "manifest_version": 3, "name": "AI Assistant Sidebar", "version": "0.1.0", "description": "在侧边栏中汇总选中文本并调用 AI 接口。", "permissions": ["storage", "sidePanel", "scripting"], "host_permissions": ["https://api.example.com/*"], "background": { "service_worker": "background.js" }, "content_scripts": [ { "matches": ["https://example.com/*"], "js": ["content.js"], "run_at": "document_idle" } ], "action": { "default_title": "打开 AI 助手", "default_icon": "icons/icon128.png" }, "side_panel": { "default_path": "sidebar.html" }, "icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" } }权限字段可以按下面的表格对照选择:
| 权限 | 作用 | 什么时候需要 |
|---|---|---|
storage | 保存用户配置和历史记录 | 只要需要记忆设置就需要 |
sidePanel | 使用侧边栏 API | 使用侧边栏 UI 时需要 |
scripting | 通过编程方式注入脚本 | 需要在某些页面按需注入时 |
tabs | 读取当前标签页信息 | 需要拿到当前 tabId 时 |
host_permissions | 允许跨域请求特定域名 | 后台调用外部 AI API 时必须 |
注意tabs权限和<all_urls>这种宽泛声明会显著增加商店审核的说明成本。如果只需要读取当前标签页,可以优先用activeTab权限,它会在用户点击扩展时临时授予权限,审核风险更低。
3. 核心实现:消息传递、AI 调用和页面交互
3.1 在 Service Worker 里接收消息并调用 AI API
所有外部 AI 请求都放在 background 里,扩展页面和内容脚本都通过消息请求后台转发。
// background.js const AI_API_URL = 'https://api.example.com/v1/chat/completions'; chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (!message || message.type !== 'ASK_AI') { return; } handleAskAi(message.payload) .then((data) => sendResponse({ ok: true, data })) .catch((error) => sendResponse({ ok: false, error: error.message })); return true; }); async function handleAskAi(payload) { const apiKey = await getApiKey(); const response = await fetch(AI_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, body: JSON.stringify({ model: payload.model || 'gpt-3.5-turbo', messages: [ { role: 'system', content: '你是一个网页阅读助手。' }, { role: 'user', content: payload.text } ], temperature: payload.temperature ?? 0.3, max_tokens: payload.maxTokens ?? 800 }) }); if (!response.ok) { throw new Error(`AI API ${response.status}: ${await response.text()}`); } return response.json(); }这段代码必须注意两个细节:
第一,return true告诉浏览器这个监听器要异步调用sendResponse。如果漏掉这行,消息通道会直接关闭,调用方会收到 “The message port closed before a response was received”。
第二,sendResponse只能执行一次。如果把handleAskAi().then()放到了return true之后,逻辑没问题,但一定不要在 try/catch 里调用两次sendResponse。
3.2 从侧边栏发起请求
侧边栏是扩展页面,可以直接调用chrome.runtime.sendMessage。
// sidebar.js const resultBox = document.getElementById('resultBox'); async function askAi(text) { resultBox.textContent = '处理中...'; try { const response = await chrome.runtime.sendMessage({ type: 'ASK_AI', payload: { text, model: 'gpt-3.5-turbo', temperature: 0.3, maxTokens: 800 } }); if (!response || !response.ok) { throw new Error(response ? response.error : '消息发送失败'); } resultBox.textContent = response.data.choices[0].message.content; } catch (error) { resultBox.textContent = `调用失败:${error.message}`; } }在较新的 Chrome 版本中,chrome.runtime.sendMessage支持 Promise 写法。如果兼容范围包含旧版本,需要改成回调方式:
chrome.runtime.sendMessage( { type: 'ASK_AI', payload: { text } }, (response) => { if (chrome.runtime.lastError) { resultBox.textContent = chrome.runtime.lastError.message; return; } // 处理 response } );使用 Promise 写法时,chrome.runtime.lastError会以异常形式抛出,需要在 try/catch 里捕获。
3.3 获取当前页面选中文本
Content Script 读取选区文本并回传给调用方:
// content.js chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message && message.type === 'GET_SELECTION') { const selection = window.getSelection(); const text = selection ? selection.toString().trim() : ''; sendResponse({ text: text.slice(0, 4000) }); return false; } });这里做了两个限制:
第一,文本截断为 4000 字符,避免把整份网页塞进消息通道,导致序列化和传输都变慢。
第二,如果当前是 PDF 查看器、Shadow DOM 内部或某些富文本编辑器,window.getSelection()可能拿不到有效文本。这种情况需要降级策略,比如读取document.body.innerText并截断。
3.4 关键参数:模型、温度、max_tokens、超时
扩展里调用 AI API,参数不能照搬后端脚本的默认值。用户是实时等待的,参数选择直接影响体验。
| 参数 | 含义 | 建议初始值 | 调大影响 | 调小影响 |
|---|---|---|---|---|
model | 模型名称 | 按供应商实际可用模型 | 能力更强但更慢更贵 | 更快更便宜但能力弱 |
temperature | 采样温度 | 0.3 | 更有创造性但容易跑题 | 更稳定但过于保守 |
max_tokens | 输出最大 token 数 | 800 | 能输出长文但等待更久 | 输出短,可能被截断 |
| 超时 | 请求超时时间 | 30 秒 | 长请求不易断,但用户等待久 | 快速失败,但可能误判 |
在浏览器端,建议用AbortController实现超时:
const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 30000); try { const response = await fetch(AI_API_URL, { method: 'POST', signal: controller.signal, // ... }); } finally { clearTimeout(timer); }超时后要主动通过消息告诉用户“请求超时”,而不是让 UI 一直转圈。
4. 上线后我遇到并修复的六个故障
4.1 Service Worker 在 AI 长请求中途被休眠
现象:第一次点击正常,几秒钟后再次点击没有反应;过一会儿又恢复正常。控制台出现The message port closed before a response was received。
原因:Manifest V3 的 Service Worker 是事件驱动的,空闲后会被浏览器回收。AI 请求如果耗时较长,Service Worker 可能在 fetch 过程中被终止,消息通道随之关闭。
检查方式:打开chrome://extensions,开启开发者模式,点击 Service Worker 链接,在控制台查看是否有报错,并观察 Service Worker 的启动和终止日志。
处理方式:
- 在
onMessage监听器中return true,保持消息通道打开。 - 对于超过一分钟的 AI 长任务,不要把 fetch 放在 Service Worker 里,改放到 Offscreen Document 中。
- 如果只是普通文本生成,尽量控制
max_tokens,让请求在 30 秒内完成。
这里要特别提醒:return true只能保证消息通道在异步任务期间保持,不能保证 Service Worker 永远存活。真正需要长时间运行的任务必须换运行环境。
4.2 Content Script 拿不到页面变量,只能拿到 DOM
现象:想在页面上读取window.__INITIAL_STATE__这类前端全局变量,结果返回 undefined。
原因:Content Script 运行在隔离世界(Isolated World)中,它和页面共享 DOM,但共享不了页面的 JavaScript 变量。这是浏览器扩展的安全设计,不是 bug。
检查方式:在 Content Script 控制台执行typeof window.__INITIAL_STATE__,结果通常是undefined。
处理方式:
如果确实需要读取页面变量,使用chrome.scripting.executeScript并指定world: 'MAIN':
// background.js const results = await chrome.scripting.executeScript({ target: { tabId }, world: 'MAIN', func: () => window.__INITIAL_STATE__ });如果目标浏览器版本较旧,不支持world参数,可以降级为向页面注入<script>标签。对于 AI 助手场景,大多数情况下只需要 DOM 文本或用户选区,不会用到页面变量,所以这个故障的修复方案是:明确区分“需要 DOM”和“需要页面 JS 状态”两种需求,前者用 Content Script,后者才用 MAIN 世界。
4.3 大文本消息传递导致发送失败或页面卡顿
现象:用户选中整页长文发送,侧边栏卡住,或者收到Could not establish connection. Receiving end does not exist.这类异常。
原因:消息传递需要 JSON 序列化,超长文本会让序列化耗时变长,内存占用升高;如果文本里有特殊字符,还可能引发编码问题。Content Script 也没有把 DOM 节点序列化进消息的能力,直接把元素对象放进消息里会变成空对象或抛异常。
处理方式:
- 发送前清洗文本,去掉多余空白和换行。
- 截断到合理长度,比如 4000 字符。
- 需要处理超长文档时,分块发送或在后台分片组装。
- 永远不要发送 DOM 节点,只发送纯文本。
function cleanText(raw) { return raw .replace(/\s+/g, ' ') .replace(/\n{3,}/g, '\n\n') .trim() .slice(0, 4000); }4.4 AI API 被 CSP 和 CORS 双重拦截
现象:控制台出现Refused to connect to 'https://api...' because it violates the following Content Security Policy directive: connect-src 'self',或者出现No 'Access-Control-Allow-Origin' header is present。
原因:这涉及两个独立规则。第一,扩展页面受自身 CSP 限制,如果不声明connect-src,外部地址的连接可能被拦截。第二,如果 fetch 请求是从 Content Script 发出的,使用的是页面源,会受目标 API 的 CORS 策略限制。AI API 通常不会给所有网页来源放行 CORS,所以从 Content Script 直接请求很容易失败。
处理方式:
- 所有 AI 请求统一放后台 Service Worker 发。
- 在 manifest 中声明
host_permissions指向 API 域名。 - 如果仍被 CSP 拦截,在 manifest 中增加
content_security_policy:
"content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'; connect-src https://api.example.com" }注意,Manifest V3 不允许通过这种声明加载远程脚本,只能放开connect-src连接地址,这个限制本身是安全设计。
推荐路径是:内容脚本只负责拿文本并发送消息,后台负责调用 API。这样 CORS 问题通过host_permissions解决,CSP 问题通过connect-src解决,两个问题分开排查。
4.5 API Key 被从安装包中提取出来
现象:扩展发布后,有人通过解包安装文件拿到了硬编码在代码里的 API Key,导致额度被大量消耗。
原因:浏览器扩展的代码对用户是可见的。无论是 background.js 还是打包后的 js 文件,只要放置了明文密钥,用户就能用编辑器打开并找到。任何客户端代码里的密钥都不能认为是机密。
处理方式:
- 不要把 API Key 写进扩展代码。
- 搭建一个受控后端代理,扩展把请求发给自己的后端,由后端持有 API Key 并调用大模型接口。
- 后端做用户鉴权、限流、日志脱敏,避免一个 Key 被多个用户刷。
- 如果供应商支持域名白名单或来源限制,把 AI API 的调用限制在代理服务的域名上。
不推荐的写法:
// background.js const API_KEY = 'sk-xxxx'; // 危险,任何人解包即可看到推荐的代理转发结构:
sidebar / content script -> chrome.runtime.sendMessage -> background service worker -> 你的后端服务(持有 API Key,做限流) -> AI API如果只是个人学习项目,没有后端,至少要把 Key 放在用户自己的配置里,并明确告知用户风险,不要在公开分享的扩展包中带私钥。
4.6 侧边栏 API 在部分浏览器和版本上不可用
现象:在最新版 Chrome 上正常,用户在旧版浏览器或 Firefox 上点击图标没反应,或直接报chrome.sidePanel is undefined。
原因:sidePanelAPI 属于较新的能力,不同浏览器和版本的实现进度不一样,不能默认所有用户环境都有。
检查方式:在扩展控制台执行typeof chrome.sidePanel,确认是否存在。
处理方式:做能力检测并提供降级方案。点击扩展图标时,优先打开侧边栏;不支持时用弹窗或新标签页代替。
// background.js chrome.action.onClicked.addListener(async (tab) => { const tabId = tab.id; if (chrome.sidePanel && chrome.sidePanel.open) { await chrome.sidePanel.open({ tabId }); return; } await chrome.windows.create({ url: 'sidebar.html', type: 'popup', width: 420, height: 600 }); });另一个细节是chrome.sidePanel.open()必须在用户手势触发的回调里调用,不能在后台定时器或消息回调中直接调用,否则会被拒绝。
5. 排查链路:从“请求发不出去”到“响应回不来”
5.1 第一站:Service Worker 控制台
所有外部请求都经过后台,所以排查先从 Service Worker 控制台开始。
操作步骤:
- 打开
chrome://extensions。 - 开启“开发者模式”。
- 找到扩展,点击“Service Worker”链接。
- 在打开的控制台里切到 Console 和 Network 两个标签页。
在这里能看到的内容:
- 是否有未捕获的异常。
- fetch 请求是否发出,状态码是多少。
- 消息监听器是否被调用。
- Service Worker 是否被重新启动。
如果 Network 面板里根本没有请求,说明问题在消息链路或 host_permissions;如果有请求但状态码不对,问题在 API 参数或服务端。
5.2 第二站:消息链路和日志埋点
扩展调试最怕“点按钮没反应”。建议在每条消息的发送端和接收端都加上带前缀的日志。
// 发送端 console.log('[send] ASK_AI', payload.text.length); // 接收端 chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { console.log('[receive]', message && message.type, sender.tab && sender.tab.id); });加上日志后重新加载扩展,在 Service Worker 控制台和页面控制台对照,就能确认断点在哪一段:
- 只看到
[send],没看到[receive]:消息没有到达后台,可能是 Content Script 没有注入成功,或者 matches 不匹配当前页面。 - 看到
[receive],但没看到 fetch 请求:问题在handleAskAi里的条件或getApiKey()。 - 看到 fetch 请求,但响应没有回传:检查
return true和sendResponse的调用时机。
5.3 第三站:网络请求与响应头
Service Worker 控制台里的 Network 面板能看到完整的请求头、响应头和响应体。结合状态码判断:
| 状态码 | 含义 | 处理方式 |
|---|---|---|
| 401 | API Key 无效 | 检查密钥和请求头 |
| 403 | 权限不足或地区限制 | 检查 host_permissions、CSP、账户权限 |
| 404 | 接口路径错误 | 对照供应商文档检查 URL |
| 429 | 触发限流 | 增加指数退避重试,减少请求量 |
| 5xx | 服务端异常 | 查看响应体,联系供应商或稍后重试 |
如果是 CORS 或 CSP 错误,响应体里通常没有 AI 返回内容,反而会有一行浏览器提示。把这条提示完整复制到搜索引擎,比凭记忆猜配置更高效。
5.4 排查顺序表
| 现象 | 优先检查 | 再检查 | 最终手段 |
|---|---|---|---|
| 点击按钮没反应 | Service Worker 是否被回收 | 消息是否到达后台 | 加日志,分段验证 |
| 显示“端口关闭” | onMessage 是否 return true | 异步任务是否超时 | 改 Offscreen Document |
| 拿不到页面文本 | Content Script 是否注入 | 页面是否是 PDF/Shadow DOM | 降级读取 body.innerText |
| 请求被拦截 | host_permissions 是否包含域名 | CSP connect-src 是否声明 | 统一走后台请求 |
| API 报 403 | API Key 是否正确 | 后端是否限流 | 检查账户和网络 |
| 某些浏览器不可用 | 是否做了能力检测 | 是否提供了降级 UI | 改成弹窗/新标签页 |
6. 从“能跑”到“能发布”:最佳实践和发布前检查清单
6.1 学习环境、开发环境、生产环境的差异
本地“能跑”和生产环境“能发布”是完全不同的标准。
| 检查项 | 学习环境 | 开发环境 | 生产环境 |
|---|---|---|---|
| API Key | 硬编码方便 | 存本机配置 | 后端代理持有,前端不出现 |
| 消息失败 | 忽略 | 打印日志 | 用户可见错误 + 内部日志 |
| Service Worker | 不关心 | 验证长任务 | 超时、降级、Offscreen |
| 页面兼容 | 本地固定页面 | 多测几个站点 | 能力检测 + 降级 |
| 权限 | 直接给足 | 尽量缩 | 最小权限,过审核 |
| 日志 | console.log | console.log + 文件记录 | 脱敏日志、监控告警 |
上架前至少要跑一遍生产环境的检查,不能把本地能跑当作发布标准。
6.2 发布前检查清单
- [ ] 权限列表是否最小化,
host_permissions是否只包含需要的 API 域名。 - [ ] 是否存在明文 API Key 或敏感配置。
- [ ] 每条异步消息是否都有超时和错误响应。
- [ ] Service Worker 是否处理了长任务,是否考虑 Offscreen Document。
- [ ] content.js 的 matches 是否限定到目标站点,而不是
<all_urls>。 - [ ] 是否做了
sidePanel、chrome.runtime.sendMessage的版本能力检测。 - [ ] CSP 配置是否正确,
connect-src是否包含 API 域名。 - [ ] 是否清理过消息中的大文本和 DOM 节点。
- [ ] 是否在 Chrome、Edge、Firefox 或目标浏览器上分别测试。
- [ ] 隐私政策里是否说明了数据采集、存储和第三方 AI 服务的使用。
- [ ] 是否有用户可见的错误提示,而不只是控制台日志。
- [ ] 是否验证了“用户不点击、浏览器自动唤起侧边栏”这类非手势场景。
6.3 安全与稳定性建议
AI 助手类扩展通常要读取用户当前页面的文本,天然涉及隐私,所以安全设计必须前置:
- 不要在本地存储用户网页正文。如果需要缓存,可以用
chrome.storage.session,它只在浏览器会话期间保留,并明确清理时机。 - 后端代理要针对每个用户做限流,避免单个账号刷爆额度。
- 日志里不要拼入完整用户文本,只记录长度、来源域名、错误码。
- 如果扩展涉及登录态或两步验证码等敏感场景,权限和数据存储更要保守,不要为了少写一个接口就把敏感数据长期留在本地。
- 用户卸载后,要清理动态注入的脚本、缓存和 IndexedDB。
6.4 扩展方向
这个最小版本跑通后,可以按下面几个方向继续完善:
- 用 Offscreen Document 处理音频输入或超过一分钟的 AI 长任务。
- 使用流式接口实现打字机效果,前提是处理好消息通道的持久化。
- 把连续多轮对话历史存到
chrome.storage.session,切换页面后恢复上下文。 - 按网站域名提供不同的 prompt 模板,让助手更贴合站点内容。
- 接入本地向量检索,提供“针对当前网页提问”的能力。
最后:这次发布教会我的三件事
发布时坏掉的东西,最终指向的都不是 AI 接口本身,而是对浏览器扩展运行时模型的理解。
第一,画清楚消息链路再写代码。AI 助手在扩展里不是简单的“页面调接口”,而是内容脚本、Service Worker、扩展页面三方的消息接力,任何一段断了,用户看到的都是“点按钮没反应”。
第二,把密钥和安全边界当成功能的一部分。客户端代码无法保护密钥,必须用后端代理、最小权限和能力检测来兜底。
第三,生产环境要的是降级方案,不是理想路径。侧边栏不可用就降级为弹窗,Service Worker 活不久就换 Offscreen Document,API 被限流就给出退避和友好提示。把每条故障路径都补齐,扩展才真正到了可以发布的状态。