这是「开源项目介绍」系列第四篇。前一篇写的是让 agent 去网上搜图,这一篇反过来——搜的是我们自己的对话。

仓库:github.com/fomalhaut1998/dsh-session-find · npm:dsh-session-find

纯前端插件,零依赖,不联网,不发任何请求。它读的是当前会话已经在内存里的那份数据。

一、起因:三分钟前的结论,翻不回去

我改一个插件,agent 在第五轮告诉我:配置写在 dsh.profile.bundles 里,重启后才会重新组装。这句话当时我扫过去了。半小时后配置不生效,我想回去看它到底怎么说的——往上滚,滚了十几屏,中间夹着四次工具调用、两段 diff、一堆日志。

浏览器 Ctrl+F 输入 dsh,只找到屏幕上的那几处。往上滚,又找到几处。再往上滚,找不到的变多了:已经在屏幕上被回收的节点,浏览器搜索根本看不见。

那天的结论很朴素:对话不是网页,用网页的搜索去搜它,注定漏。

于是有了这个插件:一个覆盖在会话上的搜索面板,Ctrl+F 打开,搜的是整段对话——包括折叠的步骤、未渲染的工具参数、以及你早就滚出屏幕的那些轮回。

二、它是什么

dsh-session-find 搜索面板:156 处匹配、已排除 4842 处

一个面板,没有侧边栏,没有设置页。输入框下面是四组合并出来的信息:

  • 匹配数:156 处匹配;
  • 合并数:已合并 3 条同文;
  • 排除数:已排除 4842 处(这些命中在工具调用内容里,默认不列,点一下就能展开);
  • 范围:全部 / 你 / 助手 三个来源筛选。

再往下是按轮次分组的结果列表,每条前面带来源标签(你 / 助手 / 思考 / 工具调用 / 工具输出 / 系统)和轮次(第 N 轮)。点一条跳过去,命中的词在页面上直接高亮。

三、为什么不用浏览器自带的 Ctrl+F

浏览器 Ctrl+F 读 DOM,插件读会话 store

浏览器搜索有个前提:字必须在 DOM 里。现代会话界面恰好不满足这个前提:

  • 折叠的步骤,不点开就不渲染;
  • 工具调用的参数,只有展开的那一截在 DOM 里;
  • 虚拟列表滚出屏幕的节点会被回收;
  • 思考内容经常是默认收起的。

所以浏览器搜的从来不是“对话”,是“当前屏幕上的一段 HTML”。

插件的做法换了一层:不读 DOM,读会话的 store。store 里存的是每条消息的原始文本,跟它有没有被渲染、被折叠、被回收无关。折叠的步骤里那个词,照样能搜到——搜到之后再负责把它展开、滚过去、标出来。

四、数据从哪来

入口只有一处:对话 store 的 canonical order 数组,逐条 nodeStore.get(key) 取出节点。

拿到节点以后,把它拆成“一段一段可搜索的文本”:

1
2
3
4
function collectParts(node) {
// node kind / block kind / raw key 三层定角色
// 返回 [{ text, role }, ...]
}

角色是三层推导出来的,不是猜的:

判断层取值归到哪个来源标签
node kinduser / steering你
node kindassistant-step助手
node kindsystem-prompt / compaction / turn-error系统
block kindreasoning思考
block kindtool-call / tool-result工具调用 / 工具输出
raw keyargs / toolCall / toolResult工具调用

拆完做两个投影,这是整个插件最核心的一个决定:

投影包含什么用在哪
full全部片段,含 dsh-ui 卡片 JSON 的可读前缀全量模式、计算“已排除”
prose只有「你」和「助手」的正文默认模式

默认搜 prose。因为一次对话里工具输出能占八成,如果全塞进结果列表,搜“配置”会得到一屏 JSON 片段,真正有用的那句人话沉在下面。

五、一次搜索的五段路

dsh-session-find 的搜索管线:store → 片段 → 匹配 → 合并 → 面板

