AI 助手最初是个网页内注入的浮动面板。它有两个治不好的毛病:

  • 抢宿主页面的空间,而宿主本来就挤;
  • 宿主是 SPA,重建 DOM 时把它冲掉,要靠 observer 不停补挂。

改成 Chrome 原生 Side Panel 之后这两条都没了——它是浏览器级的,和页面内容并排,宿主怎么重建都不影响它。

代价是三个约束,每一个都值得先知道。

约束一:侧栏够不到宿主 DOM

原生侧栏是一个独立的 HTML 文档,跑在 chrome-extension:// 源上。它有完整的 chrome.* 权限,但一个字符的宿主页面内容都读不到。

所以它只能是个瘦客户端,所有「操作页面」的能力都走消息:

侧栏 main.ts
→ chrome.tabs.sendMessage(targetTabId, { type: 'panel:grabContext' })
→ content 端的 relay 执行
→ 回传结果

两个实现细节:

① 侧栏直发 content,不必经 background 中转。 侧栏自己就能 tabs.query 找到目标标签页。多一跳只是多一个失败点。

② content 端的 relay 只在顶层帧注册。 内容脚本注入每一个 frame,不加 isTopFrame() 守卫的话,一条消息会收到多份应答,而 sendMessage 只取第一个回来的——于是你会随机拿到某个 frame 的结果,而那个 frame 里可能什么都没有。

⚠ 长任务不要走 content 通道

第一版是让 content 代跑整条链,包括调模型。结果长任务(写一份工单草稿)直接把消息通道撑爆,报「content 无响应」。

修法是拆两步:

① 侧栏 → content:prepareContext() ← 只做快操作:抓会话、检索、组 prompt(15s 够)
② 侧栏 → background:llm.chat() ← 长任务,不经 content 通道

content 通道用来拿页面上的东西,不用来跑耗时任务。 前者本来就该快,后者本来就该慢,混在一条通道上必然出问题。

约束二:侧栏是全局的,「只在某些标签页显示」要两步

这一条我漏了一步,调了很久。

Chrome 强制 side_panel 必须有 default_path——写成空对象 {} 会直接报「Manifest key is required」,扩展加载不了。

side_panel: { default_path: 'src/sidepanel/index.html' },

而声明了 default_path 之后,这个侧栏就是全局可用的:在任何网站上都能打开它。

想做成「只在目标站点可用」,正确的顺序是三步,第二步最容易漏:

// src/background/index.ts —— SW 启动时
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false });
// ★ 关键:不带 tabId 的全局禁用。漏了这一步,后面逐个 tab 设置也没用
await chrome.sidePanel.setOptions({ enabled: false });
// 然后遍历所有 tab 逐个同步:目标站点 enable,其它 disable
for (const tab of await chrome.tabs.query({})) await syncPanelForTab(tab);
// 之后靠三个事件保持同步
chrome.tabs.onCreated.addListener(/* … */);
chrome.tabs.onActivated.addListener(/* … */);
chrome.tabs.onUpdated.addListener(/* … */);

还有一条实测出来的:setOptions({ enabled: false }) 关不掉一个已经打开的侧栏。 它只影响「能不能打开」,不影响「现在开着的这个」。所以切到别的标签页时,已开的侧栏还在那——这是 Chrome 的行为,不是 bug,UI 上要考虑到(我们的做法是侧栏内容自己判断当前 tab 是不是目标站点,不是就显示一个提示)。

约束三:基准宽度是 360px,而且拖不窄

Chromium 自 Chrome 120 起把 Side Panel 的最小宽度硬编码为 360 个 CSS 逻辑像素(更早是 320)。扩展 API 突破不了,用户拖也拖不更窄。

笔记本上因为系统 DPI 缩放,物理占用会更大(125% → 450 物理像素,150% → 540),但逻辑像素恒为 360。所以按 360 设计就够了。

三条硬规矩:

