# 会话消息搜索(顶部栏搜索浮窗)技术方案 v2 > 状态:待评审(已按评审意见 v1 → v2 修订)· 关联代码:`desktop/src/components/common/TopNav.vue`、`backend/.../session/`、`desktop/src/composables/useCenterTabs.ts` > 本文档只描述实现方案,不包含任何代码改动。评审通过后再进入实施。 ## 1. 需求背景 客户端目前定位历史会话只有一条路径:左侧任务栏按工作区分组预览(每组默认 5 条),会话多了之后很难找到"当初聊过某件事"的会话。现有 `GET /v1/sessions` 的 `keyword` 参数只按**会话标题**模糊匹配,无法按消息内容检索;而用户真正记住的往往是说过的一句话。 要解决这个问题,需要在顶部操作栏增加一个搜索入口:输入关键词,按**用户发送的消息内容**匹配会话,快速跳转。搜索必须由后端完成——前端内存只缓存已打开会话的消息(`sessionMessages` 按会话按需加载),不持有全量消息,纯前端过滤必然漏结果。 ## 2. 需求描述(已确认的决策) 1. **入口**:顶部操作栏(`TopNav` 右侧按钮区)新增放大镜图标;同时支持 `Ctrl/Cmd + K` 快捷键唤起/关闭浮窗(与现有 `Ctrl+\`` 终端快捷键互不冲突)。 2. **浮窗**:锚定放大镜图标的下拉浮窗,内含搜索输入框;打开时自动聚焦;点击外部或按 `Esc` 关闭。 3. **匹配语义(明确)**:只匹配该会话中**角色为 user 的消息「用户可见文本内容」**,`包含搜索词`(子串匹配)即算命中。图片 URL、多模态 JSON 结构字段(`type`、`image_url`、`text` 等)**不参与匹配**(详见 3.1)。 4. **搜索范围**:`NORMAL` 主会话 + `SIDE_TASK` 边路会话;**排除** `SUBAGENT` 子代理会话;**排除** `ARCHIVED` 已归档会话(只搜 `ACTIVE`)。 5. **排序**:按 `session.updated_at` 倒序(消息落库时 `SessionService.saveMessage` 会刷新 `updated_at`),`updated_at` 相同按 `id` 倒序保证稳定。 6. **条数上限**:最多返回 **20 条**,服务端硬性封顶;**接口不暴露 `limit` 参数**(需求固定 20 条,无需可配)。 7. **结果条目内容**:会话标题 + 类型标签(主会话 / 边路)+ 最近更新时间 + 命中消息文本片段(围绕关键词生成,总长约 50~80 字,必然包含关键词,前端高亮)。 8. **跳转**: - 主会话命中项 → 路由 `/tasks/{id}`; - 边路会话命中项 → 路由 `/tasks/{parentSessionId}`(父会话),并在该会话页面内打开对应边路任务 Tab。 - **不做**"跳转后自动滚动定位到命中消息"——只跳会话页面。 9. **键盘交互**:浮窗内 `↑`/`↓` 移动选中项、`Enter` 跳转、`Esc` 关闭。 ### 明确不做 - 不搜索 `SUBAGENT` 子代理会话、不搜索 `ARCHIVED` 归档会话、不搜索孤儿边路会话(父会话不存在 / 非 ACTIVE / 非同用户 / 非 NORMAL)。 - 不匹配 `ASSISTANT` / `TOOL` 角色消息。 - 不匹配图片 URL、JSON 结构字段(见 3.1 的二次校验)。 - 不做分词、全文索引(FULLTEXT)、Elasticsearch。 - 不做结果分页 / "加载更多" / 搜索历史 / 热门词 / 收藏过滤。 - 不做"点击结果后定位到具体消息"。 - 搜索接口**不暴露 limit**,固定 20。 ## 3. 技术选型 ### 3.1 后端检索:MySQL `LIKE` 候选 + Java 纯文本二次校验(定) **检索方式**:MySQL `LIKE '%kw%'` 子串匹配(大小写不敏感由库表 collation 保证),配合**两层过滤**保证语义正确: - **第一层(SQL)**:`message.content LIKE '%kw%'` 取候选(覆盖纯文本消息与多模态 JSON 原文),同时过滤 `s.user_id`、`session_type IN ('NORMAL','SIDE_TASK')`、`status='ACTIVE'`、`m.role='USER'`、`m.deleted=0`。 - **第二层(Java 内存)**:对候选会话的命中消息逐条解析 `content`,提取用户可见纯文本(JSON 数组 → 拼接 `text` 部分,图片 URL 丢弃;纯文本直接使用),做**真实子串校验**。文本真正命中才产出结果与 snippet;否则跳过该消息继续查下一条候选,全无文本命中则剔除该会话。 这样确保:图片 URL / JSON 字段名造成的 SQL 假命中不会进入结果,snippet 必然包含关键词,前后端高亮一致。 **明确接受的边界**:SQL 第一层可能多取少量假命中候选,第二层过滤后最终结果可能**不足 20 条**("最多 20 条",允许不足)。候选查询 `LIMIT 20` 后过滤,不额外放宽候选数。 **大小写规则**:数据库侧 `utf8mb4` 默认 collation 不区分大小写(`A`= `a`);Java 侧用大小写不敏感定位(`indexOf` 前对文本与关键词做 `toLowerCase`),英文关键词行为一致,中文无大小写问题。 **关键词安全**: - 缺少 `keyword`:参数错误(400)。 - trim 后为空:返回空列表(`data.items=[]`),便于前端空态。 - 长度 > 100 字符:参数错误(400);前端输入框设 `maxlength=100`。 - `%`、`_`、`\` 以反斜杠转义(防通配符注入),SQL 显式 `ESCAPE '\\'`;参数一律 MyBatis `#{}` 绑定,禁止 `${}`。 **孤儿边路保护**(边路会话必须可达):命中 `SIDE_TASK` 需满足其父会话存在且 `user_id` 相同、`deleted=0`、`status='ACTIVE'`、`session_type='NORMAL'`(`SessionService.deleteSession` 目前不级联删边路会话,孤儿保护必要)。 ### 3.2 命中片段(snippet)生成(定) **不取消息开头前 50 字**(关键词可能位于长消息中段),改为围绕首次命中位置生成: 1. 提取该消息纯文本; 2. 大小写不敏感定位首次命中位置; 3. 命中词前后各保留约 25 字上下文(命中词本身完整保留); 4. 总长度控制在约 50~80 字;前后被截断时补 `…`。 示例:`……前面的上下文 登录页面为什么报 500 错误 后面的上下文……` "取哪条消息":取该会话 `message.id ASC` 中第一条**文本真正命中**的 user 消息(第二层校验通过的那条),保证 snippet 与命中一致。 ### 3.3 接口:新增 `GET /api/v1/sessions/search`(定) - 放在 `SessionController`,按认证主体 `userId` 硬过滤。 - 入参:`keyword`(必填,行为见 3.1);**无 `limit` 参数**,服务端固定 `LIMIT 20`。 - 出参:`Result`: ```json { "code": 0, "data": { "items": [ { "id": 123, "title": "修复登录 Bug", "sessionType": "NORMAL", "parentSessionId": null, "updatedAt": "2026-08-07 10:30:00", "phase": "COMPLETED", "agentName": "默认 Agent", "snippet": "……前面的上下文 登录页面为什么报 500 错误 后面的上下文……" } ] } } ``` ### 3.4 前端:`SessionSearchPopover.vue` + 模块级 Tab 打开函数(定) - 新组件 `desktop/src/components/search/SessionSearchPopover.vue`,挂 `TopNav` 的 `nav-right`(放大镜图标,风格与现有图标按钮一致)。 - `ElPopover` 承载浮窗 + `ElInput` 输入框;输入防抖 300ms;空关键词不发请求。 - **搜索竞态控制(必做,见 4.6)**:递增 request sequence + `AbortController` 取消旧请求,保证旧响应不覆盖新结果。 - 边路跳转:`desktop/src/composables/useCenterTabs.ts` 导出**模块级函数** `openSideTaskTabFor(parentSessionId, sideSessionId, title, phase)`,**始终按显式 `parentSessionId` 写模块级单例 `sessionTabsMap`,不依赖模块级 `currentSessionId`**(`router.push` 完成 ≠ 页面 `loadSession` 完成,但 Tab Map 是模块级单例、与路由加载解耦,见 4.8)。 - 深色主题与安卓 WebView:沿用 `--aw-*` CSS 变量;移动端保留图标点击入口(快捷键不注册,见 4.7)。 ### 3.5 权限说明(修正) 接口要求登录(Spring Security 提供 `userId`),**数据隔离靠 SQL 硬过滤 `s.user_id = #{userId}`**。`PermissionInterceptor` 会经过该路径,但接口**不添加 `@RequirePermission`**,因此不会执行细粒度权限码校验(`PermissionInterceptor` 无注解时直接放行,见 `PermissionInterceptor.java:25`);`AuditInterceptor` 正常记录审计。**不**给客户端接口加 `@RequirePermission("session:read")`——该权限码主要用于管理侧,未保证授予所有普通用户。 ## 4. 实现步骤 ### 后端(`backend/`) **4.1 `SessionMapper` 新增两条查询(MyBatis 注解,`#{}` 绑定)** 查询一(候选会话,`LIMIT 20`): ```sql SELECT DISTINCT s.id, s.title, s.session_type, s.parent_session_id, s.phase, s.updated_at, s.agent_id FROM session s JOIN message m ON m.session_id = s.id AND m.deleted = 0 WHERE s.user_id = #{userId} AND s.deleted = 0 AND s.session_type IN ('NORMAL', 'SIDE_TASK') AND s.status = 'ACTIVE' AND m.role = 'USER' AND m.content LIKE CONCAT('%', #{escapedKeyword}, '%') ESCAPE '\\' AND ( s.session_type = 'NORMAL' OR EXISTS ( SELECT 1 FROM session p WHERE p.id = s.parent_session_id AND p.user_id = s.user_id AND p.deleted = 0 AND p.status = 'ACTIVE' AND p.session_type = 'NORMAL' ) ) ORDER BY s.updated_at DESC, s.id DESC LIMIT 20 ``` > 说明:`OR EXISTS` 语义——`NORMAL` 会话直接满足;`SIDE_TASK` 必须存在有效父会话(同用户、未删、ACTIVE、NORMAL),孤儿边路不返回。(v1 的 `NOT EXISTS` 写法逻辑写反,v2 已修正。) 查询二(候选会话的命中消息,一次取回避免 N+1,`LIMIT 200` 兜底): ```sql SELECT m.session_id, m.id, m.content FROM message m WHERE m.deleted = 0 AND m.role = 'USER' AND m.session_id IN (#{candidateIds}) AND m.content LIKE CONCAT('%', #{escapedKeyword}, '%') ESCAPE '\\' ORDER BY m.id ASC ``` **4.2 `SessionService` 新增 `searchSessionsByUserMessage(Long userId, String keyword)`** 1. 关键词预处理:`trim`;空 → 返回空列表;>100 字符 → 抛参数错误;`%`/`_`/`\` 转义。 2. 查询一取候选会话(≤20)。 3. 查询二按候选 `session_id IN (...)` 一次取回命中消息(同一关键词转义值)。 4. 内存处理:按 `session_id` 分组,按 `id ASC` 逐条解析 `content`(JSON 数组 → 拼接 text,图片 URL 丢弃)→ 大小写不敏感子串校验 → 第一条文本命中的消息生成 snippet(围绕命中词,前后各 ~25 字,截断补 `…`,总长 ~50-80 字);无文本命中剔除该会话。 5. 按 `updated_at DESC, id DESC` 返回(候选已排序,过滤后保持原序)。 6. 批量加载 `agentName`(复用 `batchLoadAgents` 逻辑或传 agentId 列表)。 **4.3 `SessionController` 新增 `GET /v1/sessions/search`** - `@AuthenticationPrincipal Long userId` + `@RequestParam String keyword`; - keyword 校验按 3.1 规则; - 返回 `Result`(`items`)。 **4.4 单测**(`backend/src/test/`,随 `mvn test` 运行): - 命中 user 消息返回会话;未命中返回空。 - 边路会话命中(父会话 ACTIVE)返回;**孤儿边路**(父已删 / 父 ARCHIVED / 父非 NORMAL / 父属其他用户)不返回。 - 子代理会话不返回;归档会话不返回。 - 上限 20 条;`updated_at` 倒序、同时间按 id 倒序。 - 关键词转义:单独 `%`、单独 `_`、单独 `\`、组合 `\%`、`\_` 均按普通字符匹配,不透配。 - 空关键词返回空列表;缺参抛参数错误;超长关键词抛参数错误。 - **多模态假命中**:图片消息 content 含 `"image_url"`/图片 URL,但纯文本不含关键词 → 不返回;图片消息文本部分含关键词 → 返回且 snippet 为纯文本。 - snippet 生成:关键词位于消息中段时片段包含并高亮关键词;纯文本与 JSON 数组两种 content 形态。 - 英文大小写命中与 collation 一致(`Login` 命中 `login`)。 - 消息 `deleted=1` 不参与匹配。 **4.5 性能验证(上线前必做)** - 用接近真实数据量执行 `EXPLAIN ANALYZE`,确认 `message(session_id, ...)`、`session(user_id)` 索引被利用(`LIKE '%kw%'` 本身无法走内容索引,但 session/message 关联与过滤条件应走索引)。 - 记录典型用户消息量下的 P95 响应时间并写入实现记录。 - 若性能不达标,按序评估(**不在本次实现,仅记录预案**):`message(session_id, deleted, role, id)` 复合索引 → `session(user_id, status, session_type, updated_at, id)` 复合索引 → 独立可搜索纯文本列 → 全文检索。 - 已识别权衡:查询按**单个用户隔离**(不是"系统只有单用户");候选查询最多 20 条 + 一次消息批量查询,**共 2 次 SQL**,无 N+1(v1 的逐会话查片段方案已废弃)。 ### 前端(`desktop/`) **4.6 搜索竞态控制(必做)** - 组件内维护 `requestSeq` 自增计数:每次发起请求 `const seq = ++requestSeq`,仅 `seq === requestSeq` 的响应(成功或失败)允许更新状态; - 同时用 `AbortController` 取消旧请求(axios 支持 `signal`); - 关闭浮窗 / 清空输入 / `onUnmounted` 时:清除防抖定时器、`abort()` 在途请求、`requestSeq++` 使旧响应失效; - 空关键词:立即清空结果并 `requestSeq++` 失效旧请求; - 错误响应只更新当前有效请求的错误态。 **4.7 快捷键与未登录规则(定)** - 全局 `keydown` 监听(`onMounted` 注册、`onUnmounted` 移除): - 忽略 `event.repeat`; - `e.key.toLowerCase() === 'k' && (e.ctrlKey || e.metaKey)`; - 浮窗已打开 → 关闭; - 浮窗未打开 → 打开并 `e.preventDefault()`(**不依赖普通输入框是否聚焦**;`contenteditable`/代码编辑器等特殊区域的让位策略:v1 不做特判,统一拦截,如后续发现与编辑器快捷键冲突再单独处理); - Android(Capacitor)不注册桌面键盘入口,保留图标按钮; - **未登录(无 token)时无法进入主界面**:路由守卫会重定向到 `/login` 登录页,顶栏与搜索入口不存在,自然不发起搜索请求。(v1 设计中的「唤起登录对话框」已随登录页改造移除。) **4.8 `useCenterTabs.ts` 模块级边路跳转(定)** - 新增导出模块级函数 `openSideTaskTabFor(parentSessionId: string, sideSessionId: number, title: string)`: - 按 `parentSessionId` 取/建单例 `SessionTabState`(**不用模块级 `currentSessionId`**); - 先调 `unmarkSideTaskClosed(parentSessionId, sideSessionId)`(v2 新增,见 4.9),清除"用户曾关闭"记录——否则刷新后 `restoreSideTaskTabs` 会因关闭记录跳过该 Tab; - 已存在同名 `side_task` Tab(按 `sideSessionId` 匹配)→ 仅激活;否则 push 新 Tab 并激活; - 替换 Map 引用触发响应式(`notifyTabsChanged` 等价逻辑); - **不设置 `activeTabId='chat'`**;`restoreSideTaskTabs` 后续执行时按 `sideSessionId` 去重、不覆盖已激活 Tab(该函数当前不写 `activeTabId`,基础可用)。 - `SideTaskItem` 记录:搜索组件同时调 `sessionStore.addSideTask(parentId, { id, title, phase: item.phase || 'IDLE', modelId })`——**phase 用搜索结果真实值**(v1 硬编码 `'IDLE'` 会让运行中/已完成/待审批的边路任务显示错误状态);**不传 `createdAt`**(搜索结果只有 `updatedAt`,不得用更新时间冒充创建时间)。 **4.9 `side-task-tabs.ts` 新增 `unmarkSideTaskClosed`** 与 `markSideTaskClosed` 对称:从 `localStorage` 的关闭记录中移除该 `sideSessionId`。搜索打开边路 Tab 前调用。 **4.10 `SessionSearchPopover.vue`(新组件)** - 状态机:`closed` / 空关键词空态("输入关键词搜索会话")/ `loading` / `empty`("未找到相关会话")/ `error` / `results`。 - 交互:300ms 防抖;`↑`/`↓` 循环移动选中、`Enter` 跳转选中项、`Esc` 关闭;点击条目跳转;跳转后关闭浮窗并清空输入。 - 跳转: - `NORMAL`:`router.push('/tasks/' + item.id)`。 - `SIDE_TASK`:若当前路由已在父会话则直接 `openSideTaskTabFor`;否则 `await router.push('/tasks/' + parentSessionId)` 后调用(`loadSession` 异步完成与否不影响——Tab Map 模块级、按显式 parentSessionId 写入)。 - **高亮(不用 `v-html`,防 XSS)**:对 snippet 与关键词做大小写不敏感 `indexOf` 计算匹配区间,生成"普通文本段 / 命中段"数组,模板用插值 + `` 渲染;服务端 snippet 只作纯文本展示。 - 相对时间:**不声称复用公共工具**(现有 `formatElapsed` 内联在 `TaskIndexPanel.vue` 且基于 `createdAt`,与搜索的 `updatedAt` 语义不同);搜索组件内自建简单实现(刚刚 / N分 / N小时 / N天 / N月 / N年),展示 `updatedAt`。 - 响应式尺寸(安卓窄屏适配): ```css width: min(420px, calc(100vw - 24px)); max-height: min(480px, calc(100vh - var(--aw-nav-height) - 24px)); ``` - 安卓软键盘场景验证:输入框可见、结果列表可滚动、点击结果不被键盘遮挡、safe-area 正常。 **4.11 `TopNav.vue` 集成** - `nav-right` 首部插入放大镜图标按钮(tooltip "搜索会话 (Ctrl+K)"),绑定 `ElPopover`; - 接入 4.6/4.7 的快捷键与未登录处理。 **4.12 E2E**(`tests/desktop.spec.ts`,修正 v1 描述) - 现有文件**没有 `login()` 辅助函数**,采用 `mockDesktopApiFallback`(`page.route('**/api/v1/**')`)mock API: - 在 fallback 中**显式增加 `/sessions/search` 分支**(pathname `includes('/sessions/search')`,返回构造的搜索结果);注意与既有 `endsWith('/sessions')` 判断互斥——`/sessions/search` 以 `/search` 结尾不会误命中 `/sessions` 分支,但需显式注册避免落到 `data: null` 兜底; - 预置登录 token(`page.addInitScript` 写 localStorage,参考现有主题测试注入方式)或复用当前文件已有认证方式; - 用例:点击图标/触发快捷键打开浮窗 → 输入关键词 → 断言请求参数(`keyword`)→ 断言结果渲染、关键词高亮 → 断言主会话跳转路由; - 边路会话跳转用例:断言父路由 `/tasks/{parentId}` 且边路 Tab 被激活; - 未登录用例:断言未登录访问 `/` 停在 `/login`,且不发起 `/sessions/search` 请求。 **4.13 收尾** - 更新根目录 `CHANGELOG.md`:`### 后端`(搜索接口)、`### 前端(桌面 / Web / 安卓)`(顶部搜索浮窗)各记一条。 - 后端重启由用户执行(Agent 不重启服务);前端走 `deploy-desktop.sh` 部署。 ## 5. 落地清单(验收标准) | # | 验收项 | 判定 | |---|--------|------| | 1 | 后端 `GET /v1/sessions/search` 可用且单测全绿 | `mvn test` 通过 | | 2 | 顶部栏出现放大镜图标,点击弹出搜索浮窗 | 手动验证 | | 3 | `Ctrl/Cmd+K` 开/关浮窗,与终端快捷键互不干扰;Android 不注册快捷键 | 手动验证 | | 4 | 输入关键词(防抖 300ms)自动搜索,命中 user 消息文本 | 手动验证 | | 5 | 最多 20 条,按 `updated_at` 倒序、同时间按 id 倒序 | 手动 + 单测 | | 6 | 仅主会话 + 有效边路会话;子代理、归档、孤儿边路(父删/父归档/父非 NORMAL/父属他人)不出现 | 单测覆盖 | | 7 | 结果条目含标题 / 类型标签 / 相对时间 / 命中片段(围绕关键词生成,必然包含并高亮) | 手动验证 | | 8 | 关键词位于长消息中段时,snippet 仍包含并高亮关键词 | 单测 + 手动 | | 9 | 多模态消息不会因图片 URL / JSON 字段产生不可解释的命中 | 单测覆盖 | | 10 | 点击主会话跳转 `/tasks/{id}` 并加载 | 手动验证 | | 11 | 点击边路会话跳转父会话并打开对应边路 Tab,phase 显示真实值 | 手动验证 | | 12 | 主动搜索打开曾关闭的边路 Tab 后,刷新仍保持可恢复(`unmarkSideTaskClosed` 生效) | 手动验证 | | 13 | 连续快速输入时旧请求不覆盖新结果;关闭浮窗后在途请求返回不重新弹窗 | 手动 + 单测(组件逻辑) | | 14 | `↑`/`↓`/`Enter`/`Esc` 键盘交互、点击外部关闭 | 手动验证 | | 15 | 空关键词 / 无结果 / 加载中 / 接口异常四态明确;空关键词不发请求 | 手动验证 | | 16 | `%`、`_`、`\` 按普通字符匹配(ESCAPE 生效) | 单测 | | 17 | 英文大小写匹配与数据库 collation 一致 | 单测 | | 18 | 未登录访问 `/` 停在登录页,不直接调用搜索接口 | E2E | | 19 | 关键词命中消息已逻辑删除时不返回 | 单测 | | 20 | 深色主题与安卓窄屏 / 软键盘场景布局正常(`min()`/`max()` 尺寸) | 手动验证 | | 21 | 上线前 `EXPLAIN ANALYZE` 与典型数据量 P95 响应记录到位 | 实现记录 | | 22 | Playwright 搜索用例(含边路跳转、未登录)通过 | `npm test` | | 23 | `CHANGELOG.md` 已记录后端与前端条目 | 文件检查 | ## 6. 风险与边界 - **搜索性能**:`LIKE '%kw%'` 无法走内容索引,但限定单用户 + 候选 `LIMIT 20` + 仅 2 次 SQL(无 N+1),当前量级可控;上线前按 4.5 做 `EXPLAIN ANALYZE` 与 P95 记录,预案见 4.5。 - **多模态假命中的残余影响**:SQL 第一层可能多取假命中候选,第二层纯文本校验剔除后结果可能不足 20 条(需求为"最多 20 条",允许)。若未来出现"搜索带图消息的文本但结果被 JSON 字段抢占候选名额"的体验问题,可再评估独立可搜索纯文本列(预案,不在本次)。 - **孤儿边路**:`SessionService.deleteSession` 不级联删边路会话,搜索侧已用 `EXISTS` 父会话校验兜底,跳转路径不会落到不存在的父会话。 - **跳转竞态**:边路跳转先用 `router.push` 再用显式 `parentSessionId` 写模块级 Tab Map,与 `TaskView` 的 `loadSession` 解耦;`restoreSideTaskTabs` 只合并不覆盖激活 Tab。 - **XSS**:snippet 仅作纯文本插值渲染,高亮用区间计算 + ``,不使用 `v-html`。