主链路是五段,细节都在旁边:

  1. 取片段:order 里每条节点拆成 { text, role };
  2. 建匹配器:buildMatcher 返回一个字面量匹配器或正则匹配器;
  3. 跑两遍:一遍全量求总数,一遍正文求行号,差值是“已排除 N 处”;
  4. 合并:dedupeHits 按规范化后的片段分桶,同文只留最靠前一条;
  5. 上屏:按轮次分组,带来源筛选,点一条跳转并高亮。

“跑两遍”听上去浪费,但它换来一个诚实:面板上那个“已排除 4842 处”是真的算出来的,不是估的。用户看到 156 条结果时会怀疑“是不是漏了”,这句话就是答案。

六、四个真实的坑

这个插件的 NOTES.md 有 41 KB,大部分是 bug 记录。挑四个最值得说的。

① 搜页面上的原文,却提示“没有匹配”。 这是最难受的一类:我明明看见那句话,复制进去,零结果。原因是索引侧存的是节点源码——转义的 \" 和渲染后的 " 不是同一个字符;弯引号 “” 与直引号 "" 也不是;全角空格和半角空格更不是。修复是两边都加一层归一化:deescape() 还原转义,foldQuotes() 把弯引号折成直引号、全角空格折成半角、破折号折成 -。关键是 foldQuotes() 必须长度不变,否则偏移量全错、高亮框到别处去。修复前 0 处,修复后 52 处。

② 工具内容里的命中,看起来像重复结果。 同一个词在正文出现一次、在工具参数里出现一次,列表里就是两条几乎一样的行。改成:正文有命中时默认只列正文,列表上方给一个按钮「另有 N 条在工具调用内容里 · 显示」;只在工具里出现的词照常列出——不能因为怕吵就把结果吞了。

