diff --git a/.vscode/settings.json b/.vscode/settings.json index a476674e2..a2f9276a9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,5 @@ "i18n-ally.namespace": true, "i18n-ally.pathMatcher": "{locale}/{namespaces}.json", "unocss.disable": true, - "typescript.experimental.useTsgo": true + "js/ts.experimental.useTsgo": true } diff --git a/docs/specs/app-spotlight-search/plan.md b/docs/specs/app-spotlight-search/plan.md new file mode 100644 index 000000000..cd701d07e --- /dev/null +++ b/docs/specs/app-spotlight-search/plan.md @@ -0,0 +1,300 @@ +# App Spotlight Search 实施计划 + +## 1. 当前实现基线 + +### 1.1 已有搜索能力 + +1. 当前 PR 已在 `ChatPage.vue` 中实现会话内 inline 搜索,支持: + - `Cmd/Ctrl+F` + - 当前消息正文高亮 + - Enter / Shift+Enter 导航 + - Esc 关闭 +2. 消息正文 DOM 已统一暴露 `data-message-content="true"`,可作为会话内搜索与消息跳转高亮的稳定边界。 +3. 左侧 `WindowSideBar.vue` 已支持会话标题过滤与局部高亮,但范围仅限当前会话列表。 + +### 1.2 快捷键现状 + +1. `src/main/presenter/configPresenter/shortcutKeySettings.ts` 已维护 renderer/system 级默认快捷键。 +2. `ShortcutPresenter` 负责主进程注册与重注册快捷键。 +3. `SHORTCUT_EVENTS` 已经承载 main -> renderer 的快捷键分发。 + +### 1.3 设置导航现状 + +1. 设置窗口独立于主聊天窗口。 +2. `src/renderer/settings/main.ts` 已维护设置页路由,并在 `meta` 中提供 `titleKey`、`icon`、`position`。 +3. `SETTINGS_EVENTS.NAVIGATE` 已支持主进程打开/聚焦设置窗口后导航到某个设置页。 + +### 1.4 历史数据现状 + +1. 会话和消息已经持久化在 SQLite 相关链路中。 +2. 当前没有统一给 UI 使用的“历史全文搜索”主进程服务。 +3. 当前 PR 的会话内搜索主要基于 renderer DOM,而非历史索引。 + +## 2. 设计决策 + +### 2.1 功能边界 + +Spotlight 作为 follow-up feature 分两层: + +1. **主进程历史搜索** + - 只负责会话 / 历史消息检索 + - 输出结构化命中结果 +2. **渲染层统一混排** + - 将历史搜索结果与 `agent / setting / action` 本地条目合并 + - 统一排序、裁剪、active item 管理与执行行为 + +这样可以避免把 Agent / Setting / Action 这些天然本地导航项也塞进数据库索引。 + +### 2.2 默认快捷键 + +最终采用: + +- `QuickSearch = CommandOrControl+P` + +原因: + +1. 当前会话内搜索已经使用 `Cmd/Ctrl+F` +2. Spotlight 是“全局跳转器”,语义更接近 `Cmd/Ctrl+P` +3. 避免同一个快捷键在 inline search 与 Spotlight 之间抢焦点 + +### 2.3 搜索索引表 + +新增两张表: + +1. `deepchat_search_documents` + - `entity_kind` + - `entity_id` + - `session_id` + - `title` + - `body` + - `role` + - `updated_at` +2. `deepchat_search_documents_fts` + - 对 `title/body` 做 FTS5 全文索引 + +用途分离: + +1. 普通表作为真实数据表与降级查询源 +2. FTS 表只负责全文索引与匹配性能 + +### 2.4 索引数据来源 + +#### 会话文档 + +- `entity_kind = 'session'` +- `entity_id = session.id` +- `session_id = session.id` +- `title = session.title` +- `body = ''` + +#### 消息文档 + +- `entity_kind = 'message'` +- `entity_id = message.id` +- `session_id = message.sessionId` +- `title = session.title`(冗余存储,便于结果展示和排序) +- `body = 可见文本` +- `role = user | assistant` + +### 2.5 可见文本抽取规则 + +需要抽离一个可复用的“消息可见文本抽取”模块,供: + +1. Spotlight 历史搜索 +2. 未来 MCP history search +3. 可能的导出 / snippet 生成复用 + +抽取规则: + +1. 用户消息: + - 优先取结构化 `content[].type === 'text'` + - 回退到 `text` +2. 助手消息: + - 只收集 `content` 类型可见文本 + - 收集 `error` block 文本 + - 可选收集 `plan` 可见文本 +3. 忽略: + - tool call 原始 JSON + - image/audio 元数据 + - search result raw payload + - streaming 中但未落库内容 + +### 2.6 查询与降级 + +查询顺序: + +1. 优先 FTS5 +2. 若 FTS 执行失败,或 query 被 tokenizer 拒绝,则回退到 `LIKE` + +降级目标: + +1. 任何输入都尽量返回可用结果 +2. 避免特殊字符 query 直接“零结果 + 无解释” + +### 2.7 结果类型与共享接口 + +新增共享类型: + +1. `HistorySearchOptions` +2. `HistorySearchSessionHit` +3. `HistorySearchMessageHit` +4. `HistorySearchHit` + +新增 Presenter 接口: + +- `INewAgentPresenter.searchHistory(query, options?)` + +说明: + +1. Presenter 只返回 `session | message` 命中 +2. `agent | setting | action` 在 renderer 本地组装为 `SpotlightItem` + +### 2.8 设置导航 registry + +为避免把设置搜索逻辑散落在 Spotlight 组件中,新增一个共享 registry: + +- `routeName` +- `titleKey` +- `icon` +- `keywords[]` + +数据来源优先复用 `src/renderer/settings/main.ts` 路由元信息,必要时抽成共享常量,让: + +1. 设置窗口侧栏 +2. Spotlight setting items + +消费同一份元数据,避免标题 / icon 漂移。 + +### 2.9 Renderer 状态与执行流 + +新增 `spotlight store`,最少维护: + +1. `open` +2. `query` +3. `results` +4. `activeIndex` +5. `loading` +6. `requestSeq` +7. `pendingMessageJump` + +执行流程: + +1. 打开面板 -> 聚焦输入框 +2. 输入 80ms debounce +3. 发起历史搜索请求 +4. 本地合并 `agent / setting / action` +5. renderer 做统一排序和截断 +6. Enter / click 执行目标 + +### 2.10 消息跳转串联 + +`message` 结果执行后: + +1. 先写入 `pendingMessageJump = { sessionId, messageId }` +2. 切换会话 +3. Chat 页在消息加载完成后消费 `pendingMessageJump` +4. 找到目标消息 DOM +5. 滚动到目标 +6. 高亮约 2 秒 +7. 清空 `pendingMessageJump` + +这样可以避免在会话尚未激活、消息尚未加载完成时提前滚动失败。 + +## 3. 事件流 + +### 3.1 快捷键打开 + +1. `ShortcutPresenter` 注册 `QuickSearch` +2. 触发时发 `SHORTCUT_EVENTS.TOGGLE_SPOTLIGHT` +3. 主聊天 renderer 的 `App.vue` 或 Spotlight host 监听该事件 +4. 切换 Spotlight open 状态 + +### 3.2 设置命中跳转 + +1. renderer 执行 setting item +2. 调用主进程打开/聚焦设置窗口 +3. 发送 `SETTINGS_EVENTS.NAVIGATE` +4. 设置窗口跳到对应页面 + +### 3.3 Message 命中跳转 + +1. renderer 执行 message item +2. 写入 `pendingMessageJump` +3. 调用现有 session 选择逻辑 +4. `ChatPage` 在消息加载完成后消费跳转 + +## 4. 测试策略 + +### 4.1 Main + +1. session title hit 与 message hit 的排序基础正确 +2. 会话重命名后索引更新 +3. 消息编辑 / 删除后索引更新 +4. legacy import / schema rebuild 后全量回填可用 +5. FTS 查询失败时自动降级到 `LIKE` + +### 4.2 Renderer + +1. `Cmd/Ctrl+P` 打开 / `Esc` 关闭 +2. 输入框自动聚焦 +3. `↑/↓/Home/End/Enter` 全链路 +4. 混排列表稳定,不因 hover 破坏 active item +5. `message` 结果切会话后成功滚动并高亮 +6. `agent / setting / action` 执行正确 +7. 空查询 recent/agents/actions 可见 + +### 4.3 验收场景 + +1. sidebar 收起时 Spotlight 仍可用 +2. 有查询时最多显示 12 条结果 +3. 结果 hover 与 click 行为自然 +4. 设置窗口已有焦点时,从主聊天窗口触发 Spotlight 仍只打开主聊天窗口里的唯一实例 + +## 5. 风险与缓解 + +### 风险 1:FTS 索引同步遗漏 + +如果消息编辑 / 删除 / 导入路径漏掉同步,搜索结果会漂移。 + +缓解: + +1. 把索引同步集中到统一服务 +2. 对 create/update/delete/import 分别补 main tests + +### 风险 2:Spotlight 与 inline chat search 快捷键冲突 + +缓解: + +1. Spotlight 默认改为 `Cmd/Ctrl+P` +2. 保留 inline chat search 的 `Cmd/Ctrl+F` +3. 在快捷键设置页显式展示两者 + +### 风险 3:设置页 metadata 分散 + +缓解: + +1. 抽共享 registry +2. 让设置窗口与 Spotlight 共同消费 + +### 风险 4:消息跳转时序不稳定 + +缓解: + +1. 用 `pendingMessageJump` 记录目标 +2. 只在会话激活 + 消息加载完成后消费 + +### 风险 5:大库搜索性能抖动 + +缓解: + +1. 历史查询只请求 `session | message` +2. renderer 最多渲染 12 条 +3. 使用 debounce + requestSeq 丢弃过期结果 + +## 6. 质量门槛 + +1. `pnpm run format` +2. `pnpm run i18n` +3. `pnpm run lint` +4. `pnpm run typecheck` +5. 相关 main / renderer 测试通过 diff --git a/docs/specs/app-spotlight-search/spec.md b/docs/specs/app-spotlight-search/spec.md new file mode 100644 index 000000000..77b95d081 --- /dev/null +++ b/docs/specs/app-spotlight-search/spec.md @@ -0,0 +1,178 @@ +# App Spotlight Search 规格 + +## 概述 + +新增一个单实例、App 级、Raycast 风格的 Spotlight 搜索面板,用于统一搜索: + +1. 会话标题 +2. 历史消息 +3. Agent +4. 设置页面 +5. 少量非破坏性动作 + +该面板作为当前“会话内搜索”的上层能力补充: + +- 当前会话内搜索继续保留轻量 inline 体验 +- Spotlight 负责全局检索与快速跳转 +- 两者不共享 UI,但允许复用消息文本抽取与跳转高亮能力 + +## 背景与动机 + +1. 当前 PR 已经补齐“当前会话内搜索 + 侧边栏标题过滤”,但还缺少“跨历史 / 跨导航项”的统一入口。 +2. 用户希望通过一个全局面板快速跳到会话、历史消息、Agent、设置页,而不是分别在不同区域查找。 +3. DeepChat 现有左侧 rail、欢迎页和浮层组件已经具备克制、柔和、半透明的视觉语言,Spotlight 应沿用这套风格,而不是引入一个新系统。 +4. 该功能会跨越主进程快捷键、历史索引、设置导航和会话跳转,适合先走 SDD,把产品与技术边界先固定下来。 + +## 关键决策 + +### 1. 快捷键默认值 + +为避免与已经落地的“当前会话 inline 搜索(`Cmd/Ctrl+F`)”冲突,Spotlight v1 默认快捷键定为: + +- `QuickSearch = CommandOrControl+P` + +同时: + +- 保留 `Cmd/Ctrl+F` 作为“当前会话内搜索” +- `QuickSearch` 进入现有快捷键设置页,允许用户自定义 + +### 2. UI 定位 + +Spotlight 是: + +- 单实例 +- 主聊天窗口顶层 modal overlay +- 不在设置窗口再做第二套面板 + +### 3. 搜索范围 + +Spotlight 搜索默认忽略当前 sidebar 的 agent 过滤,始终面向“全量历史 + 全量导航项”。 + +### 4. 设置项粒度 + +v1 只搜索“设置页面”,不下钻到单个开关或表单项。 + +### 5. 动作范围 + +v1 只包含非破坏性动作: + +- New Chat +- Open Settings +- 跳到 Providers / Agents / MCP / Shortcuts / Remote 等设置页 + +## 用户故事 + +### US-1:全局快速唤起 + +作为用户,我希望在主聊天窗口内按 `Cmd/Ctrl+P` 就能呼出统一搜索面板,而不必先决定去侧边栏、设置页还是聊天正文里找。 + +### US-2:统一搜索结果列表 + +作为用户,我希望在一个稳定的列表里同时看到 session / message / agent / setting / action 结果,并通过键盘连续浏览,不被多段分组打断。 + +### US-3:消息级跳转 + +作为用户,我希望命中某条历史消息后,应用能自动切到对应会话、等待消息加载完成、滚动到目标消息并做短暂高亮,而不是只打开会话顶部。 + +### US-4:快速导航 + +作为用户,我希望 Spotlight 能直接跳到 Agent、设置页和少量常用动作,从而减少点击层级。 + +### US-5:空查询可用 + +作为用户,我希望即使不输入关键词,也能看到最近会话、常用 Agent 和常用动作,从而把 Spotlight 当成轻量启动器使用。 + +## 功能需求 + +### A. 唤起与关闭 + +- [ ] 新增快捷键配置项 `QuickSearch` +- [ ] `QuickSearch` 默认值为 `CommandOrControl+P` +- [ ] 主进程注册并分发 `SHORTCUT_EVENTS.TOGGLE_SPOTLIGHT` +- [ ] 左侧 rail 增加常驻 Spotlight 入口 +- [ ] 面板打开时自动聚焦输入框 +- [ ] `Esc` 关闭面板 + +### B. 键盘与鼠标交互 + +- [ ] `↑/↓` 切换 active item +- [ ] `Home/End` 跳首尾 +- [ ] `Enter` 执行 active item +- [ ] 鼠标 hover 会同步 active item +- [ ] 鼠标 click 执行当前项 +- [ ] 键盘 active item 与 hover 状态不得互相打架 + +### C. 结果形态 + +- [ ] 空查询展示 `Recent Sessions + Agents + Actions` +- [ ] 有查询时展示单一混排结果列表 +- [ ] 每个结果都有 `kind pill`,仅使用: + - [ ] `Session` + - [ ] `Message` + - [ ] `Agent` + - [ ] `Setting` + - [ ] `Action` +- [ ] 单次请求最多渲染 12 条结果 + +### D. 命中行为 + +- [ ] `session` 命中切到目标会话 +- [ ] `message` 命中切到目标会话,并在消息加载完成后滚动到目标消息并高亮约 2 秒 +- [ ] `agent` 命中复用现有侧边栏切换逻辑 +- [ ] `setting` 命中打开/聚焦设置窗口并导航到页面 +- [ ] `action` 命中只执行非破坏性动作 + +### E. 历史索引 + +- [ ] 主进程提供统一的历史搜索入口 +- [ ] 默认使用 SQLite FTS5 做全文索引 +- [ ] FTS 失败或 query 不适配 tokenizer 时自动降级到 `LIKE` +- [ ] 首次启动或 schema 变更时支持全量回填 +- [ ] 会话与消息变更路径支持增量同步 + +### F. 索引内容边界 + +- [ ] 会话索引包含用户可见标题 +- [ ] 用户消息只索引可见文本 +- [ ] 助手消息只索引可见内容块与错误文本 +- [ ] 不索引工具调用原始 JSON +- [ ] 不索引图片元数据 +- [ ] 不索引尚未持久化的 streaming 临时内容 + +### G. 排序 + +- [ ] 标题前缀命中优先于名称前缀命中 +- [ ] 名称前缀命中优先于正文命中 +- [ ] `session` 结果略高于 `message` +- [ ] 同层级结果叠加轻量 recency boost + +## 验收标准 + +- [ ] 在主聊天窗口按 `Cmd/Ctrl+P` 可打开 Spotlight;按 `Esc` 可关闭 +- [ ] 空查询时能看到 Recent Sessions、Agents、Actions +- [ ] 输入关键词后,结果列表混排展示 `Session / Message / Agent / Setting / Action` +- [ ] 命中会话可直接切换;命中消息会切到会话并滚动到目标消息 +- [ ] 命中 Agent 时可复用现有 agent 切换流程 +- [ ] 命中设置页时会聚焦或打开设置窗口并导航到正确页面 +- [ ] FTS 查询失败时仍能通过降级查询返回结果 +- [ ] Spotlight 在 sidebar 收起状态下仍可用 + +## 非目标 + +1. v1 不做 prefix scopes(如 `> / @ / #`)。 +2. v1 不做设置项级搜索,仅支持设置页面级导航。 +3. v1 不提供 destructive actions。 +4. v1 不覆盖设置窗口内部的独立 Spotlight UI。 +5. v1 不索引未持久化的 streaming 消息。 + +## 约束 + +1. 保持现有 Presenter + EventBus + preload IPC 架构。 +2. 所有用户可见文案必须走 i18n。 +3. 视觉风格沿用现有侧边栏 / 欢迎页的半透明卡片样式:`rounded-2xl + border + bg-card/40 + backdrop-blur`。 +4. 动效保持轻量,并尊重 `prefers-reduced-motion`。 +5. Spotlight 与当前 inline chat search 不能互相抢占默认快捷键。 + +## 开放问题 + +无。 diff --git a/docs/specs/app-spotlight-search/tasks.md b/docs/specs/app-spotlight-search/tasks.md new file mode 100644 index 000000000..156b76444 --- /dev/null +++ b/docs/specs/app-spotlight-search/tasks.md @@ -0,0 +1,84 @@ +# App Spotlight Search Tasks + +## T0 规格与设计 + +- [x] 完成 `docs/specs/app-spotlight-search/spec.md` +- [x] 完成 `docs/specs/app-spotlight-search/plan.md` +- [x] 完成 `docs/specs/app-spotlight-search/tasks.md` + +## T1 快捷键与事件 + +- [ ] 新增快捷键配置项 `QuickSearch` +- [ ] 默认值设为 `CommandOrControl+P` +- [ ] 新增 `SHORTCUT_EVENTS.TOGGLE_SPOTLIGHT` +- [ ] `ShortcutPresenter` 注册 / 重注册 Spotlight 快捷键 +- [ ] 快捷键设置页展示并允许修改 `QuickSearch` + +## T2 历史搜索服务 + +- [ ] 抽离“消息可见文本抽取”公共逻辑 +- [ ] 新增 `deepchat_search_documents` 普通表 +- [ ] 新增 `deepchat_search_documents_fts` FTS5 虚表 +- [ ] 实现首次回填 / schema rebuild +- [ ] 实现会话创建 / 重命名 / 删除的索引同步 +- [ ] 实现消息写入 / 编辑 / 删除的索引同步 +- [ ] 实现 FTS 失败回退到 `LIKE` + +## T3 Presenter 与共享类型 + +- [ ] 新增 `HistorySearchOptions` +- [ ] 新增 `HistorySearchHit / SessionHit / MessageHit` +- [ ] `INewAgentPresenter` 增加 `searchHistory(query, options?)` +- [ ] 补充共享类型导出 + +## T4 设置导航 registry + +- [ ] 抽取设置页共享 registry +- [ ] 字段包含 `routeName / titleKey / icon / keywords[]` +- [ ] 设置窗口侧栏复用该 registry +- [ ] Spotlight setting items 复用该 registry + +## T5 Spotlight Renderer 状态 + +- [ ] 新增 `spotlight store` +- [ ] 管理 `open/query/results/activeIndex/loading/requestSeq/pendingMessageJump` +- [ ] 输入 80ms debounce +- [ ] 按 requestSeq 丢弃过期响应 +- [ ] 结果截断到 12 条 + +## T6 Spotlight UI + +- [ ] 新增主聊天窗口顶层 Spotlight overlay +- [ ] 沿用 `rounded-2xl + border + bg-card/40 + backdrop-blur` 视觉样式 +- [ ] 左侧 rail 增加 Spotlight 入口 +- [ ] 空查询展示 `Recent Sessions + Agents + Actions` +- [ ] 查询态展示单一混排结果列表 +- [ ] 增加 `kind pill` +- [ ] 支持 `Esc / ↑ / ↓ / Home / End / Enter / hover / click` +- [ ] 尊重 `prefers-reduced-motion` + +## T7 执行行为 + +- [ ] `session` 命中切会话 +- [ ] `message` 命中写入 `pendingMessageJump` +- [ ] `ChatPage` 在消息加载完成后滚动并高亮目标消息 +- [ ] `agent` 命中复用现有侧栏切换逻辑 +- [ ] `setting` 命中打开 / 聚焦设置窗口并导航 +- [ ] `action` 命中只执行非破坏性动作 + +## T8 测试 + +- [ ] main tests:排序、回填、增量同步、降级查询 +- [ ] renderer tests:打开关闭、自动聚焦、键盘链路 +- [ ] renderer tests:混排与去重 +- [ ] renderer tests:message jump + scroll highlight +- [ ] renderer tests:agent / setting / action 执行 +- [ ] 验收场景:sidebar 收起、空查询、设置窗口聚焦 + +## T9 质量检查 + +- [ ] `pnpm run format` +- [ ] `pnpm run i18n` +- [ ] `pnpm run lint` +- [ ] `pnpm run typecheck` +- [ ] 运行相关 main / renderer 测试 diff --git a/src/main/events.ts b/src/main/events.ts index 77da1c471..5c2ec8b80 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -186,6 +186,7 @@ export const SHORTCUT_EVENTS = { ZOOM_RESUME: 'shortcut:zoom-resume', CREATE_NEW_WINDOW: 'shortcut:create-new-window', CREATE_NEW_CONVERSATION: 'shortcut:create-new-conversation', + TOGGLE_SPOTLIGHT: 'shortcut:toggle-spotlight', GO_SETTINGS: 'shortcut:go-settings', CLEAN_CHAT_HISTORY: 'shortcut:clean-chat-history', DELETE_CONVERSATION: 'shortcut:delete-conversation' diff --git a/src/main/presenter/configPresenter/shortcutKeySettings.ts b/src/main/presenter/configPresenter/shortcutKeySettings.ts index 769b776e3..3fb017c11 100644 --- a/src/main/presenter/configPresenter/shortcutKeySettings.ts +++ b/src/main/presenter/configPresenter/shortcutKeySettings.ts @@ -6,6 +6,7 @@ const ShiftKey = 'Shift' // Below are regular shortcut key definitions export const rendererShortcutKey = { NewConversation: `${CommandKey}+N`, + QuickSearch: `${CommandKey}+P`, NewWindow: `${CommandKey}+${ShiftKey}+N`, CloseWindow: `${CommandKey}+W`, ZoomIn: `${CommandKey}+=`, diff --git a/src/main/presenter/newAgentPresenter/index.ts b/src/main/presenter/newAgentPresenter/index.ts index 9b9456a21..0b1d776a6 100644 --- a/src/main/presenter/newAgentPresenter/index.ts +++ b/src/main/presenter/newAgentPresenter/index.ts @@ -27,6 +27,10 @@ import type { SearchResult } from '@shared/types/core/search' import type { AcpConfigState, IConfigPresenter, + HistorySearchHit, + HistorySearchOptions, + HistorySearchSessionHit, + HistorySearchMessageHit, ILlmProviderPresenter, ISkillPresenter, CONVERSATION @@ -60,6 +64,22 @@ import { import { rtkRuntimeService } from '@/lib/agentRuntime/rtkRuntimeService' import { resolveAcpAgentAlias } from '../configPresenter/acpRegistryConstants' +type SearchableSessionRow = { + id: string + title: string + projectDir: string | null + updatedAt: number +} + +type SearchableMessageRow = { + id: string + sessionId: string + title: string + role: 'user' | 'assistant' + content: string + updatedAt: number +} + const RETIRED_DEFAULT_AGENT_TOOLS = new Set(['find', 'grep', 'ls']) const LEGACY_AGENT_TOOL_NAME_MAP: Record = { yo_browser_cdp_send: 'cdp_send', @@ -67,6 +87,131 @@ const LEGACY_AGENT_TOOL_NAME_MAP: Record = { yo_browser_window_list: 'get_browser_status' } +const clampHistorySearchLimit = (value: number | undefined): number => { + if (typeof value !== 'number' || Number.isNaN(value)) { + return 12 + } + + return Math.min(Math.max(Math.floor(value), 1), 50) +} + +const normalizeSearchText = (value: string): string => value.trim().toLowerCase() +const SESSION_SEARCH_OVERQUERY_FACTOR = 2 +const MESSAGE_SEARCH_OVERQUERY_FACTOR = 4 + +const buildSearchSnippet = (content: string, query: string, maxLength: number = 120): string => { + const normalizedContent = content.trim() + if (!normalizedContent) { + return '' + } + + const lowerContent = normalizedContent.toLowerCase() + const lowerQuery = query.toLowerCase() + const index = lowerContent.indexOf(lowerQuery) + + if (index === -1) { + return normalizedContent.length > maxLength + ? normalizedContent.slice(0, maxLength).trimEnd() + '…' + : normalizedContent + } + + const start = Math.max(0, index - 48) + const end = Math.min(normalizedContent.length, index + query.length + 48) + let snippet = normalizedContent.slice(start, end).trim() + + if (start > 0) { + snippet = '…' + snippet + } + if (end < normalizedContent.length) { + snippet += '…' + } + + return snippet +} + +const scoreSessionHit = (session: SearchableSessionRow, normalizedQuery: string): number => { + const title = session.title.toLowerCase() + if (title.startsWith(normalizedQuery)) { + return 400 + } + if (title.includes(normalizedQuery)) { + return 320 + } + return 0 +} + +const scoreMessageHit = (message: SearchableMessageRow, normalizedQuery: string): number => { + const title = message.title.toLowerCase() + const content = message.content.toLowerCase() + + if (title.startsWith(normalizedQuery)) { + return 280 + } + if (title.includes(normalizedQuery)) { + return 220 + } + if (content.startsWith(normalizedQuery)) { + return 180 + } + if (content.includes(normalizedQuery)) { + return 140 + } + return 0 +} + +const extractSearchableMessageContent = (rawContent: string): string => { + try { + const parsed = JSON.parse(rawContent) as + | { text?: string; content?: Array<{ type?: string; text?: string }> } + | Array<{ + type?: string + content?: string + text?: string + error?: string + }> + + if (Array.isArray(parsed)) { + const segments = parsed + .flatMap((block) => { + if (!block || typeof block !== 'object') { + return [] + } + + const values = [block.content, block.text, block.error] + return values.filter( + (value): value is string => typeof value === 'string' && !!value.trim() + ) + }) + .map((value) => value.trim()) + + if (segments.length > 0) { + return segments.join('\n') + } + } else if (parsed && typeof parsed === 'object') { + if (typeof parsed.text === 'string' && parsed.text.trim()) { + return parsed.text.trim() + } + + if (Array.isArray(parsed.content)) { + const segments = parsed.content + .filter( + (item): item is { type?: string; text?: string } => + typeof item?.text === 'string' && item.text.trim().length > 0 + ) + .map((item) => item.text!.trim()) + + if (segments.length > 0) { + return segments.join('\n') + } + } + } + } catch { + // Plain-text messages are expected here; fall through and return the raw string content. + } + + return rawContent +} + export class NewAgentPresenter { private agentRegistry: AgentRegistry private sessionManager: NewSessionManager @@ -786,6 +931,98 @@ export class NewAgentPresenter { return agent.getMessages(sessionId) } + async searchHistory(query: string, options?: HistorySearchOptions): Promise { + const normalizedQuery = normalizeSearchText(query) + if (!normalizedQuery) { + return [] + } + + const limit = clampHistorySearchLimit(options?.limit) + const db = this.sqlitePresenter.getDatabase() + if (!db) { + return [] + } + + const likeQuery = `%${normalizedQuery}%` + + const sessionRows = db + .prepare( + ` + SELECT + id, + title, + project_dir AS projectDir, + updated_at AS updatedAt + FROM new_sessions + WHERE session_kind = 'regular' + AND lower(title) LIKE ? + ORDER BY updated_at DESC + LIMIT ? + ` + ) + // Pull a slightly larger working set so this method can score and trim cleaner matches. + .all(likeQuery, limit * SESSION_SEARCH_OVERQUERY_FACTOR) as SearchableSessionRow[] + + const messageRows = db + .prepare( + ` + SELECT + m.id AS id, + m.session_id AS sessionId, + s.title AS title, + m.role AS role, + m.content AS content, + m.updated_at AS updatedAt + FROM deepchat_messages m + INNER JOIN new_sessions s + ON s.id = m.session_id + WHERE s.session_kind = 'regular' + AND lower(m.content) LIKE ? + ORDER BY m.updated_at DESC + LIMIT ? + ` + ) + // Message hits are noisier than title hits, so fetch more candidates before final sorting here. + .all(likeQuery, limit * MESSAGE_SEARCH_OVERQUERY_FACTOR) as SearchableMessageRow[] + + const sessionHits: Array = sessionRows + .map((session) => ({ + kind: 'session' as const, + sessionId: session.id, + title: session.title, + projectDir: session.projectDir, + updatedAt: Number(session.updatedAt ?? 0), + score: scoreSessionHit(session, normalizedQuery) + })) + .filter((item) => item.score > 0) + + const messageHits: Array = messageRows + .map((message) => { + const content = extractSearchableMessageContent(message.content) + return { + kind: 'message' as const, + sessionId: message.sessionId, + messageId: message.id, + title: message.title, + role: message.role, + snippet: buildSearchSnippet(content, normalizedQuery), + updatedAt: Number(message.updatedAt ?? 0), + score: scoreMessageHit({ ...message, content }, normalizedQuery) + } + }) + .filter((item) => item.score > 0) + + return [...sessionHits, ...messageHits] + .sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score + } + return right.updatedAt - left.updatedAt + }) + .slice(0, limit) + .map(({ score: _score, ...item }) => item) + } + async getSessionCompactionState(sessionId: string): Promise { const session = this.sessionManager.get(sessionId) if (!session) { diff --git a/src/main/presenter/shortcutPresenter.ts b/src/main/presenter/shortcutPresenter.ts index 23be2a1ff..d68f2db83 100644 --- a/src/main/presenter/shortcutPresenter.ts +++ b/src/main/presenter/shortcutPresenter.ts @@ -43,6 +43,31 @@ export class ShortcutPresenter implements IShortcutPresenter { }) } + if (this.shortcutKeys.QuickSearch) { + globalShortcut.register(this.shortcutKeys.QuickSearch, async () => { + const focusedWindow = presenter.windowPresenter.getFocusedWindow() + if (!focusedWindow?.isFocused()) { + return + } + + const settingsWindowId = presenter.windowPresenter.getSettingsWindowId() + const targetWindow = + settingsWindowId != null && focusedWindow.id === settingsWindowId + ? presenter.windowPresenter.mainWindow + : focusedWindow + + if (!targetWindow) { + return + } + + presenter.windowPresenter.show(targetWindow.id, true) + void presenter.windowPresenter.sendToWebContents( + targetWindow.webContents.id, + SHORTCUT_EVENTS.TOGGLE_SPOTLIGHT + ) + }) + } + // Command+Shift+N 或 Ctrl+Shift+N 创建新窗口 if (this.shortcutKeys.NewWindow) { globalShortcut.register(this.shortcutKeys.NewWindow, () => { diff --git a/src/main/presenter/sqlitePresenter/index.ts b/src/main/presenter/sqlitePresenter/index.ts index 1d41056a4..e1cf019fb 100644 --- a/src/main/presenter/sqlitePresenter/index.ts +++ b/src/main/presenter/sqlitePresenter/index.ts @@ -121,6 +121,10 @@ export class SQLitePresenter implements ISQLitePresenter { return this.messagesTable.deleteAllInConversation(conversationId) } + public getDatabase(): Database.Database { + return this.db + } + private backupDatabase(): void { const timestamp = new Date().toISOString().replace(/[:.]/g, '-') const backupPath = `${this.dbPath}.${timestamp}.bak` diff --git a/src/main/presenter/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index efc53e5c0..3940e1740 100644 --- a/src/main/presenter/windowPresenter/index.ts +++ b/src/main/presenter/windowPresenter/index.ts @@ -8,10 +8,15 @@ import { webContents as electronWebContents } from 'electron' import { join } from 'path' +import { pathToFileURL } from 'url' import icon from '../../../../resources/icon.png?asset' // App icon (macOS/Linux) import iconWin from '../../../../resources/icon.ico?asset' // App icon (Windows) import { is } from '@electron-toolkit/utils' // Electron utilities import { IConfigPresenter, IWindowPresenter } from '@shared/presenter' // Window Presenter interface +import { + resolveSettingsNavigationPath, + type SettingsNavigationPayload +} from '@shared/settingsNavigation' import { eventBus } from '@/eventbus' // Event bus import { CONFIG_EVENTS, @@ -1189,12 +1194,17 @@ export class WindowPresenter implements IWindowPresenter { /** * Create or show Settings Window (singleton pattern) */ - public async createSettingsWindow(): Promise { + public async createSettingsWindow( + navigation?: SettingsNavigationPayload + ): Promise { // If settings window already exists, just show and focus it if (this.settingsWindow && !this.settingsWindow.isDestroyed()) { console.log('Settings window already exists, showing and focusing.') this.settingsWindow.show() this.settingsWindow.focus() + if (navigation) { + this.sendToWindow(this.settingsWindow.id, SETTINGS_EVENTS.NAVIGATE, navigation) + } return this.settingsWindow.id } @@ -1283,10 +1293,12 @@ export class WindowPresenter implements IWindowPresenter { } }) - settingsWindow.webContents.on('did-start-loading', () => { - if (this.settingsWindow?.id === windowId) { - this.settingsWindowReady = false - } + settingsWindow.webContents.on('did-start-navigation', (details) => { + this.handleSettingsWindowNavigationStart( + windowId, + details.isMainFrame, + details.isSameDocument + ) }) settingsWindow.on('closed', () => { @@ -1298,16 +1310,26 @@ export class WindowPresenter implements IWindowPresenter { }) // Load settings renderer HTML + const initialNavigationPath = navigation + ? resolveSettingsNavigationPath(navigation.routeName, navigation.params) + : null + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - console.log( - `Loading settings renderer URL in dev mode: ${process.env['ELECTRON_RENDERER_URL']}/settings/index.html` - ) - await settingsWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/settings/index.html') + const settingsUrl = new URL('/settings/index.html', process.env['ELECTRON_RENDERER_URL']) + if (initialNavigationPath) { + settingsUrl.hash = initialNavigationPath + } + console.log(`Loading settings renderer URL in dev mode: ${settingsUrl.toString()}`) + await settingsWindow.loadURL(settingsUrl.toString()) } else { - console.log( - `Loading packaged settings renderer file: ${join(__dirname, '../renderer/settings/index.html')}` - ) - await settingsWindow.loadFile(join(__dirname, '../renderer/settings/index.html')) + const packagedSettingsUrl = pathToFileURL( + join(__dirname, '../renderer/settings/index.html') + ).toString() + const targetUrl = initialNavigationPath + ? `${packagedSettingsUrl}#${initialNavigationPath}` + : packagedSettingsUrl + console.log(`Loading packaged settings renderer URL: ${targetUrl}`) + await settingsWindow.loadURL(targetUrl) } // Open DevTools in development mode @@ -1386,6 +1408,18 @@ export class WindowPresenter implements IWindowPresenter { this.flushPendingSettingsMessages() } + private handleSettingsWindowNavigationStart( + windowId: number, + isMainFrame: boolean, + isSameDocument: boolean + ): void { + if (!isMainFrame || isSameDocument || this.settingsWindow?.id !== windowId) { + return + } + + this.settingsWindowReady = false + } + private flushPendingSettingsMessages(): void { if ( !this.settingsWindow || diff --git a/src/renderer/settings/App.vue b/src/renderer/settings/App.vue index ab4f08679..3452c800c 100644 --- a/src/renderer/settings/App.vue +++ b/src/renderer/settings/App.vue @@ -86,6 +86,8 @@ import { useFontManager } from '../src/composables/useFontManager' import type { LLM_PROVIDER, ProviderInstallPreview } from '@shared/presenter' import ProviderDeeplinkImportDialog from './components/ProviderDeeplinkImportDialog.vue' import { nanoid } from 'nanoid' +import { SETTINGS_NAVIGATION_ITEMS } from '@shared/settingsNavigation' +import type { SettingsNavigationPayload } from '@shared/settingsNavigation' const devicePresenter = usePresenter('devicePresenter') const windowPresenter = usePresenter('windowPresenter') @@ -145,15 +147,43 @@ const navigateToProviderSettings = async (providerId?: string) => { }) } -const handleSettingsNavigate = async ( - _event: unknown, - payload?: { routeName?: string; section?: string } -) => { +const normalizeRouteParams = (params?: Record) => + Object.entries(params ?? {}) + .filter(([, value]) => typeof value === 'string' && value.trim().length > 0) + .reduce>((acc, [key, value]) => { + acc[key] = value + return acc + }, {}) + +const hasSameRouteParams = ( + currentParams: Record, + nextParams: Record +): boolean => { + const currentEntries = Object.entries(currentParams).filter( + ([, value]) => typeof value === 'string' + ) + const nextEntries = Object.entries(nextParams) + + if (currentEntries.length !== nextEntries.length) { + return false + } + + return nextEntries.every(([key, value]) => currentParams[key] === value) +} + +const handleSettingsNavigate = async (_event: unknown, payload?: SettingsNavigationPayload) => { const routeName = payload?.routeName + const params = normalizeRouteParams(payload?.params) if (!routeName || !router.hasRoute(routeName)) return await router.isReady() - if (router.currentRoute.value.name !== routeName) { - await router.push({ name: routeName }) + if ( + router.currentRoute.value.name !== routeName || + !hasSameRouteParams(router.currentRoute.value.params, params) + ) { + await router.push({ + name: routeName, + params: Object.keys(params).length > 0 ? params : undefined + }) } if (routeName === 'settings-provider') { await syncPendingProviderInstall() @@ -312,38 +342,17 @@ const settings: Ref< icon: string path: string }[] -> = ref([]) +> = ref( + SETTINGS_NAVIGATION_ITEMS.map((item) => ({ + title: item.titleKey, + name: item.routeName, + icon: item.icon, + path: item.path + })) +) -// Get all routes and build settings navigation -const routes = router.getRoutes() onMounted(() => { void initializeSettingsStores() - const tempArray: { - title: string - name: string - icon: string - path: string - position: number - }[] = [] - routes.forEach((route) => { - // In settings window, all routes are top-level, no parent 'settings' route - if (route.path !== '/' && route.meta?.titleKey) { - console.log(`Adding settings route: ${route.path} with titleKey: ${route.meta.titleKey}`) - tempArray.push({ - title: route.meta.titleKey as string, - icon: route.meta.icon as string, - path: route.path, - name: route.name as string, - position: (route.meta.position as number) || 999 - }) - } - // Sort by position meta field, default to 999 if not present - tempArray.sort((a, b) => { - return a.position - b.position - }) - settings.value = tempArray - console.log('Final sorted settings routes:', settings.value) - }) }) const initializeSettingsStores = async () => { diff --git a/src/renderer/settings/components/ShortcutSettings.vue b/src/renderer/settings/components/ShortcutSettings.vue index bb6085d10..9a7ca40fc 100644 --- a/src/renderer/settings/components/ShortcutSettings.vue +++ b/src/renderer/settings/components/ShortcutSettings.vue @@ -184,6 +184,10 @@ const shortcutMapping: Record< icon: 'lucide:plus-square', label: 'settings.shortcuts.newConversation' }, + QuickSearch: { + icon: 'lucide:search', + label: 'settings.shortcuts.quickSearch' + }, NewWindow: { icon: 'lucide:app-window', label: 'settings.shortcuts.newWindow' diff --git a/src/renderer/settings/main.ts b/src/renderer/settings/main.ts index 17805411a..b2dd83442 100644 --- a/src/renderer/settings/main.ts +++ b/src/renderer/settings/main.ts @@ -9,6 +9,26 @@ import { createRouter, createWebHashHistory } from 'vue-router' import { createI18n } from 'vue-i18n' import locales from '@/i18n' +import { SETTINGS_NAVIGATION_ITEMS } from '@shared/settingsNavigation' + +const settingsRouteComponents = { + 'settings-common': () => import('./components/CommonSettings.vue'), + 'settings-display': () => import('./components/DisplaySettings.vue'), + 'settings-environments': () => import('./components/EnvironmentsSettings.vue'), + 'settings-provider': () => import('./components/ModelProviderSettings.vue'), + 'settings-dashboard': () => import('./components/DashboardSettings.vue'), + 'settings-mcp': () => import('./components/McpSettings.vue'), + 'settings-deepchat-agents': () => import('./components/DeepChatAgentsSettings.vue'), + 'settings-acp': () => import('./components/AcpSettings.vue'), + 'settings-remote': () => import('./components/RemoteSettings.vue'), + 'settings-notifications-hooks': () => import('./components/NotificationsHooksSettings.vue'), + 'settings-skills': () => import('./components/skills/SkillsSettings.vue'), + 'settings-prompt': () => import('./components/PromptSetting.vue'), + 'settings-knowledge-base': () => import('./components/KnowledgeBaseSettings.vue'), + 'settings-database': () => import('./components/DataSettings.vue'), + 'settings-shortcut': () => import('./components/ShortcutSettings.vue'), + 'settings-about': () => import('./components/AboutUsSettings.vue') +} as const // Create i18n instance const i18n = createI18n({ @@ -22,166 +42,16 @@ const i18n = createI18n({ const router = createRouter({ history: createWebHashHistory(import.meta.env.BASE_URL), routes: [ - { - path: '/common', - name: 'settings-common', - component: () => import('./components/CommonSettings.vue'), - meta: { - titleKey: 'routes.settings-common', - icon: 'lucide:bolt', - position: 1 - } - }, - { - path: '/display', - name: 'settings-display', - component: () => import('./components/DisplaySettings.vue'), - meta: { - titleKey: 'routes.settings-display', - icon: 'lucide:monitor', - position: 2 - } - }, - { - path: '/environments', - name: 'settings-environments', - component: () => import('./components/EnvironmentsSettings.vue'), - meta: { - titleKey: 'routes.settings-environments', - icon: 'lucide:folders', - position: 2.5 - } - }, - { - path: '/provider/:providerId?', - name: 'settings-provider', - component: () => import('./components/ModelProviderSettings.vue'), - meta: { - titleKey: 'routes.settings-provider', - icon: 'lucide:cloud-cog', - position: 3 - } - }, - { - path: '/dashboard', - name: 'settings-dashboard', - component: () => import('./components/DashboardSettings.vue'), - meta: { - titleKey: 'routes.settings-dashboard', - icon: 'lucide:layout-dashboard', - position: 4.5 - } - }, - { - path: '/mcp', - name: 'settings-mcp', - component: () => import('./components/McpSettings.vue'), - meta: { - titleKey: 'routes.settings-mcp', - icon: 'lucide:server', - position: 5 - } - }, - { - path: '/deepchat-agents', - name: 'settings-deepchat-agents', - component: () => import('./components/DeepChatAgentsSettings.vue'), - meta: { - titleKey: 'routes.settings-deepchat-agents', - icon: 'lucide:bot', - position: 3.5 - } - }, - { - path: '/acp', - name: 'settings-acp', - component: () => import('./components/AcpSettings.vue'), - meta: { - titleKey: 'routes.settings-acp', - icon: 'lucide:shield-check', - position: 4 - } - }, - { - path: '/remote', - name: 'settings-remote', - component: () => import('./components/RemoteSettings.vue'), - meta: { - titleKey: 'routes.settings-remote', - icon: 'lucide:smartphone', - position: 5.25 - } - }, - { - path: '/notifications-hooks', - name: 'settings-notifications-hooks', - component: () => import('./components/NotificationsHooksSettings.vue'), - meta: { - titleKey: 'routes.settings-notifications-hooks', - icon: 'lucide:bell', - position: 5.5 - } - }, - { - path: '/skills', - name: 'settings-skills', - component: () => import('./components/skills/SkillsSettings.vue'), - meta: { - titleKey: 'routes.settings-skills', - icon: 'lucide:wand-sparkles', - position: 6 - } - }, - { - path: '/prompt', - name: 'settings-prompt', - component: () => import('./components/PromptSetting.vue'), - meta: { - titleKey: 'routes.settings-prompt', - icon: 'lucide:book-open-text', - position: 7 - } - }, - { - path: '/knowledge-base', - name: 'settings-knowledge-base', - component: () => import('./components/KnowledgeBaseSettings.vue'), - meta: { - titleKey: 'routes.settings-knowledge-base', - icon: 'lucide:book-marked', - position: 8 - } - }, - { - path: '/database', - name: 'settings-database', - component: () => import('./components/DataSettings.vue'), - meta: { - titleKey: 'routes.settings-database', - icon: 'lucide:database', - position: 9 - } - }, - { - path: '/shortcut', - name: 'settings-shortcut', - component: () => import('./components/ShortcutSettings.vue'), - meta: { - titleKey: 'routes.settings-shortcut', - icon: 'lucide:keyboard', - position: 10 - } - }, - { - path: '/about', - name: 'settings-about', - component: () => import('./components/AboutUsSettings.vue'), + ...SETTINGS_NAVIGATION_ITEMS.map((item) => ({ + path: item.path, + name: item.routeName, + component: settingsRouteComponents[item.routeName], meta: { - titleKey: 'routes.settings-about', - icon: 'lucide:info', - position: 11 + titleKey: item.titleKey, + icon: item.icon, + position: item.position } - }, + })), { path: '/', redirect: '/common' diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 72876c021..16c4f6b8d 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -26,6 +26,8 @@ import { useFontManager } from './composables/useFontManager' import AppBar from '@/components/AppBar.vue' import { useDeviceVersion } from '@/composables/useDeviceVersion' import WindowSideBar from './components/WindowSideBar.vue' +import SpotlightOverlay from '@/components/spotlight/SpotlightOverlay.vue' +import { useSpotlightStore } from '@/stores/ui/spotlight' const DEV_WELCOME_OVERRIDE_KEY = '__deepchat_dev_force_welcome' @@ -36,6 +38,7 @@ const sessionStore = useSessionStore() const agentStore = useAgentStore() const draftStore = useDraftStore() const pageRouterStore = usePageRouterStore() +const spotlightStore = useSpotlightStore() const { toast } = useToast() const uiSettingsStore = useUiSettingsStore() const { setupFontListener } = useFontManager() @@ -225,7 +228,7 @@ const handleCreateNewConversation = async () => { await sessionStore.closeSession() return } - pageRouterStore.goToNewThread() + pageRouterStore.goToNewThread({ refresh: true }) } catch (error) { console.error('Failed to create new conversation:', error) } @@ -264,7 +267,7 @@ const activatePendingStartDeeplink = async () => { return } - pageRouterStore.goToNewThread() + pageRouterStore.goToNewThread({ refresh: true }) processedStartDeeplinkToken.value = token } finally { if (processingStartDeeplinkToken.value === token) { @@ -344,6 +347,10 @@ onMounted(() => { void handleCreateNewConversation() }) + window.electron.ipcRenderer.on(SHORTCUT_EVENTS.TOGGLE_SPOTLIGHT, () => { + spotlightStore.openSpotlight() + }) + // GO_SETTINGS is now handled in main process (open/focus Settings tab) window.electron.ipcRenderer.on(NOTIFICATION_EVENTS.DATA_RESET_COMPLETE_DEV, () => { @@ -423,6 +430,7 @@ onBeforeUnmount(() => { window.electron.ipcRenderer.removeAllListeners(SHORTCUT_EVENTS.ZOOM_OUT) window.electron.ipcRenderer.removeAllListeners(SHORTCUT_EVENTS.ZOOM_RESUME) window.electron.ipcRenderer.removeAllListeners(SHORTCUT_EVENTS.CREATE_NEW_CONVERSATION) + window.electron.ipcRenderer.removeAllListeners(SHORTCUT_EVENTS.TOGGLE_SPOTLIGHT) // GO_SETTINGS listener removed; handled in main window.electron.ipcRenderer.removeAllListeners(NOTIFICATION_EVENTS.SYS_NOTIFY_CLICKED) window.electron.ipcRenderer.removeAllListeners(NOTIFICATION_EVENTS.DATA_RESET_COMPLETE_DEV) @@ -456,6 +464,7 @@ onBeforeUnmount(() => { +
+ + + + + {{ t('chat.spotlight.placeholder') }} + + + + +
-

