我给 ChatGPT、DeepSeek、Kimi 都加了一个「保存为笔记」按钮

我给 ChatGPT、DeepSeek、Kimi 都加了一个「保存为笔记」按钮

AI 已经很会回答问题了,但那些真正有用的答案,往往还是会沉进越来越长的聊天记录里。

过去一年,我在 AI 对话框里得到过很多"应该留下来"的内容。当时读完觉得很有价值,过几天想再找,却只记得"好像是在某个 AI 里问过"。至于是 ChatGPT、DeepSeek 还是 Kimi,已经完全没有印象。

于是我给 PageNote 做了一个很直接的功能:在 AI 回答自己的操作栏里,插入一个「保存为笔记」按钮。

看到值得留下的回答,点一下。将回答持久化保存下来,方便查找。

先看功能:把一次性的 AI 回答变成可继续加工的笔记

这个功能的交互链路很短:

  1. 在 AI 的某条回答下方找到 PageNote 图标;
  2. 点击「保存为笔记」;
  3. PageNote 自动读取当前这条回答;
  4. 在按钮附近生成一张可编辑的 Memo;
  5. 继续删改、补充自己的判断。

很多收藏工具优化的是"先放进去",最后得到的往往是另一个稍后阅读列表、一个网址。而 PageNote 更关心的是内容本身。 保存的笔记,可以在网页内直接查看,也可以在后台管理、编辑。

同样是 AI 对话页,DOM 风格却完全不同

刚开始我以为,这个功能不过是找到回答底部的操作栏,然后 append 一个按钮。

做完第一个网站后才发现:每一家 AI 产品对"这是一条回答"的理解,都体现在完全不同的 DOM 结构里。

从当前适配规则来看,五个平台大致呈现出下面这些特点:

平台 页面结构表现 当前定位策略
ChatGPT 语义属性比较完整 使用 data-turndata-message-author-rolearia-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 支持 appendprependbeforeafter。这看起来只是几个简单的 DOM API,但它让规则可以适应不同网站的操作栏结构:有些按钮适合成为操作栏的最后一个子元素,有些则必须插在锚点节点之前。

选择器还可以配置成有顺序的候选数组。页面改版后,只要旧结构和新结构仍有一段过渡期,就可以依次尝试多个定位方式,以第一个产生结果的候选项为准。

这套拆分带来一个很实际的好处:控制器不需要知道自己正运行在哪一家 AI 网站里。

它只负责注入的生命周期;网站差异则留在可以单独更新的规则中。

难点一:AI 回答不是页面加载时一次性出现的

普通网页增强功能经常可以在 DOMContentLoaded 后执行一次:

ts 复制代码
document.querySelector(target)?.append(button);

在 AI 对话页里,这基本不够用。

回答可能正在流式生成,正文节点和操作栏出现的时间不一致;发送下一条消息后会增加新的回答;虚拟列表还可能把离开视口的消息销毁,滚回来时再创建一遍。

此外,这些网站大多是 SPA。用户从一个会话进入另一个会话,地址变了,但页面没有真正刷新。旧规则需要卸载,新规则则要根据新 URL 重新获取和应用。

因此,InjectController 启动后会先做一次全量 reconcile,然后使用 MutationObserver 监听 document 的子节点变化。

不过,直接在每条 mutation 到来时扫描整个页面也不行。AI 流式输出时,DOM 变化可能非常密集,几秒钟内触发大量回调。

优化后的处理方式是:

  1. 从 mutation 中收集真正发生变化的根节点;
  2. 合并相互包含的节点,只保留最外层的 dirty root;
  3. 使用 requestAnimationFrame 把同一帧内的变化合并;
  4. 只在 dirty root、它的子树以及相关祖先中寻找目标 scope;
  5. 只有选择器 fallback 依赖全局结果时,才重新执行全局匹配。

核心思路不是"监听到变化就重做一切",而是把 reconcile 做成一种可以频繁调用、但成本可控的校正过程。

难点二:融入原页面,但不要被原页面的 CSS 同化

把按钮插进别人的页面,还有一个现实问题:你不知道宿主页面写了什么 CSS。

一个宽泛的 button { ... },就可能把插件按钮的边框、字体或背景改得面目全非;插件自己的样式如果作用域控制不好,也可能反过来污染页面。

因此,菜单被封装成 <pagenote-inject-menu> Web Component,内部使用 React 渲染,并挂载在 closed Shadow DOM 中。

这样做有几个好处:

  • 样式与宿主页面隔离;
  • 五个平台可以复用同一套按钮、Tooltip 和交互状态;
  • 保留统一的 hover、focus-visible 和无障碍标签;
  • 宿主页面只能看到一个边界清晰的自定义元素。

这里没有追求把按钮伪装成每个平台的原生图标。它在尺寸和位置上融入原操作栏,但仍保留 PageNote 自己的蓝色视觉识别。对用户来说,这是一个外部工具提供的能力,适度可识别比完全"隐身"更诚实。

开放的个性化样式能力

如果注入配置只能传一个固定的 buttons 数组,那么控制器虽然实现了跨站复用,按钮本身却仍然是写死的:只能换标题、图标和 command,无法调整内部结构,也很难承载之后可能出现的徽标、文字按钮或组合操作。

因此,新版配置不再描述"一个按钮",而是描述"要渲染的节点"。每个节点由 typeprops 和可递归嵌套的 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}`
      })
    )
  );
}

渲染过程主要做四件事:

  1. 原始值直接作为 React 子节点返回;
  2. type 优先从受控组件表中解析,找不到时再作为原生 HTML 或自定义元素标签;
  3. children 外的属性交给 React,并允许通过 transformProp 做统一转换;
  4. 递归处理 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 的「保存为笔记」。

相关推荐
咖啡无伴侣17 分钟前
1. 从零搭建企业级 Monorepo 工程化模板:初始化、应用创建与共享 TypeScript 配置
前端·前端框架
moonsims18 分钟前
再议AiBrainBox-V的前左右三目布局-满足多目SLAM算法;对比单下视VIO(低空、地面纹理丰富、飞行速度适中无人机 )&多目VIO
前端·人工智能·量子计算
AI编程实验室19 分钟前
Agent Handoff 自动跟踪 Skill:安装、事件映射与证据分级
前端·ai编程
小海豚儿20 分钟前
从一句话,到一套系统
ai编程
KoPa20 分钟前
HeySmart:事件总线——异步解耦的艺术
前端·后端
金花顺20 分钟前
ndroid 音频系统:AudioTrack 源码深度解析(从 Java 构造到 Native 启动)
前端·架构
用户9210802628620 分钟前
AI SSE Client 和普通 SSE Client 有什么不同:一次生成任务背后的坑与设计边界
前端
PedroQue9929 分钟前
@meng-xi/create-uni-app v1.0.0 正式发布
前端·uni-app