欢迎光临
我们一直在努力

从零构建-AI-侧边栏(三)-悬浮窗-回滚与那些打磨过的细节

从零构建 AI 侧边栏(三):悬浮窗、回滚与那些打磨过的细节

最后一篇了。

前三篇聊了架构、流式对话、上下文系统和技能设计。这篇我想聊聊那些不在功能列表第一行、但使用体验好坏全靠它们的东西。

一个扩展好不好用,很多时候不是在核心功能上,而是在那些「用的时候不会注意到,但如果没做好就会很烦」的细节上。

先从悬浮窗说起。

这个扩展在网页右侧有一个圆形的渐变紫色按钮,点击可以打开侧边栏。功能很简单,但在实现上要考虑的东西比看起来多。

首先,这个按钮需要画在用户访问的任意网页上。不同网站的样式千差万别,你不能让按钮的样式被网页的 CSS 污染。

解决方案是 Shadow DOM。

我在页面上插入一个宿主元素,然后在它上面 attach 一个 closed shadow DOM。Shadow DOM 内部的样式是完全隔离的,网页的全局 CSS 不会影响到按钮的样式,按钮的样式也不会泄露到网页里。

选 closed 而不是 open 是有原因的。Open shadow DOM 可以通过 element.shadowRoot 被网页的 JavaScript 访问到,这意味着网站可以检测甚至修改你的按钮。Closed 模式从外部完全无法访问,按钮的存在对网页来说是透明的。

const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'closed' });
shadow.innerHTML = '按钮的 HTML 和 CSS';
document.body.appendChild(host);

第二个问题是页面可能会变化。很多网站是 SPA,路由切换时框架可能会清空 document.body 然后重建。你的按钮就跟着一起被删了。

我用 MutationObserver 监视了 DOM 变化。一旦检测到按钮的宿主元素从 body 里消失了,就重新创建。为了防止频繁重建,观察的粒度放在了 documentElement 和 document.body 的 childList 上。

const observer = new MutationObserver(() => {
if (!host || !document.body.contains(host)) {
createFAB();
}
});
observer.observe(document.documentElement, { childList: true });

第三个问题是拖拽。

用户可以上下拖动按钮来调整位置。但拖拽和点击是同一个元素上的两种操作,怎么区分?

我的做法是用位移阈值。在 pointerdown 时记录起始坐标,在 pointermove 时计算位移。位移超过 5 像素,判定为拖拽,给按钮加上拖拽样式并跟随手指移动。位移没超过,松手时判定为点击,触发打开侧边栏。

松手后位置存到 chrome.storage.local,这样关了浏览器再打开,按钮还在上次拖到的位置。

还有一个小细节,拖拽过程中给按钮加了 transition: none,让按钮紧贴手指移动,没有延迟感。松手后再恢复过渡效果。

第四个问题是跟侧边栏的联动。

侧边栏打开的时候,悬浮按钮应该隐藏。因为侧边栏本身就在浏览器窗口的右侧,按钮也在右侧,两个东西挤在一起很丑。

联动通过 chrome.storage 实现。Service worker 在 side panel 连接和断开时分别写入 side_panel_open: true/false。Content script 监听 storage.onChanged,根据这个值来显示或隐藏按钮。

chrome.storage.onChanged.addListener((changes) => {
if (changes.side_panel_open) {
if (changes.side_panel_open.newValue === true) hideFAB();
else showFAB();
}
});

选 storage 而不是直接发消息,是因为消息只能发给特定的 tab,而用户可能打开了很多个标签页,每个页面上都有一个按钮需要同步状态。Storage 的变化事件会被所有 content script 实例收到,天然支持一对多广播。

顺便说一句,按钮上悬停时会出现一个小叉叉,点了可以让按钮在当前站点永久消失。被隐藏的站点 hostname 存进设置,设置面板里可以统一管理和恢复。这个功能是在用户反馈「有些网站不想看到这个按钮」之后加上的。

接下来聊聊选中文字的浮窗工具条。

用户在网页上选中一段文字后,选区上方会弹出一个深色的小工具条,上面有三个按钮,问 AI、翻译、复制。

定位是这个功能的难点。

工具条是 fixed 定位的,fixed 的意思是相对于视口定位。但选区的位置是通过 getBoundingClientRect 拿到的,这个值是相对于视口的。所以直接算就行,选区上方有空位就放上方,上方不够放下方。水平方向居中,但要确保不超出视口。

防抖也很重要。selectionchange 事件在用户拖动选择的过程中会疯狂触发,如果不做防抖,工具条会跟着选区疯狂闪烁。

我做了两层处理。在 mousedown 时设一个标志位,表示正在拖选,期间不更新工具条。mouseup 时清除标志位并立即评估一次。另外给 selectionchange 加了 150 毫秒的防抖,过滤掉频繁的中间态。

document.addEventListener('mousedown', () => { dragging = true; }, true);
document.addEventListener('mouseup', () => {
dragging = false;
scheduleBubbleUpdate(60);
});
document.addEventListener('selectionchange', () => {
if (!dragging) scheduleBubbleUpdate(150);
});

