Skip to content
Chrome Extension AI 应用设计:Content Script 与模型交互
基本概念:Manifest V3 扩展的最小结构与 Content Script 的位置
Chrome 扩展在 Manifest V3(MV3)中由 manifest.json 描述基本结构。与模型请求直接相关的部分有三个:
content_scripts:声明向匹配页面注入的脚本,在页面上下文中运行。background.service_worker:指向后台 Service Worker 脚本,负责扩展的全局逻辑和消息路由。host_permissions:声明扩展可以跨域访问的主机范围,远程模型请求依赖这一配置。
下面是一个最小示例。示例中的 version 字段为扩展版本号,其格式由 Manifest 文件格式定义(见参考 [10]),具体值按项目发布策略填写。
json
{
"manifest_version": 3,
"name": "AI Context Assistant",
"version": "0.1.0",
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"permissions": [],
"host_permissions": ["https://api.openai.com/*"]
}这个结构中,content.js 负责从页面读取上下文,background.js 负责接收消息并调用远程模型 API。matches 使用 "<all_urls>" 表示所有页面都注入;实际项目中应当根据功能收敛到更窄的匹配范围。
Content Script 的注入方式与隔离世界
Content Script 可以通过两种方式注入:
- 静态声明:在
manifest.json的content_scripts数组中声明,扩展安装时自动注册。 - 动态注入:通过
chrome.scripting.executeScript在运行时注入,需要"scripting"权限。
静态注入的常用字段包括:
| 字段 | 作用 |
|---|---|
js | 注入的脚本文件列表 |
matches | 匹配的 URL 模式 |
run_at | 注入时机,document_idle、document_start 或 document_end |
Content Script 运行在**隔离世界(isolated world)**中。它和页面共享 DOM,但不共享 JavaScript 全局对象。页面主世界修改的 window.someGlobal 不会出现在 Content Script 的 window 上,反之亦然。
页面 CSP 对 Content Script 的影响需要区分两点:
- Content Script 自身的脚本执行不受页面
script-src指令限制,它运行在独立的隔离世界中。 - 如果 Content Script 向页面 DOM 注入
<script>标签、内联事件处理器或javascript:URL,这些内容会进入页面主世界,受页面 CSP 约束。
因此,在 Content Script 中通过 innerHTML 拼接带 <script> 的字符串无法绕过页面 CSP,而且出于安全考虑也不应该这样做。向页面主世界传递数据时,优先使用 window.postMessage 或自定义 DOM 事件。
页面上下文提取与消息载荷设计
Content Script 最常用的能力是从当前页面提取文本。例如:
js
function collectPageContext() {
const selection = window.getSelection().toString().trim();
const pageText = document.body.innerText || '';
return {
title: document.title,
url: location.href,
pageText: pageText.slice(0, 8000),
selection: selection.slice(0, 4000)
};
}提取后的数据要通过消息发送给后台。消息载荷需要包含三个要素:
- 请求类型:让后台知道要执行什么操作。
- 请求 ID:用于关联请求与后续的流式响应。
- 具体数据:页面文本、指令、模型参数等。
js
function createLLMRequest(instruction, context) {
return {
type: 'LLM_REQUEST',
requestId: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
payload: {
instruction,
context
}
};
}requestId 是消息协议中最重要的字段。流式响应会拆成多条消息返回,Content Script 必须依靠 requestId 判断当前消息属于哪一次请求。
Content Script 与后台 Service Worker 的消息通信
Content Script 与后台之间通过 chrome.runtime.sendMessage 通信。这个 API 向扩展内的所有 onMessage 监听器发送一条消息,并接收一个可选的回调。
MV3 中 chrome.runtime.sendMessage 返回 Promise 的时代尚未完全统一;为了兼容性与明确性,可以用 ES6 的 Promise 封装:
js
function sendMessageToBackground(message, timeout = 30000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Message response timeout'));
}, timeout);
chrome.runtime.sendMessage(message, (response) => {
clearTimeout(timer);
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (response && response.error) {
reject(new Error(response.error));
return;
}
resolve(response);
});
});
}后台 Service Worker 侧的监听器:
js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'LLM_REQUEST') {
handleLLMRequest(message.payload)
.then((result) => {
sendResponse({ requestId: message.requestId, ok: true, text: result });
})
.catch((error) => {
sendResponse({
requestId: message.requestId,
ok: false,
error: { code: 'LLM_REQUEST_FAILED', message: error.message }
});
});
return true;
}
});return true 表示监听器将异步调用 sendResponse。这是 Chrome 消息 API 的既有约定,不是扩展特有语法。
模型调用链路设计:后台中转、Offscreen Document 与生命周期限制
三层架构
Content Script 直接调用远程模型 API 在技术上并非不可能,但存在两个问题:
- API Key 会暴露在页面上下文中。 Content Script 虽在隔离世界,但其代码和全局数据仍属于扩展前端的易审计面。放在后台可以避免模型供应商的密钥被注入到每个访问的页面中。
- 跨域行为不统一。 对话式模型接口通常不接受任意来源的浏览器跨域请求。后台 Service Worker 配合
host_permissions是 MV3 中更稳定的跨域方式。
因此,推荐的结构是:
Content Script -> Background Service Worker -> LLM APIContent Script 负责页面上下文提取和 UI 渲染;后台 Service Worker 负责鉴权、请求转发和流式响应解析;两者通过扩展消息机制衔接。
Service Worker 生命周期
MV3 的后台 Service Worker 不是常驻进程。用户代理在没有事件或正在执行的任务时,可能随时终止它。远程模型请求不能假设后台永远存活。
对于单次 fetch 请求,只要在 fetch 进行期间 Service Worker 正在处理事件,通常可以完成。真正的问题出现在两种场景:
- 长连接被意外终止:Service Worker 可能在读取流的过程中被回收,导致后续消息丢失。
- 需要 DOM 的流程:Service Worker 没有
window、document,无法操作 DOM。
Offscreen Document 的作用
从 Chrome 109 开始,MV3 提供 Offscreen Document,一个只在后台创建、无法被网页访问的扩展文档。它拥有 DOM,适合在后台渲染或处理需要窗口 API 的逻辑。对于纯文本的 LLM 请求,Offscreen Document 不是必需的;但当你需要在后台生成图片、渲染 UI 或执行更复杂的交互时,它可以作为 Service Worker 的补充。
需要注意:Offscreen Document 的生命周期独立于创建它的 Service Worker,不应把主要扩展逻辑放进去。它更像一个受控的后台工作区。
本地模型方案
如果模型运行在本地,例如使用 Ollama 这类开源工具,仍然可以保持同样的三层结构:
- 后台 Service Worker 将请求转发到
http://localhost:11434/v1/chat/completions(Ollama 提供的 OpenAI-compatible 端点)。 - 由于没有远程 API Key,鉴权字段可以省略。
host_permissions需要包含"http://localhost:11434/*"。
本地模型与远程模型在选择上没有架构差异,差异主要在于鉴权、网络延迟和并发能力。
消息协议设计:请求、响应与流式事件
远程模型输出通常是逐 token 生成的。如果等待完整 JSON 再一次性显示,用户会看到很长的空白时间;流式响应用户体验更接近对话。因此,消息协议需要同时支持一次性响应与流式事件。
协议包含以下消息类型:
| 方向 | 类型 | 含义 |
|---|---|---|
| Content Script -> Background | LLM_REQUEST | 发起一次模型请求 |
| Content Script -> Background | LLM_CANCEL | 取消指定请求 |
| Background -> Content Script | LLM_START | 后台已接受请求 |
| Background -> Content Script | LLM_DELTA | 一段文本增量 |
| Background -> Content Script | LLM_DONE | 流式输出结束 |
| Background -> Content Script | LLM_ERROR | 请求或流式输出出错 |
Content Script 中的监听器:
js
let currentRequestId = null;
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.requestId !== currentRequestId) {
return;
}
switch (message.type) {
case 'LLM_START':
setStatus('start');
break;
case 'LLM_DELTA':
appendText(message.delta);
break;
case 'LLM_DONE':
setStatus('done');
break;
case 'LLM_ERROR':
showError(message.error);
break;
}
});后台在流式读取过程中持续向 Content Script 发送 LLM_DELTA 事件:
js
function sendToContentScript(message) {
chrome.tabs.sendMessage(currentTabId, message).catch(() => {
// 页面可能已导航或关闭
});
}后台调用远程 LLM API:鉴权、CORS/CSP 与 host_permissions
配置 host_permissions
host_permissions 是 MV3 中声明跨域访问范围的关键字段。后台 Service Worker 发出的请求是否符合扩展权限,由它决定,而不是页面的 CORS 或 CSP。对于 OpenAI-compatible 接口,配置为:
json
{
"host_permissions": ["https://api.openai.com/*"]
}如果使用本地模型:
json
{
"host_permissions": ["http://localhost:11434/*"]
}host_permissions 应当保持最小化。只申请实际使用的模型服务域名,不要使用 "<all_urls>"。
鉴权头
大多数使用 OpenAI-compatible 接口的服务采用 Authorization: Bearer <apiKey> 的鉴权头,这是 OAuth 2.0 Bearer Token 的标准用法(RFC 6750,见参考 [14])。API Key 应当存储在后台可访问但页面不可访问的位置,例如 chrome.storage.local 配合 chrome.storage.session。不要在 Content Script 中放入任何与密钥相关的常量。
js
const apiKey = await chrome.storage.session.get('apiKey');发起到远程模型的请求
后台 Service Worker 使用 fetch 发起流式请求。示例:
js
async function streamLLM(payload, apiKey, onEvent) {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: payload.model,
messages: payload.messages,
stream: true
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API ${response.status}: ${errorText}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const data = trimmed.slice(5).trim();
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta?.content ?? '';
if (delta) {
onEvent({ type: 'delta', delta });
}
} catch {
// 忽略无法解析的行
}
}
}
onEvent({ type: 'done' });
}Authorization 的具体格式取决于模型服务提供商的文档。上述代码假设的是一个 OpenAI-compatible 接口。
流式响应读取:fetch、ReadableStream 与 TextDecoder
模型服务的流式响应通常使用 SSE(Server-Sent Events)格式。该格式规定事件数据以 data: 开头,以换行符分隔,流结束时返回 data: [DONE]。SSE 的具体格式由 HTML Standard 定义(见参考 [13])。
response.body 是一个 ReadableStream。读取时需要处理两个问题:
- UTF-8 多字节字符可能被拆分到不同的 chunk 中。 使用
TextDecoder('utf-8')的stream: true选项可以正确处理跨 chunk 的字符。 - 一个 chunk 可能包含多行文本。 需要维护一个缓冲区,按换行符切分后再逐行解析。
TextDecoder 的流式解码是 ES6+ 时代常见的标准用法:
js
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
processLine(line);
}
}注意:decoder.decode(value, { stream: true }) 不会 flush 内部状态;最后一步应再调用一次 decoder.decode() 以 flush 剩余内容。上面的循环中,reader.read() 返回 done: true 后,缓冲区中可能还有未处理的尾部数据,需要额外处理:
js
if (buffer.trim()) {
processLine(buffer);
}请求控制:超时、取消、重试与并发
超时
远程模型请求可能长时间无响应。使用 AbortController 设置超时:
js
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000);
try {
const response = await fetch(url, {
signal: controller.signal
});
// ...
} finally {
clearTimeout(timeoutId);
}AbortController 是 Web 标准的取消机制,适用于 fetch 和 ReadableStream。
取消
Content Script 可以发送取消消息。后台维护当前活动的 AbortController:
js
let activeController = null;
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'LLM_CANCEL') {
if (activeController && message.requestId === activeController.requestId) {
activeController.abort();
}
sendResponse({ ok: true });
}
});abort() 会让正在进行的 reader.read() 抛出一个 AbortError,后台捕获后向 Content Script 发送 LLM_CANCEL 事件。
重试
合理的重试策略只针对网络层错误和可恢复的 HTTP 状态码,例如 429、502、503。4xx 请求错误不应该重试。重试次数建议限制在 2 次以内,每次重试前增加退避等待时间。
js
async function fetchWithRetry(url, options, maxRetries = 2) {
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fetch(url, options);
} catch (error) {
lastError = error;
if (attempt < maxRetries) {
await delay(1000 * (attempt + 1));
}
}
}
throw lastError;
}并发
同一时间只允许一个流式请求。Content Script 在发起新请求前检查 currentRequestId,如果已有活动请求,可以自动取消前一个或提示用户。
js
if (currentRequestId) {
await sendMessageToBackground({
type: 'LLM_CANCEL',
requestId: currentRequestId
});
}
currentRequestId = message.requestId;Content Script 中的流式 UI 渲染与状态反馈
Content Script 需要将后台传来的增量文本渲染到页面中。为了避免页面样式干扰,推荐使用 Shadow DOM:
js
function createPanel() {
const host = document.createElement('div');
host.id = 'ai-extension-root';
const shadow = host.attachShadow({ mode: 'closed' });
const container = document.createElement('div');
container.style.position = 'fixed';
container.style.right = '20px';
container.style.bottom = '20px';
container.style.width = '360px';
container.style.maxHeight = '400px';
container.style.overflow = 'auto';
container.style.fontFamily = 'sans-serif';
container.style.fontSize = '14px';
container.style.background = '#fff';
container.style.border = '1px solid #ccc';
container.style.borderRadius = '8px';
container.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)';
container.style.padding = '12px';
shadow.appendChild(container);
document.documentElement.appendChild(host);
return container;
}示例样式中的具体数值(如透明度 0.15)仅为演示,开发者可按设计需求调整。
更新文本时使用 textContent。该属性是 DOM 标准的一部分(见参考 [11]),用于设置节点文本内容,不会解析 HTML。模型输出是不可信的,innerHTML 可能导致 DOM 注入;即使运行在隔离世界,页面 DOM 的元素仍可能触发页面脚本的事件处理器。
js
const output = document.createElement('div');
container.appendChild(output);
function appendText(delta) {
output.textContent += delta;
container.scrollTop = container.scrollHeight;
}状态反馈可以分成三个状态:
- start:显示"正在请求模型…"。
- delta 期间:逐字追加文本。
- done / error:显示完成状态或错误信息。
提示词与上下文长度控制:token 预算与压缩策略
模型接口通常按 token 计费,且上下文窗口有上限。Content Script 从页面提取的文本可能远超单次请求的容量,需要在发送前做预算控制。
token 数与字符数不是线性关系,但客户端在发送前通常不知道精确的 tokenizer 结果。一个工程上的近似做法是按字符数设置预算,并在接近上限时截断最旧的内容。
下面是一个简单的字符预算适配函数。system 是 Chat Completions API 中定义的消息角色之一(见参考 [12]),用于设定模型的系统指令,因此适配时必须保留。
js
function fitMessages(messages, maxChars = 12000) {
const fitted = [];
let total = 0;
for (let i = messages.length - 1; i >= 0; i--) {
const content = messages[i].content;
if (total + content.length > maxChars) {
const remaining = maxChars - total;
fitted.unshift({
...messages[i],
content: content.slice(0, Math.max(remaining, 0)) + '…'
});
break;
}
fitted.unshift(messages[i]);
total += content.length;
}
return fitted;
}这个函数从最近的对话开始保留,超出预算时截断最早的内容。最终的消息列表必须保留 system 指令,因为它是模型行为的关键约束。
更精确的做法是使用模型的 tokenizer 在后台做一次编码统计,再把超出的部分丢弃或压缩。是否在客户端引入 tokenizer 取决于模型供应商是否提供轻量实现。对于教程级的实现,字符数预算是可以工作的替代方案。
需要注意:fitMessages 并不等价于 token 计数。它只是一个避免请求超限的客户端保险。真正的预算控制应以模型服务返回的 token usage 和错误信息为准。
安全边界、调试与工程注意点
API Key 保护
API Key 只允许出现在后台 Service Worker 或 Offscreen Document 中。Content Script 永远不要读取或转发 API Key。即使如此,chrome.storage 也不是强安全边界,因为它可以被同一扩展中的其他脚本读取。不要存储比业务需要更多的敏感信息。
用户数据隐私
Content Script 提取的页面文本可能包含密码、邮箱、个人身份信息。在把页面上下文发给模型前,应当在 UI 中明确提示用户,并允许用户在发送前移除已选内容。不要在本地持久化完整页面文本。
调试方式
- 打开
chrome://extensions,点击 Service Worker 链接,查看后台console.log输出。 - Content Script 的日志会出现在页面 DevTools 的 Console 面板中,可通过过滤器选择扩展上下文。
- 流式事件可以临时在监听器中加入日志来确认消息是否按序到达。
MV3 特定注意点
- Service Worker 没有 DOM API。需要 DOM 的流程必须放到 Offscreen Document 或扩展自带的页面中。
- 长流式请求可能被 Service Worker 生命周期中断。若发现流式请求在持续数分钟后中断,应检查是否有 Service Worker 停止的日志,并考虑将请求迁移到 Offscreen Document 中执行。
host_permissions的变更会导致扩展被禁用或需要重新加载。- 在 Content Script 中直接向
https://api.openai.com发送fetch也可能在绝大多数页面中工作,因为 MV3 扩展的host_permissions给了跨域能力;但 API Key 仍然不能放在那里,因此后台代理的结构不应省略。
消息协议的错误处理
后台处理消息时,sendResponse 只能调用一次。对于流式请求,不要试图用 sendResponse 返回最终结果,而是通过后续的 chrome.tabs.sendMessage 推送事件。sendResponse 只用来确认后台已收到请求并开始处理。
js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'LLM_REQUEST') {
startStreaming(message, sender.tab?.id);
sendResponse({ ok: true, started: true });
return false;
}
});这样避免 return true 与 sendResponse 的异步冲突。
小结与参考链接
这篇教程梳理了 Content Script 与远程 LLM 在 MV3 扩展中交互的完整链路:Content Script 在隔离世界中提取页面上下文,将消息载荷发送给后台 Service Worker;后台使用 host_permissions 的跨域能力调用模型 API,解析 SSE 流式响应,并通过消息协议将增量文本推回 Content Script;Content Script 在 Shadow DOM 中逐步渲染,同时处理超时、取消、并发和 token 预算。整个结构以保护 API Key 和用户数据为边界,以 ES6 的 Promise、async/await、fetch 和 ReadableStream 为工具。
参考链接:
[1] Reclaim Protocol,Manifest Configuration,https://docs.reclaimprotocol.org/browser-extension/extension-integration/manifest-configuration
[2] Reclaim Protocol,Browser Extension Docs,https://docs.reclaimprotocol.org/browser-extension/extension-integration/
[3] Chrome for Developers,Offscreen Documents in Manifest v3,https://developer.chrome.com/blog/Offscreen-Documents-in-Manifest-v3
[4] W3C,Service Workers,https://www.w3.org/TR/service-workers
[5] MDN,CORS,https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
[6] W3C,Content Security Policy Level 3,https://www.w3.org/TR/CSP3
[7] Reclaim Protocol,Browser Extension Docs,https://docs.reclaimprotocol.org/browser-extension/extension-integration/
[8] Reclaim Protocol,Browser Extension Docs,安全边界说明,https://docs.reclaimprotocol.org/browser-extension/extension-integration/
[9] OpenAI Developers,Streaming Responses,https://developers.openai.com/api/docs/guides/streaming-responses
[10] Chrome for Developers,Manifest file format,https://developer.chrome.com/docs/extensions/reference/manifest
[11] WHATWG,DOM Standard – textContent,https://dom.spec.whatwg.org/#dom-node-textcontent
[12] OpenAI Developers,Chat Completions API,https://platform.openai.com/docs/api-reference/chat/create
[13] WHATWG,HTML Standard – Server-Sent Events,https://html.spec.whatwg.org/multipage/server-sent-events.html
[14] IETF,RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage,https://datatracker.ietf.org/doc/html/rfc6750
参考链接
- [1] https://docs.reclaimprotocol.org/browser-extension/extension-integration/manifest-configuration
- [3] https://developer.chrome.com/blog/Offscreen-Documents-in-Manifest-v3
- [4] https://www.w3.org/TR/service-workers
- [5] https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- [6] https://www.w3.org/TR/CSP3
- [9] https://developers.openai.com/api/docs/guides/streaming-responses
