# @local/session-search — 开发规格(Lead 下发,实现以本文件为准) > **v2 变更(优先)**:用户要求改成「常驻搜索栏 + 就地浮层结果」,并提速。 > UI 与 API 的冲突处一律以 [`SPEC-v2.md`](./SPEC-v2.md) 为准:删除 `main` / `sidebar.panellist` > 两个注册,改为 `conversation.header.leading` 常驻搜索栏;Host 首屏搜索只做一次 > `searchSessions`,多命中 / turn / 标题改为 `expand` / `locate` 按需获取。 ## 0. 目标 在 DSH Web GUI 里新增一个「会话搜索」插件: 1. 左侧栏 panellist 新增一个放大镜图标(排在 Plugins / Bill / Schedules 之后)。 2. 点开后主区域出现一个整页搜索面板:输入关键词 → 搜索**所有历史会话的消息内容**(user / assistant,可选工具输出)→ 按会话分组展示命中片段。 3. 点击某条结果:打开对应会话,并**尽力滚动定位**到命中的那一轮消息(高亮 2.4s)。定位失败时用 overlay toast 提示,不阻断打开会话。 4. 附加:标题匹配(即使正文没命中,标题包含关键词的会话也列出)、工作区筛选、搜索历史、键盘操作。 已确认的运行时事实(不要再重复调研): - 客户端 slot:`sidebar.panellist`(注册字段 `id`/`order`/`label`)与 `main`(注册字段 `key`,key 即 panellist 的 id,侧栏点击会自动切到该 main 面板)。两者由 shell 声明,用 `ctx.slots.inject(name, () => ctx.slots.register(...))` 注册。 - 打开会话:`ctx.get('uiWorkspace').openSession(sessionId)`,会自动切回对话面板(`layout.selectPanel(null)`)。 - 客户端拿 host 数据:**本项目约定用 `ctx.webServer.register` 注册 HTTP 路由 + 浏览器 `fetch`**(profile 里已安装的 `dsh-bill` 插件就是这样工作的,可直接参考 `/Users/qiangbin.hu/.dsh/profiles/desktop/node_modules/dsh-bill/lib/index.js` 与 `lib/client.js`)。不要使用 `host.call`(那是动态 cordis 包专用,持久化 bundle 不可用),也不要试图生成 typert remote。 - 会话全文索引服务:`ctx.get('sessionQuery')`。注意:本 profile 默认挂载的 `session-query-sqlite` 被上层 bundle 覆盖为 `openAt: never`(内置侧栏的内容搜索因此静默降级为只匹配标题), 所以本 bundle 的 `cordis.patch.yml` 用 `cordis:group` + `isolate: {sessionQuery: true}` 自带一个 `path: ':memory:'`、`openAt: first-search` 的隔离索引(与官方 `@deepseek-ai/dsh-experimental-session-search` 同构)。Host 代码仍然只认 `ctx.get('sessionQuery')`, 不直接依赖文件路径或 sqlite。 - 会话事件 DOM:对话区的每一行带 `data-chat-turn=""`(数字)。跳转定位靠它; 外层会话容器带 `data-conversation-session=""`,定位时必须先把查找范围限定在 目标会话的容器内(切换会话的一两帧里旧会话行可能还在 DOM 中)。 - 面板组件会收到 `main` slot 的标准 props:`useSessions`、`useWorkspaces`、`usePanelInfo` 等(可能为 undefined,必须容错)。 ## 1. 文件与写入范围(严格遵守) | 文件 | 负责人 | | --- | --- | | `session-search/index.js` | host-search teammate(仅此文件) | | `session-search/client.js` | ui-panel teammate(仅此文件) | | `package.json` / `cordis.patch.yml` / `locale/*.json` / `icon.svg` / `SPEC.md` | Lead(已创建,不要改) | - 包名 `@local/session-search`,`"type": "module"`,零第三方依赖。 - 两个文件都必须通过 `node --check`(client.js 是浏览器脚本但语法检查可用)。 - 不要执行安装、不要改 profile、不要重启服务。 ## 2. Host 半:`index.js` ```js /** Host half: authorized full-text search over the session corpus. */ export const inject = ['webServer']; export function apply(ctx, config) { ctx.inject(['webServer'], (scope) => { scope.effect( () => scope.webServer.register({ kind: 'exact', path: '/dsh-session-search/api', handler: (req, res) => handle(ctx, req, res), }), 'session-search: http api', ); }); } ``` 要点: - 路由 `kind: 'exact'`、方法只接受 `POST`(其它方法回 `405` 的 JSON)。 - 请求体上限 64 KiB,读超限直接 `{ ok:false, error:{ code:'bad-request', message } }`;非法 JSON 同样。 - 所有业务响应 HTTP 200,JSON 形如 `{ ok:true, data:... }` 或 `{ ok:false, error:{ code, message } }`;`Content-Type: application/json; charset=utf-8`、`Cache-Control: no-store`。 - 用 `req`/`res` 的 abort:监听 `res.on('close')` 生成 `AbortController`,传给 `searchSessions/searchEvents/readSession` 的 signal;`req` 未必有该方法,需容错。 - `ctx.get('sessionQuery')` 不存在时返回 `{ ok:false, error:{ code:'unavailable', message:'当前配置未挂载会话全文索引(@deepseek-ai/dsh-session-query)' } }`。 ### 2.1 请求 ```jsonc { "action": "search", // 必填;另有 "ping" "query": "关键词", // 必填,trim 后 1..200 字符,禁止 NUL "limit": 20, // 可选,会话数 1..30,默认 20 "includeTools": false, // 可选,true 时把 tool/call、tool/result 也纳入内容命中 "cwd": "/abs/workspace/path" // 可选,只搜该工作区(精确等于 SessionHeader.cwd),缺省=全部 } ``` `action:"ping"` 返回 `{ ok:true, data:{ indexed:true, version:1 } }`(`indexed` 表示 sessionQuery 是否可用),供页面自检。 ### 2.2 响应 ```jsonc { "ok": true, "data": { "query": "关键词", "tookMs": 37, "truncated": false, // 是否还有更多会话命中未返回 "items": [ { "sessionId": "uuid", "title": "会话标题或 null", "cwd": "/abs/path 或 null", "createdAt": 1760000000000, "updatedAt": 1760000000000, // 该会话最后一条事件时间(拿不到就 createdAt) "turn": 12, // 最佳命中所属轮次,拿不到给 null "seq": 345, // 最佳命中的事件 seq(跳转定位用) "eventType": "user/message", "snippet": "命中片段(纯文本,约 240 字符)", "matchCount": 2, // 本会话返回的命中数(= hits.length) "hasMoreHits": false, // 该会话还有更多命中未展开 "hits": [ { "seq": 345, "turn": 12, "eventType": "user/message", "time": 1760000000000, "snippet": "..." } ] } ] } } ``` ### 2.3 搜索算法 1. `provider = ctx.get('sessionQuery')`。 2. 可见性:`visible = await provider.listSessions(signal)`;只保留 `header.cwd != null` 且 `header.origin !== 'subagent'` 的会话 id(与侧栏语义一致:不暴露子代理会话;不要额外调用 workspaceRegistry)。 3. 主搜索: ```js await provider.searchSessions({ query, eventFilters: [ { kind: 'type', values: includeTools ? ['user/message','assistant/message','tool/call','tool/result'] : ['user/message','assistant/message'] }, { kind: 'surface', values: ['current'] }, ], limit: 60, cursor, // 首页不传 }, { signal }); ``` - 最多翻 3 页(沿 `nextCursor`),按会话去重、保持返回顺序、最多收集 `limit` 个会话(多收 1 个用于 `truncated`)。 - 过滤:`visibleIds.has(hit.header.id)` 且 `hit.bestMatch.sessionId === hit.header.id`;若传了 `cwd`,再要求 `hit.header.cwd === cwd`。 - 错误处理:`SESSION_QUERY_INVALID_LIMIT` 时把 limit 减半重试一次;`SESSION_QUERY_STALE_CURSOR` 时清空重来一次;其它错误包装成 `{ ok:false, error:{ code:'search-failed', message } }`。 4. 每个入选会话再取多条命中: ```js await provider.searchEvents({ sessionId, query, filters: [ /* 同上 type + surface */ ], limit: 4, }, { signal }); ``` 取 `page.items` 的 `{seq, type, time, snippet}`;`hasMoreHits = Boolean(page.nextCursor)`。`bestMatch` 永远排在 hits 第一位(去重)。searchEvents 失败只让该会话退化为单条 bestMatch,不影响整体。 - 每会话最多并发 4 个,`searchEvents` 总并发 ≤ 4。 5. 标题:`await provider.readTitleSnapshots(sessionIds, signal)`,逐项取 `status==='fulfilled' ? value.title?.title : undefined`,失败回退 `null`。 6. turn 映射(用于客户端定位): - 每个会话 `await provider.readSession(sessionId)`,只遍历一遍事件,记录所有 `turn/start` 的 `{seq, turn}`(升序); - 对 hits 的每个 seq 用二分查找得到最近的不大于该 seq 的 turn; - 顺便记录最后一条事件的 `time` 作为 `updatedAt`; - 缓存:`Map`,TTL 60s、最多 64 项,超出删最旧; - `readSession` 失败时 turn=null、updatedAt 回退 header.createdAt,不失败整个请求。 7. `tookMs` 用 `Date.now()` 差。 8. 不要读盘、不要绕开 sessionQuery 直接解析 `~/.dsh/sessions`。 ### 2.4 代码质量 - 单文件、纯 ESM、无 import(除 node 内置外也不需要)。 - 所有异步路径必须 await/catch,路由 handler 最外层 try/catch 保证有响应。 - 不写日志噪音;错误用 `ctx.logger?.warn`(可选)。 ## 3. Client 半:`client.js` ### 3.1 模块外形 ```js window.__ModuleLoader__.load({ id: '@local/session-search', factory(require) { const React = require('react'); const h = React.createElement; // ... return { inject: ['slots', 'locale'], apply(ctx) { /* ... */ }, }; }, }); ``` - 只 `require('react')`,**不要** require `@deepseek-ai/dsh-client-ui-primitives`(宿主版本不稳定,自行实现控件,配色用 `--dsw-alias-*` token)。 - `apply(ctx)` 里做: 1. `locale`:`ctx.effect(() => locale.register('session-search', { zh: DICT_ZH, en: DICT_EN }), 'session-search: dictionaries')`,并用 `locale.bind('session-search')` 得到 `t`; 2. 注入一份 CSS(`