还有一个问题是工具条的点击会取消选区。用户在工具条上点「问 AI」的瞬间,网页上的选中高亮就消失了。虽然文本已经提前捕获到了,但如果用户看着高亮突然消失,体验不好。

所以我在工具条的 mousedown 事件上调了 preventDefault。阻止默认行为之后,点击工具条不会让选区塌陷,用户可以看着高亮被保留的同时完成操作。

复制按钮的实现用了「强行复制」。有些网站会通过 oncopy 事件拦截用户的复制行为,比如在某些内容平台上看文章,选中复制时会自动加上版权声明。我用 Clipboard.writeText 绕过页面的复制逻辑直接把文字写进剪贴板。如果 Clipboard API 不可用,退化到隐藏 textarea 加 execCommand('copy') 的古老方案。

复制成功后按钮会短暂变成一个绿色的勾加「已复制」文字,持续一秒左右后恢复原状。这个反馈很重要,如果没有视觉反馈,用户不确定到底复制成功了没有。

接下来说说对话回滚。

回滚的意思是,在对话历史中,你可以把某条消息之后的所有内容删除掉,回到那个时间点重新来。

这个功能比听起来要微妙。回滚按钮应该出现在哪些消息上?

一开始我做的是每条消息旁边都有回滚按钮,用户消息和 AI 消息都可以回滚。但用了一段时间后发现,回滚 AI 消息的体验不好。

举个例子。用户问了一个问题,AI 回答了。用户回滚了 AI 的回答,这时候对话变成了「用户问了、AI 没答」的状态。UI 上用户的输入还在输入框里,但已经作为消息发出去了。用户没法在同一个点上重新生成回答,唯一能做的就是把这条用户消息也回滚掉,重新输入再问。

那既然最终还是要回滚用户消息,为什么还要在 AI 消息上放回滚按钮呢?

所以我把它砍掉了。回滚按钮只出现在用户消息上。回滚一条用户消息时,同时删除这条消息及其后所有消息,并且把这条消息的原文恢复到输入框,用户可以修改后重发。

语义上更接近于「从此处重新分支」,而不是「撤销某条消息」。

对话历史管理上还有一个滑动窗口的设计。

如果一个对话持续了大几十轮,发给 API 的 messages 数组会非常长,token 消耗巨大,而且很可能超出模型的上下文窗口。

我的做法是保留首轮问答始终在历史里,再加上最近 18 条消息。首轮保留是因为它通常携带了网页上下文或者这次对话的初始设定,丢了之后模型就「忘了在聊什么」。最近 18 条保证对话的短期连贯性。中间的旧消息逐渐淡出。

18 这个数字是试出来的。对于大多数模型的上下文窗口来说,18 轮对话加上首轮 2 条,一共 20 条消息,基本不会爆 token。而且 18 轮对于延续一个连续讨论来说绰绰有余,人类聊天也没人记得 50 轮之前聊了啥。

输入框也有一些小打磨。

高度问题。一开始输入框是单行的,内容多了之后变成多行。但这个切换会导致布局抖动,输入框所在的那一排突然变高,上面的聊天区域突然变小,视觉上很不舒服。

改了之后输入框固定为两行高度。空态是两行,内容超出后滚动。这样不管输入多少内容,布局都不会跳。

代码块的复制按钮也调整过。一开始是个文字按钮,后来改成白底方形图标按钮,放在代码块的右上角,hover 时才显示。这个改动来自一个观察,多数 AI 对话产品都这么设计,用户已经有肌肉记忆了,换个位置或样式反而让人找不到。

最后想聊一个事。

写这个扩展的过程中,我最大的收获不是学会了某个 API 的用法,而是重新理解了什么是「打磨」。

功能做出来和功能真的好用,中间隔着的是大量不那么显眼的细节。按钮拖拽后能不能记住位置、输入框高度变化会不会导致视觉抖动、选中文本后工具条跟着鼠标闪不闪、回滚后输入框里有没有恢复原文。

这些东西没有一个是 PRD 里会写的,但每一个都影响用户会不会继续用你的产品。

很多时候我们说一个产品做得好,不是因为它的功能比别人多,而是因为它在这些你注意不到的地方没有让你难受。

回到这个扩展本身。

从最开始的「把网页版 AI 搬到侧边栏」,到现在这个带上下文的、有技能系统的、能自动装配 prompt 的侧边栏 agent。commit 记录里 28 个提交,从修 service worker 断连到调输入框高度,每一步都是在把功能推到「好用」的边界上。

开源地址在这里,https://github.com/wxj006007/ai-sidebar-extension。如果你也在写 Chrome 扩展,或者对 AI 对话的本地化部署有兴趣,欢迎来看看。

以上,既然看到这里了,如果觉得不错,随手点个赞、收藏、转发三连吧。谢谢你看我的文章,我们,下次再见。

赞(0)
未经允许不得转载:171主机测评 » 从零构建-AI-侧边栏(三)-悬浮窗-回滚与那些打磨过的细节
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址