我们最近发布了一款基于 Gemma 4 E2B 的 Transformers.js 演示版浏览器扩展,旨在帮助用户浏览网页。在构建过程中,我们针对 Manifest V3 运行时、模型加载和消息传递等方面积累了一些实用的观察经验,值得分享。
本文面向的读者
本指南面向那些希望在 Manifest V3 约束下,使用 Transformers.js 在 Chrome 扩展中运行本地 AI 功能的开发者。
阅读完本文后,你将掌握本项目所使用的相同架构:一个承载模型的后台服务工作线程、一个侧边栏聊天界面,以及一个用于页面级操作的内容脚本。
我们将构建的内容
在本指南中,我们将以已发布的扩展为参考,并以开源代码库作为实现蓝图,重现 Transformers.js Gemma 4 浏览器助手(Transformers.js Gemma 4 Browser Assistant)的核心架构。
- 在线扩展:Chrome 网上应用店
- 源代码:github.com/nico-martin/gemma4-browser-extension
- 最终成果:一个由后台托管的 Transformers.js 引擎、一个侧边栏聊天界面,以及一个用于页面内容提取和高亮显示的内容脚本。
1) Chrome 扩展架构(MV3)
在深入探讨之前,先快速说明一下范围:我不会深入讲解 React UI 层或 Vite 构建配置。本文的重点是高层架构决策:每个 Chrome 运行时中运行什么,以及这些部分如何协同工作。
如果你对 Manifest V3 还不熟悉,请先阅读这篇简短概述:什么是 Manifest V3?
1.1 运行时上下文与入口点
在 MV3 中,你的架构始于 public/manifest.json 文件。本项目定义了三个入口点:
- background.service_worker = background.js,由 src/background/background.ts 构建而来。
- side_panel.default_path = sidebar.html,由 src/sidebar/index.html 构建而来。
- content_scripts[].js = content.js,其 matches 规则为 http(s)://*/*,run_at 设置为 document_idle,由 src/content/content.ts 构建而来。
后台服务工作线程还会处理 chrome.action.onClicked 事件,以打开当前活动标签页的侧边栏。另一个需要了解的入口点是:可以使用 action.default_popup 定义一个弹出窗口,它非常适合快速操作。本项目使用侧边栏来实现持久聊天,但编排模式是相同的。
各部分运行位置
关键设计决策是将繁重的编排工作放在后台,并保持 UI/页面逻辑精简。
- 后台(src/background/background.ts)是控制平面:负责智能体生命周期、模型初始化、工具执行以及特征提取等共享服务。
- 侧边面板(src/sidebar/*)是交互层:负责聊天输入/输出、流式更新和设置控制。
- 内容脚本(src/content/content.ts)是页面桥梁:负责 DOM 提取和高亮操作。
这种划分的一个实际结果是,对话历史也保存在后台(Agent.chatMessages)中:UI 发送诸如 AGENT_GENERATE_TEXT 之类的事件,后台追加消息、运行推理,然后向侧边面板发送回 MESSAGES_UPDATE。
这种分离避免了重复加载模型,保持了 UI 的响应性,并遵守了 Chrome 关于 DOM 访问的安全边界。
1.3 消息传递契约
一旦运行时被分离,消息传递就成为核心支柱。在这个项目中,所有消息都通过 src/shared/types.ts 中的枚举进行类型化。
侧边面板 -> 后台(BackgroundTasks):
- CHECK_MODELS, INITIALIZE_MODELS
- AGENT_INITIALIZE, AGENT_GENERATE_TEXT, AGENT_GET_MESSAGES, AGENT_CLEAR
- EXTRACT_FEATURES
后台 -> 侧边面板(BackgroundMessages):
- DOWNLOAD_PROGRESS, MESSAGES_UPDATE
后台 -> 内容脚本(ContentTasks):
- EXTRACT_PAGE_DATA, HIGHLIGHT_ELEMENTS, CLEAR_HIGHLIGHTS
编排规则很简单:后台是唯一的协调者;侧边面板和内容脚本是专门的执行者,负责请求操作并渲染结果。
典型的请求流程:
- 侧边面板发送 AGENT_GENERATE_TEXT。
- 后台将其追加到 Agent.chatMessages 并运行模型/工具步骤。
- 后台发出 MESSAGES_UPDATE。
- 侧边面板根据更新后的消息列表重新渲染。
2) Transformers.js 集成细节
2.1 模型与职责
在 src/shared/constants.ts 中,此扩展使用了两个模型角色:
- 文本生成 / 大语言模型:onnx-community/gemma-4-E2B-it-ONNX(文本生成,q4f16)
- 向量嵌入:onnx-community/all-MiniLM-L6-v2-ONNX(特征提取,fp32)
这种拆分是有意为之:Gemma 4 负责推理和工具决策,而 MiniLM 则生成嵌入向量,用于 `ask_website` 和 `find_history` 中的语义相似度搜索。
2.2 推理在哪里运行
所有推理均在后台运行(`src/background/background.ts`):
- 通过 `pipeline("text-generation", ...)` 进行文本生成,并启用由我们新的 `DynamicCache` 类实现的一致性 KV 缓存
- 通过 `pipeline("feature-extraction", ...)` 进行嵌入向量生成,并辅以向量归一化
这为所有标签页/会话提供了一个统一的模型宿主,避免了重复的内存占用,并保持了侧面板 UI 的响应性。由于模型是从后台 Service Worker 加载的,工件会缓存在扩展源(`chrome-extension://<extension-id>`)下,而非每个网站的源下,从而为整个扩展安装提供了一个共享缓存。
MV3 生命周期说明:Service Worker 可能被挂起并重新启动,因此模型运行时状态应被视为可恢复的,并在需要时重新初始化。
2.3 下载与缓存生命周期
模型生命周期是明确的:
- `CHECK_MODELS` 检查已缓存的内容,并估算剩余下载大小。
- `INITIALIZE_MODELS` 下载/初始化模型,并向 UI 发送 `DOWNLOAD_PROGRESS` 事件。
- Long-lived instances are reused after setup:
- 生成管道位于 `src/background/agent/Agent.ts`
- 嵌入向量管道位于 `src/background/utils/FeatureExtractor.ts`
权限和隐私是架构的一部分,而非事后的复选框。在此项目中,`public/manifest.json` 请求了 `sidePanel`、`storage`、`scripting` 和 `tabs` 权限,以及 `http(s)://*/*` 的主机权限:
- `sidePanel`:用于打开和控制侧面板用户体验。
- `storage`:用于跨会话持久化工具/设置状态。
- `tabs` + `scripting`:用于需要感知标签页的工具和页面级操作。
- `http(s)://*/*` 的主机权限:因为内容提取/高亮功能被设计为可在任意网站上工作。
为何要保持权限范围狭窄:权限定义了用户的信任度和 Chrome 网上应用店的审核风险。只请求你的功能实际需要的权限,并明确说明推理在扩展运行时内本地执行,以便用户了解其数据在何处被处理。
3) 智能体与工具执行循环
3.1 工具调用基础(为何需要这一层)
在执行循环之前,理解模型工具调用的工作原理(任何智能体工作流的基础)会有所帮助。你传入消息加上工具架构(名称、描述和参数),然后 Transformers.js 使用模型的聊天模板将这些输入格式化为实际的提示词。由于聊天模板是模型特定的,具体的工具调用格式取决于你使用的模型。使用 Gemma-4 风格的模板时,当模型决定调用某个工具时,它会发出一个特殊的工具调用 token 块。
import { pipeline } from "@huggingface/transformers";
const generator = await pipeline(
"text-generation",
"onnx-community/gemma-4-E2B-it-ONNX",
{
dtype: "q4f16",
device: "webgpu",
},
);
const messages = [{ role: "user", content: "What's the weather in Bern?" }];
const output = await generator(messages, {
max_new_tokens: 128,
do_sample: false,
tools: [
{
type: "function",
function: {
name: "getWeather",
description: "Get the weather in a location",
parameters: {
type: "object",
properties: {
location: {
type: "string",
description: "The location to get the weather for",
},
},
required: ["location"],
},
},
},
],
});
在生成阶段,模型可以输出类似这样的内容:
<|tool_call>call:getWeather{location:<|"|>Bern<|"|>}<tool_call|>
这正是本项目拥有一个归一化层(webMcp)和一个解析器(extractToolCalls)的原因:模型输出必须被转换为确定性的工具执行。
3.2 本项目中的工具接口
src/background/agent/webMcp.tsx 将扩展工具归一化为模型友好的形状:
- 名称、描述、输入架构、执行
示例工具包括 get_open_tabs、go_to_tab、open_url、close_tab、find_history、ask_website 和 highlight_website_element。
3.3 循环设计(Agent.runAgent)
这里的核心设计选择是将内部模型消息与面向 UI 的聊天消息分离开来:
- 内部模型记录(messages):用于 generator(...) 中消息的 system/user/tool/assistant 轮次。
- UI 记录(chatMessages):用户看到的内容,包括流式传输的助手文本以及工具执行元数据(tools)和性能指标。
执行流程:
- 将用户输入添加到 chatMessages,创建一个占位符助手消息,并流式传输 token。
- 使用 extractToolCalls.ts 将流式传输的/最终的模型输出解析为 { message, toolCalls }。
- 将用户可见的助手消息保留为纯文本,同时工具调用在后台执行。
- 将工具结果附加到助手工具元数据中,并将结果作为下一轮提示词输入反馈回去。
- 重复此过程,直到没有剩余的工具调用,然后最终确定助手内容 + 指标。
这保持了用户通信的简洁性,同时在后台保留了一个确定性的工具循环。
4) 数据边界与持久化
状态放置是另一个在 MV3 中非常重要的架构决策。在此实现中,状态根据生命周期和访问模式进行拆分:
- 对话状态:后台内存(Agent.chatMessages),用于快速的逐轮编排。
- 工具偏好设置:chrome.storage.local,确保设置跨会话持久化。
- 语义历史向量:IndexedDB(VectorHistoryDB),用于存储更大的本地检索数据。
- 提取的页面内容:后台缓存(WebsiteContentManager),以当前活动 URL 为键值。
如第 1.2 节所述,将对话历史保存在后台,可在 UI 更新时提供一个规范的全局状态。这样,短生命周期状态保存在内存中,持久化设置保存在扩展存储中,而大量检索数据则保存在本地数据库中。
5) 构建与打包说明
你不需要复杂的构建配置,但 MV3 确实要求每个运行时都有可预测的输出。
在 vite.config.ts 中进行多入口构建:
- src/sidebar/index.html
- src/background/background.ts
- src/content/content.ts
确保输出名称/路径与 manifest 清单对齐(sidebar.html、background.js、content.js)。
将内容脚本保持为独立的输出,以避免运行时加载 chunk 的问题。
目标很简单:每个 Chrome 入口点对应一个产物,且恰好放在 public/manifest.json 所期望的位置。
最终总结
实现整个项目的架构选择在于清晰的关注点分离:后台负责编排和模型执行,UI 界面保持轻量,内容脚本处理页面访问。
本项目使用了侧边面板,但同样的方法也适用于其他设置:
- 以弹窗为主的助手:使用 action.default_popup 进行快速交互,由后台负责对话状态和模型执行。
- 侧边面板协同助手:在持久化面板中保持长时间运行的对话,同时由后台处理工具循环和缓存。
- 按标签页划分的智能体:当每个标签页需要拥有自己的上下文时,在后台为每个 tabId 维护一个智能体状态。
- 混合 UI(弹窗 + 侧边面板 + 选项页面):所有 UI 入口点都与同一个后台协调器通信,并复用相同的消息契约。
实际规则很简单:决定状态存放的位置(全局、tabId 或站点范围),将该状态和模型推理保留在后台(本质上作为后台服务),并让 UI/内容运行时充当专注的客户端。