我给 ChatGPT、DeepSeek、Kimi 都加了一个「保存为笔记」按钮
AI 已经很会回答问题了,但那些真正有用的答案,往往还是会沉进越来越长的聊天记录里。
过去一年,我在 AI 对话框里得到过很多"应该留下来"的内容。当时读完觉得很有价值,过几天想再找,却只记得"好像是在某个 AI 里问过"。至于是 ChatGPT、DeepSeek 还是 Kimi,已经完全没有印象。
于是我给 PageNote 做了一个很直接的功能:在 AI 回答自己的操作栏里,插入一个「保存为笔记」按钮。
看到值得留下的回答,点一下。将回答持久化保存下来,方便查找。 
先看功能:把一次性的 AI 回答变成可继续加工的笔记
这个功能的交互链路很短:
- 在 AI 的某条回答下方找到 PageNote 图标;
- 点击「保存为笔记」;
- PageNote 自动读取当前这条回答;
- 在按钮附近生成一张可编辑的 Memo;
- 继续删改、补充自己的判断。
很多收藏工具优化的是"先放进去",最后得到的往往是另一个稍后阅读列表、一个网址。而 PageNote 更关心的是内容本身。 保存的笔记,可以在网页内直接查看,也可以在后台管理、编辑。 
同样是 AI 对话页,DOM 风格却完全不同
刚开始我以为,这个功能不过是找到回答底部的操作栏,然后 append 一个按钮。
做完第一个网站后才发现:每一家 AI 产品对"这是一条回答"的理解,都体现在完全不同的 DOM 结构里。
从当前适配规则来看,五个平台大致呈现出下面这些特点:
| 平台 | 页面结构表现 | 当前定位策略 |
|---|---|---|
| ChatGPT | 语义属性比较完整 | 使用 data-turn、data-message-author-role 和 aria-label |
| DeepSeek | 回答位于虚拟列表和多层 Flex 容器中 | 从可见消息项定位回答正文与操作区 |
| Kimi | 一条回答可能由多个 segment 组合 | 过滤工具调用片段,只提取正文 Markdown |
| 豆包 | 具有较多业务含义明确的 data-* 属性 |
通过消息容器和 action bar 建立对应关系 |
| 通义千问 | 回答更接近卡片式结构 | 从回答卡片定位内容,在底部锚点前插入 |
这不是对几家产品完整前端架构的评价,只是基于当前页面 DOM 和实际适配规则得到的观察。但仅仅这些差异,就足以让"一段通用注入代码"迅速失控。
真正要解决的并不是"按钮插在哪里"这一个问题,而是三个相互关联的问题:
- 哪个节点代表一条独立回答?
- 这条回答中,哪部分才是应该保存的正文?
- 哪个节点是最自然、最不打扰原页面的按钮挂载点?
如果把这三个答案全写进控制器,支持五个网站后,代码里很快就会充满域名判断和特例分支。
把网站差异放进配置,而不是复制五套代码
最后我把注入行为拆成了一套 InjectRule。每条规则只回答三个问题:
scope:一条独立回答的边界在哪里;pick:点击时应该读取哪部分内容;inject:按钮挂在哪里,以什么位置插入。
以 ChatGPT 为例,精简后的配置大致是这样:
json
{
"id": "chatgpt-assistant-message-memo",
"scope": {
"selector": "section[data-turn=\"assistant\"]"
},
"pick": {
"selector": "[data-message-author-role=\"assistant\"] .markdown",
"source": "innerText",
"trim": true
},
"inject": {
"selector": "[aria-label=\"Response actions\"]",
"position": "append",
"children": [
{
"type": "IconButton",
"key": "create-memo",
"props": {
"className": "pagenote-inject-menu__button",
"label": "保存为笔记",
"onClick": {
"action": "command",
"id": "create-memo",
"command": "memo.summary-pick"
},
"children": [
{
"type": "img",
"props": {
"className": "pagenote-inject-menu__icon",
"src": "https://app.pagenote.cn/extension/assets/pagenote-memo.svg"
}
}
]
}
}
]
}
}
DeepSeek、Kimi、豆包和千问使用同一份类型,只替换各自的选择器和插入位置。children 则是一棵可以被 JSON 序列化的 JSX 节点树,按钮结构、图标和声明式事件都由配置描述。
position 支持 append、prepend、before 和 after。这看起来只是几个简单的 DOM API,但它让规则可以适应不同网站的操作栏结构:有些按钮适合成为操作栏的最后一个子元素,有些则必须插在锚点节点之前。
选择器还可以配置成有顺序的候选数组。页面改版后,只要旧结构和新结构仍有一段过渡期,就可以依次尝试多个定位方式,以第一个产生结果的候选项为准。
这套拆分带来一个很实际的好处:控制器不需要知道自己正运行在哪一家 AI 网站里。
它只负责注入的生命周期;网站差异则留在可以单独更新的规则中。
难点一:AI 回答不是页面加载时一次性出现的
普通网页增强功能经常可以在 DOMContentLoaded 后执行一次:
ts
document.querySelector(target)?.append(button);
在 AI 对话页里,这基本不够用。
回答可能正在流式生成,正文节点和操作栏出现的时间不一致;发送下一条消息后会增加新的回答;虚拟列表还可能把离开视口的消息销毁,滚回来时再创建一遍。
此外,这些网站大多是 SPA。用户从一个会话进入另一个会话,地址变了,但页面没有真正刷新。旧规则需要卸载,新规则则要根据新 URL 重新获取和应用。
因此,InjectController 启动后会先做一次全量 reconcile,然后使用 MutationObserver 监听 document 的子节点变化。
不过,直接在每条 mutation 到来时扫描整个页面也不行。AI 流式输出时,DOM 变化可能非常密集,几秒钟内触发大量回调。
优化后的处理方式是:
- 从 mutation 中收集真正发生变化的根节点;
- 合并相互包含的节点,只保留最外层的 dirty root;
- 使用
requestAnimationFrame把同一帧内的变化合并; - 只在 dirty root、它的子树以及相关祖先中寻找目标 scope;
- 只有选择器 fallback 依赖全局结果时,才重新执行全局匹配。
核心思路不是"监听到变化就重做一切",而是把 reconcile 做成一种可以频繁调用、但成本可控的校正过程。
难点二:融入原页面,但不要被原页面的 CSS 同化
把按钮插进别人的页面,还有一个现实问题:你不知道宿主页面写了什么 CSS。
一个宽泛的 button { ... },就可能把插件按钮的边框、字体或背景改得面目全非;插件自己的样式如果作用域控制不好,也可能反过来污染页面。
因此,菜单被封装成 <pagenote-inject-menu> Web Component,内部使用 React 渲染,并挂载在 closed Shadow DOM 中。
这样做有几个好处:
- 样式与宿主页面隔离;
- 五个平台可以复用同一套按钮、Tooltip 和交互状态;
- 保留统一的 hover、focus-visible 和无障碍标签;
- 宿主页面只能看到一个边界清晰的自定义元素。
这里没有追求把按钮伪装成每个平台的原生图标。它在尺寸和位置上融入原操作栏,但仍保留 PageNote 自己的蓝色视觉识别。对用户来说,这是一个外部工具提供的能力,适度可识别比完全"隐身"更诚实。
开放的个性化样式能力
如果注入配置只能传一个固定的 buttons 数组,那么控制器虽然实现了跨站复用,按钮本身却仍然是写死的:只能换标题、图标和 command,无法调整内部结构,也很难承载之后可能出现的徽标、文字按钮或组合操作。
因此,新版配置不再描述"一个按钮",而是描述"要渲染的节点"。每个节点由 type、props 和可递归嵌套的 children 组成。例如 type: "img" 会渲染原生图片元素,type: "IconButton" 则会命中 SDK 预先注册的 React 组件。
真正完成这层转换的是 renderJsxNode。它的核心逻辑可以简化成下面这样:
ts
function renderJsxNode(node, options) {
// 文本、数字、布尔值等叶子节点直接交给 React
if (!isJsxElement(node)) return node;
const sourceProps = node.props || {};
const props: Record<string, unknown> = {
key: node.key ?? options.key
};
for (const [name, value] of Object.entries(sourceProps)) {
if (name === "children") continue;
props[name] = options.transformProp
? options.transformProp(name, value)
: value;
}
const rawChildren = sourceProps.children;
const children =
rawChildren === undefined
? []
: Array.isArray(rawChildren)
? rawChildren
: [rawChildren];
const type = options.components?.[node.type] ?? node.type;
return createElement(
type,
props,
...children.map((child, index) =>
renderJsxNode(child, {
...options,
key: `${options.key ?? "0"}.${index}`
})
)
);
}
渲染过程主要做四件事:
- 原始值直接作为 React 子节点返回;
type优先从受控组件表中解析,找不到时再作为原生 HTML 或自定义元素标签;- 除
children外的属性交给 React,并允许通过transformProp做统一转换; - 递归处理
children,从而支持图片、文字和多层组件组合。
事件是这里最值得单独处理的部分。JSON 不能携带 JavaScript 函数,也不应该让远端配置执行任意代码。所以配置中的 onClick 不是函数,而是一个声明式 action:
json
{
"action": "command",
"id": "create-memo",
"command": "memo.summary-pick"
}
InjectMenuComponent 只会转换名称符合 onXxx 且通过类型校验的 command action,再把它包装成真正的 React 事件处理器。点击后默认阻止浏览器行为和事件冒泡,然后交给 PageNote 已注册的 command 系统执行。配置能决定"触发哪个能力",但不能夹带一段任意脚本。
这使注入菜单获得了更开放的表现形式:可以使用 SDK 提供的 IconButton,也可以组合原生标签、图片、文字、ARIA 属性和内联样式。与此同时,组件白名单、声明式事件和 Shadow DOM 又给这种开放能力划出了清晰边界------可定制的是 UI 结构与已注册行为,而不是宿主页面的任意执行权限。
为什么我更愿意把它叫作"笔记",而不是"收藏"
收藏表达的是:"这个东西以后可能有用,我要回来看看它。" 笔记表达的是:"这段信息已经进入了我的思考。我更关心内容本身,而不是它来源于哪里。"
我现在会用这个按钮保存:
- AI 给出的代码解释和问题排查步骤;
- 多轮追问后终于得到的准确结论;
- 一份可以继续调整的技术方案;
- 长文总结中真正重要的几段内容;
- 写作大纲、Prompt 版本和灵感片段。
一套注入能力,不应该只服务一个按钮
这次首先落地的是"保存为笔记",但 InjectController 本身没有绑定特定的业务。
按钮配置里使用的是 command。注入层负责确定"用户在什么上下文中点击了什么",业务控制器负责决定如何处理 payload。
这意味着同一套能力以后还可以扩展为:
- 保存回答中的代码块;
- 把回答加入稍后整理列表;
- 对指定段落创建双向引用;
- 将当前回答追加到已有笔记;
- 对选中的回答执行自定义工作流。
通用能力与具体产品功能解耦后,跨站注入不再是一组散落的 DOM 小技巧,而是 PageNote 可以持续扩展的一层页面能力。未来还将植入更多的嵌入能力。
写在最后
AI 降低了生成内容的成本,却没有自动解决信息沉淀的问题。甚至恰恰因为答案来得更快、更密集,我们更容易失去其中真正有价值的部分。
PageNote 做的事情一直很简单:看到重点,顺手留下来。
如果你经常在 ChatGPT、DeepSeek、Kimi、豆包或通义千问里查资料,可以试试 PageNote 的「保存为笔记」。