Skip to content
基于 Chrome Extension(Manifest V3)的浏览器端 AI Agent 架构设计与网页智能增强实现
1. Chrome Extension MV3 与 AI Agent 架构概览
Chrome Extension Manifest V3(简称 MV3)是 Chrome 扩展的现行平台规范。与 Manifest V2 相比,MV3 将后台页面替换为 Service Worker,移除了扩展对远程代码和动态代码执行的依赖,并将权限模型进一步收紧 [1]。这些约束反映在 AI Agent 的浏览器端架构设计上:Agent 的模型调用、工具执行、上下文组装需要拆分为独立模块,并借助扩展的消息通道完成跨环境协作。
一个运行在浏览器端的 AI Agent,通常遵循感知—决策—行动—观察的循环:
- 感知(Perceive):从当前网页提取标题、正文、选中文本等上下文。
- 决策(Decide):将上下文与用户目标交给大语言模型(LLM),模型返回计划或工具调用指令。
- 行动(Act):扩展执行具体工具,例如高亮句子、保存笔记、提取段落。
- 观察(Observe):将工具执行结果追加到上下文中,进入下一轮循环,直至模型给出最终答复。
在 MV3 中,这些步骤分别由不同组件承担。整体架构如下:
┌───────────────────────────────────────┐
│ Side Panel │
│ Agent 控制台 / 结果展示 / 用户输入 │
└──────────────────┬────────────────────┘
│ runtime.connect / postMessage
┌──────────────────▼────────────────────┐
│ Background Service Worker │
│ · Agent 主循环 │
│ · LLM 模型适配层(模型调用、流式响应) │
│ · Tool Registry(工具注册与分发) │
│ · chrome.storage(API Key 等机密) │
└──────────────────┬────────────────────┘
│ runtime.sendMessage
┌──────────────────▼────────────────────┐
│ Content Script │
│ · 网页内容提取与 DOM 读取 │
│ · 高亮、标注等 DOM 增强操作 │
│ · 选中文本捕获 │
└───────────────────────────────────────┘Content Script 运行在网页的隔离世界中,负责感知页面和修改页面;Background Service Worker 承载 Agent 主循环与模型调用;Side Panel 提供与浏览旅程互补的持久界面。三者通过消息传递机制协作 [1][2]。
2. Manifest V3 配置与权限模型
扩展的入口是 manifest.json。下面是一个面向 AI Agent 场景的基础配置:
json
{
"manifest_version": 3,
"name": "Page Agent",
"version": "1.0.0",
"description": "浏览器端 AI Agent:感知网页、调用工具、输出增强结果。",
"permissions": ["sidePanel", "storage", "scripting"],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"side_panel": {
"default_path": "sidepanel.html"
},
"web_accessible_resources": [
{
"resources": ["extraction.js"],
"matches": ["<all_urls>"]
}
]
}各字段的职责如下:
permissions:声明扩展 API 权限。sidePanel允许扩展在侧边栏显示 UI [4];storage用于持久化 API Key、笔记和 Agent 状态;scripting允许使用chrome.scripting编程式注入脚本 [3]。host_permissions:声明扩展可以访问的网站。<all_urls>表示匹配所有 URL,实际发布时应按站点范围收紧。background.service_worker:指定后台 Service Worker 脚本,它是 Agent 主循环的宿主。content_scripts:声明式注入的内容脚本数组,matches指定注入的页面匹配规则 [2]。side_panel.default_path:Side Panel 的默认页面路径 [4]。web_accessible_resources:声明可被页面或内容脚本通过fetch()访问的扩展资源。Content Script 需要访问扩展内其他文件时,必须将文件列入此字段 [2]。声明后该资源也会暴露给同一站点上的页面脚本,因此只应放置不敏感的公共资源。
权限模型遵循最小权限原则。Side Panel 可以在不请求主机权限的情况下显示 UI [5];扩展的界面展示不会触发主机权限警告。仅当 Agent 确实需要读取和修改网页内容时,才需要声明对应的 host_permissions 或 scripting 权限。
3. Content Script 与网页交互机制
3.1 Content Script 基本概念
Content Script 是注入到网页中的扩展脚本。它运行在目标页面的上下文里,可以读取和修改该页面的 DOM,但它的 JavaScript 环境与页面本身的 JavaScript 环境相互隔离 [2]。
Content Script 有两种注入方式:
- 声明式注入:在
manifest.json的content_scripts字段中按 URL 匹配规则声明,扩展安装后自动注入。 - 编程式注入:通过
chrome.scriptingAPI 在运行时按需注入。
声明式注入适合 Agent 始终需要感知页面的场景;编程式注入适合按用户操作临时注入的场景。
3.2 Isolated World 与 DOM 访问
Content Script 运行在一个称为 isolated world(隔离世界) 的特殊环境中。它能够访问页面的 DOM,但无法访问页面 JavaScript 创建的变量和函数;反过来,页面脚本也无法访问 Content Script 中定义的变量 [2]。
下面的示例来自 Chrome 官方文档。页面 webPage.html 中定义了一个全局变量和按钮,content-script.js 中也定义了同名变量并添加了一个点击监听器:
html
<!-- webPage.html -->
<script>
var greeting = 'hello, ';
document.getElementById('greet').addEventListener('click', function () {
alert(greeting + 'page');
});
</script>javascript
// content-script.js
var greeting = 'hola, ';
document.getElementById('greet').addEventListener('click', () => {
alert(greeting + 'content script');
});点击按钮后,两个 alert 都会弹出:页面脚本弹出 hello, page,Content Script 弹出 hola, content script。同名变量互不干扰 [2]。
这个隔离特性对 AI Agent 的影响如下:
- DOM 是共享的,Content Script 可以自由提取网页文本、修改元素样式。
- 页面脚本可能被第三方库污染,但 Agent 的代码不受影响,反之亦然。
- Content Script 无法直接读取页面的 JavaScript 变量(例如 Vue/React 内部状态),只能通过 DOM 间接获取数据。
由于 Content Script 与扩展的其他部分不在同一个 JavaScript 环境中,它只能直接访问一部分扩展 API。Content Script 可以直接使用的 API 包括:dom、i18n、storage、runtime.connect()、runtime.getManifest()、runtime.getURL()、runtime.id、runtime.onConnect、runtime.onMessage、runtime.sendMessage() [2]。其余 API(如 tabs、sidePanel)需要通过消息传递间接调用。
3.3 chrome.scripting 编程式注入
当需要按需注入或向页面主世界注入脚本时,使用 chrome.scripting API [3]:
javascript
chrome.scripting.executeScript({
target: { tabId: 123 },
world: 'MAIN',
func: (payload) => {
window.__agentPayload = payload;
},
args: [{ source: 'page-agent' }],
injectImmediately: false
});参数说明:
target:指定注入目标,包含tabId、frameIds等字段。world:执行环境,取值为ISOLATED或MAIN。ISOLATED表示扩展独有的隔离世界;MAIN表示与宿主页面共享 JavaScript 的主世界 [3]。files或func:指定注入的文件或函数。使用func时,函数会被序列化后注入执行。args:传递给func的参数,必须可 JSON 序列化(Chrome 92+ 支持)[3]。injectImmediately:是否在文档加载过程中尽早注入,默认为false。
版本差异需要注意:world 参数在 Chrome 102+ 可用,ExecutionWorld 类型在 Chrome 95+ 引入;InjectionResult 中的 documentId 在 Chrome 106+ 可用,frameId 在 Chrome 90+ 可用;ContentScriptFilter 在 Chrome 96+ 可用 [3]。
向 MAIN 世界注入脚本通常用于访问页面自身定义的 JavaScript 对象,或者发起页面上下文中的网络请求。MAIN 世界中的脚本不享有扩展 API 访问权限,需要与 Content Script 通过 window.postMessage 通信。
4. 消息传递与后台通信机制
Content Script、Background Service Worker 和 Side Panel 是三个独立的执行环境。它们通过 chrome.runtime 的消息通道通信 [6]。
4.1 单向消息:runtime.sendMessage
Content Script 向 Service Worker 发送提取网页内容的请求:
javascript
// content.js
const response = await chrome.runtime.sendMessage({
type: 'EXTRACT_CONTENT',
payload: { url: location.href }
});
console.log(response);Service Worker 侧监听消息并返回结果:
javascript
// background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'EXTRACT_CONTENT') {
const { url } = message.payload;
// 这里可以向 Content Script 请求更多信息,或直接返回扩展侧数据
sendResponse({ ok: true, tabId: sender.tab?.id, url });
}
return true;
});return true 的作用是告诉 Chrome:当前监听器将异步调用 sendResponse。如果不返回 true,消息通道会过早关闭 [6]。
4.2 长连接:runtime.connect
如果 Agent 需要持续推送流式输出(例如 LLM 逐 token 生成结果),使用长连接比反复调用 sendMessage 更合适:
javascript
// sidepanel.js
const port = chrome.runtime.connect({ name: 'agent-stream' });
port.postMessage({ type: 'START_AGENT', goal: '总结这篇文章' });
port.onMessage.addListener((msg) => {
if (msg.type === 'TOKEN') {
renderToken(msg.data); // 逐步渲染 token
} else if (msg.type === 'DONE') {
port.disconnect();
}
});Service Worker 侧:
javascript
// background.js
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== 'agent-stream') return;
port.onMessage.addListener(async (msg) => {
if (msg.type === 'START_AGENT') {
// 将生成结果分块推送到 Side Panel
for await (const token of agentStream(msg.goal)) {
port.postMessage({ type: 'TOKEN', data: token });
}
port.postMessage({ type: 'DONE' });
}
});
});长连接的消息收发规则与 sendMessage 相同,区别在于连接一旦建立,两端可以随时向对方发送消息,直到某一端调用 port.disconnect() [6]。
4.3 消息协议设计
随着 Agent 功能增长,消息类型会逐渐膨胀。将消息类型定义为常量模块,再在三个环境中共享,是一种常用的消息契约设计方式:
javascript
// shared/messages.js
export const MessageType = {
EXTRACT_CONTENT: 'EXTRACT_CONTENT',
START_AGENT: 'START_AGENT',
TOKEN: 'TOKEN',
TOOL_CALL: 'TOOL_CALL',
TOOL_RESULT: 'TOOL_RESULT',
HIGHLIGHT: 'HIGHLIGHT',
SAVE_NOTE: 'SAVE_NOTE',
GET_SELECTION: 'GET_SELECTION'
};约定消息统一为 { type, payload } 结构,type 是字符串常量,payload 是可 JSON 序列化的数据。这样既方便调试,也便于将来引入消息校验层。该协议属于扩展自身的代码约定,不是平台强制要求。
5. 网页内容提取与上下文构建
5.1 DOM 到文本的提取
Content Script 最基础的能力是读取网页内容。一个简单的提取函数:
javascript
// content.js
function extractPageContext() {
const title = document.title
? document.title.trim()
: location.hostname;
const metaDescription = document.querySelector(
'meta[name="description"]'
)?.content?.trim() ?? '';
const bodyText = document.body.innerText
.replace(/\n{3,}/g, '\n\n')
.trim();
return { title, metaDescription, bodyText, url: location.href };
}innerText 会触发布局计算,在超大页面上可能有性能开销。对于长页面,可以先移除导航、侧栏、广告等低价值节点,再执行 innerText:
javascript
function cleanAndExtract() {
const selectors = ['nav', 'aside', '.advertisement', 'footer'];
const clones = selectors.map((sel) => {
const el = document.querySelector(sel);
const clone = el?.cloneNode(true);
el?.remove();
return clone;
});
const text = document.body.innerText;
// 恢复被移除的节点
clones.forEach((clone) => {
if (clone) document.body.appendChild(clone);
});
return text;
}这种方法仍然依赖标签本身的可访问性。对于 SPA(单页应用),DOM 在路由切换后会变化,需要在 document_idle 之后提取,或在页面 URL 变化时重新提取。
5.2 上下文组装管线
将原始 DOM 文本转换为 LLM 可用的上下文,称为 Context Assembly Pipeline。一个完整管线包含四个阶段:
- 提取:从 DOM 获得标题、描述、正文。
- 清理:移除 HTML 实体、多余空白、重复的导航文本。
- 压缩:将文本截断到模型上下文窗口可容纳的范围。
- 格式化:将元数据与正文组装为结构化的提示词。
javascript
function assembleContext(raw, maxChars) {
const cleaned = raw.bodyText.replace(/\s+/g, ' ').trim();
const truncated = truncateText(cleaned, maxChars);
return [
`# 页面标题`,
raw.title,
'',
`# 页面描述`,
raw.metaDescription,
'',
`# 正文内容`,
truncated,
'',
`# 页面 URL`,
raw.url
].join('\n');
}5.3 Token 限制与上下文压缩
模型对输入序列的长度有硬性限制(上下文窗口),过长的网页正文必须被压缩。压缩策略按质量从低到高排列:
策略一:头部截断(Head Truncation)
只保留正文开头部分。实现简单,但会丢失文章结尾的结论。
javascript
function truncateHead(text, maxChars) {
return text.length <= maxChars ? text : text.slice(0, maxChars);
}策略二:头尾保留(Head-Tail Truncation)
许多文章的核心信息分布在开头和结尾,中间部分包含较多展开论述。可以保留两侧、截断中间:
javascript
function truncateHeadTail(text, maxChars, headRatio = 0.5) {
if (text.length <= maxChars) return text;
const headChars = Math.floor(maxChars * headRatio);
const tailChars = maxChars - headChars;
return `${text.slice(0, headChars)}\n...[截断]...\n${text.slice(-tailChars)}`;
}truncateHeadTail 的行为说明:当 text 长度不超过 maxChars 时直接返回原文;否则按 headRatio 分配头部和尾部字符数,中间用 [截断] 占位符连接。
策略三:按语义密度筛选
遍历段落,按标题层级、段落长度、是否包含关键词等特征打分,保留得分最高的段落。这种方式能更好地保留长文的核心论点,但需要额外的解析逻辑。
javascript
function scoreParagraph(paragraph) {
let score = 0;
if (paragraph.startsWith('#') || paragraph.startsWith('##')) score += 3;
if (paragraph.length > 120) score += 1;
if (/结论|总结|概述|因此/.test(paragraph)) score += 1;
return score;
}策略四:分层摘要
对超长文本先做一次摘要,将摘要结果替代原文输入模型。这要求扩展能够调用一个足够快的摘要模型,通常只在内容无法压缩到目标窗口内时才使用。
实际项目中可以将前三种策略组合:先用语义密度筛选保留高价值段落,再用头尾截断限制最终长度。压缩后的文本仍然需要保留标题、URL 等元信息,因为模型需要知道“这段文本来自哪里”来生成准确的回答。
同样需要考虑 Token 与字符数的换算。中文字符平均约 1.5 到 2 个 token,英文单词平均约 1.3 个 token。保守做法是按照“正文长度不超过模型窗口的三分之一”来预留空间,其余空间留给工具定义、系统提示词和模型输出。
6. AI Agent 主循环与工具调用设计
6.1 主循环
Agent 主循环负责协调感知、决策、行动和观察。它运行在 Service Worker 中,因为这里可以访问 fetch、chrome.storage 和 chrome.scripting 等能力。
javascript
// background.js
async function runAgent({ goal, tabId }) {
const context = await requestContentFromTab(tabId);
const messages = buildMessages({ goal, context });
const maxIterations = 5;
for (let i = 0; i < maxIterations; i++) {
const completion = await model.chat(messages, toolRegistry.describe());
const { reply, toolCall } = parseCompletion(completion);
if (toolCall) {
const result = await toolRegistry.execute(toolCall.name, toolCall.args);
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(result)
});
continue;
}
return reply;
}
throw new Error('Agent 超过最大迭代次数');
}buildMessages 负责将系统提示词、页面上下文、历史消息组装为模型接口所需的 messages 数组。每次工具调用结束后,模型的工具调用记录和工具返回值都会被追加到消息数组中,模型才能据此进行下一轮推理。
6.2 工具注册与 Function Calling 协议
工具是 Agent 作用于网页的“手”。每个工具注册时提供三项信息:
name:工具名称,模型通过名称引用工具。schema:JSON Schema 格式的函数定义,描述参数结构。execute:实际执行函数,接收参数字典并返回可 JSON 序列化的结果。
javascript
// background.js
class ToolRegistry {
constructor() {
this.tools = new Map();
}
register(tool) {
this.tools.set(tool.name, tool);
}
describe() {
return [...this.tools.values()].map((t) => t.schema);
}
async execute(name, args) {
const tool = this.tools.get(name);
if (!tool) {
throw new Error(`未知工具: ${name}`);
}
return await tool.execute(args);
}
}
const toolRegistry = new ToolRegistry();
toolRegistry.register({
name: 'read_selected_text',
schema: {
type: 'function',
function: {
name: 'read_selected_text',
description: '读取当前页面中用户选中的文本',
parameters: {
type: 'object',
properties: {}
}
}
},
async execute() {
// 内部通过消息请求 Content Script 返回当前选中文本
const tab = await getActiveTab();
const response = await chrome.tabs.sendMessage(tab.id, {
type: 'GET_SELECTION'
});
return response.data ?? '';
}
});describe() 返回的 schema 数组直接嵌入模型 API 的 tools 参数。以 OpenAI 兼容接口为例,模型返回内容可能包含 tool_calls 字段:
json
{
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": {
"name": "highlight_sentences",
"arguments": "{\"sentences\": [\"第一句\"], \"color\": \"#ffe066\"}"
}
}]
}
}]
}parseCompletion 解析这个响应,将 tool_calls 数组转换为统一的 { id, name, args } 结构,再交给 toolRegistry.execute 执行。工具执行结果需要按角色 tool 回传给模型,tool_call_id 用于关联原始调用。
6.3 模型适配层
不同的 LLM 服务商(OpenAI、Anthropic、本地 Ollama 等)在请求格式上各有差异。模型适配层将差异封装起来,使 Agent 主循环只依赖一个统一的接口。
javascript
// background.js
class OpenAICompatibleAdapter {
constructor({ baseURL, apiKey, model }) {
this.baseURL = baseURL;
this.apiKey = apiKey;
this.model = model;
}
async *stream({ messages, tools }) {
const url = `${this.baseURL}/chat/completions`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
},
body: JSON.stringify({
model: this.model,
messages,
tools,
stream: true
})
});
if (!response.ok) {
throw new Error(`模型调用失败: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
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 cleaned = line.trim();
if (!cleaned.startsWith('data:')) continue;
const data = cleaned.slice(5).trim();
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
yield json;
} catch (err) {
console.warn('无法解析 SSE 行:', cleaned);
}
}
}
}
}这个适配层是异步生成器。模型产生的每个 SSE chunk 都会依次 yield 给调用方,包括增量内容和工具调用片段。
6.4 流式响应
将模型的流式输出转发到 Side Panel,可以缩短用户等待时间。主循环配合异步生成器可以这样组织:
javascript
// background.js
async function* agentStreamGenerator({ goal, tabId }) {
const context = await requestContentFromTab(tabId);
const messages = buildMessages({ goal, context });
for (let i = 0; i < 5; i++) {
const fullMessages = [
{ role: 'system', content: SYSTEM_PROMPT },
...messages
];
for await (const chunk of model.stream({
messages: fullMessages,
tools: toolRegistry.describe()
})) {
const delta = chunk.choices?.[0]?.delta;
if (delta?.content) {
yield { type: 'TOKEN', data: delta.content };
}
if (delta?.tool_calls) {
yield { type: 'TOOL_CALL_DELTA', data: delta.tool_calls };
}
}
}
}TOOL_CALL_DELTA 需要做增量拼接,因为模型的工具调用参数是通过多个 chunk 分片传输的。拼接完整后,扩展执行对应工具,再将结果作为新的消息追加,继续下一轮循环。
流式响应的注意点:
- 每个 chunk 的
choices[0].delta可能为空,需要做空值判断。 - SSE 数据可能跨行分包,需要缓冲后再解析。
- 用户可能在流式输出过程中切换标签页,长连接端口需要处理断开重连。
7. 网页智能增强功能实现
7.1 工具与 Content Script 的协作
工具执行逻辑并非都在 Service Worker 中完成。DOM 操作必须转发给 Content Script。以高亮关键句为例,Agent 主循环通过工具调用产生指令,Service Worker 将指令消息发送给 Content Script,由 Content Script 实际修改页面。
工具侧注册:
javascript
// background.js
toolRegistry.register({
name: 'highlight_sentences',
schema: {
type: 'function',
function: {
name: 'highlight_sentences',
description: '在页面正文中高亮匹配指定文本的句子',
parameters: {
type: 'object',
properties: {
sentences: {
type: 'array',
items: { type: 'string' },
description: '需要高亮的句子列表'
}
},
required: ['sentences']
}
}
},
async execute({ sentences }) {
const tab = await getActiveTab();
const response = await chrome.tabs.sendMessage(tab.id, {
type: 'HIGHLIGHT',
payload: { sentences }
});
return { highlighted: response?.count ?? 0 };
}
});Content Script 侧实现:
javascript
// content.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'HIGHLIGHT') {
sendResponse({ count: highlightSentences(message.payload.sentences) });
}
if (message.type === 'GET_SELECTION') {
sendResponse({ data: window.getSelection()?.toString() ?? '' });
}
return true;
});
function highlightSentences(sentences) {
const bodyText = document.body.innerText;
let count = 0;
for (const sentence of sentences) {
const index = bodyText.indexOf(sentence);
if (index === -1) continue;
const range = getTextNodesInRange(document.body, index, sentence.length);
if (!range) continue;
const mark = document.createElement('mark');
mark.style.backgroundColor = '#ffe066';
mark.style.padding = '0 2px';
mark.style.borderRadius = '2px';
range.surroundContents(mark);
count++;
}
return count;
}句子定位是 DOM 编程中容易出错的部分:document.body.innerText 返回的字符串与 DOM 文本节点并不一一对应,文本在 DOM 中被拆分为多个节点,定位时需要在节点之间累计偏移。getTextNodesInRange 的常见实现思路是:用 TreeWalker 遍历文本节点,累计每个节点的文本长度,在目标偏移区间上构造 Range,最后用 Range.surroundContents() 将 <mark> 套在区间外层。
如果没有匹配到句子,函数返回 { highlighted: 0 }。该结果会作为工具返回值交给模型,模型据此调整措辞后重试。这构成 Agent 循环中的“观察”环节。
7.2 读取选中文本
读取用户选中的文本不需要在 Content Script 中持续监听事件,只需在收到消息时读取一次:
javascript
// content.js
function getSelectionText() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return '';
return selection.toString().trim();
}配合 Side Panel 中的“对选中文本提问”按钮,用户选中段落并点击按钮后,Side Panel 将选中文本作为目标上下文发送给 Agent,模型据此生成总结或翻译。
7.3 保存笔记
笔记工具返回通过 chrome.storage.local 持久化,不依赖远程服务。storage 是 Content Script 可以直接访问的少数扩展 API 之一 [2],因此笔记的存取可以直接在 Content Script 中完成:
javascript
// content.js
async function saveNote({ title, text }) {
const key = `note_${Date.now()}`;
await chrome.storage.local.set({
[key]: { title, text, url: location.href, createdAt: Date.now() }
});
return { key };
}另一种做法是统一走消息通道,由 Service Worker 写入存储,将存储逻辑集中在后台环境中。
7.4 Side Panel 集成
Side Panel API 允许扩展在浏览器侧边栏中显示自己的 UI [4]。它作为扩展页面,可以访问所有 Chrome API。配置在 manifest 的 side_panel 字段中完成 [4]。
json
{
"permissions": ["sidePanel"],
"side_panel": {
"default_path": "sidepanel.html"
}
}在 Service Worker 中,可以将 Side Panel 的启用状态与当前标签页关联。例如只在 HTTPS 页面上可用:
javascript
// background.js
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status !== 'complete') return;
const enabled = tab.url?.startsWith('https://') ?? false;
chrome.sidePanel
.setOptions({ tabId, enabled, path: 'sidepanel.html' })
.catch(() => console.warn('setOptions 调用失败'));
});setPanelBehavior 可以配置用户点击扩展图标时自动打开侧边栏:
javascript
chrome.sidePanel
.setPanelBehavior({ openPanelOnActionClick: true })
.catch(() => console.warn('当前版本不支持该行为'));chrome.sidePanel.open() 只能在用户操作(例如点击图标或按钮)的响应上下文中调用 [5]。
Side Panel 与 Agent 主循环之间的交互模式:
- 用户在 Side Panel 中输入目标(如“总结当前页面”)。
- Side Panel 通过长连接向 Service Worker 发送请求。
- Service Worker 执行 Agent 循环,并向 Content Script 请求页面内容。
- Agent 产生的流式输出通过端口消息实时渲染在 Side Panel 中。
- 工具执行的结果修改页面 DOM。
8. 安全与隐私设计
8.1 API Key 的存储与访问
LLM 服务的 API Key 属于高敏感信息。它只能存储在 chrome.storage.local 中,并且只由 Service Worker 读取。chrome.storage.local 的数据不会同步到其他设备,相比 chrome.storage.sync 更合适存放密钥。
javascript
// background.js
async function getApiKey() {
const { apiKey } = await chrome.storage.local.get('apiKey');
if (!apiKey) {
throw new Error('尚未配置 API Key');
}
return apiKey;
}API Key 不应出现在以下位置:
- 不应写入
manifest.json(扩展包会被分发到用户设备)。 - 不应保存在 Content Script 可访问的内存变量中。页面脚本无法访问隔离世界的内存,但 Content Script 的代码仍然可能被其他扩展调试工具观察到。
- 不应通过
web_accessible_resources暴露任何包含密钥逻辑的文件 [2]。
首次使用时,可以让用户在 Side Panel 的表单中手动输入 API Key,扩展将其写入 chrome.storage.local。配置页面需要提供查看和删除密钥的入口。
8.2 内容发送的知情与授权
Agent 将网页内容发送给远程模型之前,必须获得用户明确授权。一种实现方式是:在 Agent 首次运行时显示内容预览和说明,例如“将下列 800 个字符的页面内容发送给模型服务商”,用户确认后才发起请求。可以在 chrome.storage.local 中记录用户的授权粒度,但每次涉及发送敏感页面(如邮箱、网银页面)时都应重新确认。
对于高亮、保存笔记这类本地操作,不涉及外部数据发送,不需要征得同意,但仍应让用户知道 Agent 会修改页面内容。高亮操作是可逆的,保存笔记不修改 DOM,这两类工具的风险较低。
8.3 最小权限原则
MV3 的权限模型要求扩展只声明其实际使用的权限。具体到 AI Agent 扩展:
- Side Panel 界面本身不需要主机权限 [5];扩展的 UI 展示、笔记列表、模型配置页面,都不应以“读取全部网站”为代价。
host_permissions只应在确实需要读取网页内容时声明,并尽量精确到站点范围,而不是一律使用<all_urls>。web_accessible_resources中暴露的资源会被同站脚本访问 [2],应只放置无敏感信息的静态文件。- 从页面读取的内容应只保留在内存中,用后清除,不写入日志。
8.4 消息通道的校验
任何来自网页的消息(包括通过 window.postMessage 注入的)都不能被当作可信输入。Content Script 在收到 HIGHLIGHT 或 GET_SELECTION 等消息时,应校验消息来源是否为扩展自身的 Service Worker。这需要比较 sender.id 与扩展自身的 ID:
javascript
chrome.runtime.onMessage.addListener((message, sender) => {
if (sender.id !== chrome.runtime.id) return;
// 处理消息
});sender.id 校验可以防止其他扩展伪造消息。Content Script 的隔离世界已经阻止了页面脚本直接调用 chrome.runtime.sendMessage,但防御性地校验发送者仍然属于推荐做法。
9. 调试与工程化实践
9.1 三种运行环境的调试
MV3 扩展包含三个独立的 JavaScript 执行环境,调试入口各不相同:
- Service Worker:在扩展管理页面中找到对应扩展,点击“Service Worker”链接打开 DevTools。这里可以查看
background.js的日志、断点和网络请求。 - Content Script:在目标页面的 DevTools 中,下拉“上下文”选择器切换到扩展的 Content Script 上下文。这里可以实时查看 Content Script 中的变量和控制台输出。
- Side Panel:Side Panel 页面在 DevTools 的 Sources 面板中表现为一个扩展页面,需要在侧边栏打开状态下启动 DevTools 审查元素。
调试消息流时,可以在消息对象中附加 traceId:
javascript
function createMessage(type, payload) {
return {
type,
payload,
traceId: crypto.randomUUID(),
timestamp: Date.now()
};
}在 Service Worker 和 Content Script 两侧分别打印进出消息的 traceId,可以快速定位消息在哪个环节丢失或超时。
9.2 错误边界与重试
Agent 循环中至少存在三类失败:
- 模型 API 调用失败:网络断开、限流、鉴权失败。
- 工具执行失败:Content Script 未注入、DOM 节点不存在、参数不符合预期。
- 消息传递失败:标签页关闭、Service Worker 被浏览器回收。
对模型 API 调用,采用指数退避重试:
javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, options);
if (response.ok) return response;
if (response.status >= 400 && response.status < 500) break;
} catch (err) {
// 网络错误,继续重试
}
const delay = 2 ** attempt * 500;
await new Promise((resolve) => setTimeout(resolve, delay));
}
throw new Error('模型服务暂时不可用');
}4xx 错误(如 401、403)通常重试也不会成功,应直接抛出;5xx 和网络错误可以重试。
对消息传递失败,需要捕获 chrome.runtime.lastError。在 MV3 中,异步 API 调用失败会通过 Promise 的 rejection 暴露:
javascript
async function sendMessageToTab(tabId, message) {
try {
return await chrome.tabs.sendMessage(tabId, message);
} catch (err) {
// 常见原因:Content Script 未注入、标签页不存在
return null;
}
}返回 null 时,Agent 应将该情况作为工具观察结果传给模型,而不是中断整个流程。例如高亮失败时返回“目标标签页中未检测到 Content Script,请先刷新页面”。
9.3 自动化测试
绝大多数 Agent 逻辑不依赖真实的浏览器环境,可以从扩展中提取出来做纯函数单元测试。例如 truncateHeadTail、buildMessages、ToolRegistry、消息协议校验都可以在 Node.js 中直接测试。
使用 Node.js 内置的测试运行器:
javascript
// test/context.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { truncateHeadTail } from '../background/context.js';
test('短文本不截断', () => {
const text = '短文本';
assert.equal(truncateHeadTail(text, 100), text);
});
test('长文本保留头尾', () => {
const text = '开头'.repeat(100) + '中间' + '结尾'.repeat(100);
const result = truncateHeadTail(text, 50, 0.5);
assert.ok(result.startsWith('开头'));
assert.ok(result.endsWith('结尾'));
assert.ok(result.includes('[截断]'));
});
test('截断结果长度不超过限制', () => {
const text = 'x'.repeat(1000);
const result = truncateHeadTail(text, 200);
assert.ok(result.length <= 200);
});truncateHeadTail 的行为由三个用例覆盖:短文本直接返回、长文本保留头尾、输出长度不超过上限。这些约束与 5.3 节中的函数定义一致。
工具注册器的测试同样不需要浏览器环境:
javascript
// test/tool-registry.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { ToolRegistry } from '../background/tool-registry.js';
test('注册并执行工具', async () => {
const registry = new ToolRegistry();
registry.register({
name: 'echo',
schema: { type: 'function', function: { name: 'echo' } },
async execute(args) {
return { echo: args };
}
});
const result = await registry.execute('echo', { value: 1 });
assert.deepEqual(result, { echo: { value: 1 } });
});
test('执行未知工具抛出错误', async () => {
const registry = new ToolRegistry();
await assert.rejects(
() => registry.execute('missing', {}),
/未知工具/
);
});第一个用例验证工具注册后可以按名称执行并返回结果;第二个用例验证未注册的工具会抛出错误,行为符合 6.2 节中 ToolRegistry.execute 的约定。
对于涉及 Content Script 和消息传递的集成逻辑,可以使用 Puppeteer 或 Playwright 加载未打包扩展,在真实浏览器环境中模拟页面交互和扩展消息流。这类测试成本较高,建议只覆盖关键路径:内容提取、高亮执行、完整的“总结页面”Agent 流程。
9.4 模块划分与构建
Content Script 不是 ES Module,无法直接使用 import 引用其他脚本;Service Worker 虽然可以声明为 "type": "module",但在未打包时只能加载扩展包内的相对路径。内容脚本与 Service Worker 属于不同的执行环境,不能共享运行时模块实例。工程上通常用 Vite、Rollup 或 esbuild 将不同入口分别打包:
content.js入口:只包含内容提取和 DOM 增强代码。background.js入口:包含 Agent 主循环、模型适配层、工具注册表。sidepanel.js入口:包含 UI 状态管理和消息端口逻辑。shared/目录:放置消息常量、工具 schema 定义等纯模块,由各入口分别打包。
打包时需要注意 Service Worker 体积。Chrome 扩展商店对代码体积没有硬性限制,但 Service Worker 会被浏览器频繁唤醒和销毁,较小的体积可以缩短冷启动时间。模型适配层 SDK 如果体积过大,可以改用直接 fetch 调用的方式,例如上文中的 OpenAICompatibleAdapter,并不依赖官方 SDK。
10. 未来演进:浏览器原生 AI 与标准
浏览器正在逐步引入原生 AI 能力。Chrome 在桌面端实验性地提供了基于设备本地模型的推理 API,包括文本摘要、翻译、提示重写等能力。这类 API 对扩展开发者的意义在于:部分小模型的推理可以完全在本地完成,网页内容不需要离开用户设备。
如果未来浏览器将 AI 能力向扩展层开放,当前架构中“内容提取 → 上下文组装 → 模型调用”的管线仍然成立,只需要将模型适配层替换为浏览器原生 API。上下文压缩、工具注册、Agent 主循环等核心模块可以重用。反过来,本地模型在上下文窗口和推理速度上的限制,会让上下文工程策略变得更加重要。
Side Panel API 提供了不依赖主机权限的扩展 UI 通道 [5],使开发者在权限模型上可以将界面与数据访问分离。AI Agent 扩展可以沿这条路径继续演进:界面与配置尽量放在 Side Panel,网页数据访问严格限定在用户主动触发 Agent 的时机。