③ 卡片标题搜不到。 dsh-ui 卡片的标题在数据里是 JSON 源码({"title":"..."}),不是一段可读文本。修法是 proseProjection() 从 JSON 里抽字符串,抽的时候过两道筛:长度 ≥ 6、且含中日韩字符或空格,再排掉 gap、items、callout 这类纯键名。同时用 looksLikeSource() 判“这段像不像源码”——强信号是 +Q+、StaticText、data-chat-、=>,出现两个弱信号({"、":"、\"、function ()也算。像源码的收进开关后面,不再污染默认列表。

④ 点不同结果,跳到同一个地方。 元凶是“改投兄弟节点”:跳转时如果找不到精确的节点 key,就往兄弟节点上找,结果好几条都落到同一个位置。修法是允许失败——找不到就按文本内容兜底定位;命中的是一条折叠步骤时,直接标注「已跳到所在回合」,不假装精确。

七、搜索语法只有三条

开关作用注意
Aa区分大小写默认不区分
全词只匹配完整单词仅对纯 ASCII 查询生效
正则按正则解释查询写错就地报错,不静默

“全词只对纯 ASCII 生效”这条是刻意的。中文没有词边界,给「配置」加 \b 等于让它永远匹配不到。所以边界只在查询是纯 ASCII 词时才加,正则模式下包的是 (?<![A-Za-z0-9_]) / (?![A-Za-z0-9_])。

正则还有一个坑:* 和 ^ 这类零宽匹配会让 lastIndex 原地踏步,浏览器直接卡死。加一行“零宽就手动前进一格”就解决了。

八、合并、分组、已排除

列表默认按轮次分组,同一条消息里命中多次只列一次,标「同文 ×N」。

合并这里出过一个很隐蔽的 bug:合并行的轮次继承了更晚的那一条,于是列表里出现了「第 1 / 第 2 / 第 4 / 第 1 轮」这种倒序。修法是分桶时顺便记住“这段文字首次出现的轮次”,合并后用它,而不是用最后一条。

九、高亮与跳转

高亮没用 <mark>。往 React 管的 DOM 里插节点,轻则被下一次渲染抹掉,重则整块崩掉。这里用的是 CSS Custom Highlight API:

1
2
CSS.highlights.set("dsh-session-find", current);     // 当前这一条
CSS.highlights.set("dsh-session-find-all", allHits); // 全部命中

配两条 ::highlight() 规则就行,DOM 一个字节都不动,一次性最多 240 个 range——再多就只标当前这条,剩下的靠列表点。

跳转比高亮难。一个命中可能在:

  1. 一个默认折叠的回合里;
  2. 一个默认折叠的步骤里;
  3. 一个已经滚出可视区的虚拟列表节点里。

所以跳转是四步:展开目标回合与步骤 → 二分查找轮次锚点 → 单向 scrollTop 收敛到节点 key → key 对不上就按文本内容兜底。所谓“单向收敛”,是滚动位置只往命中的方向推,不来回弹——双向调整会跟虚拟列表的回收打架,最后停在半路。

十、一个数字

README 里留了一份实测:同一个会话,Ctrl+F 输入 dsh。

  • 面板:198 处匹配,已合并 3 条同文,已排除 6347 处;
  • 浏览器自带:屏幕上能找到几十处,滚两屏之后开始漏。

点一下 Aa(区分大小写)变成 156 处。差的这 42 处,是 DSH_ 这类全大写写法——大小写敏感把它们排掉了。对“找个精确的变量名”来说,这是省事,不是丢东西。

这也是“搜屏幕”和“搜全量”的区别:不是精度不同,是范围不同。

十一、装法与重启

1
2
3
4
5
6
7
8
9
10
11
12
# npm
dsh plugin add dsh-session-find

# 指定 profile
dsh plugin add dsh-session-find --profile desktop

# 锁版本
dsh plugin add [email protected]

# 从 GitHub / 本地目录
dsh plugin add github:fomalhaut1998/dsh-session-find
dsh plugin add link:"F:\DSH Projects\Untitled\plugins\dsh-session-find"

装完必须完全退出 DSH 再启动,不是刷新页面。客户端 bundle 是宿主启动时组装的,只刷页面加载不到新插件。装好之后改 client.js 只需要刷新——那时候插件已经进了 bundle 清单。

客户端本身是手写的 ModuleLoader,没有构建步骤,模块 id 必须等于包名。发布前我用一句语法自检:

1
node -e "new (require('vm').Script)(require('fs').readFileSync('client.js','utf8'))"

十二、边界与 FAQ

问题答案
能搜很早以前的会话吗只搜当前已加载的历史,更早的要先让宿主加载
能跨会话搜吗不能,设计上就不跨
结果为什么停在 300 条列表上限,面板会写清楚还剩多少
中文能开全词吗能开,但等于什么都搜不到,所以只对 ASCII 生效
折叠步骤里的命中怎么只跳回合那是 jumpApproximate,精确位置拿不到时如实标注
Ctrl+F 没反应没完全重启 DSH;或者另一个插件抢了同一个槽位
桌面端 F3 不灵被宿主吃掉,1.4.0 起改用 Ctrl+G
之前的设置丢了1.4.0 起 localStorage 前缀从 @local/dsh-session-find: 改成 dsh-session-find:

还有一条我自己踩过的:导出功能一开始想用 File System Access API,第一次调用弹窗正常,第二次开始静默降级成普通下载,用户体验是“点了没反应,但文件在下载目录里”。后来换成 Blob + <a download>,行为就稳了。有些新 API 的“可用”是概率性的。

十三、写在最后

这个插件很小:一个面板,四个开关,五段管线。但它解决的是我每天都在遇到的问题——对话越来越长,而我越来越记不住第几轮说过什么。

写它的过程也让我第一次认真看了一遍自己平时怎么用浏览器搜索:我以为我在搜“内容”,其实我只在搜“此刻渲染出来的那一屏”。

如果它对你也有用,装上试试。有问题开 issue,我一般当天会看。