从零构建 AI 侧边栏(一):架构、流式对话与模型管理
上一篇文章聊了 MV3 给 Chrome 扩展带来的架构变化,以及 service worker 生命周期那些坑。这篇开始,我想具体讲讲这个 AI 侧边栏扩展本身是怎么搭起来的。
先交代一下背景。
这个扩展不是一个简单的「把网页版 AI 对话嵌到侧边栏」的壳。它更像一个轻量的 agent,系统会主动组织上下文和指令,用户选中的文字、当前页面的正文、预设的技能 prompt,这些东西在发给模型之前都会被自动装配好。用户不需要手动复制粘贴,也基本不需要理解 prompt engineering,点一下就行。
技术上分了四层。
最外层是 content script,跑在用户访问的网页里,负责读取页面正文、画悬浮按钮、处理选中文字的工具条。
中间是 service worker,后台大脑,处理 API 请求、SSE 流式解析、右键菜单、设置持久化、以及所有组件之间的消息路由。
侧边栏是一个 React 应用,UI 层,用 Vite 构建。TypeScript 的类型和常量在 src/shared/ 里集中定义,service worker 和 side panel 两边共用。
这三层之间通过 Chrome 的消息系统通信。Side panel 和 service worker 之间走长连接 chrome.runtime.connect,content script 和其他组件之间走一次性消息 chrome.runtime.sendMessage。选长连接还是一性消息的原则很简单,需要持续收发的用长连接,触发式的用一次性消息。
说到构建,我用了 Vite 加 TypeScript。这里有个小坑,Vite 的 emptyDir 在清理 dist/assets 时会跟某些环境的安全删除 shim 冲突,删不掉旧文件导致构建失败。解决方案是每次构建前手动 rm -rf dist。
manifest 里的配置其实不复杂,但有几个关键点。
host_permissions 必须设为 <all_urls>。因为 AI API 请求是从 service worker 发出的,如果只给特定域名权限,worker 的 fetch 就无法访问外部的 API 端点。这不是说扩展会偷偷访问所有网站,只是 Chrome 要求你明确声明你可能访问哪些 URL,而 AI API 的地址是用户自己配的,你根本不知道会是什么域名。
side_panel.default_path 指向侧边栏的 HTML 入口,Chrome 会自动在侧边栏区域渲染这个页面。
content_scripts 里的 run_at 设为 document_idle,意思是等页面基本加载完再注入。对于需要读 DOM 内容的脚本,这个时机是比较合适的。
聊完架构,说说流式对话的实现。
这个扩展的核心体验是,在侧边栏里打字问问题,AI 一个字一个字地蹦出来,像 ChatGPT 网页版那样。要实现这个效果,关键在 SSE 的处理上。
OpenAI 兼容的 API 支持 stream: true 参数,开启后服务器会以 SSE 格式持续推送生成的 token,每个 token 一行 data: 格式的 JSON,最后发一个 data: [DONE] 表示结束。
在浏览器里处理 SSE 很简单,有现成的 EventSource 对象。但在 service worker 里不行,worker 环境不支持 EventSource。得手动用 fetch 加 ReadableStream 来读。
代码大概是这个结构。
javascript
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ model, messages, stream: true }),
signal: abortController.signal,
});
const reader = resp.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) {
if (!line.trim().startsWith('data:')) continue;
const data = line.trim().slice(5).trim();
if (data === '[DONE]') { /* 流结束 */ }
const json = JSON.parse(data);
const delta = json.choices[0].delta.content || '';
// 把 delta 通过 port 发给 side panel
}
}
每收到一个 token 就通过 port.postMessage 发给 side panel,side panel 接到后拼到 UI 上。关于 token 的累积有一个细节,不能用简单的字符串拼接。SSE 的分块不一定在 token 边界上断,可能一个 chunk 包含了半个 UTF-8 多字节字符。TextDecoder 的 stream: true 参数就是用来处理这个的,它会记住上次的未完成字节,等下次拼齐了再输出。
还有一个比较有意思的点,停止生成。
用户点了停止按钮,怎么让已经发出的 fetch 停下来?答案是用 AbortController。每次发起请求时创建一个 controller 存到全局变量,停止时调 controller.abort(),fetch 会抛出一个 AbortError,catch 到之后把已经收到的内容作为最终结果发回去。
javascript
let currentAbort = null;
// 发起请求
currentAbort = new AbortController();
const resp = await fetch(url, { signal: currentAbort.signal });
// 停止按钮
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'STOP_GENERATION') currentAbort?.abort();
});
接着说 reasoning 的处理。
现在很多模型支持思考过程输出,比如 DeepSeek R1 会用 标签把推理过程包起来。如果你不处理,用户在 UI 上看到的就是一堆this is a test` 标签里的技术语言,体验很差。
我的处理方式是解析 ` 标签对,把内容分离成 reasoning 和 content 两部分,分别发给 UI。UI 上 reasoning 默认折叠,用户可以点开看。这比粗暴地把标签删掉要好,有时候模型的推理过程本身就很有参考价值。
解析逻辑要处理流式中的半开标签。如果只收到了 <think> 还没收到 </think>,那后面的所有内容都是 reasoning,content 暂时为空。等闭合标签到了,再切换到 content 模式。
说到模型管理,这个扩展支持用户自己配 API,接入任意 OpenAI 兼容的服务。
配置界面有三个核心能力。填 base_url 和 api_key,测试连通性,拉取模型列表并收藏。
测试连通性和拉取模型是两个独立的功能。一开始我是把它们绑在一起的,测试连接的同时顺便拉模型列表,但有些中转站的 /models 端点返回很慢或者格式不标准,导致测试一直转圈。拆开之后,用户可以分别操作,测试归测试,拉模型归拉模型,互不干扰。
每个收藏的模型可以设置别名和独立的 API Key。比如你有一个硅基流动的 API 和一个 DeepSeek 官方的 API,你可以在模型级别分别配不同的 Key,不填就自动用全局的 Key。这个功能对同时用多个 API 提供方的人来说非常实用。
还有一个设置迁移的问题。
扩展迭代过程中,设置的数据结构变了好几次。比如最早 savedModels 存的是字符串数组,后来改成了对象数组支持别名和独立 Key。旧的字符串数据如果不做迁移,新版本代码读到之后直接就崩了。
所有的迁移逻辑我统一放在 service worker 的 getSettings 函数里。每次读设置时先检查旧格式,有就当场转成新格式再存回去。这样无论用户从哪个版本升级,都能无缝衔接,不需要手动清数据重配。
javascript
// 字符串数组 → 对象数组
settings.savedModels = settings.savedModels.map(m =>
typeof m === 'string' ? { id: m } : m
);
这个过程对于用户来说是无感知的,下次打开扩展设置还在,模型也在。
写到这,AI 侧边栏的骨架基本就搭完了。能收发消息,能流式输出,能管理模型。
但作为一个「带 agent 性质的侧边栏」,真正的差异化在下一篇文章要聊的部分,怎么让 AI 理解你正在看的网页。
上下文怎么提取、怎么拼装、为什么「选中文字反而更需要上下文」、以及总结和翻译这两个内置技能是怎么工作的。
我们下一篇继续。