Skip to content
dsh.fish
Bundle

dsh-search-plus

中文友好的会话全文搜索 + 精确跳转定位:命中后直接滚到那一行并闪光高亮,不再让人在几千条消息里翻

Source
Love-JourneY
License
AGPL-3.0-or-later
Updated
Updated 14 days ago

Readme

# dsh-search-plus

**中文友好的会话全文搜索 + 精确跳转定位。** 命中之后**直接滚到那一行并闪光高亮**,不用在几千条消息里翻。

---

## 它解决的两个真问题

### 1. 中文搜不到(上游 `unicode61` 的硬伤)

上游 `session-query-sqlite` 的 FTS5 索引写死 `tokenize = 'unicode61'`,而**它不切分 CJK** —— 一整串连续中文被当成**一个词元**。实测(2026-09-20):

| 查询 | 上游索引命中 |
|---|---|
| `绘画` | 21 |
| **`绘`** | **0** ← 明明看着这个字,就是搜不到 |
| `会话` | 528 |

**本插件自建 `trigram` 索引**:trigram 支持**任意子串**(含单个汉字),而且 SQLite 会用该索引加速 `LIKE '%…%'`。所以「搜一个字」也能命中。

#### 为什么是 trigram,而不是「CJK 逐字切分」

曾评估过一个更省空间的方案:**索引/查询两侧把中文按字插空格**(`今天天气` → `今 天 天 气`),配 `unicode61`。它的索引体积接近原文(预计 60–90 MB,而现在 167 MB),中文能力一样。

**但它会丢掉英文子串搜索** —— `sess` 再也找不到 `session`,`useCh` 找不到 `useChat`。翻自己的历史时这类搜索很常用。

**Nija 2026-09-20 拍板:保留 trigram。** 拿英文子串 + 一半体积去换,不划算。167 MB 在本机(107 GB 余量)可接受。

> 记录在案,免得以后有人看到体积又想"优化"——这是**权衡后的决定,不是疏忽**。


### 2. 命中之后不能跳转

上游搜索只回 `{ sessionId, snippet }`,**不带事件 seq** —— 所以侧边栏选中结果只能"打开会话",不能定位到命中那一行。

**本插件返回 `seq`,并由 client 半精确跳转。**

---

## 跳转定位怎么做的(这是本插件打磨的重点)

### 与同生态做法的本质区别

| | `dsh-session-workbench` | **本插件** |
|---|---|---|
| seq → 定位哪一行 | 拿 DOM `textContent` 做**文本匹配** | **`anchorSeq` 精确比对** |
| 同一句话出现多次 | **会落错行**,需"命中句 + 前文"组合锚点补救 | **天然正确** |
| 依赖 | DOM 文本内容 | chat snapshot 的 `{key, anchorSeq}` |
| 长会话 | 封顶 10 页(500 条)后降级 | 封顶 20 页(约 1000 条) |

**依据**(上游源码):
- `chat-snapshot-builder.ts:542` —— 每个会话节点同时带 `key` 与 `seq: node.anchorSeq`
- `ui-chat/src/client/contract/slots.ts:172` —— `SessionStandardProps.useChat`,**session 作用域的客户端插件都能拿到**
- `ChatNodeSeat.tsx:129` —— 每行渲染 `data-chat-anchor-key={routedNode.key}`

### 完整流程

```
搜索命中 { sessionId, seq, snippet }
   ↓
① props.sessions.open(sessionId)           切到目标会话
② 从 useChat snapshot 找 anchorSeq ≤ seq 且最接近的节点
   ├─ 找到  → querySelector([data-chat-anchor-key="<key>"])
   │          → scrollIntoView({behavior:'smooth', block:'center'})
   │          → 加 .dsh-sp-flash 类,2.2s 后移除
   └─ 没找到 → session.loadOlder() 往前翻一页
              (翻页会更新 snapshot → effect 重入 → 回到 ②)
③ 到上限 / 无法再翻 → 提示"已打开会话,但未能自动定位,请手动翻找"
```

**同一份 snapshot 同时承担"找目标行"和"判断是否该继续翻页"**,不需要额外的定时器轮询。

---

## 搜索语法

| 写法 | 含义 | 例 |
|---|---|---|
| `词` | 子串匹配(中文可单字) | `绘` |
| `词A 词B` | **AND** —— 两个都要出现 | `var dsh` |
| `"词A 词B"` | **短语** —— 必须相邻 | `"useChat store"` |
| `-词` | **排除** —— 该词不出现 | `dsh -test` |
| 组合 | 可任意组合(各限 8 个) | `var dsh -test` |

**实现**:每个词一条 `LIKE ? ESCAPE '\'` 子句,`AND` 串联、排除用 `NOT LIKE`;全部参数绑定,输入永远不拼进 SQL。`trigram` 索引会加速这些 `LIKE %…%`。