{{ t('chat.sidebar.emptyTitle') }}

+

+ {{ + sessionSearchQuery ? t('chat.sidebar.searchEmptyTitle') : t('chat.sidebar.emptyTitle') + }} +

- {{ t('chat.sidebar.emptyDescription') }} + {{ + sessionSearchQuery + ? t('chat.sidebar.searchEmptyDescription') + : t('chat.sidebar.emptyDescription') + }}

@@ -189,6 +243,7 @@ region="pinned" :hero-hidden="pinFlightSessionId === session.id" :pin-feedback-mode="pinFeedbackSessionId === session.id ? pinFeedbackMode : null" + :search-query="sessionSearchQuery" @select="handleSessionClick" @toggle-pin="handleTogglePin" @delete="openDeleteDialog" @@ -229,6 +284,7 @@ region="grouped" :hero-hidden="pinFlightSessionId === session.id" :pin-feedback-mode="pinFeedbackSessionId === session.id ? pinFeedbackMode : null" + :search-query="sessionSearchQuery" @select="handleSessionClick" @toggle-pin="handleTogglePin" @delete="openDeleteDialog" @@ -269,6 +325,7 @@ import { TooltipTrigger } from '@shadcn/components/ui/tooltip' import { Button } from '@shadcn/components/ui/button' +import { Input } from '@shadcn/components/ui/input' import { Dialog, DialogContent, @@ -280,7 +337,9 @@ import { import { usePresenter, useRemoteControlPresenter } from '@/composables/usePresenter' import { SETTINGS_EVENTS } from '@/events' import { useAgentStore } from '@/stores/ui/agent' +import { usePageRouterStore } from '@/stores/ui/pageRouter' import { useSessionStore, type SessionGroup, type UISession } from '@/stores/ui/session' +import { useSpotlightStore } from '@/stores/ui/spotlight' import type { TelegramRemoteStatus, FeishuRemoteStatus, @@ -299,9 +358,12 @@ const windowPresenter = usePresenter('windowPresenter') const remoteControlPresenter = useRemoteControlPresenter() const { t } = useI18n() const agentStore = useAgentStore() +const pageRouterStore = usePageRouterStore() const sessionStore = useSessionStore() +const spotlightStore = useSpotlightStore() const collapsed = ref(false) +const sessionSearchQuery = ref('') const remoteControlStatus = ref<{ telegram: TelegramRemoteStatus | null feishu: FeishuRemoteStatus | null @@ -381,8 +443,27 @@ const remoteControlIconClass = computed(() => { const isPinnedSectionCollapsed = ref(false) const collapsedGroupIds = ref>(new Set()) -const pinnedSessions = computed(() => sessionStore.getPinnedSessions(agentStore.selectedAgentId)) -const filteredGroups = computed(() => sessionStore.getFilteredGroups(agentStore.selectedAgentId)) +const normalizedSessionSearchQuery = computed(() => sessionSearchQuery.value.trim().toLowerCase()) +const matchesSessionSearch = (session: UISession) => { + if (!normalizedSessionSearchQuery.value) { + return true + } + + return session.title.toLowerCase().includes(normalizedSessionSearchQuery.value) +} +const pinnedSessions = computed(() => + sessionStore.getPinnedSessions(agentStore.selectedAgentId).filter(matchesSessionSearch) +) +const filteredGroups = computed(() => + sessionStore + .getFilteredGroups(agentStore.selectedAgentId) + .map((group) => ({ + label: group.label, + labelKey: group.labelKey, + sessions: group.sessions.filter(matchesSessionSearch) + })) + .filter((group) => group.sessions.length > 0) +) const pinFlightSessionId = ref(null) const pinFeedbackSessionId = ref(null) const pinFeedbackMode = ref(null) @@ -520,7 +601,12 @@ const refreshRemoteControlStatus = async () => { } const handleNewChat = () => { - void sessionStore.closeSession() + if (sessionStore.hasActiveSession) { + void sessionStore.closeSession() + return + } + + pageRouterStore.goToNewThread({ refresh: true }) } const handleAgentSelect = async (id: string | null) => { diff --git a/src/renderer/src/components/WindowSideBarSessionItem.vue b/src/renderer/src/components/WindowSideBarSessionItem.vue index 3579519d0..c0bc65482 100644 --- a/src/renderer/src/components/WindowSideBarSessionItem.vue +++ b/src/renderer/src/components/WindowSideBarSessionItem.vue @@ -18,6 +18,7 @@ const props = defineProps<{ region: SessionItemRegion heroHidden?: boolean pinFeedbackMode?: PinFeedbackMode | null + searchQuery?: string }>() const emit = defineEmits<{ @@ -32,6 +33,46 @@ const { session, active } = toRefs(props) const pinActionLabel = computed(() => session.value.isPinned ? t('thread.actions.unpin') : t('thread.actions.pin') ) + +const titleSegments = computed(() => { + const title = session.value.title + const query = props.searchQuery?.trim() + if (!query) { + return [{ text: title, match: false }] + } + + const lowerTitle = title.toLowerCase() + const lowerQuery = query.toLowerCase() + const segments: Array<{ text: string; match: boolean }> = [] + let searchIndex = 0 + let matchIndex = lowerTitle.indexOf(lowerQuery) + + while (matchIndex !== -1) { + if (matchIndex > searchIndex) { + segments.push({ + text: title.slice(searchIndex, matchIndex), + match: false + }) + } + + segments.push({ + text: title.slice(matchIndex, matchIndex + query.length), + match: true + }) + + searchIndex = matchIndex + query.length + matchIndex = lowerTitle.indexOf(lowerQuery, searchIndex) + } + + if (searchIndex < title.length) { + segments.push({ + text: title.slice(searchIndex), + match: false + }) + } + + return segments.length > 0 ? segments : [{ text: title, match: false }] +})