① min-width: 0 要显式写。

body { min-width: 0; overflow-x: hidden; }
.row > * { min-width: 0; } /* flex 子项默认 min-width:auto,不肯收缩 → 溢出 */

flex 子项的 min-width 默认是 auto,意思是「不能比内容更窄」。不显式设 0,它宁可溢出也不收缩。

② 横排按钮组一律 flex-wrap: wrap。

这条是踩出来的:输入框上方的工具条原先是 nowrap,三个按钮需要 299px 而可用只有 274px——文字被切掉了。

而我一直没发现,因为我的侧栏一直拖着 400px 宽。在 400px 下一切正常。

窄屏问题要在最窄的那个宽度上测,不是在你平时用的宽度上。

同时把按钮文案缩短(「重新抓会话」→「抓会话」),完整说明放进 title。

③ 用折叠腾垂直空间。 360px 宽意味着同样的内容要占更多行,垂直空间变得很金贵。不常用的段默认折起来,标题右侧显示条数(5 条),收起也知道里面有多少。

布局形态上,我们试了三种(顶部分段切换 / 底部 Tab / 左侧竖条),最后选顶部分段:侧栏的宽度比高度金贵,左竖条吃宽度,底部 Tab 和输入框挤在同一区容易误触。

流式:用 port,不用 sendMessage

侧栏要逐字出字,所以不能等整个回答回来再返回。

侧栏 connect('llm-stream')
→ background 跑 llmChatStream
→ { evt: 'delta', text } × N
→ { evt: 'done' } 或 { evt: 'error' }

用长连接而不是 sendMessage,因为要持续回增量。

顺带一个真实的改进:port.disconnect() 就是真的中止。 background 那侧监听断开,AbortController 切断上游请求。

旧的「停止」按钮只是丢弃结果——注释里写着「模型已经发出无法真撤」——但请求还在跑,额度照烧。改成 port 之后,点停止是真停。

一个反面教材:别给失效的上下文留「降级路径」

这条是这次改造里最值得记的。

用户报:扩展重新加载之后,在没刷新的页面上按快捷键,弹出来的是早已下线的旧面板,而且报「抓不到会话内容」。

链路是这样的:

旧 content script 的扩展上下文已失效
→ chrome.runtime.sendMessage 抛 "Extension context invalidated"
→ 撞进 openSidePanel() 的 catch
→ 回退到 openLegacyPanel() ← 本意是给「旧浏览器不支持 sidePanel API」用的

那条回退的本意没错,但它覆盖的情况不对:上下文失效意味着这个页面的脚本已经死了,此时旧面板同样调不动任何 chrome.* API——这正是它抓不到会话的原因。

给用户一个看起来能用、实则全废的界面,比明确提示「请刷新」更糟。

修法是在两个入口都先判一次:

if (!isExtensionContextValid()) {
notify({ level: 'warn', title: '扩展已重新加载', detail: '请按 F5 刷新本页' });
return; // ★ 绝不开旧面板
}
// 异步回调里再判一次 —— 上下文可能在等待期间失效

后来那条回退路径整个删掉了。原因很朴素:sidePanel API 从 Chrome 114 就有,而 manifest 本身声明了 side_panel——打不开原生侧栏的浏览器根本装不上这个扩展。

那条「旧浏览器回退」谁都到不了,它只是一段带着已下线功能入口的死代码。

删死代码之前先问一句:这条分支的前提还成立吗? 很多「兜底」是在某个前提下写的,前提没了它就变成了纯粹的风险。


改成原生侧栏之后,最舒服的一点其实不在技术上:它不再是宿主页面的一部分了。

宿主怎么折腾自己的 DOM 都影响不到它;用户切标签页它还在;宿主升级改版也不会把它挤没。

代价是所有跨界通信都要显式写出来——而这恰好也是好事,因为你被迫想清楚哪些事该在哪一侧做。