> 注:这里**不用 FTS5 的 MATCH 语法** —— 那样 `"` `*` `(` `OR` `NEAR` 都会变成可执行查询语法。用 LIKE 则**一切都是字面数据**,所见即所得。

## 接口

| 端点 | 说明 |
|---|---|
| `GET /api/plugins/search-plus/search?q=<词>&limit=<n>` | 搜索,返回 `{items:[{sessionId,seq,type,cwd,createdAt,snippet}],tookMs,count}` |
| `GET /api/plugins/search-plus/status` | 索引状态(`built_at` / `docs` / 是否正在建) |
| `POST /api/plugins/search-plus/reindex` | 手动触发重建(202,异步) |

**UI**:会话头部「**搜索+**」按钮 → 面板输入即搜(220ms 防抖)→ 点击结果跳转。

---

## 安装

```sh
dsh plugin --profile web add /home/bubu12/dev/dsh-search-plus
```

`dsh plugin` 会自动把它追加进 `dsh.profile.bundles`。**装完需重启 dsh web 才生效**(bundle 层变更不参与 live 热重载)——
用会话头部的「重启」按钮,或由你自己在终端跑 `~/.dsh/scripts/restart-dsh-web.sh`。

**首次生效后约 12 秒**开始建索引(启动高峰之后再跑,避免抢占)。

## 验证

```sh
# 1) 索引是否建好
curl -s http://127.0.0.1:3080/api/plugins/search-plus/status

# 2) 中文单字能不能搜到(这是本插件存在的理由)
curl -s "http://127.0.0.1:3080/api/plugins/search-plus/search?q=%E7%BB%98" | head -c 400
```

浏览器里点「搜索+」,搜一个汉字,点结果 —— 应能直接滚到那一行并闪光。

## 卸载与残留

```sh
dsh plugin --profile web remove dsh-search-plus
rm -f ~/.dsh/cache/search-plus.db ~/.dsh/cache/search-plus.db-shm ~/.dsh/cache/search-plus.db-wal
```

**装上能用、卸掉干净**:不写 profile 之外的文件、不起外部进程、**不修改任何上游源码或上游 bundle**。

## 维护

- **索引陈旧**:会话变了索引不会自动更新(当前版本只启动建一次)。手动重建:
  `curl -X POST http://127.0.0.1:3080/api/plugins/search-plus/reindex`
  *(自动增量更新是下一步要补的。)*
- **索引库位置**:`$DSH_HOME/cache/search-plus.db`(派生数据,可随时删掉重建)
- **已知限制**:
  - 只索引 `user/message` 与 `assistant/message` 的文本 + 工具名/参数;**不索引** reasoning 之外事件的细节
  - 定位封顶 20 页;超出会降级为"已打开会话,请手动翻找"
  - 索引是**全量重建**,大历史下首次建库有耗时

## 文件

| 文件 | 作用 |
|---|---|
| `package.json` | 双面插件声明(`dsh.bundle.patch` + `dsh.client`) |
| `cordis.patch.yml` | `insert` 本插件(**必须用包名**,client 半靠包名才能被 client-modules 扫到) |
| `index.js` | host 半:trigram 索引 + 搜索/状态/重建路由 |
| `client.js` | client 半:搜索面板 + `anchorSeq` 精确跳转定位 |
| `LICENSE` | AGPL-3.0 |
| `README.md` | 本文件 |

## 致谢与出处

**代码全部自研**(863 行,零外部依赖),但有一处**思路借鉴**必须写明 —— 这是诚信底线:

- **[dsh-session-workbench](https://github.com/PolinniZhong/dsh-session-workbench)**(MIT 协议)
  的 **Locate** 功能演示了"从搜索结果回到那条消息"的可行路径:
  打开会话 → 循环 `loadOlder()` 翻页 → 找 DOM 锚点 → `scrollIntoView` + 闪光。
  本插件的定位流程沿用了这条思路,**但把定位依据换成了精确的 `anchorSeq`**
  —— 上游 `chat-snapshot-builder` 让每个节点同时携带 `key`(DOM 锚点)与 `anchorSeq`(事件 seq),
  于是不必依赖 DOM 文本匹配,**重复内容也不会落错行**(那正是文本匹配方案的固有短板)。

- 上游 **DeepSeek Harness** 提供了全部公开契约:`ctx.sessionQuery`、`ctx.get('sessions')`、
  `SessionStandardProps.useChat`、`[data-chat-anchor-key]` 锚点、`ISession.loadOlder()`。
  本插件不修改上游任何源码或 bundle。

## 协议

**AGPL-3.0-or-later** —— 全文见 [LICENSE](./LICENSE)。

选它的理由:这是给社区用的工具,希望**基于它的改进都能回流**。
AGPL 第 13 条要求:把修改版**作为网络服务提供**时也必须公开源码(这是它区别于 GPL 的地方)。
自己内部使用、随意修改都不受限制;只有**对外分发**或**对外提供服务**时才需要开源你的改动。

Install

dsh plugin --profile web add github:Love-JourneY/dsh-search-plus

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source