From ab3b3731b75fe69baf702f5f6390e7ecba564f52 Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Fri, 11 Sep 2026 17:44:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E9=97=AE=E9=97=AE?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E5=B9=B6=E5=AE=8C=E5=96=84=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E5=BA=93=E5=A2=9E=E9=87=8F=E6=A3=80=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一问问微信与 Agent Hub 的查询链路 完善查询覆盖度、新鲜度和部分结果表达,避免索引滞后产生错误结论 基于会话实现真正的增量追新与历史补齐 支持后台同步、取消恢复、重启续传以及同步期间继续查询 优化知识库跨会话检索、同步状态、进度展示和侧栏布局 --- docs/agent/api.md | 48 + docs/concepts/answer-sources.md | 4 +- docs/user-guide/ai-search.md | 13 + docs/user-guide/knowledge.md | 25 +- src/main/index.ts | 144 +++ .../knowledge/knowledge-search-service.ts | 597 +++++++++-- src/main/knowledge/knowledge-service.ts | 10 + src/main/knowledge/knowledge-store.ts | 199 +++- src/main/knowledge/knowledge-worker-host.ts | 33 +- src/main/knowledge/knowledge-worker.ts | 27 +- src/main/query-agent-poc-cli.ts | 88 +- src/main/query-agent-poc-entry.ts | 48 +- src/main/query-agent-poc-report.ts | 6 +- src/main/services/agent-hub-routing.ts | 125 +++ src/main/services/agent-hub-service.ts | 446 ++------- .../services/ai-search-pipeline-service.ts | 5 + src/main/services/ask-wechat-service.ts | 276 +++++ src/main/services/chat-service.ts | 84 ++ src/main/services/local-query-api-service.ts | 632 +++++++++++- .../services/local-query-tool-executor.ts | 59 ++ src/main/services/messages-around.ts | 54 + .../query-agent-conversation-memory.ts | 70 ++ ...-poc-service.ts => query-agent-service.ts} | 335 ++++++- src/main/services/settings-store.ts | 9 +- src/main/wcdb4-client.ts | 44 + src/preload/index.d.ts | 21 +- src/preload/index.ts | 45 + src/renderer/src/App.tsx | 57 +- src/renderer/src/components/ChatWindow.tsx | 26 +- .../src/components/chat/MessageList.tsx | 25 +- .../src/components/chat/messageJump.ts | 51 + .../components/search/AISearchComposer.tsx | 17 +- .../components/search/AISearchWorkspace.tsx | 647 +++++++++--- .../search/askWechatPresentation.ts | 110 ++ .../search/hooks/useEvidenceCollection.ts | 6 +- .../search/hooks/useKnowledgeStatus.ts | 52 +- .../search/hooks/useQueryAgentProgress.ts | 110 ++ .../src/components/search/queryAgentBridge.ts | 47 + .../src/components/search/searchFormatters.ts | 67 +- .../src/components/search/searchMappers.ts | 13 + .../src/components/search/searchTypes.ts | 16 +- .../src/components/search/searchUtils.ts | 5 +- src/renderer/src/styles/search.scss | 111 ++- src/shared/knowledge.ts | 121 ++- src/shared/local-query-api.ts | 230 ++++- src/shared/query-agent.ts | 197 ++++ src/shared/types.ts | 17 + .../ai-search-cache-consent.test.tsx | 33 +- tests/component/ai-search-composer.test.tsx | 11 +- .../ai-search-evidence-hook.test.tsx | 16 +- .../component/ai-search-query-agent.test.tsx | 461 +++++++++ .../ai-search-workspace-regression.test.tsx | 322 +++++- tests/unit/agent-hub-routing.test.ts | 117 +++ ...ai-search-workspace-pure-functions.test.ts | 60 +- tests/unit/ask-wechat-presentation.test.ts | 94 ++ tests/unit/ask-wechat-service.test.ts | 490 +++++++++ tests/unit/knowledge-freshness.test.ts | 118 +++ tests/unit/knowledge-search-service.test.ts | 939 +++++++++++++++++- tests/unit/knowledge-store.test.ts | 119 +++ tests/unit/local-query-api-service.test.ts | 361 ++++++- tests/unit/local-query-tool-executor.test.ts | 40 + tests/unit/message-jump.test.ts | 93 ++ tests/unit/message-ref.test.ts | 121 +++ .../query-agent-conversation-memory.test.ts | 60 ++ tests/unit/query-agent-history.test.ts | 77 ++ tests/unit/query-agent-poc-cli.test.ts | 39 +- tests/unit/query-agent-poc-report.test.ts | 4 +- tests/unit/query-agent-progress.test.ts | 87 ++ ...ce.test.ts => query-agent-service.test.ts} | 181 +++- 69 files changed, 8317 insertions(+), 898 deletions(-) create mode 100644 src/main/services/agent-hub-routing.ts create mode 100644 src/main/services/ask-wechat-service.ts create mode 100644 src/main/services/local-query-tool-executor.ts create mode 100644 src/main/services/messages-around.ts create mode 100644 src/main/services/query-agent-conversation-memory.ts rename src/main/services/{query-agent-poc-service.ts => query-agent-service.ts} (69%) create mode 100644 src/renderer/src/components/chat/messageJump.ts create mode 100644 src/renderer/src/components/search/askWechatPresentation.ts create mode 100644 src/renderer/src/components/search/hooks/useQueryAgentProgress.ts create mode 100644 src/renderer/src/components/search/queryAgentBridge.ts create mode 100644 src/shared/query-agent.ts create mode 100644 tests/component/ai-search-query-agent.test.tsx create mode 100644 tests/unit/agent-hub-routing.test.ts create mode 100644 tests/unit/ask-wechat-presentation.test.ts create mode 100644 tests/unit/ask-wechat-service.test.ts create mode 100644 tests/unit/knowledge-freshness.test.ts create mode 100644 tests/unit/local-query-tool-executor.test.ts create mode 100644 tests/unit/message-jump.test.ts create mode 100644 tests/unit/message-ref.test.ts create mode 100644 tests/unit/query-agent-conversation-memory.test.ts create mode 100644 tests/unit/query-agent-history.test.ts create mode 100644 tests/unit/query-agent-progress.test.ts rename tests/unit/{query-agent-poc-service.test.ts => query-agent-service.test.ts} (82%) diff --git a/docs/agent/api.md b/docs/agent/api.md index 25b792d..a4c8906 100644 --- a/docs/agent/api.md +++ b/docs/agent/api.md @@ -146,3 +146,51 @@ curl -X POST -H "$AUTH" -H 'Content-Type: application/json' "$BASE/query/convers 每条消息都会返回 `messageType`(`text`、`image`、`voice`、`video`、`file`、`link`、`sticker`、`system` 或 `other`)。非文本消息不会伪造 `text`;可识别的图片、视频、贴纸和文件会返回不含密钥或本地路径的 `attachment` 元数据。 `conversation-overview` 同时返回 `sourceCoverage` 与 `selection`:前者描述时间范围内源消息是否完整及 `sourceMessageCount`,后者描述从源消息中选出的 Evidence 数量及是否抽样。`evidence` 最终按 `timestamp` 升序返回,`messageRef` 是唯一推荐的消息引用。 +`conversation-overview` 另有一个 `origin` 字段:`wcdb` 表示这次证据直接来自本机聊天数据库(会话概览的事实来源),`knowledge` 表示来自本地索引。 + +### 搜索范围(scope) + +`query/messages`、`query/search`、`query/message-context` 和 `query/conversation-overview` 都接受一个可选的 `scope`,用来把检索限制在一个确定的语料边界内: + +| scope | 含义 | +| ----- | ---- | +| `{"kind":"all"}` | 所有可读会话(默认;省略 `scope` 等价于此) | +| `{"kind":"groups"}` | 只搜群聊语料,**且包含群成员实际发送的消息**(不是群名称或群元数据) | +| `{"kind":"contact","conversationId":"…"}` | 只搜该一对一会话 | +| `{"kind":"current","conversationId":"…"}` | 只搜指定的那个会话(单聊或群聊) | + +`conversationId` 是会话标识,可用 `/api/v1/resolve` 或 `/api/v1/contact` 得到。`scope` 一旦给出就是**权威边界**:`target` 落在范围之外会被拒绝(`status: "invalid_tool_arguments"`、`constraint: "target_outside_scope"`),不会静默扩大范围;范围里包含多个会话时,`query/messages` 与 `query/conversation-overview` 必须显式指定 `target`(`constraint: "target_required_for_scope"`)。 + +响应会回显实际生效的边界: + +```json +{ "scope": { "kind": "groups", "conversationCount": 243 } } +``` + +跨会话检索时,`evidence` 的每一项都会带上它所属的会话,便于把结果归属到具体群 / 联系人与具体成员: + +```json +{ + "messageRef": "…", + "conversationName": "某个群", + "conversationType": "group", + "sender": "某成员", + "timestamp": 1789099069000, + "text": "…" +} +``` + +### 索引新鲜度(freshness) + +`query/search` 依赖本地索引,而本地索引是异步建立的派生数据,可能落后于聊天数据库。因此它的响应会显式给出覆盖口径: + +| 字段 | 含义 | +| ---- | ---- | +| `indexLatestAt` | 索引目前覆盖到的源数据时间(epoch ms),`null` 表示无法判定 | +| `sourceLatestAt` | 聊天数据库里最新的活跃时间(epoch ms),`null` 表示无法判定 | +| `coverage.state` | `complete` 只在索引确实覆盖了所请求的时间范围时出现 | +| `freshness.catchUp` | 本次为追赶索引做了什么:`none` / `reused` / `completed` / `pending` | + +调用方**必须**把 `coverage` 当真:`coverage.state` 不是 `complete` 且 `evidence` 为空时,只能说明"这段范围暂时无法确认",**不能**下"没有找到"的结论。索引落后时服务端会自动请求一次追赶同步,但不会让请求无限等待;`freshness.catchUp` 为 `pending` 表示追赶仍在后台进行,稍后重试即可拿到更新的覆盖。 + +`query/messages` 与 `query/conversation-overview` 直读聊天数据库,不受索引新鲜度影响。 diff --git a/docs/concepts/answer-sources.md b/docs/concepts/answer-sources.md index a215d30..1b49383 100644 --- a/docs/concepts/answer-sources.md +++ b/docs/concepts/answer-sources.md @@ -28,12 +28,12 @@ AI 回答后,你可以继续查看它参考了哪些聊天内容、这些内 来源覆盖受时间范围、会话范围、索引状态和可读媒体影响。例如: -- Knowledge 正在同步时,新的分析会被暂停; +- Knowledge 还没追到最新时,跨会话检索只覆盖到索引当前的时间点,答案会标注这个范围; - 语音没有转写时,AI 可能只能看到消息类型; - 图片无法读取或未启用图片理解时,AI 不应声称知道图片内容; - 你只选择了一个群,答案不会自动代表所有聊天。 -看到“可能遗漏”或“部分覆盖”时,扩大范围、先完成同步或检查原始媒体后再问。 +Knowledge 在后台同步时**不会**暂停分析:你仍然可以提问,只是答案基于当前已可用的覆盖范围。看到“可能遗漏”或“部分覆盖”时,扩大范围、等同步追上或检查原始媒体后再问。 ## 这不是事实保证 diff --git a/docs/user-guide/ai-search.md b/docs/user-guide/ai-search.md index 50c3e93..3e14abf 100644 --- a/docs/user-guide/ai-search.md +++ b/docs/user-guide/ai-search.md @@ -41,6 +41,19 @@ 你可以点击来源回到档案中的原始消息。产品内部将这些信息称为 Evidence、Citation 和 Search Trace,用户可以把它们理解为“依据、来源标记和查找过程”。详见[如何核对 AI 的回答来源](../concepts/answer-sources.md)。 +## 查找过程和跳回原消息 + +查找过程中,界面依次显示真实阶段:**理解问题 → 查找相关聊天 → 整理证据 → 生成回答**。跨会话、大范围检索更慢时,副提示会写明“正在搜索较大范围的聊天记录…”。阶段只在真正进入下一步时前进,不使用定时器或百分比伪造进度。 + +查找结束后,界面给出耗时拆解:**总耗时**,以及其中分别花在 **AI 生成** 和 **本地查询** 上的时间。这样你能判断慢在哪——是模型在写答案,还是本机还在翻聊天记录。 + +点击来源卡片的 **“跳转到原聊天”** 会真的打开对应会话并定位到那条消息: + +- 群聊来源打开的是那个群,而不是群里某个联系人; +- 会加载该消息前后的上下文,并滚动到它、短暂高亮; +- 只加载目标消息附近的一段,不会把整个会话历史全部读出来; +- 如果这条消息已经不在本地(例如已被删除),界面会明确说明“已打开对应会话,但暂时无法定位原消息”,不会假装跳转成功。 + ## 什么时候不要直接相信答案 - 来源很少,或时间范围与问题不一致; diff --git a/docs/user-guide/knowledge.md b/docs/user-guide/knowledge.md index 5bd259d..d77f8f3 100644 --- a/docs/user-guide/knowledge.md +++ b/docs/user-guide/knowledge.md @@ -13,7 +13,30 @@ Knowledge 不会在第一次连接后自动悄悄建立。进入“问问微信 - **建立本地知识库**:第一次读取当前账号的可检索聊天; - **同步最新记录**:已有索引时,只补充新增或变化的内容。 -同步会在后台运行,完成后页面显示已索引消息、知识片段和磁盘占用。同步期间暂不能开始新的 AI 分析;同步异常时,旧索引仍可能可以继续使用。 +同步在后台运行,**期间仍然可以正常提问和分析**,不会被禁用。索引还没追完时,答案会基于当前已经可用的部分给出,并在界面标注覆盖范围。 + +知识库卡片同时显示两组互相独立的信息: + +- **规模**:已索引消息、知识片段、磁盘占用——说明索引有多大; +- **状态与本轮进度**:说明索引现在处于什么状态、这一轮同步在做什么(扫了多少、真正新增了多少、处理到第几个会话)。 + +`最新索引` 只表示索引已经覆盖到聊天记录的哪个时间点,**不等于**整库已经建完;进度里的计数是**本轮**的数字,不是全部历史的总数。 + +### 状态怎么读 + +| 状态 | 含义 | +| ---- | ---- | +| 可用 · 已追至最新 | 索引已覆盖到聊天记录的最新位置,可以直接用 | +| 可用 · 正在追新 | 索引可用,正在后台补充最近新增的消息 | +| 可用 · 正在补齐历史 | 索引可用,正在后台补齐较早的历史内容 | +| 可用 · 同步已取消 | 索引仍然可用;上一轮同步被取消,已建立的部分保留 | +| 可用 · 更新失败 | 索引仍然可用;上一轮同步出错,可以稍后重试 | + +只有确实追平、且没有待补齐内容时才会出现“已追至最新”。索引不可查询时不会显示“可用”。 + +### 取消和继续 + +同步过程中可以点击 **取消同步**(点击后显示“正在取消…”)。取消只结束当前这一轮,不会删除已经建立的索引,也不会回滚已完成的部分;下次同步会从上次停下的位置继续,不需要从头重扫。中断过的索引仍然可以正常搜索。 ## 账号隔离 diff --git a/src/main/index.ts b/src/main/index.ts index 949d29d..0bdbe04 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -157,14 +157,31 @@ import { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use- import { VoiceBatchService } from './voice-pipeline/voice-batch-service' import type { VoiceBatchRequest, VoiceMessageReference } from '../shared/voice-recognition' import type { AiSearchPipelineRequest } from '../shared/ai-search' +import type { + AskWechatConfig, + AskWechatQueryRequest, + AskWechatQueryResult +} from '../shared/query-agent' import type { KnowledgeSearchIpcRequest, KnowledgeSearchIpcResult } from '../shared/knowledge' +// 消息身份的规范化在 main / renderer 之间必须一致,所以只从 shared 取一份实现。 +import { normalizeMessageIdentity } from '../shared/local-query-api' import { isWindowsVcRuntimeMissingError, WINDOWS_VC_RUNTIME_ERROR_MESSAGE } from '../shared/windows-runtime' import { KnowledgeSearchService } from './knowledge/knowledge-search-service' +import { + MESSAGES_AROUND_MAX_WINDOW, + messagesAroundRadii, + normalizeRadiusSeconds, + sliceMessagesAroundWindow, + widenRadiusSeconds +} from './services/messages-around' import { LocalQueryApiService } from './services/local-query-api-service' import { AiSearchPipelineService } from './services/ai-search-pipeline-service' +import { QueryAgentService } from './services/query-agent-service' +import { AskWechatService } from './services/ask-wechat-service' +import { createLocalQueryToolExecutor } from './services/local-query-tool-executor' import { runLegacySafeStorageHelper } from './legacy-safe-storage-helper' import { runFirstLaunchMigration } from './app-data-migration' import { WechatShareConfigStore } from './wechat-share-config-store' @@ -185,6 +202,8 @@ let voiceBatchService: VoiceBatchService | null = null let knowledgeSearchService: KnowledgeSearchService | null = null let localQueryApiService: LocalQueryApiService | null = null let aiSearchPipelineService: AiSearchPipelineService | null = null +let queryAgentService: QueryAgentService | null = null +let askWechatService: AskWechatService | null = null let imageDecryptService: ImageDecryptService | null = null let stickerService: StickerService | null = null let videoAssetService: VideoAssetService | null = null @@ -618,6 +637,21 @@ app.whenReady().then(async () => { aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService) localQueryApiService = new LocalQueryApiService(knowledgeSearchService) setLocalQueryApiService(localQueryApiService) + // Query Agent:生产 Runtime 只在这里实例化一次,桌面问问微信与 Agent Hub 共用同一个实例。 + queryAgentService = new QueryAgentService( + aiProviderService, + createLocalQueryToolExecutor(localQueryApiService) + ) + askWechatService = new AskWechatService(queryAgentService, { + entry: 'desktop', + // Legacy 仅在 Runtime 不可恢复错误时使用(见 AskWechatService 的 fallback 规则)。 + runLegacy: (request) => { + if (!aiSearchPipelineService) throw new Error('本地搜索服务尚未初始化') + return aiSearchPipelineService.run(request, () => undefined) + }, + log: (record) => appLogger.write({ level: record.level, scope: 'query-agent', message: record.message, details: record.details }) + }) + agentHubService.setQueryAgentService(queryAgentService) knowledgeSearchService.onStatusChange((status) => { for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) window.webContents.send('knowledge:status', status) @@ -1150,6 +1184,83 @@ app.whenReady().then(async () => { } ) + /** + * 「跳转到原聊天」的锚点读取。 + * + * 与 `db:getMessages` 的区别:目标是**某一条消息**,不是"某个时间段"。 + * 以 `anchorSeconds` 为中心读一个**有界**窗口(默认 ±6h,找不到再放宽到 ±3d), + * 在窗口内按规范化消息 id 精确匹配。 + * + * 不整段加载会话历史:最大会话可达数十万条消息,整段读既慢又会挤爆 IPC; + * 而时间窗口在真实数据上是稀疏的,±6h 的量级很小。 + * + * `found: false` 表示"已打开会话但无法定位原消息",调用方必须走降级文案, + * 不能假装跳转成功。 + */ + ipcMain.handle( + 'db:getMessagesAround', + async ( + _, + userMd5: string, + messageId: string, + anchorSeconds?: number, + radiusSeconds?: number + ) => { + const requestId = nextGetMessagesRequestId() + const startedAt = Date.now() + const target = normalizeMessageIdentity(userMd5, messageId) + if (!target) return { messages: [], found: false, radiusSeconds: 0, truncated: false } + const baseRadius = normalizeRadiusSeconds(radiusSeconds) + const widenRadius = widenRadiusSeconds(baseRadius) + const maxWindowMessages = MESSAGES_AROUND_MAX_WINDOW + const radii = messagesAroundRadii(anchorSeconds, baseRadius, widenRadius) + if (!radii.length) { + // 没有时间锚点时**不能**退化成整段加载会话历史(最大会话可达数十万条, + // 整段读既慢又会挤爆 IPC)。诚实返回"未定位到",由调用方给出降级文案。 + wcdbDebugLog(`[${requestId}] IPC db:getMessagesAround skipped reason=no-anchor`) + return { messages: [], found: false, radiusSeconds: 0, truncated: false } + } + wcdbDebugLog( + `[${requestId}] IPC db:getMessagesAround start userMd5=${userMd5} messageId=${target.messageId} anchor=${anchorSeconds || 0}` + ) + for (const radius of radii) { + const start = Math.max(0, (anchorSeconds as number) - radius) + const end = (anchorSeconds as number) + radius + let messages: Awaited> + try { + messages = await chat.listMessagesAsync(userMd5, start, end, undefined, requestId) + } catch (error) { + wcdbDebugLog(`[${requestId}] IPC db:getMessagesAround error radius=${radius}`) + throw error + } + const { window, index, truncated } = sliceMessagesAroundWindow( + messages, + userMd5, + target.messageId, + maxWindowMessages + ) + if (index >= 0) { + if (chat.isReady()) { + saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, start, end, window) + } + wcdbDebugLog( + `[${requestId}] IPC db:getMessagesAround found radius=${radius} rows=${window.length} cost=${Date.now() - startedAt}ms` + ) + return { messages: window, found: true, radiusSeconds: radius, truncated } + } + // 窗口内没有这条消息:可能是时间戳口径漂移,也可能是它已经被删除/清理。 + // 只有还值得放宽时才继续,避免把一次点击变成全库扫描。 + if (radius === radii[radii.length - 1]) { + wcdbDebugLog( + `[${requestId}] IPC db:getMessagesAround not-found rows=${window.length} cost=${Date.now() - startedAt}ms` + ) + return { messages: window, found: false, radiusSeconds: radius, truncated } + } + } + return { messages: [], found: false, radiusSeconds: 0, truncated: false } + } + ) + ipcMain.handle('db:getGroupSnapshot', async (_, userMd5: string) => { const snapshot = await chat.getGroupSnapshotAsync(userMd5) if (snapshot && chat.isReady()) { @@ -1201,6 +1312,18 @@ app.whenReady().then(async () => { if (!knowledgeSearchService) throw new Error('本地知识库服务尚未初始化') return knowledgeSearchService.startCurrentAccountIndex() }) + /** + * 取消正在跑的索引 pass。 + * + * - 只中止**索引**:并发的交互查询用另一套 Abort scope,不会被连带取消; + * - 当前 batch 安全收尾:已提交的会话保留,**不回滚**; + * - `run_state` 落到 `cancelled`,不残留 `indexing`; + * - 取消 ≠ 清空:下一次同步从 per-conversation checkpoint 继续,不从头全量重扫。 + */ + ipcMain.handle('knowledge:cancelIndex', () => { + if (!knowledgeSearchService) throw new Error('本地知识库服务尚未初始化') + return knowledgeSearchService.cancelCurrentAccountIndex() + }) ipcMain.handle('ai-search:run', (event, request: AiSearchPipelineRequest) => { if (!aiSearchPipelineService) throw new Error('本地搜索服务尚未初始化') return aiSearchPipelineService.run(request, (progress) => { @@ -1218,6 +1341,27 @@ app.whenReady().then(async () => { } }) ipcMain.handle('ai-search:getProviderStatus', () => aiProviderService.getAiSearchProviderStatus()) + // 问问微信主路径:查询大脑走 Query Agent Runtime(与 Agent Hub 同一实现)。 + ipcMain.handle( + 'ask-wechat:getConfig', + (): AskWechatConfig => ({ queryAgentEnabled: loadSettings().queryAgentEnabled !== false }) + ) + ipcMain.handle( + 'ask-wechat:query', + (event, request: AskWechatQueryRequest): Promise => { + if (!askWechatService) throw new Error('本地查询服务尚未初始化') + // 进度事件只在真实 Runtime 边界产生,并且**带 requestId** 回传: + // UI 可以据此忽略过期请求的进度(用户连问两次时不会串台)。 + return askWechatService.ask(request, 'default', (progress) => { + if (!event.sender.isDestroyed()) { + event.sender.send('ask-wechat:progress', request.requestId, progress) + } + }) + } + ) + ipcMain.handle('ask-wechat:forgetConversation', () => { + askWechatService?.forgetConversation() + }) ipcMain.handle( 'ai-search:authorizeExternalProvider', (_, request: AiSearchExternalAuthorizationRequest) => { diff --git a/src/main/knowledge/knowledge-search-service.ts b/src/main/knowledge/knowledge-search-service.ts index 3977b8d..ced5755 100644 --- a/src/main/knowledge/knowledge-search-service.ts +++ b/src/main/knowledge/knowledge-search-service.ts @@ -1,8 +1,11 @@ +import { monitorEventLoopDelay } from 'perf_hooks' import * as chat from '../services/chat-service' import type { KnowledgeAttachmentMetadata, KnowledgeEvidence, KnowledgeMessageKind, + KnowledgePassProgress, + KnowledgeRuntimeState, KnowledgeRuntimeStatus, KnowledgeSearchRequest, KnowledgeSearchIpcRequest, @@ -32,6 +35,42 @@ const MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH = 700 const MAX_SENDER_ENRICHMENT_SESSIONS = 32 const SENDER_ENRICHMENT_SESSION_TTL_MS = 5 * 60 * 1000 +/** + * 增量读取时向前回看的 overlap(epoch ms)。 + * + * delta 下界 = `checkpoint - DELTA_OVERLAP_MS`,用来吸收 timestamp 边界碰撞。 + * 它必须远大于 chunker 的 `maxGapMs`,否则跨越下界的 chunk 会缺前半段消息、重建时丢前文。 + * + * ⚠️ 单位:这里是**毫秒**(checkpoint 本身是毫秒)。传给 WCDB 读取层之前必须换成 + * epoch **秒**(`chat.listMessagesAsync` 的 start/end 是秒)。混用会让 delta 读恒为空, + * 且**不会报错**。 + */ +const DELTA_OVERLAP_MS = 24 * 60 * 60 * 1000 + +/** + * event-loop 滞后采样的分辨率(ms)。 + * + * 用 `monitorEventLoopDelay` 的 histogram 而不是手写 setInterval:后者只能以 interval + * 为粒度发现 stall,给不出分位数。 + */ +const LAG_PROBE_RESOLUTION_MS = 10 + +/** histogram 的纳秒读数转毫秒。 */ +function roundMs(nanoseconds: number): number { + if (!Number.isFinite(nanoseconds) || nanoseconds <= 0) return 0 + return Math.round(nanoseconds / 1e6) +} + +/** WCDB 读取通道:`interactive` = 交互查询,`background` = 后台索引 pass。 */ +type WcdbReadLane = 'interactive' | 'background' + +type PendingWcdbRead = { + high: boolean + run: () => Promise + resolve: (value: unknown) => void + reject: (error: unknown) => void +} + type PendingVoiceTranscriptIndex = { update: VoiceTranscriptUpdate waiters: Array<{ @@ -43,7 +82,13 @@ type PendingVoiceTranscriptIndex = { type SenderEnrichmentSession = { lastUsedAt: number contacts?: Awaited> - groupSnapshots: Map> | undefined> + /** + * conversationId → (wxid → displayName)。 + * + * 只缓存"这个群里这些 wxid 解析出来是什么名字",不再缓存整群快照。 + * 空串表示「查过、确实没有可用名字」,用于避免同一 session 内重复查询。 + */ + groupMemberNames: Map> } function looksLikeOpaqueSenderId(value: string | undefined): boolean { @@ -203,7 +248,12 @@ export class KnowledgeSearchService { private readonly statusByAccount = new Map() private readonly statusListeners = new Set<(status: KnowledgeRuntimeStatus) => void>() private readonly senderEnrichmentSessions = new Map() - private wcdbReadTail: Promise = Promise.resolve() + /** WCDB 读取的两条通道:交互(查询)优先于后台(索引 pass)。 */ + private readonly wcdbPending: PendingWcdbRead[] = [] + private wcdbReadBusy = false + private interactiveQueryDepth = 0 + private interactiveIdle: Promise = Promise.resolve() + private interactiveIdleResolve: (() => void) | null = null private wcdbQueueMsTotal = 0 private wcdbExecutionMsTotal = 0 private voiceTranscriptResolver: @@ -212,6 +262,17 @@ export class KnowledgeSearchService { private voiceIndexTail: Promise = Promise.resolve() private voiceIndexFlushScheduled = false private readonly pendingVoiceIndexes = new Map() + /** 上次由查询触发的追赶同步时间,用于节流(避免每个 Query 都重跑一次索引)。 */ + private lastCatchUpRequestedAt = 0 + /** 上一遍完整索引 pass 的实际耗时;用于让"是否值得再追一遍"的门槛自我校准。 */ + private lastIndexPassMs = 0 + /** 当前/最近一次 pass 的真实进度(供 UI 区分"追新"与"补历史")。 */ + private passProgress: KnowledgePassProgress | null = null + /** 用户是否已经请求取消当前 pass。取消后主循环在下一个安全点退出。 */ + private cancelRequested = false + private lagHistogram: ReturnType | null = null + private lagMaxMs = 0 + private lagStats: { p50: number; p95: number; p99: number; max: number } | null = null constructor(userDataPath: string, workerPath: string) { this.service = new KnowledgeService(userDataPath, workerPath) @@ -222,13 +283,32 @@ export class KnowledgeSearchService { if (!accountId) return this.emptyStatus('') const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId) if (this.indexing.has(accountId)) return current + this.cancelRequested = false + const startedAt = Date.now() + this.startLagProbe() + this.passProgress = { + // 已经有分片 = 增量追新;完全没有 = 首次全量建立。 + phase: current.indexedChunkCount > 0 || current.indexedMessageCount > 0 ? 'catchup' : 'full', + cancellable: true, + startedAt, + scannedMessages: 0, + indexedMessages: 0, + processedConversations: 0, + totalConversations: 0, + skippedConversations: 0, + catchupConversations: 0, + backfillConversations: 0, + backfillCompletedConversations: 0, + mainLoopLagMs: 0 + } const started: KnowledgeRuntimeStatus = { ...current, state: current.indexedMessageCount ? 'syncing' : 'building', processedMessages: 0, totalMessages: current.sourceMessageCount, estimatedRemainingMs: null, - lastError: undefined + lastError: undefined, + pass: { ...this.passProgress } } this.publishStatus(started) const task = this.indexAccount(accountId) @@ -243,6 +323,16 @@ export class KnowledgeSearchService { }) .finally(() => { this.indexing.delete(accountId) + this.stopLagProbe() + const finishedPass = this.passProgress + if (finishedPass) { + // 真实结束状态:取消就是取消,绝不留一个假的 indexing。 + finishedPass.cancellable = false + finishedPass.mainLoopLagMs = this.lagMaxMs + if (finishedPass.phase !== 'cancelled' && finishedPass.phase !== 'error') { + finishedPass.phase = 'idle' + } + } void this.refreshStatus(accountId).catch(() => undefined) }) this.indexing.set(accountId, task) @@ -252,6 +342,26 @@ export class KnowledgeSearchService { return started } + /** + * 取消当前正在跑的索引 pass。 + * + * 只中止**索引**(与并发查询是两套独立 Abort scope);当前 batch 安全收尾、 + * 已提交会话保留不回滚;`run_state` 落到 `cancelled`,不残留 `indexing`; + * 下一次 catch-up / 手动同步从 per-conversation checkpoint 继续,不从头全量重扫。 + */ + async cancelCurrentAccountIndex(): Promise<{ cancellable: boolean; cancelled: boolean }> { + if (!this.indexing.size) return { cancellable: false, cancelled: false } + this.cancelRequested = true + if (this.passProgress) this.passProgress.cancellable = false + const accountId = this.currentAccountId() + if (accountId) { + const current = this.statusByAccount.get(accountId) + if (current) this.publishStatus({ ...current, pass: this.passSnapshot() }) + } + const cancelled = await this.service.cancelIndex().catch(() => false) + return { cancellable: true, cancelled } + } + /** * The voice cache remains owned by the voice pipeline. Knowledge only reads * a current-account snapshot while constructing a derived local index. @@ -320,8 +430,10 @@ export class KnowledgeSearchService { } async search(request: KnowledgeSearchIpcRequest): Promise { + // 源数据最新活跃时间与索引状态无关,先取一次(零额外 WCDB 调用:读的是已缓存的 Session 列表)。 + const sourceLatestAt = this.sourceLatestAt() const accountId = this.currentAccountId() - if (!accountId) return this.searchFallback(request, 'unavailable') + if (!accountId) return { ...(await this.searchFallback(request, 'unavailable')), sourceLatestAt } try { const searchRequest: Omit = { accountId, @@ -339,19 +451,81 @@ export class KnowledgeSearchService { // An existing derived database can answer while its next incremental pass is running. // Never turn an interactive global search into another full WCDB scan during that pass. if (result.state === 'ready' || result.evidence.length) { - return this.toKnowledgeResult(result, request.retrievalSessionId) + return { ...(await this.toKnowledgeResult(result, request.retrievalSessionId)), sourceLatestAt } } if (this.indexing.has(accountId)) { return { ...result, source: 'knowledge', - totalMessages: result.indexedMessageCount + totalMessages: result.indexedMessageCount, + sourceLatestAt } } - return this.searchFallback(request, 'unavailable') + return { ...(await this.searchFallback(request, 'unavailable')), sourceLatestAt } } catch (error) { console.warn('[Knowledge] search failed, using legacy fallback:', error) - return this.searchFallback(request, 'error') + return { ...(await this.searchFallback(request, 'error')), sourceLatestAt } + } + } + + /** + * 源数据最新活跃时间(epoch ms)。派生索引看到不源数据,freshness 判定由它 + `indexLatestAt` 组成。 + */ + sourceLatestAt(): number | null { + return chat.getSourceLatestActivityMs() + } + + /** + * 上一遍完整索引 pass 的耗时(ms)。0 表示本进程还没有跑完过一遍。 + * 调用方用它作为"落后多少才值得再追一遍"的门槛下限,避免在活跃源数据上无限连续索引。 + */ + lastPassDurationMs(): number { + return this.lastIndexPassMs + } + + /** 当前是否有索引任务在跑(用于「复用当前任务」而不是再启动一个)。 */ + isIndexing(): boolean { + // 用 Map 是否为空判断,而不是用当前 accountId 去查: + // `currentAccountId()` 会在 `getSelfAccountInfo()` 就绪前后返回不同的值, + // 只按单键查会漏掉"其实已经有 pass 在跑",于是又启动一个(两个 pass 抢同一个派生库)。 + return this.indexing.size > 0 + } + + /** + * 主动请求一次追赶同步。 + * + * 复用现有增量通道(`startCurrentAccountIndex` 内部是增量的:未变化的会话不会重建分片); + * 如果已经在跑就**不**再启动第二个,并把 `triggered` 标为 false,让调用方知道这是复用。 + */ + requestCatchUp(minIntervalMs: number): { triggered: boolean; inProgress: boolean } { + const accountId = this.currentAccountId() + if (!accountId) return { triggered: false, inProgress: false } + if (this.isIndexing()) return { triggered: false, inProgress: true } + const now = Date.now() + if (now - this.lastCatchUpRequestedAt < minIntervalMs) { + return { triggered: false, inProgress: false } + } + this.lastCatchUpRequestedAt = now + this.startCurrentAccountIndex() + return { triggered: true, inProgress: this.isIndexing() } + } + + /** + * 等当前索引任务结束,最多等 `budgetMs`。返回是否已经结束。 + * 全量追赶可能远超查询预算,所以这里必须是**有界**等待,不能无限阻塞交互查询。 + */ + async waitForIndexingComplete(budgetMs: number): Promise { + const task = this.indexing.values().next().value as Promise | undefined + if (!task) return true + if (budgetMs <= 0) return false + let timer: ReturnType | undefined + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), budgetMs) + }) + try { + return await Promise.race([task.then(() => true, () => true), timeout]) + } finally { + if (timer) clearTimeout(timer) } } @@ -387,66 +561,244 @@ export class KnowledgeSearchService { } private async indexAccount(accountId: string): Promise { - const contacts = await this.listContacts() - let processedMessages = 0 + // 整遍后台索引走 background 通道:交互查询可以插到它前面,不至于被 pass 拖慢。 + const contacts = await this.listContacts('background') const startedAt = Date.now() + // 源侧每会话最后活跃时间(零额外 WCDB 调用:直接来自 Session 列表)。 + const activity = chat.getConversationActivityMs() + // 每个会话已经索引到哪(per-conversation checkpoint)。 + const marks = await this.service + .highWaterMarks({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG }) + .catch(() => ({}) as Record) + // 上一遍记录的源侧边界:被跳过的会话已覆盖到它,不能因为"这一遍没读"而回退。 + const previouslyCoveredLatestAt = + this.statusByAccount.get(accountId)?.indexLatestAt ?? 0 + + let scannedMessages = 0 + let indexedMessages = 0 + let sourceLatestAt = previouslyCoveredLatestAt + let skippedConversations = 0 + let backfillCompletedConversations = 0 + + // 「追最新」与「补历史」是两个工作概念,顺序不能反: + // catch-up:已建立 checkpoint、源侧出现新消息 → 用户最关心,排在最前; + // backfill:从来没有 checkpoint(历史缺口)→ 必须整段读,排在后面慢慢补。 + // 这个顺序保证今天的新消息不会被历史缺口堵住。 + const catchUpContacts: typeof contacts = [] + const backfillContacts: typeof contacts = [] + for (const contact of contacts) { + const mark = marks[contact.md5] + if (mark !== undefined && mark > 0) { + const previousActivity = activity.get(contact.md5) + // 源侧最后活跃时间不晚于"已经索引到的位置"→ 这个会话没有任何新消息。 + // 直接跳过:**不读 WCDB、不传 IPC、不写索引**。 + // 这正是把「一次 pass 处理百万级消息」变成「只处理真正变过的会话」的地方。 + if (previousActivity !== undefined && previousActivity <= mark) { + skippedConversations += 1 + continue + } + catchUpContacts.push(contact) + continue + } + // 没有 checkpoint → 没有"已经覆盖到哪"的证据,只能整段读(历史 backfill)。 + // + // 这里**不**用「源侧活动(Session 表)里没有它」推断「它不可能有消息」。 + // 那确实能省掉读一批空联系人的开销,但只要 Session 表在某次读取里不完整 + // (或用户删过会话),这个推断就会把一个真有消息的会话变成**永久静默不索引**。 + // 省下来的时间换不来这个风险。 + backfillContacts.push(contact) + } + const orderedContacts = [...catchUpContacts, ...backfillContacts] + + if (this.passProgress) { + this.passProgress.totalConversations = contacts.length + this.passProgress.startedAt = startedAt + this.passProgress.skippedConversations = skippedConversations + this.passProgress.catchupConversations = catchUpContacts.length + this.passProgress.backfillConversations = backfillContacts.length + this.passProgress.backfillCompletedConversations = 0 + this.passProgress.processedConversations = skippedConversations + } this.publishStatus({ ...(this.statusByAccount.get(accountId) || this.emptyStatus(accountId)), state: this.statusByAccount.get(accountId)?.indexedMessageCount ? 'syncing' : 'building', processedMessages: 0, totalMessages: null, - estimatedRemainingMs: null + estimatedRemainingMs: null, + pass: this.passSnapshot() }) - for (const [index, contact] of contacts.entries()) { + let cancelled = false + for (const [index, contact] of orderedContacts.entries()) { + if (this.cancelRequested) { + cancelled = true + break + } + // 交互查询进行中就让路:背景索引绝不能把用户查询拖慢(见 beginInteractiveQuery)。 + await this.interactiveIdle + const isBackfill = index >= catchUpContacts.length + if (this.passProgress) { + this.passProgress.phase = isBackfill ? 'backfill' : 'catchup' + this.passProgress.processedConversations = skippedConversations + index + } + const previousActivity = activity.get(contact.md5) + const mark = marks[contact.md5] // WCDB rejects overlapping async pagination. Queue every archive read so // background indexing and an interactive fallback search can interleave safely. - const messages = await this.listMessages(contact.md5) + // + // delta 读取:已建立 checkpoint 时只读 checkpoint 之后的源消息(外加有界 overlap), + // 而不是"从历史开头全扫一遍、再判断哪些已经索引过"。 + // + // ⚠️ 单位契约见 `DELTA_OVERLAP_MS`:checkpoint 是**毫秒**,传给 WCDB 读取层 + // 必须是**秒**,否则 delta 读会被静默过滤成空。 + const isDelta = mark !== undefined && mark > 0 + const sinceTime = isDelta + ? Math.max(0, Math.floor((mark - DELTA_OVERLAP_MS) / 1000)) + : undefined + const messages = await this.listMessages(contact.md5, sinceTime, undefined, 'background') + scannedMessages += messages.length + // 这一遍扫到的源数据最新时间:用**未过滤**的原始消息计算, + // 这样「不可建模」的消息(图片/空正文)不会让 freshness 口径偏旧。 + let conversationLatest = 0 + for (const message of messages) { + const createTime = (message.createTime || 0) * 1000 + if (createTime > conversationLatest) conversationLatest = createTime + } + if (conversationLatest > sourceLatestAt) sourceLatestAt = conversationLatest const sourceMessages = messages .map((message) => this.toSourceMessage(accountId, contact.md5, message)) .filter((message): message is KnowledgeSourceMessage => Boolean(message)) - await this.service.index( + // 记录**源侧**边界(而不是索引里最后一条可建模消息的时间): + // 否则"最后一条恰好落在图片上"的会话会永远被判成有新消息,增量永远跳不过它。 + // + // 安全阀:delta 范围读**空**、但源侧声称有新消息 → **不推进** checkpoint。 + // 宁可下一遍重试,也不让"一次可疑的空读"升级成"谎报已覆盖"(那会静默丢消息)。 + const suspiciousEmptyDelta = + isDelta && messages.length === 0 && (previousActivity ?? 0) > (mark ?? 0) + const sourceHighWaterTime = suspiciousEmptyDelta + ? undefined + : Math.max(previousActivity ?? 0, conversationLatest) + const result = await this.service.index( { accountId, conversations: [ { conversationId: contact.md5, - completeSnapshot: true, - messages: sourceMessages + // delta 模式下绝不能声明"完整快照":否则 store 会把"不在 delta 里的历史消息" + // 误判为被删除,从而整段重建这个会话,增量就白做了。 + completeSnapshot: !isDelta, + messages: sourceMessages, + ...(sourceHighWaterTime && sourceHighWaterTime > 0 ? { sourceHighWaterTime } : {}) } ], chunker: DEFAULT_KNOWLEDGE_CHUNKER, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG, + // 只有「这一遍真的读完了全部会话」(没有任何跳过、没有取消)才写入总量口径, + // 否则会把增量 pass 的部分计数冒充成全量。 + // + // 这里数的是真正被建模进索引的源消息,不是"扫到的原始条数";后者(含不可建模的 + // 图片/空正文)另走 pass 进度里的 scannedMessages。两个数字回答不同问题。 sourceMessageCount: - index === contacts.length - 1 ? processedMessages + sourceMessages.length : undefined + index === orderedContacts.length - 1 && skippedConversations === 0 + ? indexedMessages + sourceMessages.length + : undefined, + sourceLatestAt: + index === orderedContacts.length - 1 && sourceLatestAt > 0 ? sourceLatestAt : undefined }, (progress) => { const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId) this.publishStatus({ ...current, state: current.indexedMessageCount ? 'syncing' : 'building', - processedMessages: processedMessages + progress.processedMessages, + processedMessages: indexedMessages + progress.processedMessages, totalMessages: null, currentConversationId: progress.conversationId, - estimatedRemainingMs: null + estimatedRemainingMs: null, + pass: this.passSnapshot() }) } ) - processedMessages += sourceMessages.length + if (result.cancelled) { + cancelled = true + indexedMessages += result.processedMessages + break + } + indexedMessages += sourceMessages.length + if (isBackfill) backfillCompletedConversations += 1 + if (this.passProgress) { + this.passProgress.scannedMessages = scannedMessages + this.passProgress.indexedMessages = indexedMessages + this.passProgress.processedConversations = skippedConversations + index + 1 + this.passProgress.backfillCompletedConversations = backfillCompletedConversations + } const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId) this.publishStatus({ ...current, state: current.indexedMessageCount ? 'syncing' : 'building', - processedMessages, + processedMessages: indexedMessages, totalMessages: null, currentConversationId: contact.md5, - estimatedRemainingMs: null + estimatedRemainingMs: null, + pass: this.passSnapshot() }) } + if (cancelled && this.passProgress) this.passProgress.phase = 'cancelled' + // 一遍 pass 一行汇总(低频、只在结束时输出一次)。这些数字同时喂给 UI 的 pass 进度; + // lag 输出分位数而不是单点,因为"最大值看着还行"不能说明没有 stall。 + console.info( + `[Knowledge] pass ${cancelled ? 'cancelled' : 'done'} conversations=${contacts.length} ` + + `skipped=${skippedConversations} scanned=${scannedMessages} indexed=${indexedMessages} ` + + `catchup=${catchUpContacts.length} backfill=${backfillContacts.length}/${backfillCompletedConversations} ` + + `elapsedMs=${Date.now() - startedAt} ` + + `lagP50=${this.lagStats?.p50 ?? 0} lagP95=${this.lagStats?.p95 ?? 0} ` + + `lagP99=${this.lagStats?.p99 ?? 0} lagMax=${this.lagStats?.max ?? this.lagMaxMs} ` + + `activityEntries=${activity.size} checkpointMarks=${Object.keys(marks).length}` + ) await this.refreshStatus(accountId, { - processedMessages, - totalMessages: processedMessages, + processedMessages: indexedMessages, + totalMessages: null, startedAt }) + // 取消的一遍不计入"上一遍耗时",否则查询侧的门槛会被一次提前结束的 pass 带偏。 + if (!cancelled) this.lastIndexPassMs = Date.now() - startedAt + } + + /** 当前 pass 进度的不可变快照(含实时采样到的主线程滞后)。 */ + private passSnapshot(): KnowledgePassProgress | undefined { + if (!this.passProgress) return undefined + // 运行期间也要能读到"此刻为止"的最大滞后,而不是等 pass 结束才有数字。 + const liveMax = this.lagHistogram ? roundMs(this.lagHistogram.max) : this.lagMaxMs + return { ...this.passProgress, mainLoopLagMs: Math.max(liveMax, 0) } + } + + /** + * 在 pass 开始时启动主线程滞后采样。这是"重活没有压在主线程上"的直接证据。 + */ + private startLagProbe(): void { + if (this.lagHistogram) return + this.lagMaxMs = 0 + this.lagStats = null + try { + const histogram = monitorEventLoopDelay({ resolution: LAG_PROBE_RESOLUTION_MS }) + histogram.enable() + this.lagHistogram = histogram + } catch { + // 采样失败不应该影响索引本身;退化为"没有 lag 数据"。 + this.lagHistogram = null + } + } + + private stopLagProbe(): void { + const histogram = this.lagHistogram + if (!histogram) return + histogram.disable() + this.lagStats = { + p50: roundMs(histogram.percentile(50)), + p95: roundMs(histogram.percentile(95)), + p99: roundMs(histogram.percentile(99)), + max: roundMs(histogram.max) + } + this.lagMaxMs = Math.max(this.lagMaxMs, this.lagStats.max) + this.lagHistogram = null } private async searchFallback( @@ -502,6 +854,9 @@ export class KnowledgeSearchService { state: fallbackReason === 'indexing' ? 'indexing' : 'unavailable', indexedMessageCount: 0, indexedChunkCount: 0, + // fallback 是直接扫源数据,不走派生索引,因此没有索引覆盖口径可言。 + indexLatestAt: null, + sourceLatestAt: this.sourceLatestAt(), totalMessages, timings: { ...emptyKnowledgeSearchTimings(), @@ -639,6 +994,9 @@ export class KnowledgeSearchService { voiceCoverage.voiceCoverageComplete = voiceCoverage.voiceMessageCount === voiceCoverage.transcribedVoiceCount } + const indexLatestParts = partialResults + .map((result) => result.indexLatestAt) + .filter((value): value is number => typeof value === 'number' && value > 0) return { state: partialResults.some((result) => result.state === 'ready') ? 'ready' @@ -647,6 +1005,8 @@ export class KnowledgeSearchService { : 'unavailable', indexedMessageCount: Math.max(...partialResults.map((result) => result.indexedMessageCount)), indexedChunkCount: Math.max(...partialResults.map((result) => result.indexedChunkCount)), + // 多个分片取最新的那个:只要有一部分索引更新,整体覆盖口径就按它算。 + indexLatestAt: indexLatestParts.length ? Math.max(...indexLatestParts) : null, evidence: mergedEvidence, timings, voiceCoverage @@ -680,16 +1040,20 @@ export class KnowledgeSearchService { } } - private listContacts(): ReturnType { - return this.enqueueWcdbRead(() => chat.listContactsAsync()) + private listContacts(lane: WcdbReadLane = 'interactive'): ReturnType { + return this.enqueueWcdbRead(() => chat.listContactsAsync(), lane) } private listMessages( conversationId: string, startTime?: number, - endTime?: number + endTime?: number, + lane: WcdbReadLane = 'interactive' ): ReturnType { - return this.enqueueWcdbRead(() => chat.listMessagesAsync(conversationId, startTime, endTime)) + return this.enqueueWcdbRead( + () => chat.listMessagesAsync(conversationId, startTime, endTime), + lane + ) } private withVoiceTranscript(message: chat.FormattedMessage): chat.FormattedMessage { @@ -811,22 +1175,36 @@ export class KnowledgeSearchService { wcdbExecutionMs: this.wcdbExecutionMsTotal - beforeExecutionMs }, source: 'knowledge', - totalMessages: result.indexedMessageCount + totalMessages: result.indexedMessageCount, + sourceLatestAt: this.sourceLatestAt() } } + /** + * Evidence 的 sender 显示名 enrichment。 + * + * 只做「取名字」这一件事:按 conversation 聚合 evidence 真正需要的 wxid(不是整群成员), + * 每群一次批量 name lookup(`getGroupMemberNamesAsync`),**不**构造完整 GroupSnapshot、 + * **不** hydrate 头像 —— 后者会把整群成员的头像一起读出来,为拿几个名字付整群成本。 + * + * 头像不属于 Query Tool 的成本;若 Evidence UI 将来要头像,走 lazy 路径。 + */ private async enrichEvidenceSenders( evidence: KnowledgeEvidence[], retrievalSessionId?: string ): Promise { - const candidateConversationIds = Array.from( - new Set( - evidence - .filter((item) => item.senderId && looksLikeOpaqueSenderId(item.sender)) - .map((item) => item.conversationId) - ) - ).slice(0, MAX_SENDER_NAME_CONVERSATIONS) - if (!candidateConversationIds.length) return evidence + // 先按会话聚合需要的 sender,避免"每条 evidence 一次调用"。 + const wxidsByConversation = new Map>() + for (const item of evidence) { + if (!item.senderId || !looksLikeOpaqueSenderId(item.sender)) continue + let bucket = wxidsByConversation.get(item.conversationId) + if (!bucket) { + bucket = new Set() + wxidsByConversation.set(item.conversationId, bucket) + } + bucket.add(item.senderId) + } + if (!wxidsByConversation.size) return evidence const session = retrievalSessionId ? this.senderEnrichmentSession(retrievalSessionId) @@ -836,20 +1214,35 @@ export class KnowledgeSearchService { const groupConversationIds = new Set( contacts.filter((contact) => contact.type === 'group').map((contact) => contact.md5) ) + + const candidateConversationIds = Array.from(wxidsByConversation.keys()) + .filter((conversationId) => groupConversationIds.has(conversationId)) + .slice(0, MAX_SENDER_NAME_CONVERSATIONS) + const memberNamesByConversation = new Map>() for (const conversationId of candidateConversationIds) { - if (!groupConversationIds.has(conversationId)) continue - let snapshot = session?.groupSnapshots.get(conversationId) - if (!snapshot) { - snapshot = await this.enqueueWcdbRead(() => chat.getGroupSnapshotAsync(conversationId)) - session?.groupSnapshots.set(conversationId, snapshot) + const requested = Array.from(wxidsByConversation.get(conversationId) || []) + if (!requested.length) continue + let memberNames = session?.groupMemberNames.get(conversationId) + // 只查缓存里还没有的 wxid —— 同一 session 的后续 probe 因此不会重复读 WCDB。 + const missing = requested.filter((wxid) => !memberNames?.has(wxid)) + if (missing.length) { + const members = await this.enqueueWcdbRead(() => + chat.getGroupMemberNamesAsync(conversationId, missing) + ) + if (!memberNames) { + memberNames = new Map() + session?.groupMemberNames.set(conversationId, memberNames) + } + for (const member of members) { + memberNames.set(member.wxid, groupMemberDisplayName(member)) + } + // 请求了但没有返回名字的 wxid 也标记为"查过",避免后续 probe 反复重查。 + for (const wxid of missing) { + if (!memberNames.has(wxid)) memberNames.set(wxid, '') + } } - const memberNames = new Map( - (snapshot?.members || []) - .map((member) => [member.wxid, groupMemberDisplayName(member)] as const) - .filter(([, name]) => Boolean(name)) - ) - if (memberNames.size) memberNamesByConversation.set(conversationId, memberNames) + if (memberNames?.size) memberNamesByConversation.set(conversationId, memberNames) } return evidence.map((item) => { @@ -867,7 +1260,7 @@ export class KnowledgeSearchService { } let session = this.senderEnrichmentSessions.get(retrievalSessionId) if (!session) { - session = { lastUsedAt: now, groupSnapshots: new Map() } + session = { lastUsedAt: now, groupMemberNames: new Map() } this.senderEnrichmentSessions.set(retrievalSessionId, session) } session.lastUsedAt = now @@ -879,24 +1272,76 @@ export class KnowledgeSearchService { return session } - private enqueueWcdbRead(operation: () => Promise): Promise { - const enqueuedAt = Date.now() - const run = async (): Promise => { - const startedAt = Date.now() - this.wcdbQueueMsTotal += Math.max(0, startedAt - enqueuedAt) - try { - return await operation() - } finally { - this.wcdbExecutionMsTotal += Date.now() - startedAt - } + /** + * 交互查询进行中:后台索引会让路。 + * + * 追赶同步会自动遍历上千个会话;如果不让路,一次用户查询会和后台 pass 抢同一个 + * Worker 与 WCDB 读取通道,被拖到几十秒 —— 查询不能因为索引 backlog 卡住。 + */ + beginInteractiveQuery(): void { + if (this.interactiveQueryDepth === 0) { + this.interactiveIdle = new Promise((resolve) => { + this.interactiveIdleResolve = resolve + }) } - const result = this.wcdbReadTail.then(run, run) - // Keep the queue usable after a read failure while returning that failure to its caller. - this.wcdbReadTail = result.then( - () => undefined, - () => undefined - ) - return result + this.interactiveQueryDepth += 1 + } + + endInteractiveQuery(): void { + this.interactiveQueryDepth = Math.max(0, this.interactiveQueryDepth - 1) + if (this.interactiveQueryDepth === 0) { + this.interactiveIdleResolve?.() + this.interactiveIdleResolve = null + } + } + + /** + * WCDB 异步分页不允许重叠,所有会话读取都必须串行。 + * + * 但**后台索引**与**交互查询**不能同权排队:追赶同步会在后台遍历上千个会话, + * 交互读取排在它后面就会被拖成几十秒。因此分两条通道,交互读取优先于尚未开始的后台读取; + * 交互查询最多只等"一个正在执行的读"(WCDB 不允许重叠,这点无法避免)。 + */ + private enqueueWcdbRead(operation: () => Promise, lane: WcdbReadLane = 'interactive'): Promise { + const enqueuedAt = Date.now() + return new Promise((resolve, reject) => { + const pending: PendingWcdbRead = { + high: lane === 'interactive', + run: async () => { + const startedAt = Date.now() + this.wcdbQueueMsTotal += Math.max(0, startedAt - enqueuedAt) + try { + return await operation() + } finally { + this.wcdbExecutionMsTotal += Date.now() - startedAt + } + }, + resolve: resolve as (value: unknown) => void, + reject + } + if (pending.high) { + const firstBackground = this.wcdbPending.findIndex((item) => !item.high) + if (firstBackground < 0) this.wcdbPending.push(pending) + else this.wcdbPending.splice(firstBackground, 0, pending) + } else { + this.wcdbPending.push(pending) + } + this.drainWcdbReads() + }) + } + + private drainWcdbReads(): void { + if (this.wcdbReadBusy) return + const next = this.wcdbPending.shift() + if (!next) return + this.wcdbReadBusy = true + void next + .run() + .then(next.resolve, next.reject) + .finally(() => { + this.wcdbReadBusy = false + this.drainWcdbReads() + }) } private emptyStatus(accountId: string): KnowledgeRuntimeStatus { @@ -911,7 +1356,10 @@ export class KnowledgeSearchService { estimatedRemainingMs: null, databaseBytes: 0, walBytes: 0, - shmBytes: 0 + shmBytes: 0, + indexLatestAt: null, + sourceLatestAt: null, + pass: this.passSnapshot() } } @@ -924,20 +1372,29 @@ export class KnowledgeSearchService { const remote = await this.service.status({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG }) const current = this.statusByAccount.get(accountId) const indexing = this.indexing.has(accountId) + const pass = this.passProgress const processedMessages = progress?.processedMessages ?? current?.processedMessages ?? remote.processedMessages const totalMessages = progress?.totalMessages ?? remote.sourceMessageCount - const state = indexing + // 一遍 pass 结束后,worker 只知道「派生库能不能查」,不知道这一遍是**被取消**还是**出错**。 + // 这两个语义只在这里有(worker 侧的 run_state 已经落库),所以由本地 pass 覆盖, + // 避免取消之后又冒充成一个干净的 ready。 + const state: KnowledgeRuntimeState = indexing ? remote.indexedMessageCount > 0 ? 'syncing' : 'building' - : remote.state + : pass && (pass.phase === 'cancelled' || pass.phase === 'error') + ? pass.phase + : remote.state const status: KnowledgeRuntimeStatus = { ...remote, state, processedMessages, totalMessages, - estimatedRemainingMs: null + estimatedRemainingMs: null, + // 派生库自己看不到源数据;这里补上源侧最新活跃时间,UI 才能区分 READY 与 FRESH。 + sourceLatestAt: this.sourceLatestAt(), + pass: this.passSnapshot() } this.publishStatus(status) return status diff --git a/src/main/knowledge/knowledge-service.ts b/src/main/knowledge/knowledge-service.ts index 9da77cf..d4c059a 100644 --- a/src/main/knowledge/knowledge-service.ts +++ b/src/main/knowledge/knowledge-service.ts @@ -48,6 +48,16 @@ export class KnowledgeService { return this.worker.status({ ...request, databaseRoot: this.databaseRoot }) } + /** 每个会话已经索引到的源侧时刻(epoch ms);增量 pass 用它跳过没有变化的会话。 */ + highWaterMarks(request: Omit): Promise> { + return this.worker.highWaterMarks({ ...request, databaseRoot: this.databaseRoot }) + } + + /** 只中止正在跑的索引任务;查询请求不受影响。 */ + cancelIndex(): Promise { + return this.worker.cancelActiveIndex() + } + dispose(): Promise { return this.worker.dispose() } diff --git a/src/main/knowledge/knowledge-store.ts b/src/main/knowledge/knowledge-store.ts index 77a5965..9f24b85 100644 --- a/src/main/knowledge/knowledge-store.ts +++ b/src/main/knowledge/knowledge-store.ts @@ -191,6 +191,7 @@ export class KnowledgeStore { async index( request: Pick & { sourceMessageCount?: number + sourceLatestAt?: number }, signal?: AbortSignal, onProgress?: (progress: KnowledgeIndexProgress) => void @@ -242,6 +243,9 @@ export class KnowledgeStore { this.writeMeta('source_message_count', String(request.sourceMessageCount)) this.refreshStatsSnapshot() } + if (request.sourceLatestAt !== undefined) { + this.writeMeta('source_latest_at', String(request.sourceLatestAt)) + } this.setRunState('ready') return { accountId: this.accountId, @@ -293,23 +297,64 @@ export class KnowledgeStore { } getSearchStatus(): Omit { - this.ensureStatsSnapshot() + this.ensureStatsSnapshotForQuery() const indexedMessageCount = this.readStatNumber('stats_message_count') const indexedChunkCount = this.readStatNumber('stats_chunk_count') const runState = this.readMeta('run_state') return { - state: - runState === 'indexing' - ? 'indexing' - : runState === 'ready' && indexedChunkCount > 0 - ? 'ready' - : 'unavailable', + // READY 的含义是「这个派生库可以被查询」。一次被中断的 pass 会把 run_state 留在 + // 'indexing',但已落盘的分片仍然可用 —— 用它当 state 会让可查询的库看起来不可用。 + // 「正在同步」由 KnowledgeSearchService 依据真实索引任务表达,不靠这里的残留状态。 + state: runState === 'error' ? 'unavailable' : indexedChunkCount > 0 ? 'ready' : 'unavailable', indexedMessageCount, indexedChunkCount, + indexLatestAt: this.readIndexLatestAt(), timings: emptyKnowledgeSearchTimings() } } + /** + * 索引已经覆盖到的源数据时间(epoch ms)——「索引更新到哪」的权威口径。 + * + * 优先用 per-conversation 的 `source_high_water_time` 聚合(`MAX(...)`):每个**成功处理** + * 的会话都会立刻推进并持久化它(不是等整遍 pass 结束才写),所以增量 pass 同样能让 + * freshness 前进。 + * + * 为什么这是源侧口径:它是会话最后活跃时间与原始消息 create_time 的最大值,对不可建模的 + * 图片/空正文也照算,不是"索引里最新一条可建模消息的时间",所以不会被不可建模消息带偏; + * 并且它在 delta 空读可疑时**不推进**(安全阀在 KnowledgeSearchService 侧), + * 不会把"没读到"谎报成"已覆盖"。 + * + * 不用 `source_latest_at` meta 优先:它的写入条件要求「这一遍没有任何跳过」, + * 而增量世界里"有跳过"是常态,于是它会冻结在最后一次全量 pass 的值上, + * 导致 `isKnowledgeFresh()` 恒为 false。 + * + * 回退顺序:老库没有该列(或整列为 NULL)→ `source_latest_at` meta → + * `MAX(high_water_time)`(被建模消息的最新时间,只会偏旧,作下限是安全的)。 + */ + private readIndexLatestAt(): number | null { + try { + const covered = this.database + .prepare('SELECT MAX(source_high_water_time) AS latest FROM knowledge_index_state') + .get() as DbRow | undefined + const value = Number(covered?.latest) + if (Number.isFinite(value) && value > 0) return value + } catch { + // 老库可能还没有 source_high_water_time 这一列(migration 之前)→ 走回退。 + } + const recorded = Number(this.readMeta('source_latest_at')) + if (Number.isFinite(recorded) && recorded > 0) return recorded + try { + const row = this.database + .prepare('SELECT MAX(high_water_time) AS latest FROM knowledge_index_state') + .get() as DbRow | undefined + const value = Number(row?.latest) + return Number.isFinite(value) && value > 0 ? value : null + } catch { + return null + } + } + getRuntimeStatus(): KnowledgeRuntimeStatus { const search = this.getSearchStatus() const storage = this.getStorageStats() @@ -320,7 +365,20 @@ export class KnowledgeStore { const error = this.readMeta('run_error') || undefined return { accountId: this.accountId, - state: runState === 'error' ? 'error' : search.state === 'ready' ? 'ready' : 'unavailable', + // 注意区分三种「不是 ready」: + // - 'error' :这一遍真的失败了; + // - 'cancelled' :用户主动取消(已提交的会话保留、可继续,绝不留一个假的 indexing); + // - 'unavailable':还没有任何可用分片。 + // 这里**不**用 `search.state`(那是「能不能查」):取消后派生库仍然可查, + // 但 UI 需要区分「可用 · 已追至最新」与「可用 · 同步已取消」。 + state: + runState === 'error' + ? 'error' + : runState === 'cancelled' + ? 'cancelled' + : search.state === 'ready' + ? 'ready' + : 'unavailable', indexedMessageCount: search.indexedMessageCount, indexedChunkCount: search.indexedChunkCount, sourceMessageCount, @@ -330,7 +388,10 @@ export class KnowledgeStore { databaseBytes: storage.databaseBytes, walBytes: storage.walBytes, shmBytes: storage.shmBytes, - lastError: error + lastError: error, + // sourceLatestAt 属于源数据(WCDB),派生库本身看不到,由 KnowledgeSearchService 补齐。 + indexLatestAt: search.indexLatestAt, + sourceLatestAt: null } } @@ -345,6 +406,7 @@ export class KnowledgeStore { } { const startedAt = Date.now() let ftsMs = 0 + let shortTermSearchMs = 0 let messageLoadMs = 0 let chunkExpandMs = 0 let rankingMs = 0 @@ -505,6 +567,7 @@ export class KnowledgeStore { timings: { ...emptyKnowledgeSearchTimings(), ftsMs, + shortTermSearchMs, messageLoadMs, chunkExpandMs, rankingMs, @@ -733,7 +796,9 @@ export class KnowledgeStore { searchWithStatus(query: KnowledgeQuery): KnowledgeSearchResult { const startedAt = Date.now() + const statusStartedAt = Date.now() const status = this.getSearchStatus() + const statusMs = Date.now() - statusStartedAt const measured = status.indexedChunkCount > 0 ? this.searchMeasured(query) : null const voiceStartedAt = Date.now() const voiceCoverage = this.getVoiceCoverage(query) @@ -750,6 +815,7 @@ export class KnowledgeStore { totalMs: workerExecutionMs, globalCountMs: statsRefreshMs, voiceCoverageMs, + statusMs, workerExecutionMs }, conversationRetrieval: measured?.conversationRetrieval, @@ -837,6 +903,15 @@ export class KnowledgeStore { ) STRICT; CREATE INDEX IF NOT EXISTS knowledge_messages_conversation_time ON knowledge_messages (conversation_id, create_time); + -- 跨会话 lexical probe 的短词回退路径是「全表 LIKE + ORDER BY create_time DESC LIMIT k」。 + -- 没有这个索引时 SQLite 只能 SCAN + TEMP B-TREE,代价随表增长线性上升; + -- 有了它就能按时间倒序走索引并提前终止(同一 ORDER BY / 同一 LIMIT,结果集完全一致), + -- 降到毫秒级。它不改变任何检索语义,只是让同一条 SQL 有可用的访问路径。 + CREATE INDEX IF NOT EXISTS knowledge_messages_time + ON knowledge_messages (create_time); + -- 语音覆盖聚合按 (kind, conversation_id[, create_time]) 过滤;没有它就只能全表扫。 + CREATE INDEX IF NOT EXISTS knowledge_messages_kind_conversation + ON knowledge_messages (kind, conversation_id); CREATE TABLE IF NOT EXISTS knowledge_chunks ( rowid INTEGER PRIMARY KEY, chunk_id TEXT NOT NULL UNIQUE, @@ -871,6 +946,9 @@ export class KnowledgeStore { if (!stateColumns.has('complete_snapshot')) { this.database.exec('ALTER TABLE knowledge_index_state ADD COLUMN complete_snapshot INTEGER NOT NULL DEFAULT 0') } + if (!stateColumns.has('source_high_water_time')) { + this.database.exec('ALTER TABLE knowledge_index_state ADD COLUMN source_high_water_time INTEGER') + } const messageColumns = new Set( asRows(this.database.prepare('PRAGMA table_info(knowledge_messages)').all()).map((row) => String(row.name) @@ -959,7 +1037,24 @@ export class KnowledgeStore { } } } - if (changedAt < 0) return { chunkCount: 0, updatedChunks: 0 } + if (changedAt < 0) { + // 内容没有变化 → 不必重建分片。但仍然要记下这一遍扫到的**源侧**边界, + // 否则下一次增量 pass 又会因为缺少标记而重读这个会话(永远无法跳过)。 + // + // 单调推进:checkpoint 只应该前进。让一个"看起来更旧"的值覆盖它,会把已经追到最新的 + // 会话重新打回"有新消息",于是每一遍都白读一次,还会让 freshness 误判回退。 + if (conversation.sourceHighWaterTime !== undefined) { + this.database + .prepare( + `UPDATE knowledge_index_state + SET source_high_water_time = MAX(COALESCE(source_high_water_time, 0), ?), + updated_at = ? + WHERE conversation_id = ?` + ) + .run(conversation.sourceHighWaterTime, Date.now(), conversation.conversationId) + } + return { chunkCount: 0, updatedChunks: 0 } + } const rebuildStart = Math.max(0, changedAt - chunker.overlapMessages) const boundaryTime = normalized[rebuildStart]?.createTime ?? 0 @@ -1021,7 +1116,8 @@ export class KnowledgeStore { highWater, normalized.length, null, - conversation.completeSnapshot + conversation.completeSnapshot, + conversation.sourceHighWaterTime ?? null ) this.database.exec('COMMIT') return { chunkCount: chunks.length, updatedChunks: chunks.length } @@ -1162,14 +1258,20 @@ export class KnowledgeStore { highWater: number | null, messageCount: number, error: string | null = null, - completeSnapshot = false + completeSnapshot = false, + /** + * 这一遍从 WCDB 读到的**原始**最新 create_time(epoch ms,未经过滤)。 + * 与 `highWater`(索引里最后一条可建模消息的时间)不同:图片等不可建模消息会被后者漏掉, + * 于是「最后一条恰好是图片」的会话每次都会被认为是"有新消息"。源侧边界没有这个问题。 + */ + sourceHighWater: number | null = null ): void { this.database .prepare( `INSERT INTO knowledge_index_state ( conversation_id, account_id, chunker_version, state, high_water_time, - indexed_message_count, complete_snapshot, last_error, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + indexed_message_count, complete_snapshot, last_error, updated_at, source_high_water_time + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(conversation_id) DO UPDATE SET account_id = excluded.account_id, chunker_version = excluded.chunker_version, @@ -1178,7 +1280,14 @@ export class KnowledgeStore { indexed_message_count = excluded.indexed_message_count, complete_snapshot = excluded.complete_snapshot, last_error = excluded.last_error, - updated_at = excluded.updated_at` + updated_at = excluded.updated_at, + -- 单调推进 + 保留 NULL 语义:新值为 NULL 时保持旧值;否则取两者较大者。 + -- 若退化成写 0,readSourceHighWaterMarks() 的 IS NOT NULL 就会把该会话 + -- 当成"有 checkpoint 但等于 0",从而每遍都误走 backfill 全量读。 + source_high_water_time = CASE + WHEN excluded.source_high_water_time IS NULL THEN knowledge_index_state.source_high_water_time + ELSE MAX(COALESCE(knowledge_index_state.source_high_water_time, 0), excluded.source_high_water_time) + END` ) .run( conversationId, @@ -1189,10 +1298,31 @@ export class KnowledgeStore { messageCount, completeSnapshot ? 1 : 0, error, - Date.now() + Date.now(), + sourceHighWater ) } + /** + * 每个会话「已经索引到源数据的哪个时刻」(epoch ms)。 + * + * 增量 pass 用它判断哪些会话真的需要重新读取:Session 行的 `last_timestamp` 不晚于这个值 + * 就说明没有新消息,可以直接跳过(不读 WCDB、不写索引)。 + */ + readSourceHighWaterMarks(): Record { + const marks: Record = {} + for (const row of asRows( + this.database + .prepare( + 'SELECT conversation_id, source_high_water_time FROM knowledge_index_state WHERE source_high_water_time IS NOT NULL' + ) + .all() + )) { + marks[String(row.conversation_id)] = Number(row.source_high_water_time) + } + return marks + } + private readMeta(key: string): string | null { const row = this.database.prepare('SELECT value FROM knowledge_meta WHERE key = ?').get(key) as | DbRow @@ -1226,6 +1356,43 @@ export class KnowledgeStore { this.refreshStatsSnapshot() } + /** + * 搜索热路径专用的快照读取(**不做全表聚合**)。 + * + * 分开的原因:`markStatsStale()` 在**每次** `index()` 调用时都会执行,而 + * `KnowledgeSearchService.indexAccount` 是**逐会话**调用 `index()` 的,所以一遍后台 pass + * 进行中 `stats_state` 几乎永远是 `'stale'`,pass 被中断后更是会一直留在 `'stale'`。 + * 如果每次搜索都因此重做三次全表聚合(messages / chunks / voice),单个 probe 的代价就是 + * 数十秒级,交互查询会被卡住。 + * + * 规则:只有在快照**从未建立**(首库)或**明显与真实数据不符**(快照说 0 个分片、 + * 但库里确实有分片 → 会把可查询的库误报成 unavailable)时才刷新。其余情况沿用上一次完整 + * pass 写下的结论:数字可能略旧,但不会把可查询的库说成不可用,也不会把交互查询卡在 + * 全表聚合上。 + */ + private ensureStatsSnapshotForQuery(): void { + const state = this.readMeta('stats_state') + if (state === 'fresh') return + if (state === null) { + this.refreshStatsSnapshot() + return + } + if (this.readStatNumber('stats_chunk_count') === 0 && this.hasAnyChunk()) { + this.refreshStatsSnapshot() + } + } + + /** 只探一行,用于避免把"有分片但快照过期"的库报成不可用。 */ + private hasAnyChunk(): boolean { + try { + return Boolean( + this.database.prepare('SELECT 1 AS present FROM knowledge_chunks LIMIT 1').get() + ) + } catch { + return false + } + } + private markStatsStale(): void { this.writeMeta('stats_state', 'stale') } diff --git a/src/main/knowledge/knowledge-worker-host.ts b/src/main/knowledge/knowledge-worker-host.ts index 3d42f56..c84740a 100644 --- a/src/main/knowledge/knowledge-worker-host.ts +++ b/src/main/knowledge/knowledge-worker-host.ts @@ -19,6 +19,7 @@ type WorkerResult = | KnowledgeCapacityPreflight | KnowledgeSearchResult | KnowledgeRuntimeStatus + | { marks: Record } | { removed: true } type PendingRequest = { resolve: (result: WorkerResult) => void @@ -36,6 +37,14 @@ export class KnowledgeWorkerHost { private child: ChildProcess | null = null private childStartedAt = 0 private readonly pending = new Map() + /** + * 当前在跑的索引请求 id。 + * + * 之前没有它,所以「取消同步」在 UI 上不存在、在主进程里也无法表达 —— + * 唯一能停下来的方式就是退出应用。这里显式跟踪,`cancelActiveIndex()` 才能 + * 精确地只中止索引,而**不会**影响任何并发进行的查询请求。 + */ + private activeIndexRequestId: string | null = null constructor(private readonly workerPath: string) {} @@ -43,7 +52,9 @@ export class KnowledgeWorkerHost { payload: KnowledgeIndexRequest, onProgress?: (progress: KnowledgeIndexProgress) => void ): Promise { - return this.request('index', payload, onProgress) as Promise + return this.request('index', payload, onProgress, (requestId) => { + this.activeIndexRequestId = requestId + }) as Promise } preflight(payload: KnowledgeCapacityPreflightRequest): Promise { @@ -58,6 +69,21 @@ export class KnowledgeWorkerHost { return this.request('status', payload) as Promise } + /** 每个会话已经索引到的源侧时刻;用于增量 pass 跳过没有变化的会话。 */ + highWaterMarks(payload: KnowledgeStatusRequest): Promise> { + return this.request('highWater', payload as unknown as KnowledgeWorkerRequest['payload']).then( + (result) => ('marks' in result ? result.marks : {}) + ) + } + + /** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */ + async cancelActiveIndex(): Promise { + const target = this.activeIndexRequestId + if (!target) return false + await this.cancel(target) + return true + } + remove(accountId: string, databaseRoot: string): Promise<{ removed: true }> { return this.request('remove', { accountId, databaseRoot }) as Promise<{ removed: true }> } @@ -81,13 +107,15 @@ export class KnowledgeWorkerHost { private request( type: KnowledgeWorkerRequest['type'], payload: KnowledgeWorkerRequest['payload'], - onProgress?: (progress: KnowledgeIndexProgress) => void + onProgress?: (progress: KnowledgeIndexProgress) => void, + onRequestId?: (requestId: string) => void ): Promise { const hadWorker = Boolean(this.child?.connected) const child = this.ensureChild() const requestId = randomUUID() const sentAt = Date.now() const request: KnowledgeWorkerRequest = { version: 1, type, requestId, sentAt, payload } + onRequestId?.(requestId) return new Promise((resolve, reject) => { this.pending.set(requestId, { resolve, @@ -145,6 +173,7 @@ export class KnowledgeWorkerHost { const pending = this.pending.get(requestId) if (!pending) return this.pending.delete(requestId) + if (this.activeIndexRequestId === requestId) this.activeIndexRequestId = null if (error) pending.reject(error) else if (result) pending.resolve(this.applyTransportTimings(result, pending, transport)) else pending.reject(new Error('Knowledge worker returned no result')) diff --git a/src/main/knowledge/knowledge-worker.ts b/src/main/knowledge/knowledge-worker.ts index 26271d8..b5c6ce1 100644 --- a/src/main/knowledge/knowledge-worker.ts +++ b/src/main/knowledge/knowledge-worker.ts @@ -114,6 +114,7 @@ async function handleSearch( evidence: [], indexedMessageCount: 0, indexedChunkCount: 0, + indexLatestAt: null, timings: emptyKnowledgeSearchTimings() }, workerReceivedAt, @@ -153,7 +154,9 @@ async function handleStatus( estimatedRemainingMs: null, databaseBytes: 0, walBytes: 0, - shmBytes: 0 + shmBytes: 0, + indexLatestAt: null, + sourceLatestAt: null } send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable }) return @@ -166,6 +169,24 @@ async function handleStatus( }) } +/** + * 每个会话「已经索引到源数据的哪个时刻」。 + * 增量 pass 靠它决定哪些会话可以整段跳过(见 `KnowledgeStore.readSourceHighWaterMarks`)。 + */ +async function handleHighWater( + request: KnowledgeWorkerRequest, + payload: KnowledgeStatusRequest +): Promise { + const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId) + const marks = existsSync(path) ? getStore(payload).readSourceHighWaterMarks() : {} + send({ + version: 1, + type: 'result', + requestId: request.requestId, + payload: { marks } as unknown as KnowledgeWorkerResponse['payload'] + }) +} + async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise { try { if (request.type === 'cancel') { @@ -201,6 +222,10 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number await handleStatus(request, request.payload as KnowledgeStatusRequest) return } + if (request.type === 'highWater') { + await handleHighWater(request, request.payload as KnowledgeStatusRequest) + return + } if (request.type === 'index') { await handleIndex(request, request.payload as KnowledgeIndexRequest) return diff --git a/src/main/query-agent-poc-cli.ts b/src/main/query-agent-poc-cli.ts index f5500a4..5165f4f 100644 --- a/src/main/query-agent-poc-cli.ts +++ b/src/main/query-agent-poc-cli.ts @@ -1,8 +1,15 @@ /** - * Query Agent POC 的 CLI 参数解析。 + * Query Agent CLI 的参数解析。 * - * 单独抽成纯函数,便于单测;避免 entry 文件的副作用(app bootstrap / app.whenReady)影响测试。 + * 单独抽成纯函数便于单测,避免 entry 文件的副作用(app bootstrap / app.whenReady)影响测试。 + * + * 两种模式: + * 1. 自然语言:`"问题" [--scope '{...}']` → 走完整 Runtime(LLM tool loop) + * 2. 裸工具诊断:`--tool --args '{...json...}'` → 直接调 Local Query API, + * 用于在没有 GUI 的情况下审计 engine 侧数据(group 解析、覆盖度、Evidence 计数) */ +import type { QueryCorpusScope } from '../shared/local-query-api' + export function parsePocQuestion(argv: readonly string[]): string { const args = [...argv] // npm / pnpm 在复合 script(`build && electron ...`)里会把 `--` 一并追加到命令末尾, @@ -11,3 +18,80 @@ export function parsePocQuestion(argv: readonly string[]): string { if (args[0] === '--') args.shift() return args.join(' ').trim() } + +export type PocInvocation = + | { kind: 'question'; question: string; scope?: QueryCorpusScope; pretty: boolean } + | { kind: 'tool'; toolName: string; args: Record; pretty: boolean } + +export const POC_TOOL_NAMES = [ + 'query_messages', + 'search_messages', + 'message_context', + 'conversation_overview' +] as const + +/** + * 解析 CLI 调用意图。任何参数错误都直接抛出,避免静默变成"提问"。 + */ +export function parsePocInvocation(argv: readonly string[]): PocInvocation { + const args = [...argv] + if (args[0] === '--') args.shift() + const pretty = args.includes('--pretty') + const scopeIndex = args.indexOf('--scope') + let scope: QueryCorpusScope | undefined + if (scopeIndex >= 0) { + const rawScope = args[scopeIndex + 1] + if (!rawScope) throw new Error('缺少 --scope ') + let parsedScope: unknown + try { + parsedScope = JSON.parse(rawScope) + } catch { + throw new Error('--scope 不是合法 JSON') + } + if (!parsedScope || typeof parsedScope !== 'object' || Array.isArray(parsedScope)) { + throw new Error('--scope 必须是 JSON 对象') + } + const kind = (parsedScope as { kind?: unknown }).kind + if (kind !== 'all' && kind !== 'groups' && kind !== 'contact' && kind !== 'current') { + throw new Error('--scope.kind 必须是 all / groups / contact / current') + } + scope = parsedScope as QueryCorpusScope + if ((kind === 'contact' || kind === 'current') && !(parsedScope as { conversationId?: unknown }).conversationId) { + throw new Error('--scope.kind=contact|current 时必须给 conversationId') + } + } + const toolIndex = args.indexOf('--tool') + if (toolIndex < 0) { + const question = + scopeIndex < 0 + ? args.join(' ').trim() + : args + .filter((_, index) => index !== scopeIndex && index !== scopeIndex + 1) + .join(' ') + .trim() + return { kind: 'question', question, ...(scope ? { scope } : {}), pretty } + } + + const toolName = (args[toolIndex + 1] || '').trim() + if (!POC_TOOL_NAMES.includes(toolName as (typeof POC_TOOL_NAMES)[number])) { + throw new Error(`不支持的诊断工具: ${toolName || '(空)'}(可选:${POC_TOOL_NAMES.join(' / ')})`) + } + const argsIndex = args.indexOf('--args') + const rawArgs = argsIndex >= 0 ? args[argsIndex + 1] : undefined + if (!rawArgs) throw new Error('缺少 --args ') + let parsed: unknown + try { + parsed = JSON.parse(rawArgs) + } catch { + throw new Error('--args 不是合法 JSON') + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('--args 必须是 JSON 对象') + } + return { + kind: 'tool', + toolName, + args: { ...(parsed as Record), ...(scope ? { scope } : {}) }, + pretty + } +} diff --git a/src/main/query-agent-poc-entry.ts b/src/main/query-agent-poc-entry.ts index 59d2150..1953d18 100644 --- a/src/main/query-agent-poc-entry.ts +++ b/src/main/query-agent-poc-entry.ts @@ -1,13 +1,28 @@ +/** + * Query Agent CLI 入口(adapter)。 + * + * 这里**只负责** CLI 专属的部分:argv 解析、HTTP Tool Executor、诊断摘要、stdout JSON。 + * Query Agent 的行为完全来自 `src/main/services/query-agent-service.ts` —— CLI 与桌面问问微信 / + * Agent Hub 共用同一份实现,不存在第二套 prompt 或 orchestration。 + */ import './app-data-bootstrap' import { app } from 'electron' import { apiTokenStore } from './api-token-store' import { AIProviderService } from './services/ai-provider-service' -import { QueryAgentPocService, type QueryAgentToolResult } from './services/query-agent-poc-service' -import { parsePocQuestion } from './query-agent-poc-cli' +import { QueryAgentService, type QueryAgentToolResult } from './services/query-agent-service' +import { parsePocInvocation } from './query-agent-poc-cli' import { formatProviderDiagnostics, formatTiming } from './query-agent-poc-report' +import type { QueryCorpusScope } from '../shared/local-query-api' + +/** CLI 侧没有联系人列表,范围说明只能是通用文案(生产由 UI 提供带名字的 label)。 */ +const describePocScope = (scope: QueryCorpusScope): string => { + if (scope.kind === 'all') return '所有聊天记录' + if (scope.kind === 'groups') return '群聊专属(全部群聊)' + return scope.kind === 'contact' ? '单聊专属(指定联系人)' : '当前会话' +} const baseUrl = (process.env.TRACEMEMO_QUERY_API_BASE || 'http://127.0.0.1:6131/api/v1').replace(/\/+$/, '') -const question = parsePocQuestion(process.argv.slice(2)) +const invocation = parsePocInvocation(process.argv.slice(2)) async function callQueryApi(name: string, input: Record): Promise { const paths: Record = { @@ -26,11 +41,34 @@ async function callQueryApi(name: string, input: Record): Promi return { ...payload, status } } +/** + * CLI 的 Tool Executor:与生产的进程内 executor 等价 —— 语料边界由 Host 注入请求, + * LLM 无法提供它。两者只差 transport(这里走 HTTP,生产走进程内调用)。 + */ +const executePocTool = ( + name: string, + input: Record, + context?: { conversationScope?: unknown } +): Promise => + callQueryApi( + name, + context?.conversationScope ? { ...input, scope: context.conversationScope } : input + ) + async function main(): Promise { await app.whenReady() + if (invocation.kind === 'tool') { + // 裸工具诊断:直接调 Local Query API,用来在没有 GUI 的情况下审计引擎侧真实数据。 + const toolResult = await callQueryApi(invocation.toolName, invocation.args) + process.stdout.write(`${JSON.stringify(toolResult, null, 2)}\n`) + app.quit() + return + } const provider = new AIProviderService() - const service = new QueryAgentPocService(provider, callQueryApi) - const result = await service.run(question) + const service = new QueryAgentService(provider, executePocTool) + const result = await service.run(invocation.question, { + ...(invocation.scope ? { conversationScope: { scope: invocation.scope, label: describePocScope(invocation.scope) } } : {}) + }) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) // 诊断摘要写 stderr,保持 stdout 仍是纯 JSON,方便管道与脚本消费。 process.stderr.write(formatTiming(result)) diff --git a/src/main/query-agent-poc-report.ts b/src/main/query-agent-poc-report.ts index ca19fca..64eab6b 100644 --- a/src/main/query-agent-poc-report.ts +++ b/src/main/query-agent-poc-report.ts @@ -1,4 +1,4 @@ -import type { QueryAgentPocResult } from './services/query-agent-poc-service' +import type { QueryAgentResult } from './services/query-agent-service' const WIDTH = 24 @@ -16,7 +16,7 @@ function row(label: string, value: string): string { * - 3 次以上(无法逐段归属工具耗时时,明确标注为聚合) * - 首次模型调用失败 / 末尾模型调用失败 */ -export function formatTiming(result: QueryAgentPocResult): string { +export function formatTiming(result: QueryAgentResult): string { const durations = result.modelDurationsMs || [] const toolTotalMs = result.toolTotalMs || 0 const toolCount = result.toolCallCount || 0 @@ -52,7 +52,7 @@ export function formatTiming(result: QueryAgentPocResult): string { * 请求级诊断。 * 只输出 host 与状态字段;绝不输出 API key / Authorization / 完整 URL / 响应正文。 */ -export function formatProviderDiagnostics(result: QueryAgentPocResult, host?: string): string { +export function formatProviderDiagnostics(result: QueryAgentResult, host?: string): string { const lines = [ '', '[Provider]', diff --git a/src/main/services/agent-hub-routing.ts b/src/main/services/agent-hub-routing.ts new file mode 100644 index 0000000..de8a544 --- /dev/null +++ b/src/main/services/agent-hub-routing.ts @@ -0,0 +1,125 @@ +/** + * Agent Hub 入站文本的**纯路由**:无 LLM、无副作用、可单测。 + * 明确的产物 / 群成员分析 / 会话列表请求各走确定性 Action,其余一律进 Query Agent Runtime。 + * + * **"总结" 不等于 Report**:只有同时提到"群"与明确产物才触发 Report Action。 + * 查询类问题直接进 Query Agent,不做意图分类 —— 避免"先分类一次 LLM、再查询一次 LLM"。 + */ +import type { AskWechatQueryResult } from '../../shared/query-agent' + +export interface GroupReportIntent { + group: string + range: 'today' | 'yesterday' | '7days' +} + +export interface GroupMemberChatIntent { + group: string + member: string + range: 'today' | 'yesterday' | '7days' + days: number + goal: string +} + +export type InboundRoute = + | { kind: 'report_action'; intent: GroupReportIntent } + | { kind: 'group_member_action'; intent: GroupMemberChatIntent } + | { kind: 'recent_list'; limit: number } + | { kind: 'knowledge_query' } + +/** + * Report Action 快捷匹配。 + * + * 必须同时满足:提到"群" + 提到明确的**产物**(图片 / 长图 / 日报 / 报告)。 + * 只提到"总结"不算 —— 那是 Query Agent 的 conversation_overview 场景。 + */ +export function matchGroupReportIntent(text: string): GroupReportIntent | null { + const normalized = text.trim() + if (!normalized.includes('群') || !/(图片|长图|日报|报告)/.test(normalized)) return null + const range = /(7天|七天|一周)/.test(normalized) + ? '7days' + : /(昨天|昨日)/.test(normalized) + ? 'yesterday' + : 'today' + const group = normalized + .replace( + /请|帮我|生成|做一份|做个|今天的|今日的|今天|今日|昨天的|昨日的|昨天|昨日|最近7天的|最近七天的|最近7天|最近七天|近7天的|近七天的|近7天|近七天|消息|聊天记录|聊天|群聊总结|群总结|群日报|群报告|总结|日报|报告|图片|长图/g, + '' + ) + .replace(/[,。!??::]/g, '') + .trim() + .replace(/成$/, '') + .replace(/群$/, '') + .trim() + return group ? { group, range } : null +} + +/** 群成员专用分析的快捷匹配(保留为 Action:它有自己的 goal 与输出形态)。 */ +export function matchGroupMemberChatIntent(text: string): GroupMemberChatIntent | null { + const normalized = text.trim().replace(/[,。!??::]/g, '') + const timePattern = '(今天|今日|昨天|昨日|最近\\d{1,2}天|近\\d{1,2}天|最近|近来|这几天)' + const actionPattern = '(?:说了什么|聊了什么|发言|说过什么|都聊什么|都说什么|干了什么)' + const patterns = [ + new RegExp( + `(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)[\\s,,]+(.+?)${timePattern}${actionPattern}` + ), + new RegExp( + `(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)(?:里|中的)(.+?)${timePattern}${actionPattern}` + ) + ] + for (const pattern of patterns) { + const match = normalized.match(pattern) + if (match?.[1]?.trim() && match[2]?.trim()) { + const range = /昨天|昨日/.test(match[3] || '') + ? 'yesterday' + : /今天|今日/.test(match[3] || '') + ? 'today' + : '7days' + const days = Math.max(1, Math.min(30, Number((match[3] || '').match(/\d{1,2}/)?.[0]) || 7)) + return { + group: match[1].trim(), + member: match[2].trim(), + range, + days, + goal: normalized + } + } + } + return null +} + +/** + * "最近有哪些会话"类请求。 + * + * 这是**确定性能力**(列出会话),不是消息内容查询 —— Query Agent 无法表达, + * 因此保留为不经过模型的无 LLM 快捷路径。 + */ +export function matchRecentChatIntent(text: string): number | null { + const normalized = text.replace(/\s+/g, '') + if (!normalized.includes('最近') || !/(消息|会话|聊天)/.test(normalized)) return null + const limit = Number(normalized.match(/\d{1,2}/)?.[0] || 5) + return Math.max(1, Math.min(20, limit)) +} + +export function resolveInboundRoute(text: string): InboundRoute { + const report = matchGroupReportIntent(text) + if (report) return { kind: 'report_action', intent: report } + const member = matchGroupMemberChatIntent(text) + if (member) return { kind: 'group_member_action', intent: member } + const recent = matchRecentChatIntent(text) + if (recent !== null) return { kind: 'recent_list', limit: recent } + return { kind: 'knowledge_query' } +} + +/** 查询大脑不可用时的统一文案(不暴露 stack / provider raw response / 内部 id)。 */ +export const QUERY_AGENT_UNAVAILABLE_TEXT = '当前 AI 查询服务暂时不可用,请稍后再试。' + +/** + * 把 Query Agent 结果映射成**微信文字回复**。 + * 只输出用户可读文本;Tool trace / temporalBasis / Evidence / 诊断字段一律不下发。 + */ +export function queryAgentReplyText(result: AskWechatQueryResult): string { + if (result.status === 'answered') return result.answer + if (result.status === 'provider_unavailable' || result.status === 'error') return result.message + // Agent Hub 没有 Legacy runner,不会产生 legacy 结果;真出现时按服务不可用处理,不暴露内部结构。 + return QUERY_AGENT_UNAVAILABLE_TEXT +} diff --git a/src/main/services/agent-hub-service.ts b/src/main/services/agent-hub-service.ts index 19613e0..a1b98c3 100644 --- a/src/main/services/agent-hub-service.ts +++ b/src/main/services/agent-hub-service.ts @@ -15,6 +15,15 @@ import type { import type { AppSettings } from './settings-store' import { generateAgentGroupReport } from './agent-group-report-service' import { AIProviderService } from './ai-provider-service' +import { QueryAgentService } from './query-agent-service' +import { AskWechatService } from './ask-wechat-service' +import { + QUERY_AGENT_UNAVAILABLE_TEXT, + queryAgentReplyText, + resolveInboundRoute, + type GroupMemberChatIntent, + type GroupReportIntent +} from './agent-hub-routing' import { isPackagedRuntime } from '../runtime-mode' import { getGroupSnapshot, @@ -53,30 +62,6 @@ export interface AgentHubNotificationResult { error?: string } -interface GroupReportIntent { - group: string - range: 'today' | 'yesterday' | '7days' -} - -interface ContactChatIntent { - contact: string - limit: number - summarize: boolean -} - -interface GroupMemberChatIntent { - group: string - member: string - range: 'today' | 'yesterday' | '7days' - days: number - goal: string -} - -interface NaturalLanguageResult { - command?: string - reply?: string -} - const agentAIProvider = new AIProviderService() function resolveBundledBinary( @@ -127,6 +112,24 @@ export class AgentHubService { updatedAt: Date.now() } + /** + * 查询大脑。由主进程注入**同一个** QueryAgentRuntime 实例(桌面问问微信也用它), + * Agent Hub 只负责把微信问题送进去、把回答发回去。 + */ + private queryAgent: AskWechatService | null = null + + /** + * 注入生产 Query Agent Runtime(桌面与微信机器人共用同一实现,避免第二套 Query 语义)。 + */ + setQueryAgentService(runtime: QueryAgentService): void { + this.queryAgent = new AskWechatService(runtime, { + entry: 'agent-hub', + // Agent Hub 没有 Legacy AI Search 通道:查询失败时给出明确文案,绝不误触 Report Action。 + log: (record) => + this.addLog('agent-hub', record.level === 'info' ? 'info' : record.level, record.message) + }) + } + async start(settings: AppSettings): Promise { void settings this.stopping = false @@ -397,301 +400,82 @@ export class AgentHubService { .join(' ') this.addLog('agent-hub', 'info', `收到微信消息 message_id=${messageId || 'unknown'}`) - const reportIntent = this.matchGroupReportIntent(text) + // 三路边界:明确产物 → Report / 成员分析 Action;会话列表 → 确定性能力;其余 → Query Agent。 + // 注意:这里**不再**先跑意图分类 LLM,查询类问题直接进入 Query Agent(避免双重 LLM 语义系统)。 + const route = resolveInboundRoute(text) - if (reportIntent) { + if (route.kind === 'report_action') { if (messageId) this.processedMessages.set(messageId, Date.now()) this.addLog( 'agent-hub', 'info', - `匹配群聊总结:${reportIntent.group}(${reportIntent.range})` + `匹配群聊总结:${route.intent.group}(${route.intent.range})` ) await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch((error) => { this.addLog('agent-hub', 'warn', `等待提示发送失败:${this.errorMessage(error)}`) }) - void this.generateAndSendReport(inbound, reportIntent) + void this.generateAndSendReport(inbound, route.intent) return this.sendHubJson(response, 202, { status: 'generating' }) } - const groupMemberIntent = this.matchGroupMemberChatIntent(text) - if (groupMemberIntent) { + if (route.kind === 'group_member_action') { if (messageId) this.processedMessages.set(messageId, Date.now()) - void this.summarizeGroupMemberChat(inbound, groupMemberIntent) + void this.summarizeGroupMemberChat(inbound, route.intent) return this.sendHubJson(response, 202, { status: 'generating', mode: 'group-member-summary' }) } - const contactChatIntent = this.matchContactChatIntent(text) - if (contactChatIntent) { - if (messageId) this.processedMessages.set(messageId, Date.now()) - if (contactChatIntent.summarize) { - void this.summarizeContactChat(inbound, contactChatIntent) - return this.sendHubJson(response, 202, { status: 'generating', mode: 'contact-summary' }) + if (route.kind === 'recent_list') { + if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' }) + const items = listRecentChat(route.limit) + const lines = items.map((item, index) => { + const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim() + return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})` + }) + const reply = lines.length + ? `最近 ${items.length} 个会话:\n${lines.join('\n')}` + : '暂时没有找到最近会话。' + try { + await this.sendConnector(inbound, reply) + } catch (error) { + this.addLog('agent-hub', 'error', `回复发送失败:${this.errorMessage(error)}`) + return this.sendHubJson(response, 502, { error: 'reply delivery failed' }) } - await this.replyContactChat(inbound, contactChatIntent) + if (messageId) this.processedMessages.set(messageId, Date.now()) + this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`) return this.sendHubJson(response, 200, { status: 'ok' }) } - const recentChatLimit = this.matchRecentChatIntent(text) - if (recentChatLimit === null && text.trim()) { - if (messageId) this.processedMessages.set(messageId, Date.now()) - void this.handleNaturalLanguage(inbound, text) - return this.sendHubJson(response, 202, { - status: 'processing', - mode: 'natural-language' - }) - } - if (recentChatLimit === null) { - this.addLog('agent-hub', 'info', '消息已忽略:没有匹配到支持的意图') - return this.sendHubJson(response, 202, { status: 'ignored', reason: 'no matching intent' }) - } - if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' }) - const items = listRecentChat(recentChatLimit) - const lines = items.map((item, index) => { - const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim() - return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})` - }) - const reply = lines.length - ? `最近 ${items.length} 个会话:\n${lines.join('\n')}` - : '暂时没有找到最近会话。' - try { - await this.sendConnector(inbound, reply) - } catch (error) { - this.addLog('agent-hub', 'error', `回复发送失败:${this.errorMessage(error)}`) - return this.sendHubJson(response, 502, { error: 'reply delivery failed' }) + if (!text.trim()) { + this.addLog('agent-hub', 'info', '消息已忽略:内容为空') + return this.sendHubJson(response, 202, { status: 'ignored', reason: 'empty text' }) } if (messageId) this.processedMessages.set(messageId, Date.now()) - this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`) - this.sendHubJson(response, 200, { status: 'ok' }) + void this.handleKnowledgeQuery(inbound, text) + return this.sendHubJson(response, 202, { status: 'processing', mode: 'query-agent' }) } - private async handleNaturalLanguage(inbound: InboundMessage, text: string): Promise { - try { - const result = await this.resolveNaturalLanguage(text) - if (result.reply) { - await this.sendConnector(inbound, this.formatAIReply(result.reply)) - this.addLog('agent-hub', 'info', '自然语言回复已发送') - return - } - if (!result.command) { - await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。') - return - } - - this.addLog('agent-hub', 'info', `自然语言已理解为:${result.command}`) - const reportIntent = this.matchGroupReportIntent(result.command) - if (reportIntent) { - await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch(() => undefined) - await this.generateAndSendReport(inbound, reportIntent) - return - } - - const groupMemberIntent = this.matchGroupMemberChatIntent(result.command) - if (groupMemberIntent) { - await this.summarizeGroupMemberChat(inbound, groupMemberIntent) - return - } - - const contactIntent = this.matchContactChatIntent(result.command) - if (contactIntent) { - if (contactIntent.summarize) await this.summarizeContactChat(inbound, contactIntent) - else await this.replyContactChat(inbound, contactIntent) - return - } - - const recentLimit = this.matchRecentChatIntent(result.command) - if (recentLimit !== null) { - await this.replyRecentChats(inbound, recentLimit) - return - } - await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。') - } catch (error) { - this.addLog('agent-hub', 'error', `自然语言处理失败:${this.errorMessage(error)}`) - await this.sendConnector(inbound, `处理失败:${this.errorMessage(error)}`).catch( - () => undefined - ) - } - } - - private async replyRecentChats(inbound: InboundMessage, limit: number): Promise { - if (!isReady()) { - await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。') + /** + * 查询类问题("微信里发生了什么"、普通闲聊)统一走 Query Agent Runtime。 + * 失败时不回退 Report Action,只给用户明确文案。 + */ + private async handleKnowledgeQuery(inbound: InboundMessage, text: string): Promise { + const service = this.queryAgent + if (!service) { + this.addLog('agent-hub', 'error', '查询大脑尚未初始化') + await this.sendConnector(inbound, QUERY_AGENT_UNAVAILABLE_TEXT).catch(() => undefined) return } - const items = listRecentChat(limit) - const lines = items.map((item, index) => { - const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim() - return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})` - }) - await this.sendConnector( - inbound, - lines.length ? `最近 ${items.length} 个会话:\n${lines.join('\n')}` : '暂时没有找到最近会话。' - ) - this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`) - } - - private async resolveNaturalLanguage(text: string): Promise { - const result = await agentAIProvider.chat([ - { - role: 'system', - content: `你是 TraceMemo 微信机器人的意图理解器。只能输出一行 JSON,不要 Markdown。 -支持的工具: -1. recent:查看最近会话,参数 limit 为 1-20。 -2. contact:查看我与某个联系人的最近聊天,参数 contact 和 limit。 -3. report:生成某个群的群聊总结图片,参数 group 和 range(today、yesterday、7days)。 -4. group_member:分析某个群里某位成员的发言,参数 group、member、range(today、yesterday、7days)、days(1-30)和 goal(保留用户希望总结、研究人物、提取观点等完整目标)。只要用户同时提到群聊和群成员,应优先使用 group_member,不能识别成 contact。 -5. chat:不需要工具的普通对话,reply 用简洁中文直接回答。 -输出格式:{"type":"recent|contact|report|group_member|chat","limit":5,"contact":"","group":"","member":"","range":"today","days":3,"goal":"","reply":""} -不要声称已经读取未调用的聊天记录,不要执行电脑控制、文件操作、付款或发送给其他联系人。` - }, - { role: 'user', content: text.slice(0, 1000) } - ]) - if (!result.success || !result.data) { - this.addLog('agent-hub', 'warn', `自然语言理解不可用:${result.error || 'AI 未返回内容'}`) - return {} - } - try { - const json = result.data.match(/\{[\s\S]*\}/)?.[0] - if (!json) return {} - const parsed = JSON.parse(json) as Record - const limit = Math.max(1, Math.min(20, Number(parsed['limit']) || 5)) - if (parsed['type'] === 'recent') return { command: `最近${limit}条消息` } - if (parsed['type'] === 'contact' && String(parsed['contact'] || '').trim()) { - return { command: `我和${String(parsed['contact']).trim()}最近${limit}条聊了什么` } - } - if (parsed['type'] === 'report' && String(parsed['group'] || '').trim()) { - const range = - parsed['range'] === '7days' - ? '最近7天' - : parsed['range'] === 'yesterday' - ? '昨天' - : '今天' - return { command: `生成${String(parsed['group']).trim()}${range}的群聊总结图片` } - } - if ( - parsed['type'] === 'group_member' && - String(parsed['group'] || '').trim() && - String(parsed['member'] || '').trim() - ) { - const range = - parsed['range'] === 'yesterday' - ? '昨天' - : parsed['range'] === 'today' - ? '今天' - : '最近7天' - const group = String(parsed['group'] || '') - .trim() - .replace(/(?:群聊|群)+$/g, '') - const days = Math.max(1, Math.min(30, Number(parsed['days']) || 7)) - const goal = String(parsed['goal'] || '总结发言').trim() - return { - command: `看看${group}群里${String(parsed['member']).trim()}${range === '最近7天' ? `最近${days}天` : range}说了什么,${goal}` - } - } - if (parsed['type'] === 'chat') { - const reply = String(parsed['reply'] || '').trim() - return reply ? { reply: reply.slice(0, 1500) } : {} - } + const conversationKey = `${String(inbound.account_id || '')}::${String(inbound.from_user_id || '')}` + const result = await service.ask( + { requestId: `agent-hub-${Date.now()}-${this.nextLogId}`, text }, + conversationKey + ) + await this.sendConnector(inbound, this.formatAIReply(queryAgentReplyText(result))) + this.addLog('agent-hub', 'info', `查询回答已发送(${result.status})`) } catch (error) { - this.addLog('agent-hub', 'warn', `自然语言结果解析失败:${this.errorMessage(error)}`) - } - return {} - } - - private async replyContactChat( - inbound: InboundMessage, - intent: ContactChatIntent - ): Promise { - if (!isReady()) { - await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。') - return - } - - const contact = resolveMd5(intent.contact) - if (!contact || contact.type !== 'user') { - this.addLog('agent-hub', 'info', `没有匹配到联系人:${intent.contact}`) - await this.sendConnector(inbound, `没有找到联系人“${intent.contact}”。`) - return - } - - this.addLog( - 'agent-hub', - 'info', - `匹配联系人聊天查询:${contact.m_nsNickName}(最近 ${intent.limit} 条)` - ) - const messages = listMessages(contact.md5, undefined, undefined, { limit: intent.limit }) - const recent = messages.slice(-intent.limit) - const lines = recent.map((message) => { - const speaker = message.isSender ? '我' : contact.m_nsNickName - const content = this.describeChatMessage(message.content, message.type) - return `${speaker}:${content}` - }) - const reply = lines.length - ? `我和${contact.m_nsNickName}最近聊了这些:\n${lines.join('\n')}` - : `暂时没有找到和${contact.m_nsNickName}的聊天记录。` - await this.sendConnector(inbound, reply) - this.addLog('agent-hub', 'info', `联系人聊天回复已发送(${recent.length} 条)`) - } - - private async summarizeContactChat( - inbound: InboundMessage, - intent: ContactChatIntent - ): Promise { - try { - if (!isReady()) { - await this.sendConnector(inbound, 'TraceMemo 本地数据库尚未连接,请连接后再试。') - return - } - const contact = resolveMd5(intent.contact) - if (!contact || contact.type !== 'user') { - await this.sendConnector(inbound, `没有找到联系人“${intent.contact}”。`) - return - } - - await this.sendConnector( - inbound, - `收到!正在整理和${contact.m_nsNickName}的近期聊天,请等待…` - ) - const endTime = Math.floor(Date.now() / 1000) - const startTime = endTime - 7 * 24 * 60 * 60 - const messages = listMessages(contact.md5, startTime, endTime, { limit: 300 }).slice(-300) - if (!messages.length) { - await this.sendConnector(inbound, `最近 7 天没有找到和${contact.m_nsNickName}的聊天记录。`) - return - } - - const transcript = messages - .map((message) => { - const speaker = message.isSender ? '我' : contact.m_nsNickName - return `[${message.datetime}] ${speaker}:${this.describeChatMessage(message.content, message.type)}` - }) - .join('\n') - const summary = await agentAIProvider.chat([ - { - role: 'system', - content: - '你是私人聊天记录总结助手。仅根据提供的记录总结,不编造。按日期或主题整理关键进展、双方观点、决定、待办和未解决问题;忽略无意义表情,保留重要数字与事实。使用适合微信阅读的简洁中文。' - }, - { - role: 'user', - content: `请总结我和“${contact.m_nsNickName}”最近 7 天聊了什么。\n\n聊天记录:\n${transcript}` - } - ]) - if (!summary.success || !summary.data?.trim()) { - throw new Error(summary.error || 'AI 未返回总结') - } - await this.sendConnector( - inbound, - this.formatAIReply( - `和${contact.m_nsNickName}最近聊天总结(近 7 天,共 ${messages.length} 条):\n\n${summary.data.trim().slice(0, 3500)}` - ) - ) - this.addLog('agent-hub', 'info', `联系人聊天总结已发送(${messages.length} 条)`) - } catch (error) { - this.addLog('agent-hub', 'error', `联系人聊天总结失败:${this.errorMessage(error)}`) - await this.sendConnector(inbound, `聊天总结失败:${this.errorMessage(error)}`).catch( - () => undefined - ) + this.addLog('agent-hub', 'error', `查询处理失败:${this.errorMessage(error)}`) + await this.sendConnector(inbound, QUERY_AGENT_UNAVAILABLE_TEXT).catch(() => undefined) } } @@ -881,65 +665,6 @@ export class AgentHubService { return { ok: response.ok, status: response.status, body: await response.text() } } - private matchRecentChatIntent(text: string): number | null { - const normalized = text.replace(/\s+/g, '') - if (!normalized.includes('最近') || !/(消息|会话|聊天)/.test(normalized)) return null - const limit = Number(normalized.match(/\d{1,2}/)?.[0] || 5) - return Math.max(1, Math.min(20, limit)) - } - - private matchContactChatIntent(text: string): ContactChatIntent | null { - const normalized = text.replace(/\s+/g, '').replace(/[,。!??::]/g, '') - if (!normalized.includes('最近') || !/(聊|消息|会话)/.test(normalized)) return null - - const patterns = [ - /(?:看一下|看看|查一下|查询)?我和(.+?)最近(?:\d{1,2}条)?(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/, - /(?:看一下|看看|查一下|查询)?(?:我)?最近(?:\d{1,2}条)?和(.+?)(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/, - /(?:看一下|看看|查一下|查询)?和(.+?)最近(?:\d{1,2}条)?(?:聊了什么|聊什么|的聊天|的消息|聊天|消息)/ - ] - const contact = patterns - .map((pattern) => normalized.match(pattern)?.[1]?.trim()) - .find((value): value is string => Boolean(value)) - if (!contact) return null - - const limit = Number(normalized.match(/最近(\d{1,2})条/)?.[1] || 10) - const summarize = /(聊了什么|聊什么|说了什么|谈了什么|总结)/.test(normalized) - return { contact, limit: Math.max(1, Math.min(20, limit)), summarize } - } - - private matchGroupMemberChatIntent(text: string): GroupMemberChatIntent | null { - const normalized = text.trim().replace(/[,。!??::]/g, '') - const timePattern = '(今天|今日|昨天|昨日|最近\\d{1,2}天|近\\d{1,2}天|最近|近来|这几天)' - const actionPattern = '(?:说了什么|聊了什么|发言|说过什么|都聊什么|都说什么|干了什么)' - const patterns = [ - new RegExp( - `(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)[\\s,,]+(.+?)${timePattern}${actionPattern}` - ), - new RegExp( - `(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)(?:里|中的)(.+?)${timePattern}${actionPattern}` - ) - ] - for (const pattern of patterns) { - const match = normalized.match(pattern) - if (match?.[1]?.trim() && match[2]?.trim()) { - const range = /昨天|昨日/.test(match[3] || '') - ? 'yesterday' - : /今天|今日/.test(match[3] || '') - ? 'today' - : '7days' - const days = Math.max(1, Math.min(30, Number((match[3] || '').match(/\d{1,2}/)?.[0]) || 7)) - return { - group: match[1].trim(), - member: match[2].trim(), - range, - days, - goal: normalized - } - } - } - return null - } - private resolveGroup(query: string): ReturnType { const normalize = (value: string): string => value @@ -961,27 +686,6 @@ export class AgentHubService { ) } - private matchGroupReportIntent(text: string): GroupReportIntent | null { - const normalized = text.trim() - if (!normalized.includes('群') || !/(总结|日报|报告)/.test(normalized)) return null - const range = /(7天|七天|一周)/.test(normalized) - ? '7days' - : /(昨天|昨日)/.test(normalized) - ? 'yesterday' - : 'today' - const group = normalized - .replace( - /请|帮我|生成|做一份|做个|今天的|今日的|今天|今日|昨天的|昨日的|昨天|昨日|最近7天的|最近七天的|最近7天|最近七天|近7天的|近七天的|近7天|近七天|消息|聊天记录|聊天|群聊总结|群总结|群日报|群报告|总结|日报|报告|图片|长图/g, - '' - ) - .replace(/[,。!??::]/g, '') - .trim() - .replace(/成$/, '') - .replace(/群$/, '') - .trim() - return group ? { group, range } : null - } - private authorized(header: string | undefined): boolean { if (!header?.startsWith('Bearer ')) return false const expected = Buffer.from(this.inboundToken) diff --git a/src/main/services/ai-search-pipeline-service.ts b/src/main/services/ai-search-pipeline-service.ts index b04ee37..26536d1 100644 --- a/src/main/services/ai-search-pipeline-service.ts +++ b/src/main/services/ai-search-pipeline-service.ts @@ -586,6 +586,9 @@ export class AiSearchPipelineService { state: 'ready', indexedMessageCount: 0, indexedChunkCount: 0, + // Legacy 路径不消费 freshness 口径;显式写 null,避免与 Query Agent 的覆盖语义混淆。 + indexLatestAt: null, + sourceLatestAt: null, totalMessages: 0, evidence: [], timings: emptyKnowledgeSearchTimings() @@ -1379,6 +1382,8 @@ export class AiSearchPipelineService { state: 'unavailable', indexedMessageCount: 0, indexedChunkCount: 0, + indexLatestAt: null, + sourceLatestAt: null, totalMessages: 0, evidence: [], timings: emptyKnowledgeSearchTimings() diff --git a/src/main/services/ask-wechat-service.ts b/src/main/services/ask-wechat-service.ts new file mode 100644 index 0000000..9b361cb --- /dev/null +++ b/src/main/services/ask-wechat-service.ts @@ -0,0 +1,276 @@ +import type { AiSearchPipelineRequest, AiSearchPipelineResult } from '../../shared/ai-search' +import type { + AskWechatEvidenceItem, + AskWechatOutcome, + AskWechatFallbackReason, + AskWechatQueryRequest, + AskWechatQueryResult, + AskWechatScope, + AskWechatStats, + QueryAgentDiagnostics, + QueryAgentEntry, + QueryAgentProgressEvent +} from '../../shared/query-agent' +import { QueryAgentService, type QueryAgentResult } from './query-agent-service' +import { QueryAgentConversationMemory } from './query-agent-conversation-memory' + +/** Provider 不可用时的用户可见文案。不下发内部错误、stack、raw provider response。 */ +const PROVIDER_UNAVAILABLE_MESSAGE = '当前 AI 查询服务暂时不可用,请稍后再试。' +/** Runtime 不可恢复错误(且无法回退)时的用户可见文案。 */ +const RUNTIME_FAILURE_MESSAGE = '本次查询没有完成,请稍后再试或换一种问法。' +const EMPTY_QUESTION_MESSAGE = '请先输入想了解的问题。' + +export type AskWechatLegacyRunner = ( + request: AiSearchPipelineRequest +) => Promise + +export interface AskWechatLogRecord { + level: 'info' | 'warn' | 'error' + message: string + details?: Record +} + +export interface AskWechatServiceOptions { + /** 入口标记,只用于诊断。 */ + entry: QueryAgentEntry + /** + * 仅桌面使用:Runtime 不可恢复错误时允许回退 Legacy。 + * 不传表示该入口没有 Legacy fallback(Agent Hub 就没传)。 + */ + runLegacy?: AskWechatLegacyRunner + log?: (record: AskWechatLogRecord) => void + memory?: QueryAgentConversationMemory +} + +/** + * 「问问微信」/ Agent Hub 查询入口的 Adapter。 + * + * 职责:调用 Query Agent Runtime、维护有界的澄清上下文、把结构化结果映射成可展示的形状、 + * 并决定是否允许 Legacy fallback。它**不含**任何 prompt / tool / temporalBasis 语义。 + * + * Fallback 规则:只在 Runtime 不可恢复错误(异常 / tool limit)时回退 Legacy。 + * Provider 失败不回退(Legacy 用同一个 Provider,只会更慢);0 结果、模型说"没找到"、 + * 要求澄清、答案很短 —— 一律不回退。 + */ +export class AskWechatService { + private readonly memory: QueryAgentConversationMemory + + constructor( + private readonly runtime: QueryAgentService, + private readonly options: AskWechatServiceOptions + ) { + this.memory = options.memory || new QueryAgentConversationMemory() + } + + async ask( + request: AskWechatQueryRequest, + conversationKey = 'default', + /** + * 真实 Runtime 进度(ADDITIVE)。由 IPC 层转发给 renderer。 + * 不传时行为与之前完全一致;回调抛异常不会影响查询本身(Runtime 侧已兜住)。 + */ + onProgress?: (event: QueryAgentProgressEvent) => void + ): Promise { + const startedAt = Date.now() + const question = String(request.text || '').trim() + let result: QueryAgentResult + try { + result = await this.runtime.run(question, { + history: this.memory.history(conversationKey), + // 搜索范围:UI 决定的数据边界,Runtime 透传给 Engine 并结构性强制。 + ...(request.scope ? { conversationScope: request.scope } : {}), + ...(onProgress ? { onProgress } : {}) + }) + } catch { + // Runtime 抛出未分类异常 = UNEXPECTED_INTERNAL_ERROR,允许 Legacy fallback。 + const diagnostics = this.diagnostics( + { provider: '', model: '', modelCallCount: 0, toolCallCount: 0, traces: [] }, + startedAt, + 'runtime_error' + ) + this.writeLog('error', `Query Agent Runtime 异常(${this.options.entry})`, diagnostics) + return this.fallback(request, 'runtime_error', diagnostics) + } + + if (result.errorKind === 'invalid_question') { + return { + engine: 'query-agent', + status: 'error', + message: EMPTY_QUESTION_MESSAGE, + diagnostics: this.diagnostics(result, startedAt, 'invalid_question') + } + } + + if (result.errorKind === 'provider_unavailable' || result.errorKind === 'provider_failure') { + const outcome: AskWechatOutcome = result.errorKind + const diagnostics = this.diagnostics(result, startedAt, outcome) + this.writeLog('warn', `查询 Provider 不可用(${this.options.entry})`, diagnostics) + return { + engine: 'query-agent', + status: 'provider_unavailable', + message: PROVIDER_UNAVAILABLE_MESSAGE, + diagnostics + } + } + + if (result.errorKind === 'tool_limit') { + const diagnostics = this.diagnostics(result, startedAt, 'tool_limit') + this.writeLog('warn', `查询超出工具调用上限(${this.options.entry})`, diagnostics) + return this.fallback(request, 'runtime_error', diagnostics) + } + + if (!result.answer?.trim()) { + const diagnostics = this.diagnostics(result, startedAt, 'runtime_error') + this.writeLog('warn', `Query Agent 未返回回答(${this.options.entry})`, diagnostics) + return this.fallback(request, 'runtime_error', diagnostics) + } + + const answer = result.answer.trim() + // 澄清回答也记录:下一句("是 BOBO")需要接得上上文。 + this.memory.record(conversationKey, question, answer) + const diagnostics = this.diagnostics(result, startedAt, 'answered') + this.writeLog('info', `Query Agent 回答完成(${this.options.entry})`, diagnostics) + return { + engine: 'query-agent', + status: 'answered', + answer, + // 直接透传 Runtime 收集的真实证据:UI 不允许从 answer 文本反解析。 + evidence: (result.evidence || []) as AskWechatEvidenceItem[], + stats: buildAskWechatStats(result, request.scope, startedAt), + diagnostics + } + } + + /** 桌面点「新问题」时清掉当前会话的澄清上下文。 */ + forgetConversation(conversationKey = 'default'): void { + this.memory.forget(conversationKey) + } + + private async fallback( + request: AskWechatQueryRequest, + reason: AskWechatFallbackReason, + diagnostics: QueryAgentDiagnostics + ): Promise { + if (!this.options.runLegacy) { + return { + engine: 'query-agent', + status: 'error', + message: RUNTIME_FAILURE_MESSAGE, + diagnostics + } + } + try { + const legacyResult = await this.options.runLegacy({ + scope: 'global', + range: 'all', + ...request.legacy, + requestId: request.requestId, + text: request.text + }) + this.writeLog('warn', `Query Agent 失败后回退 Legacy(${this.options.entry})`, diagnostics, reason) + return { engine: 'legacy', status: 'legacy', reason, result: legacyResult } + } catch { + this.writeLog('error', `Legacy fallback 也失败(${this.options.entry})`, diagnostics, reason) + return { + engine: 'query-agent', + status: 'error', + message: RUNTIME_FAILURE_MESSAGE, + diagnostics + } + } + } + + private diagnostics( + result: Pick< + QueryAgentResult, + 'provider' | 'model' | 'modelCallCount' | 'toolCallCount' | 'traces' + > & + Partial>, + startedAt: number, + outcome: AskWechatOutcome + ): QueryAgentDiagnostics { + return { + entry: this.options.entry, + provider: result.provider, + model: result.model, + modelCallCount: result.modelCallCount, + toolCallCount: result.toolCallCount, + tools: (result.traces || []).map((trace) => trace.toolName), + totalMs: result.totalMs || Date.now() - startedAt, + outcome + } + } + + private writeLog( + level: AskWechatLogRecord['level'], + message: string, + details: QueryAgentDiagnostics, + fallbackReason?: AskWechatFallbackReason + ): void { + // 展开成匿名对象:只传形态字段(入口 / provider / 次数 / 耗时 / 结果),不含聊天内容。 + this.options.log?.({ + level, + message, + details: { ...details, ...(fallbackReason ? { fallbackReason } : {}) } + }) + } +} + +/** + * 顶部统计只用**真实执行**产生的数字:三个 Query Tool 的数据来源不同 + * (`query_messages` 直读 WCDB,`search_messages` / `conversation_overview` 走检索), + * 统计口径必须各自如实,不能套用统一的"已收录 / 读取"文案。 + */ +export function buildAskWechatStats( + result: Pick< + QueryAgentResult, + 'traces' | 'evidence' | 'modelCallCount' | 'toolCallCount' | 'totalMs' | 'modelDurationsMs' + >, + scope: AskWechatScope | undefined, + startedAt: number +): AskWechatStats { + let messageCount = 0 + let matchedCount = 0 + let overviewSourceCount = 0 + const tools: string[] = [] + for (const trace of result.traces || []) { + tools.push(trace.toolName) + if (trace.toolName === 'query_messages') { + messageCount += trace.resultCount || 0 + } else { + matchedCount += trace.evidenceCount || 0 + overviewSourceCount += trace.sourceMessageCount || 0 + } + } + const totalMs = result.totalMs || Date.now() - startedAt + // 真实拆解:模型总耗时直接来自每次模型调用的测量;本地查询 = 所有 Tool 的 durationMs 之和。 + // 两者不互相推算(用 total - model 反推会把"框架开销"混进"本地查询",那是另一种谎)。 + const modelDurationsMs = (result.modelDurationsMs || []).filter((value) => + Number.isFinite(value) + ) + const toolDurationsMs = (result.traces || []) + .map((trace) => trace.durationMs) + .filter((value) => Number.isFinite(value)) + return { + tools, + reads: { + messageCount, + matchedCount, + overviewSourceCount, + evidenceCount: (result.evidence || []).length + }, + ...(scope + ? { scope: { kind: scope.scope.kind, ...(scope.label ? { label: scope.label } : {}) } } + : {}), + modelCallCount: result.modelCallCount, + toolCallCount: result.toolCallCount, + totalMs, + timings: { + modelMs: modelDurationsMs.reduce((sum, value) => sum + value, 0), + localQueryMs: toolDurationsMs.reduce((sum, value) => sum + value, 0), + totalMs, + ...(modelDurationsMs.length ? { modelDurationsMs } : {}), + ...(toolDurationsMs.length ? { toolDurationsMs } : {}) + } + } +} diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 3b55ef4..32c5969 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -192,6 +192,61 @@ export function isReady(): boolean { return dbRef !== null } +/** Session 行的时间字段可能是秒,也可能是毫秒;1e11 以下按秒换算。 */ +function sessionTimeToEpochMs(value: unknown): number | null { + const numeric = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN + if (!Number.isFinite(numeric) || numeric <= 0) return null + return Math.round(numeric < 1e11 ? numeric * 1000 : numeric) +} + +/** + * 源数据(WCDB Session)里最新的活跃时间(epoch ms)。 + * + * Session 列表本来就带着 `last_timestamp`,所以这是**零额外 WCDB 调用**的 freshness 信号: + * 有了它才能区分「源数据本来就没有新消息」和「有新消息但派生索引还没追到」。 + * 只读取会话级的活跃时间戳,不读取任何消息内容。 + */ +export function getSourceLatestActivityMs(): number | null { + if (!dbRef) return null + try { + let latest = 0 + for (const session of dbRef.getWcdb4Client().getSessions()) { + const value = sessionTimeToEpochMs(session.raw?.['last_timestamp']) + if (value !== null && value > latest) latest = value + } + return latest > 0 ? latest : null + } catch { + return null + } +} + +/** + * 每个会话在源数据里最后的活跃时间(epoch ms),按会话 md5 索引。 + * + * 与 `getSourceLatestActivityMs` 同源(Session 行的 `last_timestamp`),同样是**零额外 + * WCDB 调用**。增量索引 pass 用它判断「这个会话自上次索引以来有没有新消息」, + * 从而整段跳过没有变化的会话 —— 这是增量同步名副其实的前提。 + */ +export function getConversationActivityMs(): Map { + const result = new Map() + if (!dbRef) return result + try { + for (const session of dbRef.getWcdb4Client().getSessions()) { + const username = typeof session.username === 'string' ? session.username : '' + if (!username) continue + const value = sessionTimeToEpochMs(session.raw?.['last_timestamp']) + if (value === null) continue + const md5 = dbRef.md5(username) + if (!md5) continue + const existing = result.get(md5) + if (existing === undefined || value > existing) result.set(md5, value) + } + } catch { + return result + } + return result +} + export function listContacts(filter?: string): FormattedContact[] { if (!dbRef) return [] @@ -700,6 +755,35 @@ export async function getGroupMemberIdsAsync( return memberIds ? { roomId, memberIds } : null } +/** + * Evidence sender enrichment 专用:只解析**请求到的** wxid 的显示名。 + * + * **不**走 `getGroupSnapshotAsync` —— 后者会 materialize 整群成员并 hydrate 头像, + * 为拿 1~N 个名字付整群成本。返回结构故意与 `GroupSnapshot['members']` 一致, + * 这样调用方可以复用同一套显示名优先级规则,不会把「张三」退化成「wxid_xxx」。 + * `avatar` 恒为 `''`:头像若将来需要,走 lazy UI 路径,不进入 Query Tool 成本。 + */ +export async function getGroupMemberNamesAsync( + userMd5: string, + wxids: string[] +): Promise { + if (!dbRef) return [] + const requested = Array.from(new Set((wxids || []).filter(Boolean))) + if (requested.length === 0) return [] + const wcdb4Client = dbRef.getWcdb4Client() + const roomId = wcdb4Client.getUsernameByMd5(userMd5) + if (!roomId || !roomId.endsWith('@chatroom')) return [] + const members = await wcdb4Client.getGroupMemberNamesAsync(roomId, requested) + return members.map((member) => ({ + wxid: member.m_nsUsrName, + nickname: member.nickname || '', + groupNickname: member.groupNickname || '', + wechatNickname: member.wechatNickname || '', + remark: member.remark || '', + avatar: member.m_nsHeadImgUrl || '' + })) +} + export function isGroupMemberIdsBatchAvailable(): boolean { return Boolean(dbRef?.getWcdb4Client().isGroupMemberIdsBatchAvailable()) } diff --git a/src/main/services/local-query-api-service.ts b/src/main/services/local-query-api-service.ts index 0aed8c0..015361b 100644 --- a/src/main/services/local-query-api-service.ts +++ b/src/main/services/local-query-api-service.ts @@ -2,12 +2,128 @@ import { listContactsAsync, listMessagesAsync, isReady, type FormattedContact, t import { resolveContact } from './contact-resolution-service' import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service' import { inferAiSearchTimeRange } from '../../shared/ai-search' -import type { QueryCapabilitiesResponse, QueryMessage, QueryMessageType, QueryTimeRange, ResolvedTimeRange, QueryMessagesRequest, SearchMessagesRequest, MessageContextRequest, ConversationOverviewRequest } from '../../shared/local-query-api' +import { KNOWLEDGE_FRESHNESS_TOLERANCE_MS } from '../../shared/knowledge' +import type { + QueryCapabilitiesResponse, + QueryCorpusScope, + QueryEvidenceItem, + QueryMessage, + QueryMessageType, + QueryTimeRange, + ResolvedCorpusScope, + ResolvedTimeRange, + QueryIndexCoverage, + QuerySearchTimings, + QueryMessagesRequest, + SearchMessagesRequest, + MessageContextRequest, + ConversationOverviewRequest +} from '../../shared/local-query-api' +// messageRef 编解码是 main 与 renderer 共用的契约,只定义一次(`src/shared/local-query-api.ts`): +// 两侧各写一份 base64url 实现会悄悄漂移,那会让「跳转到原聊天」偶发失效。 +import { + encodeMessageRef as toRef, + decodeMessageRef as fromRef, + normalizeMessageIdentity +} from '../../shared/local-query-api' const LIMIT_MAX = 200 const CONTEXT_MAX = 50 +/** 会话概览直读源数据时的上限(与派生索引的会话概览同量级)。 */ +const OVERVIEW_SOURCE_CAP = 2000 +/** 会话概览最多挑选多少条代表证据(与派生索引的候选上限一致)。 */ +const OVERVIEW_EVIDENCE_TARGET = 60 +const OVERVIEW_CHUNK_GAP_MS = 2 * 60 * 60 * 1000 +const OVERVIEW_CHUNK_MAX_MESSAGES = 24 const kinds: QueryMessageType[] = ['text', 'image', 'voice', 'video', 'file', 'link', 'sticker', 'system', 'other'] +/** + * 查询触发追赶同步后最多等待多久(`QUERY_FRESHNESS_WAIT_BUDGET`)。 + * + * 一次完整 pass 的耗时以分钟计,远超任何交互预算。所以这个预算只用来兜住「已经很接近追平」 + * 的情况:追不上就按当前覆盖如实回答并让后台继续追,而不是把查询卡在索引上。 + */ +const QUERY_FRESHNESS_WAIT_BUDGET_MS = 2000 + +/** + * 两次由查询触发的追赶之间的最小间隔,避免每个 Query 都重跑一遍索引。 + */ +const QUERY_CATCH_UP_MIN_INTERVAL_MS = 30 * 1000 + +/** + * 值得为它跑一遍索引的最小落后量(下限)。 + * + * 追赶一遍的成本以分钟计,因此几秒钟/一两分钟的落后并不值得触发。真正的门槛是 + * `max(这个下限, 上一遍实际耗时)` —— 见 `LocalQueryApiService.ensureFreshness`。 + * 这样在"源数据一直在长"的情况下会自然收敛:一遍跑完后剩下的落后量约等于这一遍的耗时, + * 于是不会立刻再触发一遍(否则会变成永不停止的连续索引)。 + */ +const QUERY_CATCH_UP_MIN_LAG_MS = 2 * 60 * 1000 + +/** + * 请求的时间范围是否已经被派生索引覆盖。 + * + * 需要覆盖的真实边界是 `min(requestedEnd, sourceLatestAt)`: + * - 请求范围早于源数据最新时间 → 必须覆盖到 requestedEnd; + * - 请求范围延伸到"现在" → 覆盖到源数据最新就已经完整(其后本来没有内容)。 + * + * freshness 与 coverage 共用这**一处**判据,避免两套口径漂移。 + */ +function indexCovers(indexLatestAt: number | null, requestedEnd: number, sourceLatestAt: number | null): boolean { + if (indexLatestAt === null) return false + const requiredEnd = sourceLatestAt === null ? requestedEnd : Math.min(requestedEnd, sourceLatestAt) + return indexLatestAt + KNOWLEDGE_FRESHNESS_TOLERANCE_MS >= requiredEnd +} + +/** + * `ResolvedTimeRange` 用的是 **epoch 秒**(Local Query API contract), + * 而 freshness 口径(indexLatestAt / sourceLatestAt)是 **epoch 毫秒**。 + * 这里统一到毫秒,避免混单位把"落后"误判成"已覆盖"。 + */ +function rangeEndMs(range: ResolvedTimeRange, fallbackNowMs: number): number { + return range.endTime === undefined ? fallbackNowMs : range.endTime * 1000 +} + +/** 本地时间(`MM-DD HH:mm`);只用于给模型一句可引用的人话,不参与任何判断。 */ +function formatLocalMinute(ms: number): string { + const date = new Date(ms) + const pad = (value: number): string => String(value).padStart(2, '0') + return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +/** + * 索引覆盖结论:模型直接引用,不要自己换算时间、也不要输出 epoch 数字。 + * 落后时必须把"这段时间暂时无法确认"写进结论句,避免被读成"整段时间都没有"。 + */ +function buildIndexCoverage( + indexLatestAt: number | null, + sourceLatestAt: number | null, + covered: boolean +): QueryIndexCoverage | undefined { + if (indexLatestAt === null) return undefined + const indexLabel = formatLocalMinute(indexLatestAt) + return { + covered, + indexLatestAtLabel: indexLabel, + ...(sourceLatestAt !== null ? { sourceLatestAtLabel: formatLocalMinute(sourceLatestAt) } : {}), + summary: covered + ? `可搜索索引已覆盖所问的时间范围(索引最新到 ${indexLabel})。` + : `可搜索索引只更新到 ${indexLabel},这之后的聊天还没进索引,这段时间是否聊过暂时无法确认。` + } +} + +const KIND_LABELS: Record = { + text: '文本', + image: '图片', + voice: '语音', + video: '视频', + file: '文件', + link: '链接', + sticker: '表情', + system: '系统消息', + other: '消息' +} + function kindOf(message: FormattedMessage): QueryMessageType { if (message.contentData?.type === 'system') return 'system' if (message.exportMediaType) return message.exportMediaType @@ -21,32 +137,6 @@ function kindOf(message: FormattedMessage): QueryMessageType { if (message.content?.trim()) return 'text' return 'other' } -interface CanonicalMessageIdentity { - conversationId: string - messageId: string -} - -function normalizeMessageIdentity(conversationId: string, messageId: string): CanonicalMessageIdentity | null { - const normalizedConversationId = conversationId.trim() - const normalizedMessageId = messageId.trim().replace(/^local:/, '') - if (!normalizedConversationId || !normalizedMessageId) return null - return { conversationId: normalizedConversationId, messageId: normalizedMessageId } -} - -function toRef(conversationId: string, messageId: string): string { - const identity = normalizeMessageIdentity(conversationId, messageId) - if (!identity) throw new Error('消息引用无效') - return Buffer.from(JSON.stringify({ c: identity.conversationId, m: identity.messageId }), 'utf8').toString('base64url') -} - -function fromRef(value: string): CanonicalMessageIdentity | null { - try { - const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) - return typeof parsed?.c === 'string' && typeof parsed?.m === 'string' - ? normalizeMessageIdentity(parsed.c, parsed.m) - : null - } catch { return null } -} function contactView(contact: FormattedContact) { return { displayName: contact.m_nsNickName || contact.m_nsUsrName, type: contact.type } as const } function resolvedTimeRange(input: QueryTimeRange, now = new Date()): ResolvedTimeRange { if (input.kind === 'absolute') { @@ -76,54 +166,490 @@ function toQueryMessage(conversationId: string, message: FormattedMessage, targe return { messageRef: toRef(conversationId, message.id), timestamp: (message.createTime || 0) * 1000, datetime: message.datetime, sender: message.isSender ? '我' : (message.name || target.m_nsNickName), direction: message.isSender ? 'to_target' : 'from_target', messageType: kind, sourceKind: kind, ...(attachment ? { attachment } : {}), ...(text ? { text } : {}) } } +/** + * 单条证据的展示形态:群消息必须带**群名 + 发送者**,否则模型无法回答"谁聊过"。 + * 不含 wxid / md5 / DB id;messageRef 保持 opaque。 + */ +function toEvidenceItem(contact: FormattedContact, message: FormattedMessage): QueryEvidenceItem { + const view = toQueryMessage(contact.md5, message, contact) + const name = contactView(contact).displayName + return { + messageRef: view.messageRef, + timestamp: view.timestamp, + sender: view.sender, + sourceKind: view.sourceKind, + text: view.text || `[${KIND_LABELS[view.messageType]}]`, + conversationName: name, + conversationType: contact.type + } +} + +/** 语料边界的解析结果。 */ +interface ResolvedCorpus { + /** undefined = 不限会话(全部可读会话)。 */ + conversationIds?: string[] + scope: ResolvedCorpusScope + /** contact / current 命中的会话。 */ + contact?: FormattedContact + error?: { status: string; [key: string]: unknown } +} + +function describeScope(scope: ResolvedCorpusScope): string { + if (scope.kind === 'all') return '所有聊天记录' + if (scope.kind === 'groups') return '群聊专属' + return `${scope.kind === 'contact' ? '单聊专属' : '当前会话'}:${scope.displayName || '未知会话'}` +} + +/** + * 语料边界违规 → 返回**可修正**的 invalid_tool_arguments(Runtime 会重开该 Tool 让模型改)。 + * 这是结构性约束:模型无法用 prompt 绕过。 + */ +function outsideScopeError(scope: ResolvedCorpusScope, actual: string): { status: string; [key: string]: unknown } { + return { + status: 'invalid_tool_arguments', + field: 'target', + constraint: 'target_outside_scope', + expected: describeScope(scope), + actual, + hint: 'target 必须落在应用当前的搜索范围内。需要查其他会话时,请让用户切换搜索范围;不要自行扩大范围。' + } +} + export class LocalQueryApiService { constructor(private readonly knowledge?: KnowledgeSearchService, private readonly nowProvider: () => Date = () => new Date()) {} capabilities(): QueryCapabilitiesResponse { return { version: 1, tools: { query_messages: { operation: '读取指定联系人的确定性消息', directions: ['any', 'from_target', 'to_target'], messageTypes: kinds, timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX }, search_messages: { operation: '受限 Knowledge 关键词检索', timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX }, message_context: { operation: '读取消息前后文', timeRanges: ['all'], limitMax: CONTEXT_MAX }, conversation_overview: { operation: '按会话时间片提取概览证据', timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX } } } } - private async resolve(target: { query: string }) { - const contacts = await listContactsAsync() - const result = resolveContact(target.query, contacts) - return { contacts, result } + + /** + * 解析语料边界。`scope` 由调用方(UI / Host)提供;省略 = 不限。 + * 这里只做**确定性**展开(群列表 / 会话身份校验),不做任何语义推断。 + */ + private async resolveCorpus(scope: QueryCorpusScope | undefined, contacts: FormattedContact[]): Promise { + if (!scope || scope.kind === 'all') { + return { scope: { kind: 'all', conversationCount: contacts.length } } + } + if (scope.kind === 'groups') { + const ids = contacts.filter((contact) => contact.type === 'group').map((contact) => contact.md5) + return { conversationIds: ids, scope: { kind: 'groups', conversationCount: ids.length } } + } + const contact = contacts.find((item) => item.md5 === scope.conversationId) + if (!contact) { + return { scope: { kind: scope.kind, conversationCount: 0 }, error: { status: 'scope_conversation_not_found', kind: scope.kind } } + } + if (scope.kind === 'contact' && contact.type !== 'user') { + return { scope: { kind: scope.kind, conversationCount: 0 }, error: { status: 'scope_contact_requires_direct', actual: contact.type } } + } + return { + conversationIds: [contact.md5], + contact, + scope: { kind: scope.kind, conversationCount: 1, displayName: contactView(contact).displayName, conversationType: contact.type } + } } + async messages(request: QueryMessagesRequest) { - if (!request?.target?.query?.trim() || !request.timeRange || !['any', 'from_target', 'to_target'].includes(request.direction || 'any') || (request.messageTypes || []).some((type) => !kinds.includes(type))) return { status: 'invalid_request' as const } + if (!request?.timeRange || !['any', 'from_target', 'to_target'].includes(request.direction || 'any') || (request.messageTypes || []).some((type) => !kinds.includes(type))) return { status: 'invalid_request' as const } if (!isReady()) return { status: 'knowledge_unavailable' as const } - const { contacts, result } = await this.resolve(request.target) - if (!result.matched || !result.conversationId) return { status: result.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: result.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) } - const contact = contacts.find((c) => c.md5 === result.conversationId)!; if (contact.type === 'group' && request.direction && request.direction !== 'any') return { status: 'unsupported_query' as const }; const range = resolvedTimeRange(request.timeRange, this.nowProvider()) + const contacts = await listContactsAsync() + const corpus = await this.resolveCorpus(request.scope, contacts) + if (corpus.error) return corpus.error as { status: string } + const targetQuery = request.target?.query?.trim() + let contact: FormattedContact | undefined + if (targetQuery) { + const result = resolveContact(targetQuery, contacts) + if (!result.matched || !result.conversationId) return { status: result.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: result.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) } + contact = contacts.find((c) => c.md5 === result.conversationId)! + if (corpus.conversationIds && !corpus.conversationIds.includes(contact.md5)) { + return outsideScopeError(corpus.scope, contactView(contact).displayName) as { status: string } + } + } else if (corpus.contact) { + // 省略 target:范围恰好只有一个会话(单聊专属 / 当前会话)时直接查它,避免让模型重新拼会话名。 + contact = corpus.contact + } else if (corpus.conversationIds && corpus.conversationIds.length === 1) { + contact = contacts.find((c) => c.md5 === corpus.conversationIds![0]) + } + if (!contact) { + return { + status: 'invalid_tool_arguments', + field: 'target', + constraint: 'target_required_for_scope', + expected: describeScope(corpus.scope), + actual: '省略 target', + hint: '当前搜索范围包含多个会话。精确读取消息必须指定 target(必须在该范围内),或改用 search_messages 做跨会话检索。' + } + } + if (contact.type === 'group' && request.direction && request.direction !== 'any') return { status: 'unsupported_query' as const }; const range = resolvedTimeRange(request.timeRange, this.nowProvider()) const raw = await listMessagesAsync(contact.md5, range.startTime, range.endTime) const direction = request.direction || 'any'; const allowed = new Set(request.messageTypes || kinds) const filtered = raw.filter((message) => !(request.excludeSystem !== false && kindOf(message) === 'system')).filter((message) => allowed.has(kindOf(message))).filter((message) => direction === 'any' || (direction === 'to_target' ? message.isSender : !message.isSender)).sort((a, b) => ((a.createTime || 0) - (b.createTime || 0)) * ((request.order || 'asc') === 'asc' ? 1 : -1)).slice(0, Math.min(LIMIT_MAX, Math.max(1, request.limit || 20))) - return { status: 'completed' as const, target: contactView(contact), query: { direction, messageTypes: request.messageTypes || [], order: request.order || 'asc', limit: Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)), excludeSystem: request.excludeSystem !== false, resolvedTimeRange: range }, coverage: { state: 'complete' as const }, returnedCount: filtered.length, messages: filtered.map((message) => toQueryMessage(contact.md5, message, contact)) } + return { status: 'completed' as const, target: contactView(contact), query: { direction, messageTypes: request.messageTypes || [], order: request.order || 'asc', limit: Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)), excludeSystem: request.excludeSystem !== false, resolvedTimeRange: range }, coverage: { state: 'complete' as const }, returnedCount: filtered.length, messages: filtered.map((message) => toQueryMessage(contact.md5, message, contact)), scope: corpus.scope } } async search(request: SearchMessagesRequest) { - if (!request?.target?.query?.trim() || !request.timeRange || typeof request.query !== 'string' || !request.query.trim() || (request.variants || []).some((value) => typeof value !== 'string')) return { status: 'invalid_request' as const } - const { contacts, result } = await this.resolve(request.target) - if (!result.matched || !result.conversationId) return { status: result.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: result.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) } + const requestStartedAt = Date.now() + if (!request?.timeRange || typeof request.query !== 'string' || !request.query.trim() || (request.variants || []).some((value) => typeof value !== 'string')) return { status: 'invalid_request' as const } + // 真实耗时分解:每一段都用 Date.now() 实测,不做任何推断。 + const scopeStartedAt = Date.now() + const contacts = await listContactsAsync() + const corpus = await this.resolveCorpus(request.scope, contacts) + if (corpus.error) return corpus.error as { status: string } + let conversationIds = corpus.conversationIds + let targetView: { displayName: string; type: 'user' | 'group' } | undefined + const targetQuery = request.target?.query?.trim() + if (targetQuery) { + const resolved = resolveContact(targetQuery, contacts) + if (!resolved.matched || !resolved.conversationId) { + return { status: resolved.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: resolved.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) } + } + const target = contacts.find((c) => c.md5 === resolved.conversationId)! + if (corpus.conversationIds && !corpus.conversationIds.includes(target.md5)) { + return outsideScopeError(corpus.scope, contactView(target).displayName) as { status: string } + } + conversationIds = [target.md5] + targetView = contactView(target) + } else if (corpus.conversationIds && corpus.conversationIds.length === 0) { + // 范围内没有任何会话(例如没有任何群聊):明确返回空,而不是悄悄退化成全局搜索。 + return { status: 'completed' as const, coverage: { state: 'unknown' as const }, probeCount: 0, evidenceCount: 0, evidence: [], scope: corpus.scope } + } + const scopeMs = Date.now() - scopeStartedAt if (!this.knowledge) return { status: 'knowledge_unavailable' as const } - const contact = contacts.find((c) => c.md5 === result.conversationId)!; const range = resolvedTimeRange(request.timeRange, this.nowProvider()); const probes = [request.query, ...(request.variants || [])] + const range = resolvedTimeRange(request.timeRange, this.nowProvider()); const probes = [request.query, ...(request.variants || [])] if (probes.length > 5) return { status: 'invalid_request' as const } - const all = new Map(); let coverage: 'complete' | 'partial' | 'unknown' = 'unknown' - for (const probe of probes) { const found = await this.knowledge.search({ text: probe, terms: [probe], conversationIds: [contact.md5], startTime: range.startTime, endTime: range.endTime, limit: Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)) }); coverage = found.state === 'ready' ? 'complete' : found.evidence.length ? 'partial' : 'unknown'; for (const item of found.evidence) all.set(`${item.conversationId}:${item.messageId}`, { messageRef: toRef(item.conversationId, item.messageId), messageId: item.messageId, timestamp: item.timestamp, sender: item.sender, sourceKind: item.sourceKind, text: item.text }) } - return { status: 'completed' as const, target: contactView(contact), resolvedTimeRange: range, coverage: { state: coverage }, probeCount: probes.length, evidenceCount: all.size, evidence: Array.from(all.values()).map(({ messageId: _messageId, ...item }) => item).slice(0, request.limit || 20) } + const contactByMd5 = new Map(contacts.map((contact) => [contact.md5, contact])) + const limit = Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)) + // 一次查询内多个 probe 共用同一个 retrieval session:Knowledge 会按它缓存 + // "群会话 → 成员昵称" 的解析结果,否则每个 probe 都要重读一遍群成员快照, + // 跨会话检索会被放大成 N 倍。 + const retrievalSessionId = `query-${this.nowProvider().getTime()}-${Math.random().toString(36).slice(2, 8)}` + + // Freshness:请求的时间范围越过索引覆盖时,先请求既有增量通道去追一次; + // 这属于 Engine/Host 的确定性处理,**不增加 LLM 往返**。 + // + // 整段交互检索期间必须让后台索引让路:追赶同步会遍历上千个会话, + // 否则本次查询会和它抢 Worker 与 WCDB,被拖成几十秒。 + this.knowledge.beginInteractiveQuery() + const probeMs: number[] = [] + let mergeMs = 0 + let enrichmentMs = 0 + let knowledgeTiming: NonNullable | undefined + let found: Awaited> + let freshness: { resynced: boolean; catchUp: 'none' | 'reused' | 'skipped' | 'completed' | 'pending' } + let freshnessMs = 0 + try { + found = await this.probe(probes, conversationIds, range, limit, contactByMd5, retrievalSessionId) + probeMs.push(...found.probeMs) + mergeMs = found.mergeMs + enrichmentMs = found.enrichmentMs + knowledgeTiming = found.knowledge + const requestedEnd = rangeEndMs(range, this.nowProvider().getTime()) + const freshnessStartedAt = Date.now() + freshness = await this.ensureFreshness(found, requestedEnd) + freshnessMs = Date.now() - freshnessStartedAt + // 触发过追赶就必须用(可能已更新的)索引重新检索,不能拿同步前的结果回答。 + if (freshness.resynced) { + const reProbe = await this.probe(probes, conversationIds, range, limit, contactByMd5, retrievalSessionId) + probeMs.push(...reProbe.probeMs) + mergeMs += reProbe.mergeMs + enrichmentMs += reProbe.enrichmentMs + found = reProbe + } + } finally { + this.knowledge.endInteractiveQuery() + } + const requestedEnd = rangeEndMs(range, this.nowProvider().getTime()) + + const covered = indexCovers(found.indexLatestAt, requestedEnd, found.sourceLatestAt) + const indexCoverage = buildIndexCoverage(found.indexLatestAt, found.sourceLatestAt, covered) + const timings: QuerySearchTimings = { + totalMs: Date.now() - requestStartedAt, + scopeMs, + freshnessMs, + probeMs, + mergeMs, + enrichmentMs, + ...(knowledgeTiming ? { knowledge: knowledgeTiming } : {}) + } + return { + status: 'completed' as const, + ...(targetView ? { target: targetView } : {}), + resolvedTimeRange: range, + coverage: { state: this.searchCoverage(found, requestedEnd) }, + probeCount: probes.length, + evidenceCount: found.evidence.size, + evidence: Array.from(found.evidence.values()).slice(0, limit), + scope: corpus.scope, + indexLatestAt: found.indexLatestAt, + sourceLatestAt: found.sourceLatestAt, + freshness: { catchUp: freshness.catchUp }, + ...(indexCoverage ? { indexCoverage } : {}), + timings + } + } + + /** 逐 probe 检索并合并去重;同时记录派生索引的覆盖口径与真实耗时分解。 */ + private async probe( + probes: string[], + conversationIds: string[] | undefined, + range: ResolvedTimeRange, + limit: number, + contactByMd5: Map, + retrievalSessionId: string + ): Promise<{ + evidence: Map + indexLatestAt: number | null + sourceLatestAt: number | null + derivedReady: boolean + probeMs: number[] + mergeMs: number + enrichmentMs: number + knowledge?: NonNullable + }> { + let indexLatestAt: number | null = null + let sourceLatestAt: number | null = null + let derivedReady = false + let enrichmentMs = 0 + const probeMs: number[] = [] + const knowledge = { + shortTermSearchMs: 0, + ftsMs: 0, + messageLoadMs: 0, + statusMs: 0, + voiceCoverageMs: 0, + workerExecutionMs: 0 + } + // 先逐 probe 收证据,再统一合并:这样「probe 检索」与「合并去重」的耗时是分开测量的, + // 不会把合并成本摊到最后一个 probe 上(诊断时最容易被误读的地方)。 + const perProbe: Array> = [] + for (const probe of probes) { + const probeStartedAt = Date.now() + const found = await this.knowledge!.search({ + text: probe, + terms: [probe], + conversationIds, + startTime: range.startTime, + endTime: range.endTime, + limit, + retrievalSessionId + }) + probeMs.push(Date.now() - probeStartedAt) + if (found.state === 'ready') derivedReady = true + if (typeof found.indexLatestAt === 'number' && (indexLatestAt === null || found.indexLatestAt > indexLatestAt)) indexLatestAt = found.indexLatestAt + if (typeof found.sourceLatestAt === 'number' && (sourceLatestAt === null || found.sourceLatestAt > sourceLatestAt)) sourceLatestAt = found.sourceLatestAt + const measured = found.timings + if (measured) { + knowledge.shortTermSearchMs += measured.shortTermSearchMs || 0 + knowledge.ftsMs += measured.ftsMs || 0 + knowledge.messageLoadMs += measured.messageLoadMs || 0 + knowledge.statusMs += measured.statusMs || 0 + knowledge.voiceCoverageMs += measured.voiceCoverageMs || 0 + knowledge.workerExecutionMs += measured.workerExecutionMs || measured.totalMs || 0 + enrichmentMs += measured.senderEnrichmentMs || 0 + } + perProbe.push( + found.evidence.map((item) => { + const owner = contactByMd5.get(item.conversationId) + return [ + `${item.conversationId}:${item.messageId}`, + { + messageRef: toRef(item.conversationId, item.messageId), + timestamp: item.timestamp, + sender: item.sender, + sourceKind: item.sourceKind, + text: item.text, + conversationName: owner ? contactView(owner).displayName : undefined, + conversationType: owner?.type + } satisfies QueryEvidenceItem + ] as [string, QueryEvidenceItem] + }) + ) + } + const mergeStartedAt = Date.now() + const all = new Map() + for (const entries of perProbe) for (const [key, item] of entries) all.set(key, item) + const mergeMs = Date.now() - mergeStartedAt + const hasKnowledgeTiming = + knowledge.shortTermSearchMs > 0 || + knowledge.ftsMs > 0 || + knowledge.messageLoadMs > 0 || + knowledge.workerExecutionMs > 0 + return { + evidence: all, + indexLatestAt, + sourceLatestAt, + derivedReady, + probeMs, + mergeMs, + enrichmentMs, + ...(hasKnowledgeTiming ? { knowledge } : {}) + } + } + + /** + * 索引新鲜度处理。 + * + * 派生索引是异步的,可能停在几天前。请求范围越过索引覆盖时,**不能**直接把 0 条 Evidence + * 当成"没有",而是请求既有增量通道去追一次,并在有界预算内等它。 + */ + private async ensureFreshness( + found: { indexLatestAt: number | null; sourceLatestAt: number | null; derivedReady: boolean }, + requestedEnd: number + ): Promise<{ resynced: boolean; catchUp: 'none' | 'reused' | 'skipped' | 'completed' | 'pending' }> { + if (!this.knowledge || !found.derivedReady) return { resynced: false, catchUp: 'none' } + if (indexCovers(found.indexLatestAt, requestedEnd, found.sourceLatestAt)) { + return { resynced: false, catchUp: 'none' } + } + // 落后量是否值得再跑一遍索引:门槛同时受"上一遍实际耗时"约束, + // 这样"源数据一直在长"时不会退化成永不停止的连续索引。 + const lag = found.sourceLatestAt !== null && found.indexLatestAt !== null ? found.sourceLatestAt - found.indexLatestAt : Number.POSITIVE_INFINITY + const worthThreshold = Math.max(QUERY_CATCH_UP_MIN_LAG_MS, this.knowledge.lastPassDurationMs()) + if (lag <= worthThreshold) return { resynced: false, catchUp: 'skipped' } + + const request = this.knowledge.requestCatchUp(QUERY_CATCH_UP_MIN_INTERVAL_MS) + if (!request.triggered) { + // 已经在跑 → 复用;刚触发过 → 节流。两种都不阻塞本次查询,后台继续追。 + return { resynced: false, catchUp: request.inProgress ? 'reused' : 'skipped' } + } + const completed = await this.knowledge.waitForIndexingComplete(QUERY_FRESHNESS_WAIT_BUDGET_MS) + return { resynced: true, catchUp: completed ? 'completed' : 'pending' } + } + + /** + * 覆盖度。 + * + * 只有「派生索引可用 + 请求范围被索引完整覆盖」才是 `complete`。 + * `complete` 是 0 结果时允许说"没有找到"的唯一前提;索引落后时必须 `partial`。 + */ + private searchCoverage( + found: { indexLatestAt: number | null; sourceLatestAt: number | null; derivedReady: boolean; evidence: Map }, + requestedEnd: number + ): 'complete' | 'partial' | 'unknown' { + if (!found.derivedReady) { + // 派生索引不可用(未建立 / 直读源数据的 fallback):有证据也只能算 partial。 + return found.evidence.size ? 'partial' : 'unknown' + } + // 索引可用但覆盖口径缺失时不能宣称完整;此时按 partial 处理(宁可保守)。 + if (found.indexLatestAt === null) return 'partial' + return indexCovers(found.indexLatestAt, requestedEnd, found.sourceLatestAt) ? 'complete' : 'partial' } async context(request: MessageContextRequest) { const ref = fromRef(request.messageRef); if (!ref) return { status: 'invalid_request' as const } + if (request.scope && request.scope.kind !== 'all') { + const contacts = await listContactsAsync() + const corpus = await this.resolveCorpus(request.scope, contacts) + if (corpus.error) return corpus.error as { status: string } + if (corpus.conversationIds && !corpus.conversationIds.includes(ref.conversationId)) { + return outsideScopeError(corpus.scope, '其他会话的消息') as { status: string } + } + } const before = Math.min(CONTEXT_MAX, Math.max(0, request.before ?? 10)); const after = Math.min(CONTEXT_MAX, Math.max(0, request.after ?? 10)); const messages = await listMessagesAsync(ref.conversationId); const index = messages.findIndex((message) => normalizeMessageIdentity(ref.conversationId, message.id)?.messageId === ref.messageId); if (index < 0) return { status: 'contact_not_found' as const } const contact = (await listContactsAsync()).find((item) => item.md5 === ref.conversationId); if (!contact) return { status: 'contact_not_found' as const }; const map = (message: FormattedMessage) => toQueryMessage(ref.conversationId, message, contact) return { status: 'completed' as const, anchor: map(messages[index]), before: messages.slice(Math.max(0, index - before), index).map(map), after: messages.slice(index + 1, index + 1 + after).map(map) } } + /** + * 会话概览。 + * + * **事实来源是 WCDB(源数据),不是派生 Knowledge 索引**:索引是异步派生的、可能滞后, + * 把"索引里 0 行"当成"完整范围内没有"会产生高置信度的错误否定。这里改为直读源数据 + * 并显式区分 complete / partial。 + */ async overview(request: ConversationOverviewRequest) { - if (!request?.target?.query?.trim() || !request.timeRange) return { status: 'invalid_request' as const } - const { contacts, result } = await this.resolve(request.target); if (!result.matched || !result.conversationId) return { status: result.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: result.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) }; if (!this.knowledge) return { status: 'knowledge_unavailable' as const } - const contact = contacts.find((c) => c.md5 === result.conversationId)!; const range = resolvedTimeRange(request.timeRange, this.nowProvider()); const found = await this.knowledge.search({ text: '', terms: [], conversationIds: [contact.md5], startTime: range.startTime, endTime: range.endTime, limit: LIMIT_MAX }); const retrieval = found.conversationRetrieval - const sourceMessageCount = retrieval?.totalMessages || 0 - const evidence = found.evidence - .map((item, index) => ({ messageRef: toRef(item.conversationId, item.messageId), timestamp: item.timestamp, sender: item.sender, sourceKind: item.sourceKind, text: item.text, index })) - .sort((left, right) => left.timestamp - right.timestamp || left.index - right.index) - .map(({ index: _index, ...item }) => item) - const sourceState = retrieval?.complete ? 'complete' as const : 'partial' as const - return { status: found.state === 'ready' ? 'completed' as const : 'retrieval_incomplete' as const, target: contactView(contact), resolvedTimeRange: range, coverage: { state: sourceState }, sourceMessageCount, evidenceCount: evidence.length, sourceCoverage: { state: sourceState, sourceMessageCount }, selection: { mode: 'temporal_coverage' as const, selectedEvidenceCount: evidence.length, sampled: evidence.length < sourceMessageCount }, voiceCoverage: found.voiceCoverage, evidence } + if (!request?.timeRange) return { status: 'invalid_request' as const } + if (!isReady()) return { status: 'knowledge_unavailable' as const } + const contacts = await listContactsAsync() + const corpus = await this.resolveCorpus(request.scope, contacts) + if (corpus.error) return corpus.error as { status: string } + const targetQuery = request.target?.query?.trim() + let contact: FormattedContact | undefined + if (targetQuery) { + const resolved = resolveContact(targetQuery, contacts) + if (!resolved.matched || !resolved.conversationId) { + return { status: resolved.ambiguous ? 'ambiguous_contact' as const : 'contact_not_found' as const, candidates: resolved.candidates.map((candidate) => ({ displayName: candidate.displayName, type: contacts.find((c) => c.md5 === candidate.conversationId)?.type || 'user' })) } + } + contact = contacts.find((c) => c.md5 === resolved.conversationId)! + if (corpus.conversationIds && !corpus.conversationIds.includes(contact.md5)) { + return outsideScopeError(corpus.scope, contactView(contact).displayName) as { status: string } + } + } else if (corpus.contact) { + contact = corpus.contact + } else if (corpus.conversationIds && corpus.conversationIds.length === 1) { + contact = contacts.find((c) => c.md5 === corpus.conversationIds![0]) + } + if (!contact) { + return { + status: 'invalid_tool_arguments', + field: 'target', + constraint: 'target_required_for_scope', + expected: describeScope(corpus.scope), + actual: '省略 target', + hint: '当前搜索范围包含多个会话,会话概览只能针对单个会话。请显式指定 target(必须在该范围内),或改用 search_messages。' + } + } + const range = resolvedTimeRange(request.timeRange, this.nowProvider()) + // 注意:这里**不能**给 listMessagesAsync 传 limit —— 实测在有界时间范围下 + // `{ limit }` 会让 WCDB 读取返回 0 条(而同一范围不传 limit 能正常返回)。 + // 与 query_messages 保持一致:读完整区间,再在 JS 侧截断/采样。 + const raw = await listMessagesAsync(contact.md5, range.startTime, range.endTime) + const messages = raw.length > OVERVIEW_SOURCE_CAP ? raw.slice(-OVERVIEW_SOURCE_CAP) : raw + const truncated = raw.length > OVERVIEW_SOURCE_CAP + const evidence = selectTemporalCoverageEvidence(contact, messages, OVERVIEW_EVIDENCE_TARGET) + const state: 'complete' | 'partial' = truncated ? 'partial' : 'complete' + return { + status: 'completed' as const, + target: contactView(contact), + resolvedTimeRange: range, + coverage: { state }, + sourceMessageCount: raw.length, + evidenceCount: evidence.length, + sourceCoverage: { state, sourceMessageCount: raw.length }, + selection: { mode: 'temporal_coverage' as const, selectedEvidenceCount: evidence.length, sampled: truncated || evidence.length < messages.length }, + evidence, + scope: corpus.scope, + origin: 'wcdb' as const + } } } + +/** + * 时间片代表证据:按时间间隔切块,每块取"最长文本 / 首条 / 末条",再轮转挑选, + * 保证每个时间片都至少有一条代表,避免长会话里最近的时间片被整体丢弃。 + */ +export function selectTemporalCoverageEvidence( + contact: FormattedContact, + messages: FormattedMessage[], + target: number +): QueryEvidenceItem[] { + if (!messages.length || target <= 0) return [] + const chunks: FormattedMessage[][] = [] + for (const message of messages) { + const current = chunks.at(-1) + const previous = current?.at(-1) + const gapMs = ((message.createTime || 0) - (previous?.createTime || 0)) * 1000 + const isNewChunk = !current || gapMs > OVERVIEW_CHUNK_GAP_MS || current.length >= OVERVIEW_CHUNK_MAX_MESSAGES + if (isNewChunk) chunks.push([]) + chunks.at(-1)!.push(message) + } + const representativesByChunk = chunks.map((chunk) => { + const preferred = chunk.filter((message) => kindOf(message) !== 'system') + const pool = preferred.length ? preferred : chunk + const ranked = [...pool].sort( + (left, right) => + String(right.content || '').length - String(left.content || '').length || + (right.createTime || 0) - (left.createTime || 0) + ) + return [ranked[0], pool[0], pool.at(-1)].filter( + (value, index, items): value is FormattedMessage => Boolean(value) && items.indexOf(value) === index + ) + }) + const selected: FormattedMessage[] = [] + for (let representativeIndex = 0; selected.length < target; representativeIndex += 1) { + let added = false + for (const representatives of representativesByChunk) { + const representative = representatives[representativeIndex] + if (representative && selected.length < target) { + selected.push(representative) + added = true + } + } + if (!added) break + } + return selected.map((message) => toEvidenceItem(contact, message)) +} diff --git a/src/main/services/local-query-tool-executor.ts b/src/main/services/local-query-tool-executor.ts new file mode 100644 index 0000000..6dfe680 --- /dev/null +++ b/src/main/services/local-query-tool-executor.ts @@ -0,0 +1,59 @@ +import type { + ConversationOverviewRequest, + MessageContextRequest, + QueryMessagesRequest, + SearchMessagesRequest +} from '../../shared/local-query-api' +import type { LocalQueryApiService } from './local-query-api-service' +import type { + QueryAgentToolContext, + QueryAgentToolExecutor, + QueryAgentToolResult +} from './query-agent-service' + +/** + * 进程内 Tool Executor:把 Query Agent 的 Tool 调用直接映射到 Local Query API Service。 + * + * 桌面问问微信与 Agent Hub 共用这一份;不经过 HTTP、不需要 token、不依赖 6131 端口。 + * Local Query API 的 public contract 未改变:时间仍是 epoch seconds(absolute 的 ISO-8601 + * 已由 Runtime 在 Host 侧换算),temporalBasis 已在 Host 侧剥离。 + * + * CLI 仍使用自己的 HTTP executor(它跑在独立进程里),属于 Adapter 层差异。 + */ +export function createLocalQueryToolExecutor( + queryApi: LocalQueryApiService +): QueryAgentToolExecutor { + return async ( + name: string, + input: Record, + context?: QueryAgentToolContext + ): Promise => { + // 语料边界由 Host 注入(LLM 无法提供,Tool schema 里也没有这个字段)。 + // 越界 target 由 Engine 结构化拒绝,这里不做语义判断。 + const request = context?.conversationScope + ? { ...input, scope: context.conversationScope } + : input + switch (name) { + case 'query_messages': + // Local Query API 的 response 是显式 interface,没有 index signature, + // 这里显式窄化到 Runtime 的宽松 Tool Result 形状(Runtime 只读 status 与计数字段)。 + return (await queryApi.messages( + request as unknown as QueryMessagesRequest + )) as unknown as QueryAgentToolResult + case 'search_messages': + return (await queryApi.search( + request as unknown as SearchMessagesRequest + )) as unknown as QueryAgentToolResult + case 'message_context': + return (await queryApi.context( + request as unknown as MessageContextRequest + )) as unknown as QueryAgentToolResult + case 'conversation_overview': + return (await queryApi.overview( + request as unknown as ConversationOverviewRequest + )) as unknown as QueryAgentToolResult + default: + throw new Error(`不允许的工具: ${name}`) + } + } +} diff --git a/src/main/services/messages-around.ts b/src/main/services/messages-around.ts new file mode 100644 index 0000000..f1d02f1 --- /dev/null +++ b/src/main/services/messages-around.ts @@ -0,0 +1,54 @@ +import { normalizeMessageIdentity } from '../../shared/local-query-api' + +/** + * 「跳到原消息」的时间窗口规则。 + * + * 抽成纯函数是因为它承载一条必须成立的性质:**锚点加载必须围绕 messageRef 的时间位置, + * 不能退化成加载整段会话历史** —— 最大会话可达数十万条消息,整段读既慢又会挤爆 IPC。 + */ +export const MESSAGES_AROUND_MAX_WINDOW = 2000 + +/** 默认锚点半径 6 小时;真实数据上时间窗口是稀疏的,±6h 的量级很小。 */ +export const MESSAGES_AROUND_DEFAULT_RADIUS_SECONDS = 6 * 3600 +export const MESSAGES_AROUND_MIN_RADIUS_SECONDS = 60 +export const MESSAGES_AROUND_MAX_RADIUS_SECONDS = 24 * 3600 +export const MESSAGES_AROUND_MAX_WIDEN_FACTOR = 12 +export const MESSAGES_AROUND_MAX_WIDEN_SECONDS = 3 * 24 * 3600 + +export const normalizeRadiusSeconds = (radiusSeconds?: number): number => + Math.max( + MESSAGES_AROUND_MIN_RADIUS_SECONDS, + Math.min(radiusSeconds || MESSAGES_AROUND_DEFAULT_RADIUS_SECONDS, MESSAGES_AROUND_MAX_RADIUS_SECONDS) + ) + +export const widenRadiusSeconds = (baseRadius: number): number => + Math.min(baseRadius * MESSAGES_AROUND_MAX_WIDEN_FACTOR, MESSAGES_AROUND_MAX_WIDEN_SECONDS) + +/** + * 需要尝试的半径列表。 + * + * **没有时间锚点时返回空数组** —— 这不是"退化成一个半径为 0 的查询"。 + * 半径 0 会变成 `start=undefined / end=undefined`,也就是整段会话历史; + * 调用方必须据此直接给出「已打开对应会话,但暂时无法定位原消息」的降级文案。 + */ +export const messagesAroundRadii = ( + anchorSeconds: number | undefined, + baseRadius: number, + widenRadius: number +): number[] => (anchorSeconds && anchorSeconds > 0 ? [baseRadius, widenRadius] : []) + +/** 有界窗口 + 精确身份定位。窗口按上限截断,绝不返回整段历史。 */ +export const sliceMessagesAroundWindow = ( + messages: T[], + conversationId: string, + targetMessageId: string, + maxWindowMessages: number = MESSAGES_AROUND_MAX_WINDOW +): { window: T[]; index: number; truncated: boolean } => { + const truncated = messages.length > maxWindowMessages + const window = truncated ? messages.slice(0, maxWindowMessages) : messages + const index = window.findIndex( + (message) => + normalizeMessageIdentity(conversationId, String(message.id ?? ''))?.messageId === targetMessageId + ) + return { window, index, truncated } +} diff --git a/src/main/services/query-agent-conversation-memory.ts b/src/main/services/query-agent-conversation-memory.ts new file mode 100644 index 0000000..b3bee0f --- /dev/null +++ b/src/main/services/query-agent-conversation-memory.ts @@ -0,0 +1,70 @@ +import type { QueryAgentHistoryTurn } from './query-agent-service' + +/** + * 最小多轮澄清上下文的边界(刻意写成显式常量,便于审计)。 + * + * 只保留极少数轮次、极短文本、极短有效期 —— 目的是让"你说的是哪位联系人?"这类 + * 澄清之后的下一句能被接上,而不是实现 Agent 长期记忆。 + */ +export const CONVERSATION_MEMORY_MAX_TURNS = 2 +export const CONVERSATION_MEMORY_QUESTION_MAX_CHARS = 300 +export const CONVERSATION_MEMORY_ANSWER_MAX_CHARS = 800 +export const CONVERSATION_MEMORY_TTL_MS = 10 * 60 * 1000 + +interface ConversationEntry { + turns: QueryAgentHistoryTurn[] + updatedAt: number +} + +function truncate(value: string, maxChars: number): string { + const normalized = String(value || '') + .replace(/\s+/g, ' ') + .trim() + return normalized.length > maxChars ? `${normalized.slice(0, maxChars)}…` : normalized +} + +/** + * 有界的问答上下文。 + * + * 隐私边界: + * - 只保存「用户问题 + Query Agent 最终回答」,**不保存** Tool 结果 / Evidence / 聊天原文; + * - 只存在内存,不落盘、不进日志、不跨进程; + * - 超过 TTL 或超过轮次上限即丢弃。 + */ +export class QueryAgentConversationMemory { + private readonly entries = new Map() + + constructor(private readonly now: () => number = () => Date.now()) {} + + history(key: string): QueryAgentHistoryTurn[] { + const entry = this.entries.get(key) + if (!entry) return [] + if (this.now() - entry.updatedAt > CONVERSATION_MEMORY_TTL_MS) { + this.entries.delete(key) + return [] + } + return entry.turns.map((turn) => ({ ...turn })) + } + + record(key: string, question: string, answer: string): void { + const turn: QueryAgentHistoryTurn = { + question: truncate(question, CONVERSATION_MEMORY_QUESTION_MAX_CHARS), + answer: truncate(answer, CONVERSATION_MEMORY_ANSWER_MAX_CHARS) + } + if (!turn.question || !turn.answer) return + const previous = this.history(key) + this.entries.set(key, { + turns: [...previous, turn].slice(-CONVERSATION_MEMORY_MAX_TURNS), + updatedAt: this.now() + }) + } + + /** 只忘记某一路会话(例如用户点了「新问题」)。不传 key 时清空全部。 */ + forget(key?: string): void { + if (key === undefined) { + this.entries.clear() + return + } + this.entries.delete(key) + } +} diff --git a/src/main/services/query-agent-poc-service.ts b/src/main/services/query-agent-service.ts similarity index 69% rename from src/main/services/query-agent-poc-service.ts rename to src/main/services/query-agent-service.ts index a121163..ece0e4c 100644 --- a/src/main/services/query-agent-poc-service.ts +++ b/src/main/services/query-agent-service.ts @@ -1,5 +1,21 @@ +/** + * Shared Query Agent runtime used by Ask WeChat, Agent Hub, and the CLI harness. + * Query semantics and tool orchestration are centralized here — prompt, tool mapping, + * validation, temporal policy, retry/stopping, and the bounded model loop — so every + * entry point behaves consistently; they differ only by injected tool executor and adapter. + */ import type { AIChatToolCall, AIChatToolDefinition } from './ai-provider-service' -import { LOCAL_QUERY_TOOL_DEFINITIONS, type QueryTemporalBasisKind } from '../../shared/local-query-api' +import { + LOCAL_QUERY_TOOL_DEFINITIONS, + type QueryCorpusScope, + type QuerySearchTimings, + type QueryTemporalBasisKind +} from '../../shared/local-query-api' +// 进度事件定义在 shared(renderer 也要用),这里只是把它带进本文件作用域。 +import type { + QueryAgentProgressEvent, + QueryAgentProgressStage +} from '../../shared/query-agent' const MAX_TOOL_CALLS = 5 const FORBIDDEN_INPUT_KEYS = new Set(['apiKey', 'authorization', 'token', 'databasePath', 'sql', 'wxid', 'md5']) @@ -70,6 +86,8 @@ export interface QueryAgentTraceItem { status: string resultCount?: number evidenceCount?: number + /** 会话概览覆盖的源消息条数(additive,用于 UI 顶部真实统计)。 */ + sourceMessageCount?: number /** LLM 声明的 temporalBasis。Host 消费它决定 policy,但不会传给 Local Query API。 */ temporalBasis?: { kind: QueryTemporalBasisKind; sourceText?: string } /** Host 自动执行的扩大查询(当前仅 temporalBasis.kind=recall_hint + 有界范围 + 0 结果)。 */ @@ -81,6 +99,13 @@ export interface QueryAgentTraceItem { resultCount?: number evidenceCount?: number } + /** + * Engine 侧的真实耗时分解(ADDITIVE 诊断)。 + * + * 由 `search_messages` 的 Tool Result 携带,**不会进入模型上下文**(`toolResultForModel` + * 会剥离)。用途:把不透明的 Tool 总耗时拆成 scope / freshness / 每个 probe / 合并 / 证据补全。 + */ + searchTimings?: QuerySearchTimings } export interface QueryAgentModelCallDiagnostic { @@ -95,7 +120,24 @@ export interface QueryAgentModelCallDiagnostic { error?: string } -export interface QueryAgentPocResult { +/** + * 失败分类(additive 诊断字段,供 Adapter 决定展示与是否允许 Legacy fallback)。 + * + * 'provider_unavailable' = Provider 未配置;'provider_failure' = 模型请求本身失败 + * (网络 / 上游 / 超时)。未分类的异常由 Adapter 归类为 runtime_error。 + */ +export type QueryAgentErrorKind = 'invalid_question' | 'provider_unavailable' | 'provider_failure' | 'tool_limit' + +/** + * 多轮澄清所需的最小历史。**由 Adapter 提供**,Runtime 只负责按顺序放进 messages。 + * Runtime 自身仍然是无状态单轮执行器;历史长度 / 保留时间的边界由调用方负责。 + */ +export interface QueryAgentHistoryTurn { + question: string + answer: string +} + +export interface QueryAgentResult { question: string provider: string model: string @@ -112,13 +154,54 @@ export interface QueryAgentPocResult { traces: QueryAgentTraceItem[] answer?: string error?: string + /** 失败分类;additive 诊断字段,成功时为 undefined */ + errorKind?: QueryAgentErrorKind + /** 本次回答实际依据的证据(additive);按首次命中顺序去重。 */ + evidence?: QueryAgentEvidenceItem[] +} + +/** + * 语料边界(conversation scope):由 UI / Adapter 传入,**不是** LLM 的输入。 + * `label` 只用于给模型描述"当前范围是什么",强制逻辑完全在 Engine(target 越界会被拒绝)。 + */ +export interface QueryAgentConversationScope { + scope: QueryCorpusScope + label?: string +} + +/** 单次 Tool 执行的上下文(由 Runtime 注入,LLM 无法提供)。 */ +export interface QueryAgentToolContext { + conversationScope?: QueryCorpusScope } export type QueryAgentToolExecutor = ( name: string, - input: Record + input: Record, + context?: QueryAgentToolContext ) => Promise +/** + * 本次回答实际依据的证据(ADDITIVE,供 Adapter 展示)。 + * 只保留可展示字段;messageRef 仍是 opaque 引用。 + */ +export interface QueryAgentEvidenceItem { + messageRef: string + conversationName?: string + conversationType?: 'user' | 'group' + sender?: string + /** epoch ms */ + timestamp?: number + messageType?: string + text?: string + attachment?: { kind?: string; name?: string; url?: string; sizeBytes?: number } + /** 产生这条证据的 Tool 名(诊断 / UI 分组用)。 */ + source: string +} + +/** 一次回答最多带出多少条证据(IPC 体积与 UI 噪声控制)。 */ +const MAX_EVIDENCE_ITEMS = 40 + + const SYSTEM_PROMPT = `你是 TraceMemo 的本地聊天查询助手,只能使用提供的四个 Query Tool 获取事实,最终回答只基于 Tool Result。 规划原则: @@ -131,6 +214,11 @@ const SYSTEM_PROMPT = `你是 TraceMemo 的本地聊天查询助手,只能使 - 普通聊天查询不是 exhaustive investigation。经过合理的检索或可选 context 仍不足以形成强结论时,直接说明证据范围和不确定性,不要循环调用 search、overview、context。 事实边界:不得编造未返回的消息、猜测联系人、修改 resolvedTimeRange,或把 partial/unknown 当作 complete。coverage complete 且结果为 0 时,可以说明当前可读取的完整范围没有找到;coverage partial/unknown 且结果为 0 时,必须说明无法确认绝对不存在。不要把 sampled Evidence 当作完整聊天,也不要把 source message count 和 selected evidence count 混为一谈。 +索引新鲜度:search_messages 的 indexLatestAt / sourceLatestAt 是**结构化事实**,indexCoverage 是 Engine 给出的结论句。规则: +- 覆盖边界只能引用 indexCoverage(含本地时间与结论),**不要自己换算时间,也不要把 epoch 数字写进回答**。 +- indexCoverage.covered 为 false 时,说明这段时间还没进索引:此时即使结果为 0 也只能说"索引尚未覆盖这段时间,暂时无法确认",**绝不能**说成"没有"。必须如实引用结论里的索引更新时间。 +- 已经检索到 Evidence 时,只有当这个覆盖边界真的会影响结论时才补一句说明,不要机械附加警告。 +- 只有 coverage.state 为 complete(indexCoverage.covered 为 true)且结果为 0,才可以下"没有找到"的结论。不要自己把 partial 说成 complete。 缺少必要信息时用自然语言澄清;超出工具能力时说明不能可靠完成,并给出当前工具可以执行的替代方向。` function toolDefinitions(): AIChatToolDefinition[] { @@ -299,12 +387,10 @@ interface CanonicalTemporalBasis { } /** - * LLM Tool Adapter 的 canonicalization: - * - temporalBasis 是 LLM-facing 元数据:Host 用它决定 policy,但**剥离**后不传给 Local Query API - * - absolute 的 ISO-8601 → Local Query API 的 epoch seconds - * - search_messages 的 queries[] → Local Query API 的 query + variants + * LLM Tool Adapter 的 canonicalization:剥离 LLM-facing 元数据(temporalBasis)、把 absolute 的 + * ISO-8601 换成 Local Query API 的 epoch seconds、把 `queries[]` 摊平成 query + variants。 * - * 这里只做**纯 lexical** 校验(sourceText 是否为用户问题子串 + kind 与 timeRange 是否自洽), + * 这里只做**纯 lexical** 校验(sourceText 是否为用户问题子串、kind 与 timeRange 是否自洽), * 不解释时间短语的意思。 */ function canonicalizeToolInput( @@ -482,10 +568,12 @@ export function validateToolArguments( return canonicalizeToolInput(name, value as Record, now, question) } -function resultCount(result: QueryAgentToolResult): { resultCount?: number; evidenceCount?: number } { +function resultCount(result: QueryAgentToolResult): { resultCount?: number; evidenceCount?: number; sourceMessageCount?: number } { return { resultCount: typeof result.returnedCount === 'number' ? result.returnedCount : undefined, - evidenceCount: typeof result.evidenceCount === 'number' ? result.evidenceCount : Array.isArray(result.evidence) ? result.evidence.length : undefined + evidenceCount: typeof result.evidenceCount === 'number' ? result.evidenceCount : Array.isArray(result.evidence) ? result.evidence.length : undefined, + // 会话概览用它说明"覆盖了多少条源消息",UI 顶部统计需要真实数字。 + sourceMessageCount: typeof result.sourceMessageCount === 'number' ? result.sourceMessageCount : undefined } } @@ -551,31 +639,220 @@ function nextToolDefinitions(name: string, result: QueryAgentToolResult, state: return [] } -export class QueryAgentPocService { +/** 进度阶段的类型定义在 `src/shared/query-agent.ts`(renderer 也要消费它,放这里会让 renderer 反向依赖 main)。 */ +export type { QueryAgentProgressEvent, QueryAgentProgressStage } from '../../shared/query-agent' + +export interface QueryAgentRunOptions { + /** + * 最小多轮澄清支持:前几轮(问 + 答)的问答对,由 Adapter 负责长度 / 时间 / 隐私边界。 + * 不传时 messages = [system, user]。 + */ + history?: QueryAgentHistoryTurn[] + /** + * 语料边界(搜索范围)。由 UI 决定;Runtime 负责把它传给 Engine 并在 prompt 里说明, + * 但**强制**发生在 Engine(target 越界 → 可修正的 invalid_tool_arguments)。 + * 不传则不限制范围、不加范围说明。 + */ + conversationScope?: QueryAgentConversationScope + /** + * 真实进度回调(ADDITIVE)。由 Adapter 转发给 UI;不传则零额外开销。 + * 回调抛出的异常不会影响查询本身。 + */ + onProgress?: (event: QueryAgentProgressEvent) => void +} + +/** 给模型的范围说明:只描述边界,不做"请遵守"的祈祷式约束(约束由 Engine 强制)。 */ +function conversationScopeNote(input: QueryAgentConversationScope): string { + const { scope } = input + const label = input.label?.trim() + const header = label ? `当前搜索范围(由应用界面决定):${label}。` : '当前搜索范围由应用界面决定。' + const rules = [ + '所有工具调用都会被强制限制在这个范围内;target 若不在范围内会被拒绝,被拒绝时请如实说明范围限制,不要试图绕过。', + '范围之外还有别的会话,但你**看不到**它们,也不要在回答里声称它们的情况。' + ] + if (scope.kind === 'groups') { + rules.push( + '范围是群聊专属:包含全部群会话以及群成员实际发送的消息。问"谁聊过某话题"时应省略 search_messages 的 target 做跨群检索,并在回答里保留群名与发送者。' + ) + } else if (scope.kind === 'contact' || scope.kind === 'current') { + rules.push( + '范围只有一个会话:所有工具都可以省略 target(query_messages 也可以),省略即在该会话内检索;不要猜会话名,也不要指定其他会话。' + ) + } + return `${header}\n${rules.map((rule) => `- ${rule}`).join('\n')}` +} + +/** + * 证据收集器(Host 侧)。 + * + * 从 Tool Result 中提取**真实**证据供 UI 展示 —— UI 不允许从回答文本里反解析证据。 + * - 按 `messageRef` 去重(search 与 context 命中同一条消息只显示一次); + * - 顺序 = 首次命中顺序;上限 MAX_EVIDENCE_ITEMS; + * - 只保留展示字段,不携带 wxid / md5 / DB id / raw Tool JSON。 + */ +class EvidenceCollector { + private readonly items = new Map() + + addFromToolResult(toolName: string, result: QueryAgentToolResult): void { + const target = result.target && typeof result.target === 'object' ? (result.target as Record) : undefined + const defaults: { conversationName?: string; conversationType?: 'user' | 'group' } = { + conversationName: typeof target?.displayName === 'string' ? target.displayName : undefined, + conversationType: target?.type === 'user' || target?.type === 'group' ? target.type : undefined + } + const push = (value: unknown): void => { + const item = this.normalize(value, toolName, defaults) + if (item) this.merge(item) + } + if (Array.isArray(result.messages)) result.messages.forEach(push) + if (Array.isArray(result.evidence)) result.evidence.forEach(push) + // message_context:只收 anchor(被补充语境的那条证据),前后文不是本次结论的依据。 + if (result.anchor) push(result.anchor) + // recall_hint 自动扩大的那次查询也是真实证据。 + const fallback = result.fallbackLookup && typeof result.fallbackLookup === 'object' ? (result.fallbackLookup as Record) : undefined + if (Array.isArray(fallback?.messages)) fallback.messages.forEach(push) + } + + list(): QueryAgentEvidenceItem[] { + return Array.from(this.items.values()).slice(0, MAX_EVIDENCE_ITEMS) + } + + private merge(item: QueryAgentEvidenceItem): void { + const existing = this.items.get(item.messageRef) + if (!existing) { + this.items.set(item.messageRef, item) + return + } + // 同一消息被不同 Tool 命中:补齐缺失字段,保留首次的 source。 + const merged = existing as unknown as Record + for (const key of Object.keys(item)) { + if (key === 'source') continue + const value = (item as unknown as Record)[key] + if (value !== undefined && merged[key] === undefined) merged[key] = value + } + } + + private normalize( + value: unknown, + source: string, + defaults: { conversationName?: string; conversationType?: 'user' | 'group' } + ): QueryAgentEvidenceItem | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + const record = value as Record + const messageRef = typeof record.messageRef === 'string' ? record.messageRef : undefined + if (!messageRef) return undefined + const attachment = + record.attachment && typeof record.attachment === 'object' && !Array.isArray(record.attachment) + ? (record.attachment as Record) + : undefined + const attachmentView = attachment + ? { + ...(typeof attachment.kind === 'string' ? { kind: attachment.kind } : {}), + ...(typeof attachment.name === 'string' ? { name: attachment.name } : {}), + ...(typeof attachment.url === 'string' ? { url: attachment.url } : {}), + ...(typeof attachment.sizeBytes === 'number' ? { sizeBytes: attachment.sizeBytes } : {}) + } + : undefined + return { + messageRef, + ...(typeof record.conversationName === 'string' + ? { conversationName: record.conversationName } + : defaults.conversationName + ? { conversationName: defaults.conversationName } + : {}), + ...(record.conversationType === 'user' || record.conversationType === 'group' + ? { conversationType: record.conversationType } + : defaults.conversationType + ? { conversationType: defaults.conversationType } + : {}), + ...(typeof record.sender === 'string' ? { sender: record.sender } : {}), + ...(typeof record.timestamp === 'number' ? { timestamp: record.timestamp } : {}), + ...(typeof record.messageType === 'string' + ? { messageType: record.messageType } + : typeof record.sourceKind === 'string' + ? { messageType: record.sourceKind } + : {}), + ...(typeof record.text === 'string' && record.text ? { text: record.text } : {}), + ...(attachmentView && Object.keys(attachmentView).length ? { attachment: attachmentView } : {}), + source + } + } +} + +export class QueryAgentService { constructor( private readonly provider: QueryAgentProvider, private readonly executeTool: QueryAgentToolExecutor, private readonly nowProvider: () => Date = () => new Date() ) {} - async run(question: string): Promise { + async run(question: string, options: QueryAgentRunOptions = {}): Promise { + const startedAt = Date.now() + // 计数用可变持有者:`completed` 由 finally 发出,那时 result 已经离开作用域。 + const counters = { modelCallCount: 0, toolCallCount: 0 } + const emit = (stage: QueryAgentProgressStage, extra: { toolName?: string } = {}): void => { + const onProgress = options.onProgress + if (!onProgress) return + const at = Date.now() + try { + onProgress({ + stage, + elapsedMs: at - startedAt, + at, + ...(extra.toolName ? { toolName: extra.toolName } : {}), + modelCallCount: counters.modelCallCount, + toolCallCount: counters.toolCallCount + }) + } catch { + // 进度上报失败绝不能影响查询本身。 + } + } + try { + return await this.runLoop(question, options, emit, counters) + } finally { + // 成功、Provider 失败、tool_limit、异常 —— 一律以 completed 收尾, + // 避免 UI 永远停在某个中间阶段。 + emit('completed') + } + } + + private async runLoop( + question: string, + options: QueryAgentRunOptions, + emit: (stage: QueryAgentProgressStage, extra?: { toolName?: string }) => void, + counters: { modelCallCount: number; toolCallCount: number } + ): Promise { const trimmed = question.trim() const startedAt = Date.now() const runtime = this.provider.getRuntimeConfig() - const result: QueryAgentPocResult = { question: trimmed, provider: runtime.providerName, model: runtime.modelName || runtime.model, modelCallCount: 0, toolCallCount: 0, toolTotalMs: 0, totalMs: 0, traces: [], modelDurationsMs: [], modelDiagnostics: [] } - if (!trimmed) return { ...result, error: '请输入查询问题', totalMs: Date.now() - startedAt } - if (!runtime.configured) return { ...result, error: '当前 AI Provider 尚未配置', totalMs: Date.now() - startedAt } + const result: QueryAgentResult = { question: trimmed, provider: runtime.providerName, model: runtime.modelName || runtime.model, modelCallCount: 0, toolCallCount: 0, toolTotalMs: 0, totalMs: 0, traces: [], modelDurationsMs: [], modelDiagnostics: [] } + if (!trimmed) return { ...result, error: '请输入查询问题', errorKind: 'invalid_question', totalMs: Date.now() - startedAt } + if (!runtime.configured) return { ...result, error: '当前 AI Provider 尚未配置', errorKind: 'provider_unavailable', totalMs: Date.now() - startedAt } + const history = options.history || [] + const scopeNote = options.conversationScope ? conversationScopeNote(options.conversationScope) : undefined const messages: Array> = [ { role: 'system', content: SYSTEM_PROMPT }, + // 范围说明是**上下文**,不是强制执行手段:真正的边界由 Engine 拒绝越界 target 来保证。 + ...(scopeNote ? [{ role: 'system', content: scopeNote }] : []), + ...history.flatMap((turn) => [ + { role: 'user', content: turn.question }, + { role: 'assistant', content: turn.answer } + ]), { role: 'user', content: trimmed } ] + const toolContext: QueryAgentToolContext = options.conversationScope + ? { conversationScope: options.conversationScope.scope } + : {} + const evidence = new EvidenceCollector() let tools = toolDefinitions() const retry = newRetryState() const now = this.nowProvider() let firstModelAt: number | undefined let finalModelDuration: number | undefined while (result.toolCallCount < MAX_TOOL_CALLS) { + // 真实生命周期边界:还没有任何 Tool 结果 → 这次模型调用是"理解问题"; + // 已经有结果 → 这次是在消化证据并**生成回答**。不用定时器、不猜进度。 + emit(result.toolCallCount === 0 ? 'understanding' : 'generating_answer') const modelStartedAt = Date.now() const model = await this.provider.chatWithTools(messages, tools) result.modelCallCount += 1 @@ -594,7 +871,7 @@ export class QueryAgentPocService { ...(model.success ? {} : { error: model.error || '模型调用失败' }) }) if (firstModelAt === undefined) firstModelAt = Date.now() - if (!model.success) return { ...result, error: model.error || '模型调用失败', firstModelMs: firstModelAt - startedAt, totalMs: Date.now() - startedAt } + if (!model.success) return { ...result, error: model.error || '模型调用失败', errorKind: 'provider_failure', firstModelMs: firstModelAt - startedAt, totalMs: Date.now() - startedAt } const calls = model.toolCalls || [] if (calls.length === 0) { finalModelDuration = modelDuration @@ -605,7 +882,7 @@ export class QueryAgentPocService { return result } if (result.toolCallCount + calls.length > MAX_TOOL_CALLS) { - return { ...result, error: `超过最大工具调用次数(${MAX_TOOL_CALLS})`, firstModelMs: firstModelAt - startedAt, totalMs: Date.now() - startedAt } + return { ...result, error: `超过最大工具调用次数(${MAX_TOOL_CALLS})`, errorKind: 'tool_limit', firstModelMs: firstModelAt - startedAt, totalMs: Date.now() - startedAt } } messages.push({ role: 'assistant', content: model.data || '', tool_calls: calls.map((call) => ({ id: call.id, type: 'function', function: { name: call.name, arguments: call.arguments } })) }) for (const call of calls) { @@ -639,13 +916,17 @@ export class QueryAgentPocService { toolResult = { status: 'invalid_tool_arguments', field: '$', constraint: 'duplicate_retry', expected: '与上一次实质不同的条件', actual: '与上一次完全相同的条件' } } else { recordAttempt(call.name, traceInput, retry) - const primary = await this.executeTool(call.name, traceInput) + // Tool 真正开始执行 = "在搜索聊天记录"。跨会话范围会明显更慢, + // UI 用范围(不是调用次数)决定副提示文案。 + emit('searching', { toolName: call.name }) + const primary = await this.executeTool(call.name, traceInput, toolContext) toolResult = primary // recall_hint + 有界范围 + 0 结果 → Host 自动做一次“全部历史”corrective lookup。 // 这是一次本地 Query API 调用:不增加 LLM 往返,也不占用 MAX_TOOL_CALLS。 + // 注意:自动补查必须沿用同一个语料边界,不能借它逃出当前搜索范围。 if (shouldAutoBroaden(call.name, temporalBasis, traceInput, primary)) { const fallbackStartedAt = Date.now() - const fallbackResult = await this.executeTool('query_messages', { ...traceInput, timeRange: { kind: 'all' } }) + const fallbackResult = await this.executeTool('query_messages', { ...traceInput, timeRange: { kind: 'all' } }, toolContext) const fallbackCounts = resultCount(fallbackResult) autoFallback = { reason: AUTO_FALLBACK_REASON, timeRange: { kind: 'all' }, status: fallbackResult.status, durationMs: Date.now() - fallbackStartedAt, ...fallbackCounts } toolResult = { @@ -672,9 +953,21 @@ export class QueryAgentPocService { const completedToolResult = toolResult || { status: 'invalid_request', error: '工具调用失败' } const durationMs = Date.now() - inputStartedAt result.toolCallCount += 1 + counters.toolCallCount = result.toolCallCount result.toolTotalMs += durationMs + // Tool Result 已经拿到 → 真实进入"整理证据"阶段。 + emit('organizing_evidence', { toolName: call.name }) + // 收集真实证据(去重、限量),供 UI 展示;不进入模型上下文。 + evidence.addFromToolResult(call.name, completedToolResult) + result.evidence = evidence.list() const counts = resultCount(completedToolResult) - result.traces.push({ toolName: call.name, input: sanitizeInput(traceInput), durationMs, status: completedToolResult.status, ...counts, ...(temporalBasis ? { temporalBasis } : {}), ...(autoFallback ? { autoFallback } : {}) }) + // 引擎耗时分解留在 Host 侧(诊断 / UI),不进入模型上下文。 + const rawTimings = completedToolResult.timings + const searchTimings: QuerySearchTimings | undefined = + rawTimings && typeof rawTimings === 'object' && !Array.isArray(rawTimings) + ? (rawTimings as QuerySearchTimings) + : undefined + result.traces.push({ toolName: call.name, input: sanitizeInput(traceInput), durationMs, status: completedToolResult.status, ...counts, ...(temporalBasis ? { temporalBasis } : {}), ...(autoFallback ? { autoFallback } : {}), ...(searchTimings ? { searchTimings } : {}) }) const nextTools = completedToolResult.constraint === 'tool_availability' ? tools : nextToolDefinitions(call.name, completedToolResult, retry, rangeKind(traceInput) === 'all') @@ -685,6 +978,6 @@ export class QueryAgentPocService { } result.firstModelMs = firstModelAt ? firstModelAt - startedAt : undefined result.totalMs = Date.now() - startedAt - return { ...result, error: `超过最大工具调用次数(${MAX_TOOL_CALLS})` } + return { ...result, error: `超过最大工具调用次数(${MAX_TOOL_CALLS})`, errorKind: 'tool_limit' } } } diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index 8c002d9..993f8e9 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -42,6 +42,12 @@ export interface AppSettings { /** Keep a running personal-WeChat OneBot process across app restarts. */ keepPersonalWechatProcess?: boolean windowsWechatPort: string + /** + * Query Agent 是否为桌面「问问微信」与 Agent Hub 查询类问题的主路径。 + * 默认开启;关闭后回退到 Legacy AI Search Pipeline(仅作 runtime regression 时的回退开关, + * 不在用户界面暴露实验性名称)。 + */ + queryAgentEnabled: boolean } function getDefaultDbRoot(): string { @@ -125,7 +131,8 @@ const DEFAULT_SETTINGS: AppSettings = { ttsSelectedVoiceId: '', ttsModel: 's2.1-pro-free', keepPersonalWechatProcess: false, - windowsWechatPort: '' + windowsWechatPort: '', + queryAgentEnabled: true } const SETTINGS_FILE = path.join( diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 155bf41..f75a6c2 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -2011,6 +2011,50 @@ export class Wcdb4Client { } } + /** + * 只取「成员显示名」的轻量路径(Evidence sender enrichment 专用)。 + * + * 与 `getGroupMembersAsync` 的关键差别是它**不做**重活:不 materialize 整群成员行、 + * 不 hydrate 任何头像、contact 表只查**请求到的** wxid。只做两件必要的事 —— + * 群昵称表(native 没有"按成员过滤"的接口,但有 client 级缓存)+ 少量 contact 名称。 + * + * 名称字段与 `normalizeGroupMembers` 同源同优先级 + * (`nickname = wechatNickname || groupNickname || username`),所以调用方复用同一套 + * 显示名规则时,语义不会比完整快照差。 + */ + async getGroupMemberNamesAsync( + chatroomId: string, + usernames: string[] + ): Promise { + if (!chatroomId || !chatroomId.endsWith('@chatroom')) return [] + const requested = this.uniq(usernames.filter(Boolean)) + if (requested.length === 0) return [] + + try { + const groupNicknames = await this.getGroupNicknamesAsync(chatroomId) + const contactNames = await this.readContactMemberNamesAsync(requested) + return requested.map((username) => { + const groupNickname = groupNicknames.get(username) || '' + const contact = contactNames.get(username) + const wechatNickname = contact?.wechatNickname || '' + const remark = contact?.remark || '' + return { + m_nsUsrName: username, + nickname: wechatNickname || groupNickname || username, + groupNickname, + wechatNickname, + remark, + // 刻意留空:Evidence 只需要 displayName。头像若将来需要,另走 lazy UI 路径, + // 绝不让 Query Tool 为"可能显示头像"付整群 hydration 的成本。 + m_nsHeadImgUrl: '' + } + }) + } catch (error) { + console.warn(`[WCDB4] async group member names failed chatroom=${chatroomId}:`, error) + return [] + } + } + isGroupMemberIdsBatchAvailable(): boolean { return Boolean(this.wcdbGetGroupMembersBatch) } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index e458645..f4f8214 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,5 +1,5 @@ import { ElectronAPI } from '@electron-toolkit/preload' -import { Contact, Message } from '../shared/types' +import { Contact, Message, MessagesAroundResult } from '../shared/types' import { GroupReportExportRequest, GroupReportExportResult, @@ -114,6 +114,12 @@ import type { AiSearchPipelineResult, AiSearchProgressEvent } from '../shared/ai-search' +import type { + AskWechatConfig, + AskWechatQueryRequest, + AskWechatQueryResult, + QueryAgentProgressEvent +} from '../shared/query-agent' import type { KnowledgeRuntimeStatus, KnowledgeSearchIpcRequest, @@ -254,6 +260,12 @@ declare global { endTime?: number, options?: { limit?: number } ) => Promise + getMessagesAround: ( + userMd5: string, + messageId: string, + anchorSeconds?: number, + radiusSeconds?: number + ) => Promise getGroupSnapshot: (userMd5: string) => Promise<{ roomId: string memberCount: number @@ -284,8 +296,15 @@ declare global { runAiSearch: (request: AiSearchPipelineRequest) => Promise cancelAiSearch: (requestId: string) => Promise onAiSearchProgress: (callback: (progress: AiSearchProgressEvent) => void) => () => void + getAskWechatConfig: () => Promise + runAskWechatQuery: (request: AskWechatQueryRequest) => Promise + onAskWechatProgress: ( + callback: (requestId: string, event: QueryAgentProgressEvent) => void + ) => () => void + forgetAskWechatConversation: () => Promise getKnowledgeStatus: () => Promise startKnowledgeIndex: () => Promise + cancelKnowledgeIndex: () => Promise<{ cancellable: boolean; cancelled: boolean }> onKnowledgeStatus: (callback: (status: KnowledgeRuntimeStatus) => void) => () => void aiChat: ( messages: { role: string; content: string }[], diff --git a/src/preload/index.ts b/src/preload/index.ts index 26a36e8..69f05b7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,6 +5,7 @@ import type { GroupReportRenderSnapshotExportRequest } from '../shared/group-report' import type { ReportTemplateOperationResult } from '../shared/report-template-package' +import type { MessagesAroundResult } from '../shared/types' import type { ReportTemplateCatalogInstallResult, ReportTemplateCatalogResult @@ -83,6 +84,12 @@ import type { AiSearchPipelineResult, AiSearchProgressEvent } from '../shared/ai-search' +import type { + AskWechatConfig, + AskWechatQueryRequest, + AskWechatQueryResult, + QueryAgentProgressEvent +} from '../shared/query-agent' import type { KnowledgeRuntimeStatus, KnowledgeSearchIpcRequest, @@ -137,6 +144,17 @@ const api = { endTime?: number, options?: { limit?: number } ) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options), + /** + * 跳转到证据的锚点读取:按稳定消息 id 在有界时间窗口内精确定位, + * 返回 `found` 让 UI 能诚实降级(而不是假装跳成功)。 + */ + getMessagesAround: ( + userMd5: string, + messageId: string, + anchorSeconds?: number, + radiusSeconds?: number + ): Promise => + ipcRenderer.invoke('db:getMessagesAround', userMd5, messageId, anchorSeconds, radiusSeconds), getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5), getGroupExitMonitorState: (): Promise => ipcRenderer.invoke('group-exit-monitor:getState'), @@ -180,10 +198,37 @@ const api = { ipcRenderer.on('ai-search:progress', listener) return () => ipcRenderer.removeListener('ai-search:progress', listener) }, + getAskWechatConfig: (): Promise => ipcRenderer.invoke('ask-wechat:getConfig'), + runAskWechatQuery: (request: AskWechatQueryRequest): Promise => + ipcRenderer.invoke('ask-wechat:query', request), + /** + * 订阅「问问微信」的真实进度事件。 + * + * 事件带 requestId:UI 必须只认自己那一次请求,否则用户连问两次时阶段文案会串台。 + */ + onAskWechatProgress: ( + callback: (requestId: string, event: QueryAgentProgressEvent) => void + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + requestId: string, + progress: QueryAgentProgressEvent + ): void => callback(requestId, progress) + ipcRenderer.on('ask-wechat:progress', listener) + return () => ipcRenderer.removeListener('ask-wechat:progress', listener) + }, + forgetAskWechatConversation: (): Promise => + ipcRenderer.invoke('ask-wechat:forgetConversation'), getKnowledgeStatus: (): Promise => ipcRenderer.invoke('knowledge:getStatus'), startKnowledgeIndex: (): Promise => ipcRenderer.invoke('knowledge:startIndex'), + /** + * 取消正在跑的索引 pass。返回 `cancelled: false` 表示请求时已经没有可取消的任务 + * (例如刚好自己跑完了)——UI 必须如实反映,而不是无条件显示"已取消"。 + */ + cancelKnowledgeIndex: (): Promise<{ cancellable: boolean; cancelled: boolean }> => + ipcRenderer.invoke('knowledge:cancelIndex'), onKnowledgeStatus: (callback: (status: KnowledgeRuntimeStatus) => void) => { const listener = (_event: Electron.IpcRendererEvent, status: KnowledgeRuntimeStatus): void => callback(status) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 74ca2e6..c13e289 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -28,6 +28,8 @@ import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/Dat import { FirstUseWelcome } from './components/FirstUseWelcome' import { ExportWorkspace } from './components/export/ExportWorkspace' import { AISearchWorkspace } from './components/search/AISearchWorkspace' +import type { EvidenceItem } from './components/search/searchTypes' +import { decodeMessageRef } from '../../shared/local-query-api' import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export' import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../shared/database-key' import { @@ -249,6 +251,13 @@ function App(): React.ReactElement { const connectionOperationRef = React.useRef(0) const [activePage, setActivePage] = useState('archive') const [archiveJumpTime, setArchiveJumpTime] = useState(null) + /** + * 精确跳转目标(规范化后的消息 id)。 + * + * 与 `archiveJumpTime` 并存而不是替代:时间只能定位到"附近",秒级时间戳在群聊里 + * 经常对应多条消息。有 messageRef 时用 id 精确定位,没有时才退回按时间找。 + */ + const [archiveJumpMessageId, setArchiveJumpMessageId] = useState(null) const [settingsCategory, setSettingsCategory] = useState('account-database') const [reportSourceContact, setReportSourceContact] = useState(null) const [reportWorkspaceView, setReportWorkspaceView] = useState('result') @@ -1191,6 +1200,7 @@ function App(): React.ReactElement { const handleSelectContact = async (contact: Contact, forceLive = false): Promise => { setArchiveJumpTime(null) + setArchiveJumpMessageId(null) setSelectedContact(contact) selectedContactMd5Ref.current = contact.md5 currentGroupSnapshotRef.current = null @@ -1265,23 +1275,49 @@ function App(): React.ReactElement { } } - const handleOpenSearchEvidence = async (contact: Contact, createTime?: number): Promise => { + /** + * 跳转到证据的原聊天。 + * + * 只靠「真实会话 id + 秒级时间戳」定位不可靠:会话 id 若是展示层合成的 key 就选不中任何 + * 真实会话,而时间戳在同一秒有多条消息时会挑错。这里从 `messageRef` 还原真实会话 id → + * 选中会话 → 用 `getMessagesAround` 按稳定消息 id 在有界窗口内精确锚定 → 高亮那一条。 + * + * 窗口内找不到时**不静默失败**,明确提示"已打开对应会话,但暂时无法定位原消息"。 + */ + const handleOpenSearchEvidence = async (evidence: EvidenceItem): Promise => { + const anchor = decodeMessageRef(evidence.messageRef) + if (!anchor) { + setReportNotice('这条证据缺少可定位的消息引用,已为你打开对应会话') + setActivePage('archive') + await handleSelectContact(evidence.contact) + return + } + // 优先用联系人列表里的真实联系人(头像 / 备注等元数据完整),兜底用证据自带的最小信息。 + const contact = contacts.find((item) => item.md5 === anchor.conversationId) || evidence.contact setActivePage('archive') await handleSelectContact(contact) - if (!createTime || selectedContactMd5Ref.current !== contact.md5) return + if (selectedContactMd5Ref.current !== contact.md5) return + const anchorSeconds = evidence.message.createTime || undefined try { - const windowStart = Math.max(0, createTime - 12 * 3600) - const windowEnd = createTime + 12 * 3600 - const nearbyMessages = await window.api.getMessages(contact.md5, windowStart, windowEnd) + const around = await window.api.getMessagesAround( + contact.md5, + anchor.messageId, + anchorSeconds + ) if (selectedContactMd5Ref.current !== contact.md5) return - const focusedMessages = sortMessagesChronologically(nearbyMessages) + const focusedMessages = sortMessagesChronologically(around.messages) messageHistoryRef.current = focusedMessages setMessages(applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, focusedMessages))) - setArchiveJumpTime(createTime) + if (around.found) { + setArchiveJumpMessageId(anchor.messageId) + setArchiveJumpTime(anchorSeconds ?? null) + } else { + setReportNotice('已打开对应会话,但暂时无法定位原消息。') + } } catch (error) { console.warn('[Search] evidence context load failed:', error) - setReportNotice('证据所在时间段加载失败,请在档案中手动查看') + setReportNotice('已打开对应会话,但暂时无法定位原消息。') } } @@ -1773,6 +1809,7 @@ function App(): React.ReactElement { onOpenPersonalWechatSettings={openWechatSendSettings} isAiLoading={reportGeneration.isGenerating} jumpToTime={archiveJumpTime} + jumpToMessageId={archiveJumpMessageId} /> ) @@ -2007,9 +2044,7 @@ function App(): React.ReactElement { dbReady={isDatabaseConnected} aiModelConfig={aiModelConfig} onSelectContact={(contact) => void handleSelectContact(contact)} - onOpenEvidence={(contact, createTime) => - void handleOpenSearchEvidence(contact, createTime) - } + onOpenEvidence={(evidence) => void handleOpenSearchEvidence(evidence)} onOpenAISettings={openModelSettings} onNotice={setReportNotice} /> diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx index f8b4fb1..b3bd4c7 100644 --- a/src/renderer/src/components/ChatWindow.tsx +++ b/src/renderer/src/components/ChatWindow.tsx @@ -24,6 +24,8 @@ interface ChatWindowProps { onOpenPersonalWechatSettings?: () => void isAiLoading?: boolean jumpToTime?: number | null + /** 精确跳转目标(消息 id)。与 jumpToTime 取或:任一存在就说明"这是一次跳转"。 */ + jumpToMessageId?: string | null } const ChatWindow: React.FC = ({ @@ -41,7 +43,8 @@ const ChatWindow: React.FC = ({ onOpenTextToSpeechSettings, onOpenPersonalWechatSettings, isAiLoading = false, - jumpToTime + jumpToTime, + jumpToMessageId }) => { const isGroupChat = Boolean( contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom') @@ -76,24 +79,30 @@ const ChatWindow: React.FC = ({ setIsAtLatest(true) }, [contact?.md5]) - useEffect(() => { - if (jumpToTime !== undefined && jumpToTime !== null) setIsAtLatest(false) - }, [jumpToTime]) + // 一次跳转 = 有精确目标或有时间目标。用统一判据,避免"只带了 messageId 但没带时间" + // 时自动滚到底把跳转结果顶掉(那会让用户看到"跳过去了但又被弹回最新")。 + const hasJumpTarget = + (jumpToTime !== undefined && jumpToTime !== null) || + (jumpToMessageId !== undefined && jumpToMessageId !== null) useEffect(() => { - if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return + if (hasJumpTarget) setIsAtLatest(false) + }, [hasJumpTarget]) + + useEffect(() => { + if (!isAtLatest || hasJumpTarget) return const frame = window.requestAnimationFrame(() => scrollToBottom()) return () => window.cancelAnimationFrame(frame) - }, [isAtLatest, jumpToTime, messages, scrollToBottom]) + }, [isAtLatest, hasJumpTarget, messages, scrollToBottom]) useEffect(() => { - if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return + if (!isAtLatest || hasJumpTarget) return const content = messageListRef.current?.querySelector('.virtual-message-list') if (!content) return const observer = new ResizeObserver(() => scrollToBottom()) observer.observe(content) return () => observer.disconnect() - }, [contact?.md5, isAtLatest, jumpToTime, scrollToBottom]) + }, [contact?.md5, isAtLatest, hasJumpTarget, scrollToBottom]) const openImagePreview = (imageUrl: string): void => { setPreviewImage(imageUrl) @@ -175,6 +184,7 @@ const ChatWindow: React.FC = ({ onReachTop={onLoadOlderMessages} onImageClick={openImagePreview} jumpToTime={jumpToTime} + jumpToMessageId={jumpToMessageId} /> Promise onImageClick: (imageUrl: string) => void jumpToTime?: number | null + /** + * 精确跳转目标(规范化消息 id,即去掉 `local:` 前缀后的 WCDB 本地 id)。 + * + * 优先于 `jumpToTime`:时间只能找到"附近的第一条",秒级时间戳在群聊里经常 + * 对应多条消息,于是会定位并高亮错一条。有 id 时必须按 id 找。 + */ + jumpToMessageId?: string | null } export function MessageList({ @@ -33,7 +41,8 @@ export function MessageList({ onScroll, onReachTop, onImageClick, - jumpToTime + jumpToTime, + jumpToMessageId }: MessageListProps): React.ReactElement { const groups = React.useMemo(() => buildMessageGroups(messages), [messages]) const groupsRef = React.useRef(groups) @@ -47,15 +56,10 @@ export function MessageList({ overscan: 8 }) const virtualItems = virtualizer.getVirtualItems() - const jumpTarget = React.useMemo(() => { - if (jumpToTime === undefined || jumpToTime === null) return null - const groupIndex = groups.findIndex((group) => - group.messages.some((message) => (message.createTime || 0) >= jumpToTime) - ) - if (groupIndex < 0) return null - const message = groups[groupIndex].messages.find((item) => (item.createTime || 0) >= jumpToTime) - return { groupIndex, messageId: message?.id } - }, [groups, jumpToTime]) + const jumpTarget = React.useMemo( + () => resolveMessageJumpTarget(groups, jumpToTime, jumpToMessageId), + [groups, jumpToTime, jumpToMessageId] + ) React.useEffect(() => { if (!jumpTarget) return @@ -70,6 +74,7 @@ export function MessageList({ const scrollElement = event.currentTarget if ( (jumpToTime !== undefined && jumpToTime !== null) || + (jumpToMessageId !== undefined && jumpToMessageId !== null) || scrollElement.scrollTop >= 48 || loadingOlderRef.current || isLoadingMessages || diff --git a/src/renderer/src/components/chat/messageJump.ts b/src/renderer/src/components/chat/messageJump.ts new file mode 100644 index 0000000..c23278e --- /dev/null +++ b/src/renderer/src/components/chat/messageJump.ts @@ -0,0 +1,51 @@ +import type { MessageGroupModel } from './messageGrouping' + +/** + * 证据 → 归档侧的定位结果。 + * + * `messageId` 刻意返回**归档侧真实出现的 id**(而不是传入的 id): + * `MessageGroup` 的高亮是拿 `message.id` 直接比对的,返回归一化后的值会让 + * `local:` 前缀不同的情况静默失配 —— 表现为"跳过去了但没有高亮"。 + */ +export interface MessageJumpTarget { + groupIndex: number + messageId?: string + /** true = 按稳定身份精确命中;false = 退化到按时间戳找最近的一条。 */ + exact: boolean +} + +/** 两侧(证据侧 / 归档侧)都用同一套归一化,否则 `local:` 前缀会让匹配静默失败。 */ +export const normalizeJumpMessageId = (value: string | undefined | null): string => + String(value ?? '').replace(/^local:/, '') + +/** + * 解析跳转目标。 + * + * 顺序(不能倒过来): + * 1. **稳定身份优先**:证据带 messageRef 时,落在哪一条就是哪一条。 + * 这是唯一能正确处理"同一秒多条消息"的方式 —— 秒级时间戳只能找到"附近"。 + * 2. 退化为按时间找第一条 `createTime >= jumpToTime` 的消息(老证据 / 无引用路径)。 + * 找不到(目标已被删除 / 清理)→ null,调用方必须给出诚实文案。 + */ +export const resolveMessageJumpTarget = ( + groups: MessageGroupModel[], + jumpToTime: number | null | undefined, + jumpToMessageId: string | null | undefined +): MessageJumpTarget | null => { + const wantedId = normalizeJumpMessageId(jumpToMessageId) + if (wantedId) { + for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) { + const hit = groups[groupIndex].messages.find( + (message) => normalizeJumpMessageId(message.id) === wantedId + ) + if (hit) return { groupIndex, messageId: hit.id, exact: true } + } + } + if (jumpToTime === undefined || jumpToTime === null) return null + const groupIndex = groups.findIndex((group) => + group.messages.some((message) => (message.createTime || 0) >= jumpToTime) + ) + if (groupIndex < 0) return null + const message = groups[groupIndex].messages.find((item) => (item.createTime || 0) >= jumpToTime) + return { groupIndex, messageId: message?.id, exact: false } +} diff --git a/src/renderer/src/components/search/AISearchComposer.tsx b/src/renderer/src/components/search/AISearchComposer.tsx index 8c80b96..840aec0 100644 --- a/src/renderer/src/components/search/AISearchComposer.tsx +++ b/src/renderer/src/components/search/AISearchComposer.tsx @@ -139,13 +139,10 @@ export function AISearchComposer({ ) : ( - + {/* 单聊专属需要一个明确的联系人(Legacy 下同样由档案选择决定,这里在 Query Agent 主路径提供显式选择) */} + {queryAgentEnabled && scope === 'contacts' && ( + + )} -
- 时间范围 -
- {(Object.keys(RANGE_LABELS) as SearchRange[]).map((item) => ( - - ))} -
-
+ {queryAgentEnabled ? ( +
+ 时间 +

+ 时间直接写在问题里,例如「上个月 BOBO 发过什么文件?」「最近 7 天群里聊了什么?」 +

+
+ ) : ( +
+ 时间范围 +
+ {(Object.keys(RANGE_LABELS) as SearchRange[]).map((item) => ( + + ))} +
+
+ )}
-
-
- KNOWLEDGE BASE - {knowledgeStateLabel(knowledgeStatus)} + {/* 卡片自上而下固定五段:HEADER → CURRENT PASS → DATABASE STATUS → CURRENT → ACTION。 + 每段的「标签 / 数值」行都用同一套栅格(label 可收缩、value 取自然宽且不折断), + 侧栏只有 ~145px 可用宽度,靠栅格而不是靠缩字号来避免挤成一团。 */} +
+
+ KNOWLEDGE BASE + {/* 折行规则在 CSS 里(word-break: keep-all):中文不在字与字之间断开, + 「 · 」两侧的空格仍是断点,所以折成两行时只会断在「可用 · 」之后。 */} + + {knowledgeStateLabel(knowledgeStatus)} +

{knowledgeStatus?.state === 'unavailable' ? '知识库不会自动建立,只有点击下方按钮后才会在后台同步。' - : '后台增量同步不会影响原始微信聊天记录。'} + : knowledgeStatus?.state === 'cancelled' + ? '同步已取消。已经建立的索引仍然可用,下次同步会从中断处继续,不会从头重扫。' + : knowledgeIsStale(knowledgeStatus) && knowledgeStatus?.indexLatestAt + ? `索引还没追上最新聊天:跨会话搜索目前只覆盖到 ${formatIndexDate(knowledgeStatus.indexLatestAt)},之后的记录需要同步后才可检索。不影响你现在提问,但答案会标注覆盖范围。` + : '后台增量同步不会影响原始微信聊天记录,也不会阻塞提问。'}

- {(knowledgeStatus?.state === 'building' || knowledgeStatus?.state === 'syncing') && ( -
-
- - 已处理 {knowledgeStatus.processedMessages.toLocaleString()} 条 - {knowledgeStatus.totalMessages - ? ` / ${knowledgeStatus.totalMessages.toLocaleString()}` - : ''} - - - {knowledgeStatus.totalMessages - ? `${Math.min(100, Math.round((knowledgeStatus.processedMessages / knowledgeStatus.totalMessages) * 100))}%` - : '统计中'} - + {/* CURRENT PASS:只有这一遍真的在跑时才出现。 */} + {knowledgeIsRunning && ( +
+
+
+ 会话进度 + + {knowledgeStatus.pass + ? `${knowledgeStatus.pass.processedConversations.toLocaleString()} / ${knowledgeStatus.pass.totalConversations.toLocaleString()}` + : '准备中'} + +
+ {/* 「已处理 X 条 / 统计中」是**无分母**的伪进度。这里改成两个真实数字: + 新增索引(真的写进去的)与已扫描(读了多少),口径写清楚。 + 这一句太长,允许在「 · 」处折成两行(同上,交给 CSS 处理)。 */} +

+ {formatKnowledgeProcessed(knowledgeStatus)} +

+ {knowledgeStatus.pass && ( +

+ {knowledgeStatus.pass.phase === 'backfill' + ? `后台补齐历史:${(knowledgeStatus.pass.backfillCompletedConversations ?? 0).toLocaleString()} / ${(knowledgeStatus.pass.backfillConversations ?? 0).toLocaleString()} 个久未更新的会话` + : knowledgeStatus.pass.phase === 'catchup' + ? `正在追最新消息:${(knowledgeStatus.pass.catchupConversations ?? 0).toLocaleString()} 个会话有新内容` + : `正在建立索引:${(knowledgeStatus.pass.totalConversations ?? 0).toLocaleString()} 个会话`} +

+ )} + {knowledgeStatus.pass && knowledgeStatus.pass.skippedConversations > 0 && ( +

+ 已跳过 {knowledgeStatus.pass.skippedConversations.toLocaleString()} 个没有新消息的会话 +

+ )}
)} -
-
- 已索引消息 - {(knowledgeStatus?.indexedMessageCount || 0).toLocaleString()} +
+
+ 已索引消息 + + {(knowledgeStatus?.indexedMessageCount || 0).toLocaleString()} +
-
- 知识片段 - {(knowledgeStatus?.indexedChunkCount || 0).toLocaleString()} +
+ 知识片段 + + {(knowledgeStatus?.indexedChunkCount || 0).toLocaleString()} +
-
- 磁盘占用 - + {knowledgeStatus?.indexLatestAt ? ( +
+ 最新索引 + + {formatIndexDate(knowledgeStatus.indexLatestAt)} + +
+ ) : null} +
+ 磁盘占用 + {formatBytes( (knowledgeStatus?.databaseBytes || 0) + (knowledgeStatus?.walBytes || 0) + @@ -992,41 +1312,70 @@ export function AISearchWorkspace({ )}
- {knowledgeStatus?.currentConversationId && - (knowledgeStatus.state === 'building' || knowledgeStatus.state === 'syncing') && ( -
- 当前会话 - - {currentSyncConversation === '未选择会话' - ? '正在切换会话' - : currentSyncConversation} +
+ {(knowledgeStatus?.currentConversationId && knowledgeIsRunning) || + (knowledgeStatus?.pass && knowledgeStatus.pass.mainLoopLagMs > 0) ? ( +
+ {knowledgeStatus?.currentConversationId && knowledgeIsRunning && ( +
+ 当前会话 + {/* 会话名可以很长(群名 / 备注),这里必须省略而不是撑破侧栏。 */} + + {knowledgeCurrentConversationName}
)} -
+ {knowledgeMainLoopLagMs > 0 && ( +
+ 界面卡顿峰值 + + {formatDuration(knowledgeMainLoopLagMs)} + +
+ )} +
+ ) : null} {knowledgeStatus?.state === 'error' && (

- {knowledgeStatus.lastError || '同步异常,旧搜索仍可使用。'} + {knowledgeStatus.lastError || '同步异常,已建立的索引仍可使用。'}

)} - + {knowledgeStatus?.state === 'cancelled' && ( +

+ 上一遍同步被取消,已建立的索引仍然可用;下次同步会从断点继续。 +

+ )} +
+ + {knowledgeIsRunning && ( + + )} +
同步详情

diff --git a/src/renderer/src/components/search/askWechatPresentation.ts b/src/renderer/src/components/search/askWechatPresentation.ts new file mode 100644 index 0000000..e1515a1 --- /dev/null +++ b/src/renderer/src/components/search/askWechatPresentation.ts @@ -0,0 +1,110 @@ +import type { AskWechatEvidenceItem, AskWechatStats } from '../../../../shared/query-agent' +import { + decodeMessageRef, + type CanonicalMessageIdentity +} from '../../../../shared/local-query-api' +import type { Contact } from '../../../../shared/types' +import { formatEvidenceTimestamp } from './searchFormatters' +import type { EvidenceItem } from './searchTypes' + +/** + * Query Agent 的展示映射。 + * + * - 证据来自 Runtime 收集的**真实** Tool 结果,不从 answer 文本反解析; + * - 顶部统计使用真实执行数字(读取条数 / 证据条数 / 模型调用 / 耗时), + * 不使用"知识库已收录"这类只有部分 Tool 才成立的文案。 + */ + +const SOURCE_LABELS: Record = { + query_messages: '精确读取', + search_messages: '关键词检索', + conversation_overview: '会话概览', + message_context: '上下文' +} + +const SCOPE_LABELS: Record = { + all: '所有聊天记录', + groups: '群聊专属', + contact: '单聊专属', + current: '当前会话' +} + +/** + * 证据卡片的会话归属:群消息必须显示**群名**,而不是把所有群消息都归成"群聊"。 + * + * `contact.md5` 必须是**真实会话 id**:合成 key(如 `query-agent:<群名>`)选不中任何会话, + * 「跳转到原聊天」只能停在档案首页。真实 id 只能从 `messageRef` 里还原 + * (展示契约里刻意不含 md5 字段);`messageRef` 缺失或解析失败时退化成合成 key, + * 并且调用方必须按"无法定位"处理。 + */ +function evidenceContact(item: AskWechatEvidenceItem, anchor: CanonicalMessageIdentity | null): Contact { + const name = item.conversationName?.trim() || '未命名会话' + const type = item.conversationType === 'group' ? 'group' : 'user' + if (anchor) { + return { md5: anchor.conversationId, m_nsUsrName: anchor.conversationId, m_nsNickName: name, type } + } + return { md5: `query-agent:${item.conversationName || 'unknown'}`, m_nsUsrName: '', m_nsNickName: name, type } +} + +export function mapAskWechatEvidence(items: AskWechatEvidenceItem[]): EvidenceItem[] { + return items.map((item, index) => { + const anchor = decodeMessageRef(item.messageRef) + return { + evidenceId: `E${index + 1}`, + sourceKind: item.messageType as EvidenceItem['sourceKind'], + contact: evidenceContact(item, anchor), + messageRef: item.messageRef, + message: { + // message 的 id 用**可读的消息 id**(不是 opaque ref):Archive 的高亮 + // 是按 `message.id === jumpTargetMessageId` 匹配的,两边必须是同一个值。 + // 解析不出身份时退化成 messageRef —— 它至少保证列表 key 唯一。 + id: anchor?.messageId || item.messageRef, + from: 'user', + type: '检索消息', + datetime: formatEvidenceTimestamp(item.timestamp || 0), + content: item.text || '', + isSender: item.sender === '我', + name: item.sender, + createTime: Math.floor((item.timestamp || 0) / 1000) + } + } + }) +} + +/** + * 顶部统计文案。 + * 按实际用到的 Tool 组合描述,例如: + * - query_messages:`读取 20 条消息 · 使用 20 条证据` + * - search/overview:`命中 12 条相关消息` / `覆盖 1200 条消息 · 使用 60 条证据` + */ +export function formatAskWechatStats(stats: AskWechatStats): string[] { + const chips: string[] = [] + const scopeLabel = stats.scope + ? stats.scope.label || SCOPE_LABELS[stats.scope.kind] || '当前范围' + : '当前范围' + chips.push(`${scopeLabel}内查询`) + const { reads, tools } = stats + if (tools.includes('query_messages') && reads.messageCount > 0) { + chips.push(`读取 ${reads.messageCount} 条消息`) + } + if (tools.includes('conversation_overview') && reads.overviewSourceCount > 0) { + chips.push(`覆盖 ${reads.overviewSourceCount} 条消息`) + } + if (reads.evidenceCount > 0) { + chips.push(`使用 ${reads.evidenceCount} 条证据`) + } else if (reads.matchedCount > 0) { + chips.push(`命中 ${reads.matchedCount} 条相关消息`) + } + chips.push(`${stats.modelCallCount} 次模型调用`) + chips.push(formatMs(stats.totalMs)) + return chips +} + +export function askWechatToolLabels(tools: string[]): string[] { + return Array.from(new Set(tools)).map((tool) => SOURCE_LABELS[tool] || tool) +} + +function formatMs(value: number): string { + if (!Number.isFinite(value) || value <= 0) return '0 ms' + return value < 1000 ? `${Math.round(value)} ms` : `${(value / 1000).toFixed(1)} s` +} diff --git a/src/renderer/src/components/search/hooks/useEvidenceCollection.ts b/src/renderer/src/components/search/hooks/useEvidenceCollection.ts index 6076a8a..2dcadaf 100644 --- a/src/renderer/src/components/search/hooks/useEvidenceCollection.ts +++ b/src/renderer/src/components/search/hooks/useEvidenceCollection.ts @@ -1,11 +1,11 @@ import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react' -import type { Contact } from '../../../../../shared/types' import type { EvidenceItem } from '../searchTypes' export const EVIDENCE_PAGE_SIZE = 8 type UseEvidenceCollectionOptions = { - onOpenEvidence: (contact: Contact, createTime?: number) => void + /** 传整条证据:跳转需要它的稳定引用(messageRef),不只是会话与时间。 */ + onOpenEvidence: (evidence: EvidenceItem) => void } export function useEvidenceCollection({ onOpenEvidence }: UseEvidenceCollectionOptions): { @@ -70,7 +70,7 @@ export function useEvidenceCollection({ onOpenEvidence }: UseEvidenceCollectionO const jumpToEvidence = (index: number): void => { const item = evidenceCollection[index] if (!item) return - onOpenEvidence(item.contact, item.message.createTime) + onOpenEvidence(item) } const setEvidenceCardRef = (index: number, node: HTMLElement | null): void => { diff --git a/src/renderer/src/components/search/hooks/useKnowledgeStatus.ts b/src/renderer/src/components/search/hooks/useKnowledgeStatus.ts index 4ae2141..a2f60ef 100644 --- a/src/renderer/src/components/search/hooks/useKnowledgeStatus.ts +++ b/src/renderer/src/components/search/hooks/useKnowledgeStatus.ts @@ -11,10 +11,20 @@ export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOpti syncStarting: boolean knowledgeSyncing: boolean knowledgeSyncingRef: React.MutableRefObject + cancelRequested: boolean startKnowledgeSync: () => Promise + cancelKnowledgeSync: () => Promise } { const [knowledgeStatus, setKnowledgeStatus] = useState(null) const [syncStarting, setSyncStarting] = useState(false) + /** + * 本地「取消已发出、但还没落地」的状态。 + * + * Worker 侧 abort 之后,当前会话还要安全收尾(事务提交 / 不残留 indexing), + * 主进程的 `pass.cancellable=false` 会先到,`phase='cancelled'` 后到。 + * 这中间的窗口如果只靠 status 渲染,按钮会闪回"取消同步"。 + */ + const [cancelRequested, setCancelRequested] = useState(false) const knowledgeSyncingRef = useRef(false) const knowledgeSyncing = syncStarting || knowledgeStatus?.state === 'building' || knowledgeStatus?.state === 'syncing' @@ -29,7 +39,11 @@ export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOpti }) .catch(() => undefined) const unsubscribe = window.api.onKnowledgeStatus((status) => { - if (active) setKnowledgeStatus(status) + if (!active) return + setKnowledgeStatus(status) + // 一遍 pass 真的结束了(不再可取消 / 已经有终态)→ 清掉本地的"正在取消"。 + const phase = status.pass?.phase + if (phase === 'idle' || phase === 'cancelled' || phase === 'error') setCancelRequested(false) }) return () => { active = false @@ -43,6 +57,7 @@ export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOpti return } setSyncStarting(true) + setCancelRequested(false) try { const status = await window.api.startKnowledgeIndex() setKnowledgeStatus(status) @@ -58,11 +73,44 @@ export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOpti } } + /** + * 取消同步。 + * + * 关键语义(不能简化成"点一下就当取消成功"): + * - 只有真的中止到了任务,才提示"已取消"; + * - `cancelled: false` 表示请求时已经没有可取消的任务(例如刚好自己跑完了), + * 这时候说"已取消"是假话; + * - 已索引数据不会被清空,下次同步会从断点继续。 + */ + const cancelKnowledgeSync = async (): Promise => { + if (cancelRequested) return + setCancelRequested(true) + try { + const result = await window.api.cancelKnowledgeIndex() + if (!result.cancellable) { + setCancelRequested(false) + onNotice('当前没有正在进行的同步') + return + } + if (!result.cancelled) { + setCancelRequested(false) + onNotice('同步刚刚已经结束,无需取消') + return + } + onNotice('已取消同步,已建立的部分会保留,下次可继续') + } catch (error) { + setCancelRequested(false) + onNotice(error instanceof Error ? error.message : '取消同步失败') + } + } + return { knowledgeStatus, syncStarting, knowledgeSyncing, knowledgeSyncingRef, - startKnowledgeSync + cancelRequested, + startKnowledgeSync, + cancelKnowledgeSync } } diff --git a/src/renderer/src/components/search/hooks/useQueryAgentProgress.ts b/src/renderer/src/components/search/hooks/useQueryAgentProgress.ts new file mode 100644 index 0000000..97c2a01 --- /dev/null +++ b/src/renderer/src/components/search/hooks/useQueryAgentProgress.ts @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from 'react' +import type { + QueryAgentProgressEvent, + QueryAgentProgressStage +} from '../../../../../shared/query-agent' + +export interface QueryAgentProgressState { + stage: QueryAgentProgressStage + elapsedMs: number + toolName?: string + modelCallCount: number + toolCallCount: number +} + +/** + * 「问问微信」的真实进度订阅。 + * + * 设计约束: + * - 只认自己那一次 requestId:用户连问两次时旧请求的进度不能串台; + * - 事件是**真实生命周期边界**(understanding / searching / organizing_evidence / + * generating_answer / completed),没有百分比、没有定时器伪进度; + * - 不缓存历史阶段:每次 `begin(requestId)` 重置,避免上一次的阶段残留成"假进度"。 + */ +export function useQueryAgentProgress(): { + progress: QueryAgentProgressState | null + begin: (requestId: string) => void + end: () => void +} { + const [progress, setProgress] = useState(null) + const activeRequestRef = useRef(null) + + useEffect(() => { + // 桥缺失(旧 preload / 测试替身 / 尚未升级的宿主)时必须**静默降级**: + // 进度只是增强信息,缺了它查询本身完全不受影响 —— 但抛异常会把整个工作区打挂。 + const subscribe = window.api?.onAskWechatProgress + if (typeof subscribe !== 'function') return + const unsubscribe = subscribe( + (requestId: string, event: QueryAgentProgressEvent) => { + if (activeRequestRef.current !== requestId) return + setProgress({ + stage: event.stage, + elapsedMs: event.elapsedMs, + ...(event.toolName ? { toolName: event.toolName } : {}), + modelCallCount: event.modelCallCount, + toolCallCount: event.toolCallCount + }) + } + ) + return unsubscribe + }, []) + + return { + progress, + begin: (requestId: string) => { + activeRequestRef.current = requestId + setProgress(null) + }, + end: () => { + activeRequestRef.current = null + setProgress(null) + } + } +} + +/** 进度阶段 → 用户可读文案。内部工具名绝不外泄(不出现 toolName / SQL / FTS / 内部 id)。 */ +export function queryAgentProgressLabel( + progress: QueryAgentProgressState | null, + /** + * 本次语料范围是不是"跨会话的大范围"(所有聊天记录 / 全部群聊)。 + * + * 用它决定 Tool 阶段的副提示,而不是用工具调用次数猜:范围是 UI 自己定的边界, + * 是**事实**;调用次数只是间接信号,容易把"重试一次"误报成"范围很大"。 + */ + wideScope: boolean +): string { + if (!progress) return '正在准备查询' + switch (progress.stage) { + case 'understanding': + return progress.modelCallCount <= 1 ? '正在理解你的问题' : '正在重新理解你的问题' + case 'searching': + // 跨会话检索明显比单会话慢一个量级,提前把预期说清楚,避免用户以为卡住了。 + return wideScope ? '正在搜索较大范围的聊天记录…' : '正在搜索聊天记录…' + case 'organizing_evidence': + return '正在整理找到的聊天记录' + case 'generating_answer': + return '正在生成回答' + case 'completed': + return '已完成' + } +} + +/** 真实阶段顺序(与 Runtime 的生命周期一一对应),用于渲染进度列表。 */ +export const QUERY_AGENT_PROGRESS_STEPS: ReadonlyArray<{ + stage: QueryAgentProgressStage + label: string +}> = [ + { stage: 'understanding', label: '理解问题' }, + { stage: 'searching', label: '查找相关聊天' }, + { stage: 'organizing_evidence', label: '整理证据' }, + { stage: 'generating_answer', label: '生成回答' } +] + +/** 当前处于第几步(0-based);`completed` 视为全部完成。 */ +export function queryAgentProgressStepIndex( + progress: QueryAgentProgressState | null +): number { + if (!progress || progress.stage === 'completed') return progress ? QUERY_AGENT_PROGRESS_STEPS.length : 0 + const index = QUERY_AGENT_PROGRESS_STEPS.findIndex((step) => step.stage === progress.stage) + return index < 0 ? 0 : index +} diff --git a/src/renderer/src/components/search/queryAgentBridge.ts b/src/renderer/src/components/search/queryAgentBridge.ts new file mode 100644 index 0000000..68d887b --- /dev/null +++ b/src/renderer/src/components/search/queryAgentBridge.ts @@ -0,0 +1,47 @@ +import type { + AskWechatConfig, + AskWechatQueryRequest, + AskWechatQueryResult +} from '../../../../shared/query-agent' + +/** + * Renderer → Query Agent 的最小桥。 + * + * Renderer 不直接碰 Query Agent 语义,只做「开关 + 转发 + 展示」; + * preload 契约缺失时(旧 preload / 测试环境)安全回退到 Legacy —— + * 这属于**能力缺失**的兜底,不是「查 0 条就回退」那种语义回退。 + */ +interface AskWechatBridge { + getAskWechatConfig?: () => Promise + runAskWechatQuery?: (request: AskWechatQueryRequest) => Promise + forgetAskWechatConversation?: () => Promise +} + +const bridge = (): AskWechatBridge => window.api as unknown as AskWechatBridge + +export async function resolveQueryAgentEnabled(): Promise { + try { + const config = await bridge().getAskWechatConfig?.() + return config?.queryAgentEnabled === true + } catch { + return false + } +} + +/** 桥不可用时返回 null,调用方据此走 Legacy。 */ +export async function requestAskWechatQuery( + request: AskWechatQueryRequest +): Promise { + const run = bridge().runAskWechatQuery + if (!run) return null + return run(request) +} + +/** 用户开始新问题时清掉澄清上下文(有界内存,不涉及持久化)。 */ +export function forgetAskWechatConversation(): void { + try { + void bridge().forgetAskWechatConversation?.() + } catch { + // 清理失败不影响主流程 + } +} diff --git a/src/renderer/src/components/search/searchFormatters.ts b/src/renderer/src/components/search/searchFormatters.ts index 11d2445..4c3d079 100644 --- a/src/renderer/src/components/search/searchFormatters.ts +++ b/src/renderer/src/components/search/searchFormatters.ts @@ -1,4 +1,5 @@ import type { KnowledgeRuntimeStatus } from '../../../../shared/knowledge' +import { isKnowledgeFresh } from '../../../../shared/knowledge' import type { Contact } from '../../../../shared/types' import type { SearchTrace } from './searchTypes' @@ -18,15 +19,67 @@ export const formatMeasuredDuration = (milliseconds: number | undefined): string export const formatEvidenceTimestamp = (timestamp: number): string => new Date(timestamp).toLocaleString('zh-CN', { hour12: false }) +/** 只显示到日期,用于「索引更新至 8/26」这类如实口径。 */ +export const formatIndexDate = (timestamp: number): string => + new Date(timestamp).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' }) + +/** + * 知识库状态文案。 + * + * 两个**互相独立**的维度: + * 1. 新鲜度:索引覆盖到源数据的哪个时刻 → `indexLatestAt` / `sourceLatestAt` + * 2. 这一遍 pass 的进度 → `pass.phase` / `pass.cancellable` + * + * 约束: + * - 只要索引还能查,文案必须是「可用 · …」; + * - **绝不**在没有 pending gap 之前说「已同步」; + * - 「正在追新」与「正在补齐历史」必须分开(前者只补新消息,后者在建库)。 + */ export const knowledgeStateLabel = (status: KnowledgeRuntimeStatus | null): string => { if (!status) return '读取中' - return { - unavailable: '未建立', - building: '建立中', - syncing: '增量同步', - ready: '已同步', - error: '异常' - }[status.state] + const phase = status.pass?.phase + const usable = status.indexedMessageCount > 0 || status.indexedChunkCount > 0 + if (status.state === 'error' || phase === 'error') return usable ? '可用 · 更新失败' : '更新失败' + if (status.state === 'cancelled' || phase === 'cancelled') + return usable ? '可用 · 同步已取消' : '同步已取消' + // 一个分片都没有:这不是"落后",是"还没建立",不该带"可用"前缀。 + // `unavailable` 必须在这里被吃掉:派生库不可查询时,残留的历史计数不能让它 + // 冒充「可用 · 已追至最新」(那是两句真话拼成的假话)。 + if (status.state === 'unavailable' || !usable) { + return status.state === 'building' ? '正在建立' : '未建立' + } + if (status.state === 'building') return '可用 · 正在补齐历史' + if (status.state === 'syncing') { + // 已经有分片还在跑:`full`(首次建库)与 `backfill`(补历史缺口)都不算"追新", + // 只有 `catchup`(读 delta 追最新)才是用户最关心、也最快的那个阶段。 + return phase === 'full' || phase === 'backfill' ? '可用 · 正在补齐历史' : '可用 · 正在追新' + } + if (isKnowledgeFresh(status) === true) return '可用 · 已追至最新' + // ready 但落后、且当前没有 pass 在跑 = 有内容还没追到,但需要用户触发同步。 + return '可用 · 待追新' +} + +/** 索引落后于源数据时为 true,用于卡片描述与详情行。 */ +export const knowledgeIsStale = (status: KnowledgeRuntimeStatus | null): boolean => + Boolean(status) && status!.state === 'ready' && isKnowledgeFresh(status!) === false + +/** + * 「已处理」这类计数必须带分母或者换成诚实语义:只有分母未知的绝对值会把"这一遍扫描" + * 说成"总量"。这里优先给分母;没有分母时退回**明确标注为单轮**的语义。 + */ +export const formatKnowledgeProcessed = (status: KnowledgeRuntimeStatus): string => { + if (status.pass) { + const { indexedMessages, scannedMessages } = status.pass + return `本轮新增索引 ${indexedMessages.toLocaleString()} 条 · 本轮已扫描 ${scannedMessages.toLocaleString()} 条` + } + if (status.totalMessages) { + const percent = Math.min( + 100, + Math.round((status.processedMessages / status.totalMessages) * 100) + ) + return `${percent}%(${status.processedMessages.toLocaleString()} / ${status.totalMessages.toLocaleString()})` + } + return `本轮已处理 ${status.processedMessages.toLocaleString()} 条` } export const contactLabel = (contact: Contact | null | undefined): string => diff --git a/src/renderer/src/components/search/searchMappers.ts b/src/renderer/src/components/search/searchMappers.ts index 4d1b08b..f6cc9b0 100644 --- a/src/renderer/src/components/search/searchMappers.ts +++ b/src/renderer/src/components/search/searchMappers.ts @@ -1,4 +1,5 @@ import type { AiSearchFinalEvidence, AiSearchPipelineResult } from '../../../../shared/ai-search' +import { encodeMessageRef } from '../../../../shared/local-query-api' import type { Contact } from '../../../../shared/types' import { compactCacheItem } from './searchUtils' import type { AISearchCacheRecord, EvidenceItem, SearchTrace } from './searchTypes' @@ -24,6 +25,9 @@ export const mapPipelineEvidenceItem = ( evidenceId: item.id, sourceKind: item.sourceKind, contact, + // 这条路径本来就同时知道真实会话 id 与消息 id,顺手补上稳定引用, + // 让 Legacy / ai-search 证据也能被精确定位(而不是只有 Query Agent 路径能跳准)。 + ...(safeRef(item.conversationId, item.messageId) || {}), message: { id: item.messageId, from: item.senderId || 'user', @@ -38,6 +42,15 @@ export const mapPipelineEvidenceItem = ( } } +/** 引用构造失败(缺 id)时返回 null,调用方退化到按时间定位 —— 不允许抛异常打断结果渲染。 */ +function safeRef(conversationId: string, messageId: string): { messageRef: string } | null { + try { + return { messageRef: encodeMessageRef(conversationId, messageId) } + } catch { + return null + } +} + export const mapPipelineEvidence = ( items: AiSearchFinalEvidence[], contacts: Contact[] diff --git a/src/renderer/src/components/search/searchTypes.ts b/src/renderer/src/components/search/searchTypes.ts index deb4f29..5a6f08b 100644 --- a/src/renderer/src/components/search/searchTypes.ts +++ b/src/renderer/src/components/search/searchTypes.ts @@ -36,6 +36,14 @@ export interface EvidenceItem { sourceKind?: KnowledgeMessageKind contact: Contact message: Message + /** + * 稳定消息引用(opaque,可还原成 `{conversationId, messageId}`)。 + * + * 只靠「会话 + 秒级时间戳」无法定位到**这一条**消息 —— 同一秒可能有多条, + * 而且时间戳只能定位到"附近"。Archive 的跳转优先用它。 + * 老缓存记录 / Legacy 路径可能没有它,所以必须是可选的。 + */ + messageRef?: string } export interface AISearchCacheRecord { @@ -82,7 +90,13 @@ export interface AISearchWorkspaceProps { dbReady: boolean aiModelConfig: AIRuntimeModelConfig onSelectContact: (contact: Contact) => void - onOpenEvidence: (contact: Contact, createTime?: number) => void + /** + * 跳转到证据的原聊天。 + * + * 传整条 EvidenceItem 而不是 `(contact, createTime)`:后者丢掉了稳定身份(messageRef), + * 跳转只能靠"会话 + 秒级时间戳"猜,而会话 id 若来自展示层合成的 key 则完全跳不过去。 + */ + onOpenEvidence: (evidence: EvidenceItem) => void onOpenAISettings: () => void onNotice: (message: string) => void } diff --git a/src/renderer/src/components/search/searchUtils.ts b/src/renderer/src/components/search/searchUtils.ts index 5506d01..3f4f56a 100644 --- a/src/renderer/src/components/search/searchUtils.ts +++ b/src/renderer/src/components/search/searchUtils.ts @@ -287,8 +287,11 @@ export const senderName = ( return contact.type === 'user' ? contact.m_nsNickName || '联系人' : '群成员' } -export const compactCacheItem = ({ evidenceId, contact, message }: EvidenceItem): EvidenceItem => ({ +export const compactCacheItem = ({ evidenceId, contact, message, messageRef }: EvidenceItem): EvidenceItem => ({ evidenceId, + // 稳定引用必须一起进缓存:否则命中缓存后「跳转到原聊天」会退化成按时间戳猜 + // (缓存写入是最容易漏掉新字段的地方,这里显式列出而不是展开对象)。 + ...(messageRef ? { messageRef } : {}), contact: { md5: contact.md5, m_nsUsrName: contact.m_nsUsrName, diff --git a/src/renderer/src/styles/search.scss b/src/renderer/src/styles/search.scss index fc145d4..1df01da 100644 --- a/src/renderer/src/styles/search.scss +++ b/src/renderer/src/styles/search.scss @@ -211,6 +211,11 @@ border: 1px solid hsl(var(--tm-border-subtle)); border-radius: var(--wxex-radius-md); background: var(--wxex-bg-elevated); + /* 侧栏最窄时卡片正文只有 ~145px。这里保持默认的 word-break / overflow-wrap: + 中文长句交给「行栅格 + 显式换行点」处理,不能在词中间乱断,也绝不允许撑破侧栏。 */ + min-width: 0; + word-break: normal; + overflow-wrap: normal; } .ai-search-knowledge-card.building, @@ -226,31 +231,42 @@ border-color: color-mix(in srgb, var(--wxex-warning) 70%, var(--wxex-border)); } -.ai-search-knowledge-card-heading, -.ai-search-sync-progress-top, -.ai-search-knowledge-details > div { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; +/* HEADER:左列(品牌 + 状态)可收缩,右侧状态点取自然宽。 */ +.ai-search-knowledge-heading { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + column-gap: 8px; + min-width: 0; } -.ai-search-knowledge-card-heading > div { +.ai-search-knowledge-heading-text { display: flex; flex-direction: column; gap: 2px; + min-width: 0; } -.ai-search-knowledge-card-heading span:first-child { +.ai-search-knowledge-kicker { color: var(--wxex-text-muted); font-size: 9px; font-weight: 700; letter-spacing: 0.08em; + white-space: nowrap; } -.ai-search-knowledge-card-heading strong { +/* 状态文案允许折成两行,但只断在「 · 」之后。 + `keep-all` 禁止在中文的字与字之间断行,「 · 」两侧的空格仍然是断点, + 于是窄侧栏下只会变成「可用 · 」/「待追新」,不会碎成「可用 · 正在」/「补齐历史」。 + `break-word` 只作兜底:万一某一段本身超过可用宽度,允许溢出时在词内断开。 */ +.ai-search-knowledge-state { + min-width: 0; color: var(--wxex-text-primary); font-size: 12px; + font-weight: 700; + line-height: 16px; + word-break: keep-all; + overflow-wrap: break-word; } .ai-search-knowledge-description, @@ -266,14 +282,22 @@ color: var(--wxex-warning); } -.ai-search-sync-progress { +/* CURRENT PASS */ +.ai-search-knowledge-pass { display: grid; gap: 5px; + min-width: 0; } -.ai-search-sync-progress-top { +.ai-search-knowledge-pass-line { + margin: 0; + min-width: 0; color: var(--wxex-text-secondary); font-size: 10px; + line-height: 15px; + /* 与状态标题同一条折行规则:只在「 · 」之后断句。 */ + word-break: keep-all; + overflow-wrap: break-word; } .ai-search-sync-progress-track { @@ -288,6 +312,7 @@ height: 100%; border-radius: inherit; background: var(--wxex-brand); + transform-origin: left center; transition: width 250ms ease; } @@ -306,21 +331,75 @@ } } -.ai-search-knowledge-details { +/* 没有真实分母时进度条改为不确定态(JSX 内联 style 挂着这个动画名)。 + 没有这段 keyframes 时那条 100% 宽的进度条是静止的,看起来像"已经跑完了"。 */ +@keyframes ai-search-indeterminate { + 0% { + transform: translateX(-100%) scaleX(0.35); + } + 100% { + transform: translateX(285%) scaleX(0.35); + } +} + +/* DATABASE STATUS / CURRENT:一套行栅格。 + 标签列 minmax(0, 1fr) 可收缩;数值列 minmax(0, auto) 取自然宽, + 空间不足时收缩的是数值列(配合 --truncate 省略),数字永远不被折断。 */ +.ai-search-knowledge-rows { display: grid; gap: 5px; - padding-top: 2px; + min-width: 0; } -.ai-search-knowledge-details span { +.ai-search-knowledge-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, auto); + align-items: baseline; + column-gap: 8px; + min-width: 0; +} + +.ai-search-knowledge-label { + min-width: 0; + overflow: hidden; color: var(--wxex-text-muted); font-size: 10px; + line-height: 15px; + text-overflow: ellipsis; + white-space: nowrap; } -.ai-search-knowledge-details strong { +.ai-search-knowledge-value { + min-width: 0; color: var(--wxex-text-secondary); font-size: 10px; + font-variant-numeric: tabular-nums; font-weight: 600; + line-height: 15px; + text-align: right; + white-space: nowrap; +} + +.ai-search-knowledge-value--truncate { + overflow: hidden; + text-overflow: ellipsis; +} + +/* ACTION:主按钮吃满剩余宽度,取消按钮取自然宽且永不换行。 */ +.ai-search-knowledge-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + min-width: 0; +} + +.ai-search-knowledge-primary { + min-width: 0; +} + +.ai-search-knowledge-cancel { + min-width: 68px; + white-space: nowrap; } .ai-search-knowledge-more { diff --git a/src/shared/knowledge.ts b/src/shared/knowledge.ts index 74ac50a..e9539a9 100644 --- a/src/shared/knowledge.ts +++ b/src/shared/knowledge.ts @@ -100,6 +100,17 @@ export interface KnowledgeConversationInput { /** true means this is a complete read-only snapshot of the conversation. */ completeSnapshot: boolean messages: KnowledgeSourceMessage[] + /** + * 这个会话在**源侧**(WCDB Session.last_timestamp)已经覆盖到的最后活跃时间(epoch ms)。 + * + * 与 `KnowledgeIndexRequest.sourceLatestAt` 的区别:后者是整遍 pass 级别的边界,这里是 + * per-conversation checkpoint —— 增量 pass 用它判断「这个会话有没有新消息」, + * 从而跳过整个会话(不读 WCDB、不传 IPC、不写索引)。 + * + * 记录**源侧**时间而不是「索引里最后一条可建模消息的时间」:否则最后一条恰好落在 + * 图片/空正文上的会话会永远被判定为「有新消息」,增量永远跳不过它。 + */ + sourceHighWaterTime?: number } export interface KnowledgeIndexRequest { @@ -110,6 +121,13 @@ export interface KnowledgeIndexRequest { fts: KnowledgeFtsConfig /** Written only after a complete source pass; used for truthful coverage. */ sourceMessageCount?: number + /** + * 这一遍完整 pass 实际扫到的源数据最新消息时间(epoch ms)。 + * + * 与 `sourceMessageCount` 一样,只在读完全部会话的那一次写入。它是 freshness 的权威口径: + * 拿它与当前源数据最新活跃时间比较,就能确定索引是否已经追上,而不必猜测派生索引的过滤落差。 + */ + sourceLatestAt?: number } export interface KnowledgeIndexProgress { @@ -236,6 +254,18 @@ export interface KnowledgeSearchTimings { responseSerializeMs: number /** FTS (or short-term database lookup) query time. */ ftsMs: number + /** + * `ftsMs` 中**短词回退路径**(<3 字的中文词,走 `LIKE` 而非 FTS `MATCH`)占用的时间。 + * + * 跨会话 lexical probe 里最常见的 2 字中文词只能走这条路径,没有这个分解就无法区分 + * 「FTS MATCH 慢」和「短词全表 LIKE 慢」。 + */ + shortTermSearchMs?: number + /** + * `getSearchStatus()`(含统计快照判定)占用的时间。 + * 用于验证「搜索热路径不再做全表聚合」这一性能约束没有被回退。 + */ + statusMs?: number /** Reading source message rows from matching chunks. */ messageLoadMs: number /** Expanding chunk members, scoring terms and per-chunk de-duplication. */ @@ -285,6 +315,13 @@ export interface KnowledgeSearchResult { evidence: KnowledgeEvidence[] indexedMessageCount: number indexedChunkCount: number + /** + * 派生索引里最新的消息时间(epoch ms);null 表示无法判定。 + * + * `state: 'ready'` 只说明「这个派生库可以被查询」,**不等于**它已经追到源数据最新位置。 + * 调用方必须把它与请求的时间范围比较,才能判断本次检索是否覆盖了用户问的时间。 + */ + indexLatestAt: number | null timings: KnowledgeSearchTimings conversationRetrieval?: KnowledgeConversationRetrieval voiceCoverage?: KnowledgeVoiceCoverage @@ -308,9 +345,53 @@ export interface KnowledgeSearchIpcResult extends KnowledgeSearchResult { source: 'knowledge' | 'fallback' totalMessages: number fallbackReason?: 'unavailable' | 'indexing' | 'error' + /** + * 源数据(WCDB Session)里最新的活跃时间(epoch ms);null 表示无法判定。 + * + * 与 `indexLatestAt` 一起构成 freshness 判据:`sourceLatestAt > indexLatestAt` + * 说明源数据里已经有了索引还没覆盖的内容。 + */ + sourceLatestAt: number | null } -export type KnowledgeRuntimeState = 'unavailable' | 'building' | 'syncing' | 'ready' | 'error' +export type KnowledgeRuntimeState = 'unavailable' | 'building' | 'syncing' | 'ready' | 'error' | 'cancelled' + +/** + * 一次后台索引 pass 的真实进度(ADDITIVE)。 + * + * 与新鲜度(`indexLatestAt` / `sourceLatestAt`)回答的是不同问题:这里描述这一遍**在做什么、 + * 扫了多少、真正写了多少**。 + */ +export interface KnowledgePassProgress { + /** + * 这一遍在做什么: + * - `full` 首次全量建立(还没有任何可用分片) + * - `catchup` 追最新:已建立 checkpoint 的会话出现了新消息 + * - `backfill` 补历史:从来没有 checkpoint 的会话(历史缺口) + * - `cancelled` / `error` / `idle` + */ + phase: 'idle' | 'full' | 'catchup' | 'backfill' | 'cancelled' | 'error' + /** 现在是否可以取消(真实在跑且未收到取消请求时才是 true)。 */ + cancellable: boolean + /** 开始时间(epoch ms)。 */ + startedAt: number + /** 这一遍从 WCDB **读取/扫描**的源消息条数(含被可索引性过滤掉的)。 */ + scannedMessages: number + /** 这一遍真正**进入索引**的源消息条数。 */ + indexedMessages: number + processedConversations: number + totalConversations: number + /** 因为「没有任何新消息」而整段跳过的会话数(增量 pass 的核心指标)。 */ + skippedConversations: number + /** 需要「追最新」的会话数(已建立 checkpoint,且源侧有更新)。 */ + catchupConversations: number + /** 需要「补历史」的会话数(从来没有 checkpoint,必须整段读)。 */ + backfillConversations: number + /** 这一遍已经补齐的历史会话数。 */ + backfillCompletedConversations: number + /** 主线程 event loop 在这一遍期间的最大滞后。用于验证重活没有压在 Main 上。 */ + mainLoopLagMs: number +} export interface KnowledgeRuntimeStatus { accountId: string @@ -328,6 +409,20 @@ export interface KnowledgeRuntimeStatus { walBytes: number shmBytes: number lastError?: string + /** + * 派生索引里最新的消息时间(epoch ms);null 表示无法判定。 + * `state: 'ready'`(READY)与「已追到源数据最新」(FRESH)是两个概念,它就是两者的判据之一。 + */ + indexLatestAt: number | null + /** 源数据(WCDB Session)最新活跃时间(epoch ms);null 表示无法判定。 */ + sourceLatestAt: number | null + /** + * 当前/最近一次后台索引 pass 的真实进度(ADDITIVE)。 + * + * 与 `indexLatestAt` 是**两个不同维度**:前者回答"这一遍在补什么、进度多少", + * 后者回答"索引已经覆盖到源数据的哪个时刻"。UI 不能再用一个「已同步」把两者混为一谈。 + */ + pass?: KnowledgePassProgress } export interface KnowledgeStatusRequest { @@ -336,9 +431,31 @@ export interface KnowledgeStatusRequest { fts: KnowledgeFtsConfig } +/** + * 索引「已覆盖到的源数据时间」与源数据「当前最新活跃时间」之间允许的固定落差。 + * + * 两侧都来自消息的 create_time(索引侧 = 完整 pass 扫到的最大 create_time, + * 源侧 = Session 行的 last_timestamp),用于吸收 Session 元数据晚于消息落库的漂移。 + * main 与 renderer 共用这一份定义,避免 UI 与 Engine 的 freshness 口径漂移。 + */ +export const KNOWLEDGE_FRESHNESS_TOLERANCE_MS = 60 * 1000 + +/** + * 索引是否已经追到源数据最新(FRESH)。 + * + * 注意与 `state === 'ready'`(READY:这个派生库**可以被查询**)区分: + * READY 不代表 FRESH。任一侧口径缺失时返回 null 表示无法判定。 + */ +export function isKnowledgeFresh( + status: Pick +): boolean | null { + if (status.indexLatestAt === null || status.sourceLatestAt === null) return null + return status.indexLatestAt + KNOWLEDGE_FRESHNESS_TOLERANCE_MS >= status.sourceLatestAt +} + export interface KnowledgeWorkerRequest { version: 1 - type: 'index' | 'preflight' | 'search' | 'status' | 'remove' | 'cancel' | 'close' + type: 'index' | 'preflight' | 'search' | 'status' | 'remove' | 'cancel' | 'close' | 'highWater' requestId: string /** Parent monotonic wall-clock used only for transport timing. */ sentAt?: number diff --git a/src/shared/local-query-api.ts b/src/shared/local-query-api.ts index 5a4550c..0a51bb3 100644 --- a/src/shared/local-query-api.ts +++ b/src/shared/local-query-api.ts @@ -55,25 +55,152 @@ const temporalBasisSchema = { } } } +/** + * LLM-facing Tool schema。 + * + * `search_messages` / `conversation_overview` 的 `target` 是**可选**的:省略表示"使用应用 + * 当前的搜索范围(conversation scope)"—— 范围由 UI/Host 决定并强制,模型无法用它切换范围, + * 也拿不到它的实现细节。`query_messages` 仍是单会话结构化查询。 + */ export const LOCAL_QUERY_TOOL_DEFINITIONS: LocalQueryToolDefinition[] = [ - { name: 'query_messages', description: '精确读取符合联系人、时间、方向、消息类型、顺序等结构条件的消息;适合具体事实和 earliest/latest 等时间边界查询,边界查询使用 order 与 limit。每次调用都必须声明 temporalBasis,说明这个时间范围来自用户的明确约束、模糊回忆线索,还是用户根本没给时间信息。', parameters: { type: 'object', required: ['target', 'timeRange', 'temporalBasis'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema, temporalBasis: temporalBasisSchema, direction: { enum: ['any', 'from_target', 'to_target'] }, messageTypes: { type: 'array', items: { enum: ['text', 'image', 'voice', 'video', 'file', 'link', 'sticker', 'system', 'other'] } }, order: { enum: ['asc', 'desc'] }, limit: { type: 'integer', minimum: 1, maximum: 200 }, excludeSystem: { type: 'boolean' } } } }, - { name: 'search_messages', description: '在指定联系人和时间范围内做关键词检索并返回相关 Evidence。queries 的每一项都是一次独立的字面检索:一项只放一个简短关键词,不要把多个近义词或整句话放进同一项,也不要指望一项内部被拆词理解。首次最多 4 项;只有在本次检索完全没有 Evidence 时,才允许再检索一次,且每一项都必须与上一次实质不同。', parameters: { type: 'object', required: ['target', 'timeRange', 'queries'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema, queries: { type: 'array', description: '独立检索项列表,每项一个简短关键词,最多 4 项;每一项单独检索,不会组合成一句话理解。', minItems: 1, maxItems: 4, items: { type: 'string', minLength: 1 } }, limit: { type: 'integer', minimum: 1, maximum: 200 } } } }, + { name: 'query_messages', description: '精确读取**单个**会话中符合联系人、时间、方向、消息类型、顺序等结构条件的消息;适合具体事实和 earliest/latest 等时间边界查询,边界查询使用 order 与 limit。省略 target 表示"当前搜索范围恰好只有一个会话"(例如单聊专属或当前会话)时直接查该会话;范围里有多个会话时必须显式指定 target,且 target 必须落在当前搜索范围内。每次调用都必须声明 temporalBasis,说明这个时间范围来自用户的明确约束、模糊回忆线索,还是用户根本没给时间信息。跨多个会话的"谁聊过某话题"请改用 search_messages。', parameters: { type: 'object', required: ['timeRange', 'temporalBasis'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema, temporalBasis: temporalBasisSchema, direction: { enum: ['any', 'from_target', 'to_target'] }, messageTypes: { type: 'array', items: { enum: ['text', 'image', 'voice', 'video', 'file', 'link', 'sticker', 'system', 'other'] } }, order: { enum: ['asc', 'desc'] }, limit: { type: 'integer', minimum: 1, maximum: 200 }, excludeSystem: { type: 'boolean' } } } }, + { name: 'search_messages', description: '在应用当前的搜索范围内做关键词检索并返回相关 Evidence。queries 的每一项都是一次独立的字面检索:一项只放一个简短关键词,不要把多个近义词或整句话放进同一项,也不要指望一项内部被拆词理解。首次最多 4 项;只有在本次检索完全没有 Evidence 时,才允许再检索一次,且每一项都必须与上一次实质不同。省略 target 表示在整个当前搜索范围(可能是多个会话,例如所有群聊)内检索——问"最近谁聊过某个话题"这类跨会话问题时应当省略 target;只有当问题明确指向某一个会话时才传 target,且该 target 必须在当前搜索范围内。', parameters: { type: 'object', required: ['timeRange', 'queries'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema, queries: { type: 'array', description: '独立检索项列表,每项一个简短关键词,最多 4 项;每一项单独检索,不会组合成一句话理解。', minItems: 1, maxItems: 4, items: { type: 'string', minLength: 1 } }, limit: { type: 'integer', minimum: 1, maximum: 200 } } } }, { name: 'message_context', description: '补充已找到的单条有价值 Evidence 的前后消息;仅在该 Evidence 缺少语境、无法判断含义时使用,不是默认确认步骤。', parameters: { type: 'object', required: ['messageRef'], additionalProperties: false, properties: { messageRef: { type: 'string', minLength: 1 }, before: { type: 'integer', minimum: 0, maximum: 50 }, after: { type: 'integer', minimum: 0, maximum: 50 } } } }, - { name: 'conversation_overview', description: '提取指定联系人和时间范围的整体聊天覆盖样本;只用于 broad summary,不是语义搜索 fallback,也不能确定 earliest/latest 等精确时间边界。', parameters: { type: 'object', required: ['target', 'timeRange'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema } } } + { name: 'conversation_overview', description: '提取**单个**会话在一段时间内的整体聊天覆盖样本;只用于 broad summary,不是语义搜索 fallback,也不能确定 earliest/latest 等精确时间边界。省略 target 只在当前搜索范围恰好只有一个会话时成立(例如"当前会话");范围里有多个会话时必须显式指定 target,且目标必须落在该范围内。', parameters: { type: 'object', required: ['timeRange'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema } } } ] export type QueryTimeRange = | { kind: 'all' | 'today' | 'yesterday' | 'this_week' | 'last_7_days' | 'this_month' | 'previous_month' | 'this_year' | 'previous_year' } | { kind: 'absolute'; startTime?: number; endTime?: number } export interface QueryTarget { query: string } + +/** + * 语料边界(conversation scope)。 + * + * 由**调用方(UI / Host)**决定,**不是** LLM 的输入 —— 它不进入 LLM-facing Tool schema, + * 也不与 temporalBasis(时间语义)混在一起:scope 是"去哪里搜",时间是"搜什么时候"。 + * + * - `all`:所有可读会话(单聊 + 群聊,群聊成员的每条消息都是普通可搜索消息) + * - `groups`:只搜群聊语料(= 全部群会话及其成员消息),不是"群摘要 / metadata" + * - `contact`:只搜指定的一对一会话(单聊专属) + * - `current`:只搜当前打开的那个会话(可能是单聊或群) + * + * `conversationId` 使用应用内部会话身份(与 `Contact.md5` 一致)。 + */ +export type QueryCorpusScope = + | { kind: 'all' } + | { kind: 'groups' } + | { kind: 'contact'; conversationId: string } + | { kind: 'current'; conversationId: string } + +/** 解析后的语料边界(回显给调用方与模型,用于说明"这次只在哪个范围里查")。 */ +export interface ResolvedCorpusScope { + kind: QueryCorpusScope['kind'] + /** 实际参与检索的会话数量;`all` 为可读会话总数。 */ + conversationCount: number + /** `contact` / `current` 解析出的会话展示名。 */ + displayName?: string + conversationType?: 'user' | 'group' +} + +/** + * Tool 返回的单条证据。 + * 群消息必须能归属到**具体群 + 具体成员**,否则模型无法回答"谁聊过"。 + */ +export interface QueryEvidenceItem + extends Pick { + messageRef: string + /** 该证据所属会话的展示名(群名 / 联系人名)。 */ + conversationName?: string + conversationType?: 'user' | 'group' +} + +/** + * 一条消息的**稳定身份**。 + * + * 不靠「会话 + 秒级时间戳」定位:同一秒里可能有多条消息,时间戳也只能定位到"附近"。 + * `messageRef` 是 opaque 字符串(base64url 的 `{c,m}`),对模型只暴露成不可读 token, + * 对本进程/渲染进程可以还原成稳定身份。 + */ +export interface CanonicalMessageIdentity { + conversationId: string + messageId: string +} + +/** 归一化消息身份:`local:` 前缀是 WCDB 侧的本地 id 装饰,不属于身份本身。 */ +export function normalizeMessageIdentity( + conversationId: string, + messageId: string +): CanonicalMessageIdentity | null { + const normalizedConversationId = String(conversationId ?? '').trim() + const normalizedMessageId = String(messageId ?? '') + .trim() + .replace(/^local:/, '') + if (!normalizedConversationId || !normalizedMessageId) return null + return { conversationId: normalizedConversationId, messageId: normalizedMessageId } +} + +/** + * base64url 编解码。 + * + * 刻意**不用 `Buffer`**:这份实现被 main 与 renderer 共用,而 renderer 在 + * `contextIsolation` + 无 nodeIntegration 下没有 `Buffer` 全局。`TextEncoder` / + * `btoa` / `atob` 在 Node ≥16 与 Chromium 里都是标准全局,两侧行为一致。 + */ +function bytesToBase64Url(bytes: Uint8Array): string { + let binary = '' + for (let index = 0; index < bytes.length; index += 1) binary += String.fromCharCode(bytes[index]) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function base64UrlToBytes(value: string): Uint8Array | null { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4) + try { + const binary = atob(padded) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) + return bytes + } catch { + return null + } +} + +/** 生成 opaque messageRef。语义与 `decodeMessageRef` 严格互逆。 */ +export function encodeMessageRef(conversationId: string, messageId: string): string { + const identity = normalizeMessageIdentity(conversationId, messageId) + if (!identity) throw new Error('消息引用无效') + return bytesToBase64Url( + new TextEncoder().encode(JSON.stringify({ c: identity.conversationId, m: identity.messageId })) + ) +} + +/** 还原 opaque messageRef;无法解析(老缓存 / 伪造)时返回 null,调用方必须走降级路径。 */ +export function decodeMessageRef(value: unknown): CanonicalMessageIdentity | null { + if (typeof value !== 'string' || !value) return null + const bytes = base64UrlToBytes(value) + if (!bytes) return null + try { + const parsed = JSON.parse(new TextDecoder().decode(bytes)) as { c?: unknown; m?: unknown } + return typeof parsed?.c === 'string' && typeof parsed?.m === 'string' + ? normalizeMessageIdentity(parsed.c, parsed.m) + : null + } catch { + return null + } +} + export interface ResolvedTimeRange { kind: QueryTimeRange['kind']; startTime?: number; endTime?: number; label: string } export interface QueryMessagesRequest { - target: QueryTarget + /** 省略 = 使用当前搜索范围(仅当范围恰好只有一个会话时成立)。 */ + target?: QueryTarget timeRange: QueryTimeRange direction?: QueryDirection messageTypes?: QueryMessageType[] order?: QueryOrder limit?: number excludeSystem?: boolean + /** 语料边界;省略 = 不限制(等价于 all)。目标必须落在该边界内,否则被 Host 拒绝。 */ + scope?: QueryCorpusScope } export interface QueryMessage { messageRef: string @@ -99,6 +226,8 @@ export interface QueryMessagesResponse { returnedCount?: number messages?: QueryMessage[] candidates?: Array<{ displayName: string; type: 'user' | 'group' }> + /** 本次实际使用的语料边界。 */ + scope?: ResolvedCorpusScope } export interface SearchMessagesRequest { target: QueryTarget @@ -106,25 +235,104 @@ export interface SearchMessagesRequest { query: string variants?: string[] limit?: number + /** 语料边界;省略 = 全部可读会话。省略 target 时用它作为跨会话检索范围。 */ + scope?: QueryCorpusScope } export interface SearchMessagesResponse { status: string target?: { displayName: string; type: 'user' | 'group' } resolvedTimeRange?: ResolvedTimeRange + /** + * 本次检索的覆盖度。 + * + * `complete` 需要同时满足:派生索引可用 **且** 请求的时间范围被索引完整覆盖 + * (索引已追到源数据最新,或 requested range 落在索引覆盖窗口内)。 + * 只要源数据在索引之后还有内容,就必须是 `partial` —— 此时 0 条 Evidence + * **不能**被解释成「整个微信里没有」。 + */ coverage?: { state: 'complete' | 'partial' | 'unknown' } probeCount?: number evidenceCount?: number - evidence?: Array & { messageRef: string }> + evidence?: QueryEvidenceItem[] candidates?: Array<{ displayName: string; type: 'user' | 'group' }> + scope?: ResolvedCorpusScope + /** + * 派生索引中最新一条消息的时间(epoch ms)。 + * 索引是**异步派生**数据:它落后于 WCDB 时必须能被调用方看见,不能冒充"完整"。 + */ + indexLatestAt?: number | null + /** 源数据(WCDB)里最新的活跃时间(epoch ms);与 indexLatestAt 比较即得 freshness。 */ + sourceLatestAt?: number | null + /** + * 本次为追赶索引新鲜度做了什么(诊断字段,不参与答案语义)。 + * - `none`:索引已覆盖请求范围,或没有可用的追赶通道 + * - `reused`:已有索引任务在跑,直接复用,不阻塞本次查询 + * - `skipped`:确实落后,但落后量不值得再跑一遍索引(低于门槛),按当前覆盖如实回答 + * - `completed`:本次触发的追赶在等待预算内完成,并已用新索引重新检索 + * - `pending`:本次触发了追赶但没在预算内完成;本次按当前覆盖如实回答,后台继续追 + */ + freshness?: { catchUp: 'none' | 'reused' | 'skipped' | 'completed' | 'pending' } + /** + * 索引覆盖结论(模型可直接引用的一句话)。 + * + * 只给结构化数字时模型会自己换算、甚至反过来宣称"覆盖完整"。这里由 Engine 直接给出 + * **本地时间**与结论,模型只需引用,不需要自己判断,也不需要输出 epoch 数字。 + */ + indexCoverage?: QueryIndexCoverage + /** 本次检索的真实耗时分解(ADDITIVE,用于诊断与 UI 展示;不进入模型上下文)。 */ + timings?: QuerySearchTimings } -export interface MessageContextRequest { messageRef: string; before?: number; after?: number } + +export interface QueryIndexCoverage { + /** 索引是否已覆盖本次请求的时间范围。 */ + covered: boolean + /** 索引覆盖到的时间(本地时间,`MM-DD HH:mm`)。 */ + indexLatestAtLabel?: string + /** 源数据最新时间(本地时间,`MM-DD HH:mm`)。 */ + sourceLatestAtLabel?: string + /** 可直接引用的结论句;`covered: false` 时明确说明这段时间暂时无法确认。 */ + summary: string +} + +/** + * `search_messages` 的真实耗时分解(ADDITIVE 诊断字段)。 + * + * 让 Tool 阶段可以被拆成 freshness 等待 / scope 解析 / 每个 probe / 证据补全, + * 而不是一个不透明的总数。这些数字全部来自实际 `Date.now()` 测量,不做任何推测。 + * + * **不进入模型上下文**:Host 在把 Tool Result 交给模型之前会剥离它。 + */ +export interface QuerySearchTimings { + /** 本次 search_messages 端到端耗时(含 freshness 处理与重检索)。 */ + totalMs: number + /** 语料范围解析(联系人列表读取 + scope 展开成会话集合)。 */ + scopeMs: number + /** 索引新鲜度判定 + 必要时触发追赶并在预算内等待。 */ + freshnessMs: number + /** 每个 probe 的检索耗时,顺序与请求中的 probe 一致(可能含重检索后的第二次)。 */ + probeMs: number[] + /** 跨 probe 证据合并 / 去重 / 排序。 */ + mergeMs: number + /** 证据展示信息补全(群名 / 成员昵称)所等待的时间,主要是 WCDB 读取。 */ + enrichmentMs: number + /** 派生库内部的分项(worker 侧测量)。 */ + knowledge?: { + shortTermSearchMs: number + ftsMs: number + messageLoadMs: number + statusMs: number + voiceCoverageMs: number + workerExecutionMs: number + } +} +export interface MessageContextRequest { messageRef: string; before?: number; after?: number; scope?: QueryCorpusScope } export interface MessageContextResponse { status: string anchor?: QueryMessage before?: QueryMessage[] after?: QueryMessage[] } -export interface ConversationOverviewRequest { target: QueryTarget; timeRange: QueryTimeRange } +export interface ConversationOverviewRequest { target: QueryTarget; timeRange: QueryTimeRange; scope?: QueryCorpusScope } export interface ConversationOverviewResponse { status: string target?: { displayName: string; type: 'user' | 'group' } @@ -135,8 +343,14 @@ export interface ConversationOverviewResponse { sourceCoverage?: { state: 'complete' | 'partial' | 'unknown'; sourceMessageCount: number } selection?: { mode: 'temporal_coverage'; selectedEvidenceCount: number; sampled: boolean } voiceCoverage?: KnowledgeVoiceCoverage - evidence?: Array & { messageRef: string }> + evidence?: QueryEvidenceItem[] candidates?: Array<{ displayName: string; type: 'user' | 'group' }> + scope?: ResolvedCorpusScope + /** + * 证据来源:`wcdb` = 直接读源数据(会话概览的事实来源);`knowledge` = 派生索引。 + * 派生索引可能滞后,故概览以源数据为准。 + */ + origin?: 'wcdb' | 'knowledge' } export interface QueryCapabilitiesResponse { version: 1 diff --git a/src/shared/query-agent.ts b/src/shared/query-agent.ts new file mode 100644 index 0000000..99543ff --- /dev/null +++ b/src/shared/query-agent.ts @@ -0,0 +1,197 @@ +import type { AiSearchPipelineRequest, AiSearchPipelineResult } from './ai-search' +import type { QueryCorpusScope } from './local-query-api' + +/** + * Query Agent 生产接入的跨层 contract。 + * + * 只描述「入口 → QueryAgentRuntime → 回答」这一段:桌面问问微信与 Agent Hub 共用同一份定义。 + * 这里**不包含**任何 Tool / prompt / temporalBasis 语义 —— 那些属于 + * `src/main/services/query-agent-service.ts`,不跨层暴露。 + */ + +export type QueryAgentEntry = 'desktop' | 'agent-hub' + +/** + * 搜索范围(WHERE TO SEARCH)。 + * + * 与时间(WHEN)完全正交:时间是问题文本的一部分,由 temporalBasis 理解; + * 范围是 UI 的确定性数据边界,由 Host 结构性强制(LLM 无法修改,越界 target 会被拒绝)。 + */ +export interface AskWechatScope { + scope: QueryCorpusScope + /** 人类可读的范围说明(UI 本来就知道,用于向模型描述范围与展示统计)。 */ + label?: string +} + +/** 展示用证据:只含可读字段,不含 wxid / md5 / DB id / raw Tool JSON。 */ +export interface AskWechatEvidenceItem { + messageRef: string + conversationName?: string + conversationType?: 'user' | 'group' + sender?: string + /** epoch ms */ + timestamp?: number + messageType?: string + text?: string + attachment?: { kind?: string; name?: string; url?: string; sizeBytes?: number } + /** 产生这条证据的 Tool(诊断 / 分组)。 */ + source: string +} + +/** + * 顶部统计用的**真实**读取数字。 + * 不再使用"知识库已收录"这类与具体 Tool 无关的 Legacy 文案: + * query_messages 直读 WCDB,search / overview 走不同路径。 + */ +export interface AskWechatStats { + /** 本次实际使用的 Tool(顺序即调用顺序)。 */ + tools: string[] + /** 各 Tool 的结构化计数。 */ + reads: { + /** query_messages 读到的消息条数(多次求和)。 */ + messageCount: number + /** search_messages / overview 命中的证据条数(多次求和)。 */ + matchedCount: number + /** 会话概览覆盖的源消息条数。 */ + overviewSourceCount: number + /** 去重后的证据条数。 */ + evidenceCount: number + } + /** 本次语料边界(用于"搜索所有群聊"这类文案)。 */ + scope?: { kind: QueryCorpusScope['kind']; label?: string } + modelCallCount: number + toolCallCount: number + totalMs: number + /** + * 耗时拆解(ADDITIVE)。 + * + * 只有总耗时时用户无法回答"这几十秒花在哪"。这里按用户能读懂的口径拆开 + * (AI / 本地查询 / 总耗时);更细的每次模型调用耗时留在诊断里,dev 模式才看。 + */ + timings?: { + /** 所有模型调用耗时之和(Model #1 + Model #2 + …)。 */ + modelMs: number + /** 本地 Tool 执行耗时之和(检索 / 读取,不含模型)。 */ + localQueryMs: number + /** 端到端总耗时。 */ + totalMs: number + /** 每次模型调用的耗时(按顺序),仅用于 dev 模式的细分展示。 */ + modelDurationsMs?: number[] + /** 每次 Tool 调用的耗时(按顺序),仅用于 dev 模式的细分展示。 */ + toolDurationsMs?: number[] + } +} + +/** + * 生产进度阶段。**只描述真实 Runtime 生命周期**: + * - `understanding`:第 1 次模型调用正在进行(模型在决定要查什么) + * - `searching`:正在执行一个 Query Tool(本地检索) + * - `organizing_evidence`:刚拿到 Tool Result,在整理证据 / 决定是否继续 + * - `generating_answer`:模型在已有 Tool 结果之上组织最终回答 + * - `completed`:本次 run 结束(成功或失败) + * + * 刻意**没有**百分比、没有"预计剩余":事件只在真实边界发出,不存在"每 N 秒假装跳一步" + * 的定时器 —— UI 阶段必须能对应到实际发生的事。 + */ +export type QueryAgentProgressStage = + | 'understanding' + | 'searching' + | 'organizing_evidence' + | 'generating_answer' + | 'completed' + +export interface QueryAgentProgressEvent { + stage: QueryAgentProgressStage + /** 从本次 run 开始的已用时间。 */ + elapsedMs: number + /** 真实进入该阶段的时间戳。 */ + at: number + /** + * 正在执行 / 刚完成的 Tool 名。 + * + * 只用于 UI 侧把 `searching` 细分成"正在搜索聊天记录"/"正在搜索较大范围"等可读文案。 + * **绝不**直接呈现给用户(不能暴露 toolName / SQL / FTS 等内部概念)。 + */ + toolName?: string + /** 到目前为止已发生的模型调用次数(含正在进行的那次)。 */ + modelCallCount: number + /** 到目前为止已完成的工具调用次数。 */ + toolCallCount: number +} + +/** + * 诊断摘要:只含**形态**信息(入口 / provider / 调用次数 / 耗时 / 工具路径 / 结果类型)。 + * 绝不含聊天原文、Evidence、messageRef、token、内部 id。 + */ +export interface QueryAgentDiagnostics { + entry: QueryAgentEntry + provider: string + model: string + modelCallCount: number + toolCallCount: number + /** 本次实际使用到的 Tool 名称(顺序即调用顺序),不含参数。 */ + tools: string[] + totalMs: number + outcome: AskWechatOutcome +} + +export type AskWechatOutcome = + | 'answered' + | 'provider_unavailable' + | 'provider_failure' + | 'tool_limit' + | 'invalid_question' + | 'runtime_error' + +export interface AskWechatQueryRequest { + requestId: string + text: string + /** 搜索范围(UI 决定)。省略 = 不限制(等价于 all)。 */ + scope?: AskWechatScope + /** + * Legacy fallback 需要沿用 UI 当前的范围 / 会话选择(可选;缺省 global + all)。 + * + * Query Agent 主路径**不消费**这些字段:时间语义来自问题本身(temporalBasis), + * 不能被 UI 的时间开关覆盖。 + */ + legacy?: Partial> +} + +/** 允许回退 Legacy 的原因(仅 Runtime 不可恢复错误)。 */ +export type AskWechatFallbackReason = 'runtime_error' + +export type AskWechatQueryResult = + /** Query Agent 正常回答(含 clarification:模型用自然语言追问,UI 当普通回复显示)。 */ + | { + engine: 'query-agent' + status: 'answered' + answer: string + /** 本次回答实际依据的证据(去重、限量);UI 不允许从 answer 反解析。 */ + evidence: AskWechatEvidenceItem[] + stats: AskWechatStats + diagnostics: QueryAgentDiagnostics + } + /** + * Provider 不可用 / 未配置。 + * 不回退 Legacy:Legacy 走同一个 AIProviderService,同样会失败,只会增加等待时间。 + */ + | { + engine: 'query-agent' + status: 'provider_unavailable' + message: string + diagnostics: QueryAgentDiagnostics + } + /** Runtime 抛出未分类异常 → 按既定规则回退 Legacy。 */ + | { + engine: 'legacy' + status: 'legacy' + reason: AskWechatFallbackReason + result: AiSearchPipelineResult + } + /** 无法回答且不回退(空问题等)。 */ + | { engine: 'query-agent'; status: 'error'; message: string; diagnostics: QueryAgentDiagnostics } + +export interface AskWechatConfig { + /** Query Agent 是否为桌面问问微信的主路径。 */ + queryAgentEnabled: boolean +} diff --git a/src/shared/types.ts b/src/shared/types.ts index c0e282b..77df791 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -56,6 +56,23 @@ export interface Message { exportConversationAvatarUrl?: string } +/** + * 「跳转到原聊天」的锚点读取结果。 + * + * `found: false` 是**有意义**的返回值,不是错误:它表示会话已经打开、窗口也加载了, + * 但目标消息不在窗口里(被清理 / 时间戳口径漂移)。UI 必须据此诚实提示, + * 而不是把"跳到了会话"说成"定位到了消息"。 + */ +export interface MessagesAroundResult { + messages: Message[] + /** 窗口内精确匹配到目标消息(按规范化消息 id,不是按时间)。 */ + found: boolean + /** 实际使用的窗口半径(秒);0 表示没有可用锚点时间、只做了兜底读取。 */ + radiusSeconds: number + /** 窗口消息数超过单次上限被截断。 */ + truncated: boolean +} + type TextContent = { type: 'text'; content: string } type VoiceContent = { type: 'voice'; duration?: number } type LocationContent = { diff --git a/tests/component/ai-search-cache-consent.test.tsx b/tests/component/ai-search-cache-consent.test.tsx index 83f59fc..e5f83ae 100644 --- a/tests/component/ai-search-cache-consent.test.tsx +++ b/tests/component/ai-search-cache-consent.test.tsx @@ -6,6 +6,7 @@ import { SEARCH_CACHE_KEY, buildSearchCacheKey } from '../../src/renderer/src/components/search/searchUtils' +import { makeSearchResult } from './support/ai-search-fixtures' const api = { getSettings: vi.fn(), @@ -352,12 +353,18 @@ describe('AISearchWorkspace cache privacy boundary', () => { expect(input).toHaveValue('') }) - it('disables and guards analysis while the knowledge base is synchronizing', async () => { + it('keeps analysis available while the knowledge base is synchronizing (partial result + freshness warning)', async () => { api.getKnowledgeStatus.mockResolvedValue({ state: 'syncing', + indexedMessageCount: 20, + indexedChunkCount: 4, processedMessages: 20, totalMessages: 100 }) + api.runAiSearch.mockResolvedValue(makeSearchResult()) + // 本地 Provider:本用例测的是"同步中能否提问",不是远程授权边界 + // (远程授权边界由本文件另外的用例覆盖)。 + api.getAiSearchProviderStatus.mockResolvedValue({ configured: true, requiresConsent: false }) const onNotice = vi.fn() render( { /> ) - await userEvent.type(screen.getByRole('textbox'), '同步时不能分析') - const button = await screen.findByRole('button', { name: /同步中,暂不可分析/ }) - expect(button).toBeDisabled() - const form = screen.getByRole('textbox').closest('form') - expect(form).not.toBeNull() - fireEvent.submit(form as HTMLFormElement) + await userEvent.type(screen.getByRole('textbox'), '同步时也要能分析') + // 等状态真的落地(同步中)再点,否则测的是"状态还没到"的竞态而不是产品行为。 + await screen.findByText('Knowledge 可用 · 正在追新') + // 同步中**不允许**禁用提问:按钮必须可用,文案也不能变成"暂不可分析"。 + const button = screen.getByRole('button', { name: /开始分析/ }) + expect(button).toBeEnabled() + expect(screen.queryByRole('button', { name: /暂不可分析/ })).not.toBeInTheDocument() + await userEvent.click(button) + + // 查询真的走到了检索(走完 consent 才会到这里),而不是被"请等待同步完成"挡回。 + await waitFor(() => expect(api.getAiSearchProviderStatus).toHaveBeenCalled()) await waitFor(() => - expect(onNotice).toHaveBeenCalledWith('知识库正在同步,请等待同步完成后再开始分析') + expect(onNotice).toHaveBeenCalledWith('知识库正在后台同步,本次结果可能未覆盖最新消息') ) - expect(api.getAiSearchProviderStatus).not.toHaveBeenCalled() - expect(api.runAiSearch).not.toHaveBeenCalled() + expect(onNotice).not.toHaveBeenCalledWith('知识库正在同步,请等待同步完成后再开始分析') + await waitFor(() => expect(api.runAiSearch).toHaveBeenCalledOnce()) + expect(await screen.findByText('测试搜索答案')).toBeInTheDocument() }) it('submits with Enter and keeps Shift+Enter available for a new line', async () => { diff --git a/tests/component/ai-search-composer.test.tsx b/tests/component/ai-search-composer.test.tsx index 9830b93..2467db5 100644 --- a/tests/component/ai-search-composer.test.tsx +++ b/tests/component/ai-search-composer.test.tsx @@ -71,8 +71,15 @@ describe('AISearchComposer', () => { expect(props.onHistoryOpenChange).toHaveBeenCalledWith(false) }) - it('disables submission while Knowledge is syncing', () => { + it('keeps submission available while Knowledge is syncing and only warns about coverage', () => { + // 知识库同步是后台 / 可取消 / 可断点续传的,**不允许**因此禁止提问。 + // 索引没追平时应由 coverage/freshness 契约如实标注覆盖范围。 renderComposer({ knowledgeSyncing: true }) - expect(screen.getByRole('button', { name: /同步中,暂不可分析/ })).toBeDisabled() + const submit = screen.getByRole('button', { name: /开始分析/ }) + expect(submit).toBeEnabled() + expect(screen.queryByRole('button', { name: /暂不可分析/ })).not.toBeInTheDocument() + expect( + screen.getByText('知识库后台同步中 · 仍可提问,答案会标注覆盖范围') + ).toBeInTheDocument() }) }) diff --git a/tests/component/ai-search-evidence-hook.test.tsx b/tests/component/ai-search-evidence-hook.test.tsx index c9a35eb..90a0f0e 100644 --- a/tests/component/ai-search-evidence-hook.test.tsx +++ b/tests/component/ai-search-evidence-hook.test.tsx @@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useEvidenceCollection } from '../../src/renderer/src/components/search/hooks/useEvidenceCollection' import type { EvidenceItem } from '../../src/renderer/src/components/search/searchTypes' +import { decodeMessageRef, encodeMessageRef } from '../../src/shared/local-query-api' import { aiSearchContact, makeCacheRecord, @@ -16,6 +17,9 @@ const makeEvidence = (count: number, offset = 0): EvidenceItem[] => return { evidenceId: item.id, contact: aiSearchContact, + // 稳定引用是"跳转到这一条"的唯一可靠身份:只给会话 + 秒级时间戳, + // 同一秒多条消息时根本定位不到目标。 + messageRef: encodeMessageRef(aiSearchContact.md5, item.messageId), message: { id: item.messageId, from: item.senderId, @@ -168,14 +172,22 @@ describe('useEvidenceCollection', () => { expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'nearest' }) }) - it('passes the selected Evidence contact and timestamp to the jump callback', () => { + it('passes the whole Evidence item — including its stable messageRef — to the jump callback', () => { const { result } = renderHook(() => useEvidenceHarness()) const collection = makeEvidence(1) act(() => result.current.setEvidenceResult(collection, collection)) act(() => result.current.jumpToEvidence(0)) - expect(onOpenEvidence).toHaveBeenCalledWith(aiSearchContact, collection[0].message.createTime) + // 传整条证据而不是 `(contact, createTime)`:后者丢掉了稳定身份,跳转只能靠 + // "会话 + 秒级时间戳"猜。引用必须能被还原成真实的 conversationId / messageId。 + expect(onOpenEvidence).toHaveBeenCalledTimes(1) + const passed = onOpenEvidence.mock.calls[0][0] as EvidenceItem + expect(passed).toBe(collection[0]) + expect(decodeMessageRef(passed.messageRef)).toEqual({ + conversationId: aiSearchContact.md5, + messageId: collection[0].message.id + }) }) it('clears the previous request Evidence and selection before the next result is applied', () => { diff --git a/tests/component/ai-search-query-agent.test.tsx b/tests/component/ai-search-query-agent.test.tsx new file mode 100644 index 0000000..40ab4e9 --- /dev/null +++ b/tests/component/ai-search-query-agent.test.tsx @@ -0,0 +1,461 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { AISearchWorkspace } from '../../src/renderer/src/components/search/AISearchWorkspace' +import { aiSearchContact, aiSearchGroup, makeSearchResult } from './support/ai-search-fixtures' +import type { AskWechatQueryResult, AskWechatStats, QueryAgentProgressEvent } from '../../src/shared/query-agent' + +type AnsweredResult = Extract + +const api = { + getSettings: vi.fn(), + getAppLogPath: vi.fn(), + getKnowledgeStatus: vi.fn(), + onKnowledgeStatus: vi.fn(), + onAiSearchProgress: vi.fn(), + getAiSearchProviderStatus: vi.fn(), + authorizeAiSearchExternalProvider: vi.fn(), + runAiSearch: vi.fn(), + cancelAiSearch: vi.fn(), + startKnowledgeIndex: vi.fn(), + writeAppLog: vi.fn(), + revealAppLog: vi.fn(), + copyText: vi.fn(), + getAskWechatConfig: vi.fn(), + runAskWechatQuery: vi.fn(), + forgetAskWechatConversation: vi.fn(), + onAskWechatProgress: vi.fn(), + cancelKnowledgeIndex: vi.fn() +} + +const indexLatestAt = new Date('2026-09-11T11:57:24+08:00').getTime() + +/** 主进程推来的**真实**进度事件(不是定时器伪进度)。 */ +let askWechatProgressListener: + | ((requestId: string, event: QueryAgentProgressEvent) => void) + | undefined + +const readyKnowledgeStatus = { + accountId: 'fixture-account', + state: 'ready' as const, + indexedMessageCount: 20, + indexedChunkCount: 4, + sourceMessageCount: 20, + processedMessages: 20, + totalMessages: 20, + estimatedRemainingMs: null, + databaseBytes: 128, + walBytes: 64, + shmBytes: 32, + indexLatestAt, + sourceLatestAt: indexLatestAt +} + +const diagnostics = { + entry: 'desktop' as const, + provider: 'Fixture Provider', + model: 'Fixture Model', + modelCallCount: 2, + toolCallCount: 1, + tools: ['query_messages'], + totalMs: 1200, + outcome: 'answered' as const +} + +const makeProps = (): Record => ({ + contacts: [aiSearchContact, aiSearchGroup], + selectedContact: aiSearchContact, + dbReady: true, + aiModelConfig: { + configured: true, + providerName: 'Fixture Provider', + model: 'fixture-model', + modelName: 'Fixture Model', + status: 'connected' as const + }, + onSelectContact: vi.fn(), + onOpenEvidence: vi.fn(), + onOpenAISettings: vi.fn(), + onNotice: vi.fn() +}) + +const submitQuery = async (query: string): Promise => { + const user = userEvent.setup() + await user.type(screen.getByRole('textbox'), query) + await user.click(screen.getByRole('button', { name: /开始分析/ })) +} + +beforeEach(() => { + localStorage.clear() + sessionStorage.clear() + vi.clearAllMocks() + Object.defineProperty(window, 'api', { configurable: true, value: api }) + api.getSettings.mockResolvedValue({ settings: { debugEnabled: false } }) + api.getAppLogPath.mockResolvedValue('') + api.getKnowledgeStatus.mockResolvedValue(readyKnowledgeStatus) + api.onKnowledgeStatus.mockImplementation(() => vi.fn()) + api.onAiSearchProgress.mockImplementation(() => vi.fn()) + api.getAiSearchProviderStatus.mockResolvedValue({ configured: true, requiresConsent: false }) + api.authorizeAiSearchExternalProvider.mockResolvedValue({ success: true }) + api.cancelAiSearch.mockResolvedValue({ cancelled: true }) + api.startKnowledgeIndex.mockResolvedValue(readyKnowledgeStatus) + api.writeAppLog.mockResolvedValue(undefined) + api.revealAppLog.mockResolvedValue(undefined) + api.copyText.mockResolvedValue({ success: true }) + api.forgetAskWechatConversation.mockResolvedValue(undefined) + api.runAiSearch.mockResolvedValue(makeSearchResult()) + api.getAskWechatConfig.mockResolvedValue({ queryAgentEnabled: true }) + api.runAskWechatQuery.mockResolvedValue(answeredResult()) + api.onAskWechatProgress.mockImplementation( + (listener: (requestId: string, event: QueryAgentProgressEvent) => void) => { + askWechatProgressListener = listener + return () => { + askWechatProgressListener = undefined + } + } + ) + api.cancelKnowledgeIndex.mockResolvedValue({ cancellable: false, cancelled: false }) +}) + +const askStats = (): AskWechatStats => ({ + tools: ['query_messages'], + reads: { messageCount: 10, matchedCount: 0, overviewSourceCount: 0, evidenceCount: 0 }, + scope: { kind: 'all' as const, label: '所有聊天记录' }, + modelCallCount: 2, + toolCallCount: 1, + totalMs: 3343 +}) + +const answeredResult = (patch: Partial = {}): AnsweredResult => ({ + engine: 'query-agent' as const, + status: 'answered' as const, + answer: '你们的第一次聊天是一条问候。', + evidence: [], + stats: askStats(), + diagnostics, + ...patch +}) + +describe('AISearchWorkspace — Query Agent 主路径', () => { + it('Query Agent 开启时用它回答,不再调用 Legacy 检索', async () => { + render() + await submitQuery('我和 BOBO 第一次聊了什么') + + expect(await screen.findByText('你们的第一次聊天是一条问候。')).toBeTruthy() + expect(api.runAskWechatQuery).toHaveBeenCalledWith( + expect.objectContaining({ text: '我和 BOBO 第一次聊了什么' }) + ) + expect(api.runAiSearch).not.toHaveBeenCalled() + }) + + it('Query Agent 查 0 条时仍然是它回答,不触发 Legacy 二次检索', async () => { + api.runAskWechatQuery.mockResolvedValue( + answeredResult({ answer: '当前可读取的完整范围里没有找到相关记录。' }) + ) + render() + await submitQuery('BOBO 给我发过文件吗') + + expect(await screen.findByText('当前可读取的完整范围里没有找到相关记录。')).toBeTruthy() + expect(api.runAiSearch).not.toHaveBeenCalled() + }) + + it('Provider 不可用时给出明确文案,不静默回退成另一次检索', async () => { + api.runAskWechatQuery.mockResolvedValue({ + engine: 'query-agent', + status: 'provider_unavailable', + message: '当前 AI 查询服务暂时不可用,请稍后再试。', + diagnostics: { ...diagnostics, outcome: 'provider_failure' } + }) + render() + await submitQuery('BOBO 最近说过什么') + + expect(await screen.findByText('当前 AI 查询服务暂时不可用,请稍后再试。')).toBeTruthy() + expect(api.runAiSearch).not.toHaveBeenCalled() + }) + + it('Runtime 不可恢复错误时接住主进程回退的 Legacy 结果', async () => { + api.runAskWechatQuery.mockResolvedValue({ + engine: 'legacy', + status: 'legacy', + reason: 'runtime_error', + result: makeSearchResult({ answer: 'Legacy 兜底答案' }) + }) + render() + await submitQuery('BOBO 最近说过什么') + + expect(await screen.findByText('Legacy 兜底答案')).toBeTruthy() + }) + + it('Query Agent 关闭时仍走 Legacy 检索(可回退)', async () => { + api.getAskWechatConfig.mockResolvedValue({ queryAgentEnabled: false }) + render() + await submitQuery('我和测试会话最近聊了什么') + + expect(await screen.findByText('测试搜索答案')).toBeTruthy() + expect(api.runAskWechatQuery).not.toHaveBeenCalled() + expect(api.runAiSearch).toHaveBeenCalled() + }) + + it('preload 契约缺失(测试 / 旧版本)时安全回退 Legacy', async () => { + Object.defineProperty(window, 'api', { + configurable: true, + value: { ...api, getAskWechatConfig: undefined, runAskWechatQuery: undefined } + }) + render() + await submitQuery('我和测试会话最近聊了什么') + + expect(await screen.findByText('测试搜索答案')).toBeTruthy() + expect(api.runAiSearch).toHaveBeenCalled() + }) +}) + +describe('AISearchWorkspace — 搜索范围 / 时间 UI / 真实统计与证据', () => { + it('Query Agent 开启时保留搜索范围、隐藏时间范围,并提示时间写在问题里', async () => { + render() + + expect(await screen.findByText('搜索范围')).toBeTruthy() + // 开关是异步解析的:等提示出现,说明 Query Agent 已生效 + expect(await screen.findByText(/时间直接写在问题里/)).toBeTruthy() + expect(screen.getAllByText('所有聊天记录').length).toBeGreaterThan(0) + expect(screen.getByText('群聊专属')).toBeTruthy() + expect(screen.getByText('单聊专属')).toBeTruthy() + // 时间范围控件在 Query Agent 主路径下移除 + expect(screen.queryByText('时间范围')).toBeNull() + expect(screen.queryByText('不限时间')).toBeNull() + }) + + it('Query Agent 关闭时恢复 Legacy 的时间范围控件', async () => { + api.getAskWechatConfig.mockResolvedValue({ queryAgentEnabled: false }) + render() + + expect(await screen.findByText('时间范围')).toBeTruthy() + expect(screen.getByText('不限时间')).toBeTruthy() + expect(screen.queryByText(/时间直接写在问题里/)).toBeNull() + }) + + it('把界面选择的搜索范围传给 Query Agent', async () => { + render() + const user = userEvent.setup() + await user.click(await screen.findByText('群聊专属')) + await submitQuery('最近谁聊过健身') + + expect(api.runAskWechatQuery).toHaveBeenCalledWith( + expect.objectContaining({ + text: '最近谁聊过健身', + scope: expect.objectContaining({ scope: { kind: 'groups' } }) + }) + ) + }) + + it('展示真实统计与真实证据,不再显示「知识库已收录 0」', async () => { + api.runAskWechatQuery.mockResolvedValue( + answeredResult({ + answer: '张三最近提过健身。', + evidence: [ + { + messageRef: 'ref-1', + conversationName: 'TraceMemo 交流群', + conversationType: 'group', + sender: '张三', + timestamp: 1_787_650_302_000, + messageType: 'text', + text: '最近重新开始健身了', + source: 'search_messages' + } + ], + stats: { + tools: ['search_messages'], + reads: { messageCount: 0, matchedCount: 12, overviewSourceCount: 0, evidenceCount: 1 }, + scope: { kind: 'groups', label: '群聊专属' }, + modelCallCount: 2, + toolCallCount: 1, + totalMs: 4034 + } + }) + ) + render() + await submitQuery('最近谁聊过健身') + + expect(await screen.findByText('张三最近提过健身。')).toBeTruthy() + // 群消息证据必须能归属到具体群 + 成员 + expect(screen.getAllByText(/TraceMemo 交流群/).length).toBeGreaterThan(0) + // 证据卡片把"编号 · 发送者"渲染在同一行内,用正则匹配文本内容 + expect(screen.getAllByText(/张三/).length).toBeGreaterThan(0) + expect(screen.getByText('最近重新开始健身了')).toBeTruthy() + // 真实统计(不使用 Legacy 的"知识库已收录") + expect(screen.getByText('群聊专属内查询')).toBeTruthy() + expect(screen.getByText('使用 1 条证据')).toBeTruthy() + expect(screen.queryByText(/知识库已收录/)).toBeNull() + }) +}) + +describe('AISearchWorkspace — 知识库新鲜度状态', () => { + it('索引落后时显示「可用 · 待追新」并说明覆盖范围,而不是「已同步」', async () => { + api.getKnowledgeStatus.mockResolvedValue({ + ...readyKnowledgeStatus, + indexLatestAt: new Date('2026-08-26T11:37:24+08:00').getTime(), + sourceLatestAt: new Date('2026-09-11T11:57:24+08:00').getTime() + }) + render() + + // ready ≠ fresh:落后时必须说「可用 · 待追新」,并在描述里给出真实覆盖边界, + // 绝不能用笼统的「已同步」把两件事混为一谈。 + expect(await screen.findByText('可用 · 待追新')).toBeTruthy() + expect(screen.queryByText('已同步')).toBeNull() + expect(screen.queryByText('可用 · 已追至最新')).toBeNull() + expect(screen.getByText(/跨会话搜索目前只覆盖到 8\/26/)).toBeTruthy() + expect(screen.getByText('最新索引')).toBeTruthy() + expect(screen.getByText('8/26')).toBeTruthy() + }) + + it('索引已追平源数据最新时才显示「可用 · 已追至最新」', async () => { + render() + + expect(await screen.findByText('可用 · 已追至最新')).toBeTruthy() + expect(screen.queryByText('已同步')).toBeNull() + expect(screen.queryByText(/跨会话搜索目前只覆盖到/)).toBeNull() + }) +}) + +const emitProgress = async ( + requestId: string, + event: Partial & { stage: QueryAgentProgressEvent['stage'] } +): Promise => { + await act(async () => { + askWechatProgressListener?.(requestId, { + elapsedMs: 0, + at: Date.now(), + modelCallCount: 1, + toolCallCount: 0, + ...event + } as QueryAgentProgressEvent) + }) +} + +describe('AISearchWorkspace — 查询进度与耗时拆解', () => { + it('advances the visible stage from real Runtime progress instead of a static 4-step list', async () => { + let release: ((value: unknown) => void) | undefined + api.runAskWechatQuery.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + render() + await userEvent.type(screen.getByRole('textbox'), '最近谁聊过健身') + await userEvent.click(screen.getByRole('button', { name: /开始分析/ })) + const requestId = api.runAskWechatQuery.mock.calls[0][0].requestId as string + + await emitProgress(requestId, { stage: 'understanding' }) + expect(screen.getByRole('heading', { name: '正在理解你的问题' })).toBeTruthy() + + // 跨会话范围(所有聊天记录)必须提前说明"范围较大",否则用户会以为卡住了。 + await emitProgress(requestId, { stage: 'searching', toolCallCount: 1 }) + expect(screen.getByRole('heading', { name: '正在搜索较大范围的聊天记录…' })).toBeTruthy() + + await emitProgress(requestId, { stage: 'organizing_evidence', toolCallCount: 1 }) + expect(screen.getByRole('heading', { name: '正在整理找到的聊天记录' })).toBeTruthy() + + await emitProgress(requestId, { stage: 'generating_answer', toolCallCount: 1 }) + expect(screen.getByRole('heading', { name: '正在生成回答' })).toBeTruthy() + + // 进度里绝不能出现内部工具名 / SQL / 内部 id。 + expect(screen.queryByText(/search_messages|query_messages|SELECT|FTS/)).toBeNull() + expect(screen.queryByText(/local:/)).toBeNull() + + release?.(answeredResult()) + expect(await screen.findByText('你们的第一次聊天是一条问候。')).toBeTruthy() + }) + + it('uses the narrower wording for a single-conversation scope', async () => { + let release: ((value: unknown) => void) | undefined + api.runAskWechatQuery.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + render() + await userEvent.click(screen.getByRole('button', { name: /当前会话/ })) + await userEvent.type(screen.getByRole('textbox'), '最近聊了什么') + await userEvent.click(screen.getByRole('button', { name: /开始分析/ })) + const requestId = api.runAskWechatQuery.mock.calls[0][0].requestId as string + + await emitProgress(requestId, { stage: 'searching', toolCallCount: 1 }) + + expect(screen.getByRole('heading', { name: '正在搜索聊天记录…' })).toBeTruthy() + expect(screen.queryByRole('heading', { name: '正在搜索较大范围的聊天记录…' })).toBeNull() + + release?.(answeredResult()) + expect(await screen.findByText('你们的第一次聊天是一条问候。')).toBeTruthy() + }) + + it('ignores progress that belongs to a different request', async () => { + let release: ((value: unknown) => void) | undefined + api.runAskWechatQuery.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + render() + await submitQuery('最近谁聊过健身') + + await emitProgress('stale-request-id', { stage: 'generating_answer' }) + + // 串台的进度会让用户看到一个根本不属于本次查询的阶段。 + expect(screen.queryByRole('heading', { name: '正在生成回答' })).toBeNull() + expect(screen.getByRole('heading', { name: '正在准备查询' })).toBeTruthy() + + release?.(answeredResult()) + expect(await screen.findByText('你们的第一次聊天是一条问候。')).toBeTruthy() + }) + + it('breaks the total duration down into AI and local query instead of a bare 48.0s', async () => { + api.runAskWechatQuery.mockResolvedValue( + answeredResult({ + stats: { + ...askStats(), + totalMs: 19_548, + timings: { + totalMs: 19_548, + modelMs: 3_892, + localQueryMs: 15_648, + modelDurationsMs: [1059, 2833], + toolDurationsMs: [15_648] + } + } + }) + ) + render() + await submitQuery('最近谁聊过健身') + await screen.findByText('你们的第一次聊天是一条问候。') + + const breakdown = screen.getByLabelText('本次查询耗时拆解') + expect(breakdown.textContent).toContain('总耗时 19.5s') + expect(breakdown.textContent).toContain('AI 3.9s') + expect(breakdown.textContent).toContain('本地查询 15.6s') + // 普通 UI 只出现用户能理解的名字,不出现 firstModelMs / toolTotalMs 这类工程字段。 + expect(breakdown.textContent).not.toMatch(/firstModelMs|toolTotalMs|finalModelMs/) + }) + + it('does not render a timing breakdown when the runtime reported none', async () => { + render() + await submitQuery('最近谁聊过健身') + await screen.findByText('你们的第一次聊天是一条问候。') + + expect(screen.queryByLabelText('本次查询耗时拆解')).toBeNull() + expect(screen.queryByText(/总耗时/)).toBeNull() + }) + + it('cancelling a query never touches the Knowledge sync abort scope', async () => { + api.runAskWechatQuery.mockImplementation(() => new Promise(() => undefined)) + render() + await submitQuery('最近谁聊过健身') + + await userEvent.click(await screen.findByRole('button', { name: /取消分析/ })) + + // 查询取消与索引取消必须是两套独立的 Abort scope,共用一个会互相误杀。 + expect(api.cancelKnowledgeIndex).not.toHaveBeenCalled() + }) +}) diff --git a/tests/component/ai-search-workspace-regression.test.tsx b/tests/component/ai-search-workspace-regression.test.tsx index acbfe9e..6a0159d 100644 --- a/tests/component/ai-search-workspace-regression.test.tsx +++ b/tests/component/ai-search-workspace-regression.test.tsx @@ -6,6 +6,7 @@ import { SEARCH_CACHE_KEY, SEARCH_HISTORY_KEY } from '../../src/renderer/src/components/search/searchUtils' +import { decodeMessageRef } from '../../src/shared/local-query-api' import { aiSearchContact, aiSearchGroup, @@ -321,7 +322,7 @@ describe('AISearchWorkspace regression coverage before decomposition', () => { expect(api.runAiSearch).toHaveBeenCalledOnce() }) - it('passes the selected Evidence contact and timestamp to the conversation jump callback', async () => { + it('passes the whole Evidence item with a decodable stable reference to the conversation jump callback', async () => { const onOpenEvidence = vi.fn() const item = makePipelineEvidence(1, aiSearchContact) api.runAiSearch.mockResolvedValue(makeSearchResult({ evidence: [item] })) @@ -330,7 +331,19 @@ describe('AISearchWorkspace regression coverage before decomposition', () => { await screen.findByText('E1 · 发送者 1') await userEvent.click(screen.getByRole('button', { name: '跳转到原聊天 ↗' })) - expect(onOpenEvidence).toHaveBeenCalledWith(aiSearchContact, Math.floor(item.timestamp / 1000)) + + // 跳转必须拿到**稳定身份**(messageRef),而不是只靠"会话 + 秒级时间戳"猜位置: + // 秒级时间无法定位同秒多条消息,也会因为 contact.md5 是合成 key 而跳错会话。 + expect(onOpenEvidence).toHaveBeenCalledTimes(1) + const passed = onOpenEvidence.mock.calls[0][0] as { + messageRef?: string + contact: { md5: string } + } + expect(passed.contact.md5).toBe(aiSearchContact.md5) + expect(decodeMessageRef(passed.messageRef)).toEqual({ + conversationId: aiSearchContact.md5, + messageId: item.messageId + }) }) it('clears the previous request Evidence before a refreshed request completes', async () => { @@ -579,15 +592,52 @@ describe('AISearchWorkspace regression coverage before decomposition', () => { expect(api.runAiSearch).toHaveBeenCalledTimes(2) }) + /** + * Knowledge 状态语义矩阵。 + * + * 必须把 FRESHNESS(追到源数据最新)与 COMPLETENESS(这一遍 pass 的进度)分开表达, + * **不得**在落后时出现「已同步」。 + */ + const STALE_INDEX_AT = new Date('2026-08-26T11:37:24+08:00').getTime() + const SOURCE_LATEST_AT = new Date('2026-09-11T11:57:24+08:00').getTime() + const makePass = (phase: 'full' | 'catchup' | 'backfill' | 'idle') => ({ + phase, + cancellable: phase !== 'idle', + startedAt: SOURCE_LATEST_AT, + scannedMessages: 120, + indexedMessages: 20, + processedConversations: 3, + totalConversations: 10, + skippedConversations: 1, + catchupConversations: 4, + backfillConversations: 6, + backfillCompletedConversations: 2, + mainLoopLagMs: 4 + }) + it.each([ - ['unavailable', '未建立'], - ['building', '建立中'], - ['syncing', '增量同步'], - ['ready', '已同步'], - ['error', '异常'] - ] as const)('renders Knowledge state %s as %s', async (state, label) => { + ['unavailable', '未建立', {}], + ['building', '可用 · 正在补齐历史', {}], + ['syncing', '可用 · 正在追新', { pass: makePass('catchup') }], + ['syncing', '可用 · 正在补齐历史', { pass: makePass('full') }], + // backfill(补历史缺口)与 full(首次建库)对用户是同一件事:都不是"追最新"。 + ['syncing', '可用 · 正在补齐历史', { pass: makePass('backfill') }], + [ + 'ready', + '可用 · 待追新', + { indexLatestAt: STALE_INDEX_AT, sourceLatestAt: SOURCE_LATEST_AT } + ], + [ + 'ready', + '可用 · 已追至最新', + { indexLatestAt: SOURCE_LATEST_AT, sourceLatestAt: SOURCE_LATEST_AT } + ], + ['cancelled', '可用 · 同步已取消', {}], + ['error', '可用 · 更新失败', {}] + ] as const)('renders Knowledge state %s as %s', async (state, label, overrides) => { api.getKnowledgeStatus.mockResolvedValue({ ...readyKnowledgeStatus, + ...overrides, state, lastError: state === 'error' ? 'Worker 异常' : undefined }) @@ -595,11 +645,51 @@ describe('AISearchWorkspace regression coverage before decomposition', () => { expect(await screen.findAllByText(new RegExp(`Knowledge ${label}`))).not.toHaveLength(0) if (state === 'error') expect(screen.getByText('Worker 异常')).toBeInTheDocument() + // 无论哪种状态,「已同步」这种把两件事混为一谈的笼统说法都不允许出现。 + expect(screen.queryByText('已同步')).toBeNull() + }) + + it('never claims the index is up to date when freshness cannot be confirmed', async () => { + // 两个边界时间缺失(老库 / 运行态未上报)时无法判断是否追平。 + // 安全侧是「待追新」——**不能**把"无法确认"说成「已追至最新」。 + api.getKnowledgeStatus.mockResolvedValue({ + ...readyKnowledgeStatus, + indexLatestAt: null, + sourceLatestAt: null + }) + renderWorkspace() + + expect(await screen.findByText('Knowledge 可用 · 待追新')).toBeInTheDocument() + expect(screen.queryByText(/可用 · 已追至最新/)).toBeNull() + expect(screen.queryByText('已同步')).toBeNull() + }) + + it('does not claim usability when the derived index is unavailable', async () => { + // 没有分片却残留了历史计数时,不能说「可用 · …」——那是两句真话拼成的假话。 + api.getKnowledgeStatus.mockResolvedValue({ + ...readyKnowledgeStatus, + state: 'unavailable', + indexedMessageCount: 0, + indexedChunkCount: 0 + }) + renderWorkspace() + + expect(await screen.findByText('Knowledge 未建立')).toBeInTheDocument() + expect(screen.queryByText(/可用 · /)).toBeNull() }) it('starts Knowledge indexing and reflects the returned status', async () => { - api.getKnowledgeStatus.mockResolvedValue({ ...readyKnowledgeStatus, state: 'unavailable' }) - api.startKnowledgeIndex.mockResolvedValue({ ...readyKnowledgeStatus, state: 'syncing' }) + api.getKnowledgeStatus.mockResolvedValue({ + ...readyKnowledgeStatus, + state: 'unavailable', + indexedMessageCount: 0, + indexedChunkCount: 0 + }) + api.startKnowledgeIndex.mockResolvedValue({ + ...readyKnowledgeStatus, + state: 'syncing', + pass: makePass('full') + }) const onNotice = vi.fn() renderWorkspace({ onNotice }) await screen.findByText('Knowledge 未建立') @@ -607,7 +697,7 @@ describe('AISearchWorkspace regression coverage before decomposition', () => { expect(api.startKnowledgeIndex).toHaveBeenCalledOnce() expect(onNotice).toHaveBeenCalledWith(expect.stringContaining('开始同步')) - expect(await screen.findByText('Knowledge 增量同步')).toBeInTheDocument() + expect(await screen.findByText('Knowledge 可用 · 正在补齐历史')).toBeInTheDocument() }) it('unsubscribes Knowledge and Progress listeners on unmount', () => { @@ -617,3 +707,213 @@ describe('AISearchWorkspace regression coverage before decomposition', () => { expect(progressUnsubscribe).toHaveBeenCalledOnce() }) }) + +/** + * Knowledge 卡片的布局契约(侧栏宽度不变,靠栅格而不是靠缩字号解决拥挤)。 + * + * jsdom 不做布局计算,拿不到真实宽度,所以这里锁的是**布局所依赖的 DOM 契约**: + * - 五段结构(HEADER / CURRENT PASS / DATABASE STATUS / CURRENT / ACTION)各自的可见性; + * - 状态文案保持**单一文本节点**,换行只可能发生在「 · 」之后; + * - 数值行是「label + value」成对结构,nowrap 挂在 value 上; + * - 「取消同步」只在这一遍真的在跑时出现,且带不会被压扁的类。 + * + * 刻意不写整份 CSS 快照:那种断言在调样式时全是噪声,也证明不了布局真的对。 + */ +describe('Knowledge card layout contract', () => { + const STALE_INDEX_AT = new Date('2026-08-26T11:37:24+08:00').getTime() + const SOURCE_LATEST_AT = new Date('2026-09-11T11:57:24+08:00').getTime() + + const makePass = (phase: 'full' | 'catchup' | 'backfill' | 'idle') => ({ + phase, + cancellable: phase !== 'idle', + startedAt: SOURCE_LATEST_AT, + scannedMessages: 2_400, + indexedMessages: 1_200, + processedConversations: 400, + totalConversations: 4_800, + skippedConversations: 200, + catchupConversations: 4, + backfillConversations: 6, + backfillCompletedConversations: 2, + mainLoopLagMs: 0 + }) + + const status = (state: string, extra: Record = {}): unknown => ({ + ...readyKnowledgeStatus, + state, + ...extra + }) + + const findStateLabel = async (): Promise => + await screen.findByTestId('knowledge-state-label') + const queryPassSection = (): HTMLElement | null => screen.queryByTestId('knowledge-pass-progress') + + it('keeps the state label a single string that only wraps at 「 · 」', async () => { + api.getKnowledgeStatus.mockResolvedValue( + status('ready', { indexLatestAt: STALE_INDEX_AT, sourceLatestAt: SOURCE_LATEST_AT }) + ) + renderWorkspace() + + const label = await findStateLabel() + // 卡片上的字符串必须与 label 函数逐字一致(侧栏外的详情行也用同一个函数)。 + // 折行位置由 CSS 的 word-break: keep-all 决定,DOM 里不做切分 —— 一旦被拆成 + // 多个元素,页面上就会出现「可用 · 正在」/「补齐历史」这种碎句。 + expect(label.textContent).toBe('可用 · 待追新') + expect(label.childNodes).toHaveLength(1) + expect(label).toHaveClass('ai-search-knowledge-state') + }) + + it('keeps the header as one grid row with kicker, state and status dot', async () => { + api.getKnowledgeStatus.mockResolvedValue( + status('ready', { indexLatestAt: SOURCE_LATEST_AT, sourceLatestAt: SOURCE_LATEST_AT }) + ) + renderWorkspace() + + const label = await findStateLabel() + const heading = label.closest('.ai-search-knowledge-heading') + expect(heading).not.toBeNull() + expect(heading!.querySelector('.ai-search-knowledge-kicker')?.textContent).toBe('KNOWLEDGE BASE') + expect(heading!.querySelector('.ai-search-knowledge-dot')).not.toBeNull() + }) + + it('shows the CURRENT PASS section only while a pass is really running', async () => { + api.getKnowledgeStatus.mockResolvedValue( + status('unavailable', { indexedMessageCount: 0, indexedChunkCount: 0 }) + ) + const view = renderWorkspace() + await screen.findByText('Knowledge 未建立') + expect(queryPassSection()).toBeNull() + view.unmount() + + api.getKnowledgeStatus.mockResolvedValue(status('syncing', { pass: makePass('catchup') })) + renderWorkspace() + expect(await screen.findByTestId('knowledge-pass-progress')).toBeInTheDocument() + }) + + it('renders the real two counters instead of a denominator-less fake progress', async () => { + api.getKnowledgeStatus.mockResolvedValue(status('syncing', { pass: makePass('catchup') })) + renderWorkspace() + + const progress = await screen.findByTestId('knowledge-pass-progress') + expect(progress.textContent).toBe( + `本轮新增索引 ${(1_200).toLocaleString()} 条 · 本轮已扫描 ${(2_400).toLocaleString()} 条` + ) + // 同样是一个文本节点 + CSS 折行:不能被拆成多个元素(否则「 · 」会甩到行首)。 + expect(progress.childNodes).toHaveLength(1) + }) + + it.each([ + ['catchup', '正在追最新消息:4 个会话有新内容'], + ['backfill', '后台补齐历史:2 / 6 个久未更新的会话'], + ['full', '正在建立索引:4,800 个会话'] + ] as const)('describes the %s pass inside CURRENT PASS', async (phase, expected) => { + api.getKnowledgeStatus.mockResolvedValue(status('syncing', { pass: makePass(phase) })) + renderWorkspace() + + expect(await screen.findByTestId('knowledge-pass-scope')).toHaveTextContent(expected) + }) + + it('keeps every database status row a label/value pair with a non-wrapping value', async () => { + api.getKnowledgeStatus.mockResolvedValue( + status('ready', { indexLatestAt: SOURCE_LATEST_AT, sourceLatestAt: SOURCE_LATEST_AT }) + ) + renderWorkspace() + await findStateLabel() + + for (const text of ['已索引消息', '知识片段', '最新索引', '磁盘占用']) { + const label = screen.getByText(text) + expect(label).toHaveClass('ai-search-knowledge-label') + const row = label.closest('.ai-search-knowledge-row') + expect(row).not.toBeNull() + // value 是 nowrap 的载体:数字一旦折行,这一行就不可读了。 + expect(row!.querySelector('.ai-search-knowledge-value')).not.toBeNull() + } + }) + + it('gives the current conversation its own row and truncates the long name', async () => { + api.getKnowledgeStatus.mockResolvedValue( + status('syncing', { pass: makePass('catchup'), currentConversationId: aiSearchGroup.md5 }) + ) + renderWorkspace() + await findStateLabel() + + const row = screen.getByText('当前会话').closest('.ai-search-knowledge-row') + expect(row).not.toBeNull() + // 群名 / 备注可以很长:必须省略而不是撑破侧栏。 + expect(row!.querySelector('.ai-search-knowledge-value')).toHaveClass( + 'ai-search-knowledge-value--truncate' + ) + }) + + it('drops the current-conversation row once nothing is running', async () => { + api.getKnowledgeStatus.mockResolvedValue( + status('ready', { + indexLatestAt: SOURCE_LATEST_AT, + sourceLatestAt: SOURCE_LATEST_AT, + currentConversationId: aiSearchGroup.md5 + }) + ) + renderWorkspace() + await findStateLabel() + + expect(screen.queryByText('当前会话')).toBeNull() + }) + + it('keeps the main-loop lag in its own labelled row when it is measured', async () => { + api.getKnowledgeStatus.mockResolvedValue( + status('syncing', { pass: { ...makePass('catchup'), mainLoopLagMs: 42 } }) + ) + renderWorkspace() + await findStateLabel() + + const row = screen.getByText('界面卡顿峰值').closest('.ai-search-knowledge-row') + expect(row).not.toBeNull() + expect(row!.querySelector('.ai-search-knowledge-value')?.textContent).toBe('42ms') + }) + + it('keeps the cancel action on one line and only while running', async () => { + api.getKnowledgeStatus.mockResolvedValue( + status('ready', { indexLatestAt: SOURCE_LATEST_AT, sourceLatestAt: SOURCE_LATEST_AT }) + ) + const view = renderWorkspace() + await findStateLabel() + expect(screen.queryByTestId('knowledge-cancel-sync')).toBeNull() + view.unmount() + + api.getKnowledgeStatus.mockResolvedValue(status('syncing', { pass: makePass('catchup') })) + renderWorkspace() + + const cancel = await screen.findByTestId('knowledge-cancel-sync') + expect(cancel).toHaveTextContent('取消同步') + // 带 min-width 的类:按钮不会被主按钮挤到换行成「取消同」/「步」。 + expect(cancel).toHaveClass('ai-search-knowledge-cancel') + expect(cancel.closest('.ai-search-knowledge-actions')).not.toBeNull() + }) + + it('keeps the cancelled state honest and free of a stale progress block', async () => { + api.getKnowledgeStatus.mockResolvedValue(status('cancelled', { pass: makePass('idle') })) + renderWorkspace() + + expect(await screen.findByText('Knowledge 可用 · 同步已取消')).toBeInTheDocument() + expect( + screen.getByText('上一遍同步被取消,已建立的索引仍然可用;下次同步会从断点继续。') + ).toBeInTheDocument() + // 取消之后不能残留一个假的"正在同步"。 + expect(queryPassSection()).toBeNull() + expect(screen.queryByTestId('knowledge-cancel-sync')).toBeNull() + }) + + it('keeps the FRESH state free of the stale warning and the progress block', async () => { + api.getKnowledgeStatus.mockResolvedValue( + status('ready', { indexLatestAt: SOURCE_LATEST_AT, sourceLatestAt: SOURCE_LATEST_AT }) + ) + renderWorkspace() + + expect(await screen.findByText('Knowledge 可用 · 已追至最新')).toBeInTheDocument() + expect( + screen.getByText('后台增量同步不会影响原始微信聊天记录,也不会阻塞提问。') + ).toBeInTheDocument() + expect(screen.queryByText(/索引还没追上最新聊天/)).toBeNull() + expect(queryPassSection()).toBeNull() + }) +}) diff --git a/tests/unit/agent-hub-routing.test.ts b/tests/unit/agent-hub-routing.test.ts new file mode 100644 index 0000000..0a4e364 --- /dev/null +++ b/tests/unit/agent-hub-routing.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { + matchGroupReportIntent, + queryAgentReplyText, + QUERY_AGENT_UNAVAILABLE_TEXT, + resolveInboundRoute +} from '../../src/main/services/agent-hub-routing' +import type { AskWechatQueryResult } from '../../src/shared/query-agent' + +describe('Agent Hub 入站路由 — Query / Action 分流', () => { + it('A. 「TraceMemo 交流群最近聊了啥」→ Query Agent(不是 report)', () => { + expect(resolveInboundRoute('TraceMemo 交流群最近聊了啥')).toEqual({ kind: 'knowledge_query' }) + }) + + it('B. 「总结一下 TraceMemo 交流群最近聊了什么」→ Query Agent("总结"不等于 report)', () => { + expect(resolveInboundRoute('总结一下 TraceMemo 交流群最近聊了什么')).toEqual({ + kind: 'knowledge_query' + }) + }) + + it('B2. 「总结」缺少产物词时,report 快捷匹配不成立', () => { + expect(matchGroupReportIntent('总结一下这个群最近聊了什么')).toBeNull() + expect(matchGroupReportIntent('帮我总结一下 TraceMemo 交流群的近期内容')).toBeNull() + }) + + it('C. 「生成 TraceMemo 交流群今天的群聊总结图片」→ Report Action(图片)', () => { + const route = resolveInboundRoute('生成 TraceMemo 交流群今天的群聊总结图片') + expect(route.kind).toBe('report_action') + if (route.kind !== 'report_action') throw new Error('unreachable') + expect(route.intent.group).toContain('TraceMemo') + expect(route.intent.range).toBe('today') + }) + + it('D. 「帮我做一份 TraceMemo 交流群今日日报」→ Report Action(日报)', () => { + const route = resolveInboundRoute('帮我做一份 TraceMemo 交流群今日日报') + expect(route.kind).toBe('report_action') + if (route.kind !== 'report_action') throw new Error('unreachable') + expect(route.intent.group).toContain('TraceMemo') + }) + + it('D2. 群 + 时间但不要产物 → Query Agent', () => { + expect(resolveInboundRoute('TraceMemo 交流群昨天聊了啥')).toEqual({ kind: 'knowledge_query' }) + }) + + it('E. 「BOBO 最近说过什么」→ Query Agent', () => { + expect(resolveInboundRoute('BOBO 最近说过什么')).toEqual({ kind: 'knowledge_query' }) + }) + + it('F. 「我和 BOBO 第一次聊了什么」→ Query Agent', () => { + expect(resolveInboundRoute('我和 BOBO 第一次聊了什么')).toEqual({ kind: 'knowledge_query' }) + }) + + it('G. 群成员分析仍是专用 Action', () => { + const route = resolveInboundRoute('看看 TraceMemo交流群里 BOBO 最近说了什么') + expect(route.kind).toBe('group_member_action') + if (route.kind !== 'group_member_action') throw new Error('unreachable') + expect(route.intent.member).toBe('BOBO') + expect(route.intent.group).toContain('TraceMemo') + }) + + it('H. 会话列表是确定性快捷路径(不经过模型)', () => { + expect(resolveInboundRoute('最近有哪些会话')).toEqual({ kind: 'recent_list', limit: 5 }) + expect(resolveInboundRoute('最近3条消息')).toEqual({ kind: 'recent_list', limit: 3 }) + }) +}) + +describe('Agent Hub 回复文案映射', () => { + const diagnostics = { + entry: 'agent-hub' as const, + provider: 'P', + model: 'm', + modelCallCount: 2, + toolCallCount: 1, + tools: ['query_messages'], + totalMs: 10, + outcome: 'answered' as const + } + + it('answered → 直接用 Query Agent 的文本回答', () => { + expect( + queryAgentReplyText({ + engine: 'query-agent', + status: 'answered', + answer: '最近聊了两件事。', + diagnostics + }) + ).toBe('最近聊了两件事。') + }) + + it('provider_unavailable / error → 使用安全文案', () => { + const unavailable: AskWechatQueryResult = { + engine: 'query-agent', + status: 'provider_unavailable', + message: '当前 AI 查询服务暂时不可用,请稍后再试。', + diagnostics: { ...diagnostics, outcome: 'provider_failure' } + } + expect(queryAgentReplyText(unavailable)).toBe('当前 AI 查询服务暂时不可用,请稍后再试。') + + const error: AskWechatQueryResult = { + engine: 'query-agent', + status: 'error', + message: '本次查询没有完成,请稍后再试或换一种问法。', + diagnostics: { ...diagnostics, outcome: 'runtime_error' } + } + expect(queryAgentReplyText(error)).toBe('本次查询没有完成,请稍后再试或换一种问法。') + }) + + it('legacy 结果不会出现在 Agent Hub 路径上,兜底为服务不可用文案', () => { + const legacy: AskWechatQueryResult = { + engine: 'legacy', + status: 'legacy', + reason: 'runtime_error', + result: {} as never + } + expect(queryAgentReplyText(legacy)).toBe(QUERY_AGENT_UNAVAILABLE_TEXT) + }) +}) diff --git a/tests/unit/ai-search-workspace-pure-functions.test.ts b/tests/unit/ai-search-workspace-pure-functions.test.ts index 9254965..642c22e 100644 --- a/tests/unit/ai-search-workspace-pure-functions.test.ts +++ b/tests/unit/ai-search-workspace-pure-functions.test.ts @@ -4,8 +4,9 @@ import type { AiSearchPipelineResult, AiSearchTimeRange } from '../../src/shared/ai-search' -import type { KnowledgeRuntimeStatus } from '../../src/shared/knowledge' +import type { KnowledgePassProgress, KnowledgeRuntimeStatus } from '../../src/shared/knowledge' import type { Contact } from '../../src/shared/types' +import { decodeMessageRef, encodeMessageRef } from '../../src/shared/local-query-api' import { contactLabel, formatBytes, @@ -249,13 +250,54 @@ describe('AI Search workspace pure formatters', () => { ) }) - it('keeps every knowledge runtime state label unchanged', () => { + it('labels Knowledge states with the mandated freshness vocabulary', () => { + const usable = ( + state: KnowledgeRuntimeStatus['state'], + extra: Partial = {} + ): KnowledgeRuntimeStatus => ({ + ...makeKnowledgeStatus(state), + indexedMessageCount: 20, + indexedChunkCount: 4, + ...extra + }) + const pass = (phase: 'full' | 'catchup' | 'idle'): KnowledgePassProgress => ({ + phase, + cancellable: phase !== 'idle', + startedAt: 1_700_000_000_000, + scannedMessages: 120, + indexedMessages: 20, + processedConversations: 3, + totalConversations: 10, + skippedConversations: 1, + mainLoopLagMs: 4 + }) + expect(knowledgeStateLabel(null)).toBe('读取中') + // 一个分片都没有:这不是"落后",是"还没建立"。 expect(knowledgeStateLabel(makeKnowledgeStatus('unavailable'))).toBe('未建立') - expect(knowledgeStateLabel(makeKnowledgeStatus('building'))).toBe('建立中') - expect(knowledgeStateLabel(makeKnowledgeStatus('syncing'))).toBe('增量同步') - expect(knowledgeStateLabel(makeKnowledgeStatus('ready'))).toBe('已同步') - expect(knowledgeStateLabel(makeKnowledgeStatus('error'))).toBe('异常') + expect(knowledgeStateLabel(makeKnowledgeStatus('building'))).toBe('正在建立') + // ready ≠ fresh:这两件事必须在文案里分开。 + expect(knowledgeStateLabel(usable('building'))).toBe('可用 · 正在补齐历史') + expect(knowledgeStateLabel(usable('syncing', { pass: pass('catchup') }))).toBe( + '可用 · 正在追新' + ) + expect(knowledgeStateLabel(usable('syncing', { pass: pass('full') }))).toBe( + '可用 · 正在补齐历史' + ) + expect( + knowledgeStateLabel(usable('ready', { indexLatestAt: 1_700_000_000_000, sourceLatestAt: 1_700_000_999_000 })) + ).toBe('可用 · 待追新') + expect( + knowledgeStateLabel(usable('ready', { indexLatestAt: 1_700_000_999_000, sourceLatestAt: 1_700_000_999_000 })) + ).toBe('可用 · 已追至最新') + expect(knowledgeStateLabel(usable('cancelled'))).toBe('可用 · 同步已取消') + expect(knowledgeStateLabel(usable('error'))).toBe('可用 · 更新失败') + // 笼统的「已同步」把"能查"和"追平"混成一句话,已被彻底移除。 + for (const state of ['unavailable', 'building', 'syncing', 'ready', 'cancelled', 'error'] as const) { + expect(knowledgeStateLabel(usable(state))).not.toBe('已同步') + } + // 派生库不可查询时不允许冒充"可用"(残留的历史计数不算数)。 + expect(knowledgeStateLabel(usable('unavailable'))).toBe('未建立') }) it('keeps the existing contact label fallback order', () => { @@ -287,6 +329,8 @@ describe('AI Search pipeline evidence mapping', () => { evidenceId: 'E1', sourceKind: 'text', contact: aiSearchContact, + // Legacy 路径同样要带稳定引用:否则跳转只能靠"会话 + 秒级时间戳"猜。 + messageRef: encodeMessageRef(aiSearchContact.md5, item.messageId), message: { id: 'message-1', from: 'sender-1', @@ -299,6 +343,10 @@ describe('AI Search pipeline evidence mapping', () => { createTime: Math.floor(item.timestamp / 1_000) } }) + expect(decodeMessageRef(mapped.messageRef)).toEqual({ + conversationId: aiSearchContact.md5, + messageId: item.messageId + }) }) it('creates the existing group fallback and voice presentation for an unloaded conversation', () => { diff --git a/tests/unit/ask-wechat-presentation.test.ts b/tests/unit/ask-wechat-presentation.test.ts new file mode 100644 index 0000000..5887a63 --- /dev/null +++ b/tests/unit/ask-wechat-presentation.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { + askWechatToolLabels, + formatAskWechatStats, + mapAskWechatEvidence +} from '../../src/renderer/src/components/search/askWechatPresentation' +import type { AskWechatStats } from '../../src/shared/query-agent' + +const stats = (patch: Partial = {}): AskWechatStats => ({ + tools: ['query_messages'], + reads: { messageCount: 0, matchedCount: 0, overviewSourceCount: 0, evidenceCount: 0 }, + scope: { kind: 'all', label: '所有聊天记录' }, + modelCallCount: 2, + toolCallCount: 1, + totalMs: 3343, + ...patch +}) + +describe('mapAskWechatEvidence', () => { + it('群消息证据保留群名与成员,且不泄露 md5', () => { + const [item] = mapAskWechatEvidence([ + { + messageRef: 'ref-1', + conversationName: 'TraceMemo 交流群', + conversationType: 'group', + sender: '张三', + timestamp: 1_787_650_302_000, + messageType: 'text', + text: '最近重新开始健身了', + source: 'search_messages' + } + ]) + expect(item.evidenceId).toBe('E1') + expect(item.contact.m_nsNickName).toBe('TraceMemo 交流群') + expect(item.contact.type).toBe('group') + expect(item.message.name).toBe('张三') + expect(item.message.content).toBe('最近重新开始健身了') + expect(item.message.createTime).toBe(1_787_650_302) + expect(item.contact.md5).not.toMatch(/^[0-9a-f]{32}$/) + }) + + it('缺少群名时给出可读回退,而不是把群消息归成"群聊"', () => { + const [item] = mapAskWechatEvidence([{ messageRef: 'ref-2', source: 'conversation_overview' }]) + expect(item.contact.m_nsNickName).toBe('未命名会话') + }) +}) + +describe('formatAskWechatStats', () => { + it('query_messages:读取条数 + 证据条数 + 模型调用 + 耗时', () => { + const chips = formatAskWechatStats( + stats({ + tools: ['query_messages'], + reads: { messageCount: 20, matchedCount: 0, overviewSourceCount: 0, evidenceCount: 20 } + }) + ) + expect(chips).toContain('所有聊天记录内查询') + expect(chips).toContain('读取 20 条消息') + expect(chips).toContain('使用 20 条证据') + expect(chips).toContain('2 次模型调用') + expect(chips).toContain('3.3 s') + }) + + it('overview:覆盖源消息 + 代表证据;search 无证据时说明命中为 0 的事实', () => { + const overview = formatAskWechatStats( + stats({ + tools: ['conversation_overview'], + scope: { kind: 'current', label: '当前会话:TraceMemo 交流群' }, + reads: { messageCount: 0, matchedCount: 0, overviewSourceCount: 1200, evidenceCount: 60 } + }) + ) + expect(overview).toContain('当前会话:TraceMemo 交流群内查询') + expect(overview).toContain('覆盖 1200 条消息') + expect(overview).toContain('使用 60 条证据') + + const search = formatAskWechatStats( + stats({ + tools: ['search_messages'], + scope: { kind: 'groups', label: '群聊专属' }, + reads: { messageCount: 0, matchedCount: 12, overviewSourceCount: 0, evidenceCount: 0 } + }) + ) + expect(search).toContain('命中 12 条相关消息') + expect(search.some((chip) => chip.includes('知识库已收录'))).toBe(false) + }) +}) + +describe('askWechatToolLabels', () => { + it('把 Tool 名映射成中文能力标签并去重', () => { + expect(askWechatToolLabels(['query_messages', 'search_messages', 'search_messages'])).toEqual([ + '精确读取', + '关键词检索' + ]) + }) +}) diff --git a/tests/unit/ask-wechat-service.test.ts b/tests/unit/ask-wechat-service.test.ts new file mode 100644 index 0000000..8225cc3 --- /dev/null +++ b/tests/unit/ask-wechat-service.test.ts @@ -0,0 +1,490 @@ +import { describe, expect, it, vi } from 'vitest' +import { AskWechatService, type AskWechatLogRecord } from '../../src/main/services/ask-wechat-service' +import { QueryAgentService, type QueryAgentProvider } from '../../src/main/services/query-agent-service' +import type { AiSearchPipelineResult } from '../../src/shared/ai-search' +import type { AskWechatQueryRequest, AskWechatQueryResult } from '../../src/shared/query-agent' + +type ChatResponse = Awaited> +type ToolExecutor = (name: string, input: Record) => Promise> + +const request = ( + text: string, + legacy?: AskWechatQueryRequest['legacy'] +): AskWechatQueryRequest => ({ requestId: 'req-1', text, legacy }) + +function providerFactory(responses: ChatResponse[], configured = true): { + provider: QueryAgentProvider + calls: Array<{ messages: Array>; tools: string[] }> +} { + const calls: Array<{ messages: Array>; tools: string[] }> = [] + const provider: QueryAgentProvider = { + getRuntimeConfig: () => ({ + configured, + providerName: 'Fixture Provider', + model: 'fixture-model', + modelName: 'Fixture Model' + }), + chatWithTools: vi.fn(async (messages, tools) => { + calls.push({ messages, tools: tools.map((tool) => tool.function.name) }) + return responses.shift() || { success: true, data: 'done' } + }) + } + return { provider, calls } +} + +const toolCall = (name: string, args: Record): ChatResponse => ({ + success: true, + data: '', + toolCalls: [{ id: `call-${name}`, name, arguments: JSON.stringify(args) }] +}) + +const answer = (data: string): ChatResponse => ({ success: true, data }) + +const toolMessages = (messages: Array>): string => + JSON.stringify(messages.filter((message) => message.role === 'tool')) + +const legacyResult = (patch: Record = {}): AiSearchPipelineResult => + ({ + requestId: 'req-1', + status: 'completed', + answer: 'legacy answer', + ...patch + }) as unknown as AiSearchPipelineResult + +const answered = (result: AskWechatQueryResult): Extract => { + if (result.status !== 'answered') throw new Error(`expected answered, got ${result.status}`) + return result +} + +describe('AskWechatService — 桌面问问微信主路径', () => { + it('A. 「我和 BOBO 第一次聊了什么」走 query_messages 并返回回答', async () => { + const execute = vi.fn(async () => ({ + status: 'completed', + returnedCount: 1, + messages: [{ messageRef: 'ref-1', text: '你好' }] + })) + const { provider } = providerFactory([ + toolCall('query_messages', { + target: { query: 'BOBO' }, + timeRange: { kind: 'all' }, + temporalBasis: { kind: 'none' }, + order: 'asc', + limit: 1 + }), + answer('你们的第一次聊天是一条问候。') + ]) + const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' }) + + const result = answered(await service.ask(request('我和 BOBO 第一次聊了什么'))) + + expect(result.engine).toBe('query-agent') + expect(result.answer).toBe('你们的第一次聊天是一条问候。') + expect(result.diagnostics).toMatchObject({ + entry: 'desktop', + provider: 'Fixture Provider', + model: 'Fixture Model', + modelCallCount: 2, + toolCallCount: 1, + tools: ['query_messages'], + outcome: 'answered' + }) + expect(execute).toHaveBeenCalledTimes(1) + // temporalBasis 是 LLM-facing 元数据,必须在到达 Query API 前被剥离。 + expect(execute.mock.calls[0][1]).not.toHaveProperty('temporalBasis') + expect(execute.mock.calls[0][1].timeRange).toEqual({ kind: 'all' }) + }) + + it('B. 明确时间(constraint)不会被 retry 扩大,ISO-8601 被换算成 epoch seconds', async () => { + const execute = vi.fn(async () => ({ + status: 'completed', + returnedCount: 0, + coverage: { state: 'complete' } + })) + const constraintBasis = { kind: 'constraint', sourceText: '八月份' } + const { provider, calls } = providerFactory([ + toolCall('query_messages', { + target: { query: 'BOBO' }, + timeRange: { + kind: 'absolute', + startTime: '2026-08-01T00:00:00+08:00', + endTime: '2026-09-01T00:00:00+08:00' + }, + temporalBasis: constraintBasis + }), + toolCall('query_messages', { + target: { query: 'BOBO' }, + timeRange: { kind: 'all' }, + temporalBasis: constraintBasis + }), + answer('八月份没有找到相关记录。') + ]) + const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' }) + + answered(await service.ask(request('八月份 BOBO 有没有给我发过文件'))) + + // 第二次改时间范围被 Host 结构性拒绝,没有到达 Query API。 + expect(execute).toHaveBeenCalledTimes(1) + expect(execute.mock.calls[0][1].timeRange).toEqual({ + kind: 'absolute', + startTime: Math.floor(Date.parse('2026-08-01T00:00:00+08:00') / 1000), + endTime: Math.floor(Date.parse('2026-09-01T00:00:00+08:00') / 1000) + }) + expect(toolMessages(calls[2].messages)).toContain('constraint_time_range_immutable') + }) + + it('C. 模糊时间(recall_hint)首次 0 条时由 Host 自动补查 all,且不占 toolCallCount', async () => { + const execute = vi + .fn() + .mockResolvedValueOnce({ status: 'completed', returnedCount: 0, coverage: { state: 'complete' } }) + .mockResolvedValueOnce({ + status: 'completed', + returnedCount: 2, + coverage: { state: 'complete' }, + messages: [{ messageRef: 'ref-2' }] + }) + const { provider } = providerFactory([ + toolCall('query_messages', { + target: { query: 'BOBO' }, + timeRange: { kind: 'last_7_days' }, + temporalBasis: { kind: 'recall_hint', sourceText: '前阵子' } + }), + answer('原范围没有,全部历史里有两条。') + ]) + const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' }) + + const result = answered(await service.ask(request('前阵子 BOBO 好像发过东西给我'))) + + expect(execute).toHaveBeenCalledTimes(2) + expect(execute.mock.calls[1][1].timeRange).toEqual({ kind: 'all' }) + expect(result.diagnostics.toolCallCount).toBe(1) + expect(result.diagnostics.modelCallCount).toBe(2) + }) + + it('D. 语义检索走 search_messages,queries 映射为 query + variants', async () => { + const execute = vi.fn(async () => ({ status: 'completed', evidenceCount: 2, evidence: [] })) + const { provider } = providerFactory([ + toolCall('search_messages', { + target: { query: 'BOBO' }, + timeRange: { kind: 'all' }, + queries: ['房租', '押金'] + }), + answer('找到两条相关记录。') + ]) + const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' }) + + const result = answered(await service.ask(request('BOBO 提过房租或者押金吗'))) + + expect(result.diagnostics.tools).toEqual(['search_messages']) + expect(execute.mock.calls[0][1]).toMatchObject({ query: '房租', variants: ['押金'] }) + }) + + it('E. 宽泛总结走 conversation_overview', async () => { + const execute = vi.fn(async () => ({ status: 'completed', evidenceCount: 12, evidence: [] })) + const { provider } = providerFactory([ + toolCall('conversation_overview', { + target: { query: 'TraceMemo交流群' }, + timeRange: { kind: 'last_7_days' } + }), + answer('最近一周主要讨论了两件事。') + ]) + const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' }) + + const result = answered(await service.ask(request('TraceMemo交流群最近聊了什么'))) + + expect(result.diagnostics.tools).toEqual(['conversation_overview']) + }) + + it('F. 模型追问(clarification)按普通回答返回,并进入下一轮上下文', async () => { + const { provider, calls } = providerFactory([ + answer('你想问的是哪位联系人?'), + answer('好的,是 BOBO。') + ]) + const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), { entry: 'desktop' }) + + const first = answered(await service.ask(request('我们第一次聊了什么'))) + expect(first.answer).toBe('你想问的是哪位联系人?') + + await service.ask(request('BOBO')) + + expect(calls[1].messages.map((message) => message.role)).toEqual([ + 'system', + 'user', + 'assistant', + 'user' + ]) + expect(calls[1].messages[1]).toMatchObject({ content: '我们第一次聊了什么' }) + expect(calls[1].messages[2]).toMatchObject({ content: '你想问的是哪位联系人?' }) + }) + + it('G. Provider 失败 → 安全文案,且不回退 Legacy', async () => { + const runLegacy = vi.fn(async () => legacyResult()) + const { provider } = providerFactory([{ success: false, error: 'AI 请求超时', timedOut: true }]) + const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), { + entry: 'desktop', + runLegacy + }) + + const result = await service.ask(request('BOBO 说过什么')) + + expect(result.status).toBe('provider_unavailable') + if (result.status !== 'provider_unavailable') throw new Error('unreachable') + expect(result.message).toBe('当前 AI 查询服务暂时不可用,请稍后再试。') + expect(result.diagnostics.outcome).toBe('provider_failure') + expect(runLegacy).not.toHaveBeenCalled() + }) + + it('G2. Provider 未配置 → 安全文案,且不触发任何模型 / 工具调用', async () => { + const execute = vi.fn(async () => ({ status: 'completed' })) + const { provider } = providerFactory([], false) + const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' }) + + const result = await service.ask(request('BOBO 说过什么')) + + expect(result.status).toBe('provider_unavailable') + expect(execute).not.toHaveBeenCalled() + expect(provider.chatWithTools).not.toHaveBeenCalled() + }) + + it('H. Query Agent 查 0 条 → 仍然是它回答,不调用 Legacy', async () => { + const execute = vi.fn(async () => ({ + status: 'completed', + returnedCount: 0, + coverage: { state: 'complete' } + })) + const runLegacy = vi.fn(async () => legacyResult()) + const { provider } = providerFactory([ + toolCall('query_messages', { + target: { query: 'BOBO' }, + timeRange: { kind: 'all' }, + temporalBasis: { kind: 'none' } + }), + answer('当前可读取的完整范围里没有找到相关记录。') + ]) + const service = new AskWechatService(new QueryAgentService(provider, execute), { + entry: 'desktop', + runLegacy + }) + + const result = answered(await service.ask(request('BOBO 给我发过文件吗'))) + + expect(result.answer).toContain('没有找到') + expect(result.diagnostics.outcome).toBe('answered') + expect(runLegacy).not.toHaveBeenCalled() + }) + + it('Runtime 抛出未分类异常 → 允许回退 Legacy,并沿用 UI 范围', async () => { + const runLegacy = vi.fn(async () => legacyResult({ status: 'completed' })) + const provider: QueryAgentProvider = { + getRuntimeConfig: () => ({ + configured: true, + providerName: 'P', + model: 'm', + modelName: 'M' + }), + chatWithTools: vi.fn(async () => { + throw new Error('unexpected internal failure') + }) + } + const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), { + entry: 'desktop', + runLegacy + }) + + const result = await service.ask( + request('BOBO 说过什么', { + scope: 'conversation', + range: '30d', + conversationId: 'conversation-1' + }) + ) + + expect(result.engine).toBe('legacy') + if (result.engine !== 'legacy') throw new Error('unreachable') + expect(result.reason).toBe('runtime_error') + expect(runLegacy).toHaveBeenCalledTimes(1) + expect(runLegacy.mock.calls[0][0]).toMatchObject({ + requestId: 'req-1', + text: 'BOBO 说过什么', + scope: 'conversation', + range: '30d', + conversationId: 'conversation-1' + }) + }) + + it('Runtime 异常且没有 Legacy 通道(Agent Hub)→ 明确文案,不抛异常', async () => { + const provider: QueryAgentProvider = { + getRuntimeConfig: () => ({ configured: true, providerName: 'P', model: 'm', modelName: 'M' }), + chatWithTools: vi.fn(async () => { + throw new Error('unexpected internal failure') + }) + } + const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), { + entry: 'agent-hub' + }) + + const result = await service.ask(request('BOBO 说过什么')) + + expect(result.status).toBe('error') + if (result.status !== 'error') throw new Error('unreachable') + expect(result.message).toBe('本次查询没有完成,请稍后再试或换一种问法。') + expect(result.diagnostics.entry).toBe('agent-hub') + }) + + it('超过工具调用上限 → 判为 Runtime 失败(可回退),不伪装成正常回答', async () => { + let counter = 0 + const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 0 })) + const provider: QueryAgentProvider = { + getRuntimeConfig: () => ({ configured: true, providerName: 'P', model: 'm', modelName: 'M' }), + chatWithTools: vi.fn(async () => { + counter += 1 + return { + success: true, + data: '', + toolCalls: [ + { + id: `call-${counter}`, + name: 'query_messages', + arguments: JSON.stringify({ + target: { query: `BOBO${counter}` }, + timeRange: { kind: 'all' }, + temporalBasis: { kind: 'none' } + }) + } + ] + } + }) + } + const service = new AskWechatService(new QueryAgentService(provider, execute), { + entry: 'agent-hub' + }) + + const result = await service.ask(request('一直查不完的问题')) + + expect(result.status).toBe('error') + if (result.status !== 'error') throw new Error('unreachable') + expect(result.diagnostics.outcome).toBe('tool_limit') + }) + + it('空问题 → 明确的用户文案', async () => { + const { provider } = providerFactory([]) + const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), { entry: 'desktop' }) + + const result = await service.ask(request(' ')) + + expect(result.status).toBe('error') + if (result.status !== 'error') throw new Error('unreachable') + expect(result.message).toBe('请先输入想了解的问题。') + expect(result.diagnostics.outcome).toBe('invalid_question') + }) + + it('生产日志只记录形态字段,不含回答内容', async () => { + const logs: AskWechatLogRecord[] = [] + const { provider } = providerFactory([answer('BOBO 最近在准备搬家,提到了房租和押金。')]) + const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), { + entry: 'desktop', + log: (record) => logs.push(record) + }) + + await service.ask(request('BOBO 最近在忙什么')) + + expect(logs).toHaveLength(1) + expect(Object.keys(logs[0].details ?? {}).sort()).toEqual([ + 'entry', + 'model', + 'modelCallCount', + 'outcome', + 'provider', + 'toolCallCount', + 'tools', + 'totalMs' + ]) + expect(JSON.stringify(logs)).not.toContain('准备搬家') + }) + + it('forgetConversation 清掉指定会话的澄清上下文', async () => { + const { provider, calls } = providerFactory([ + answer('哪一位?'), + answer('好的。'), + answer('好的。') + ]) + const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), { entry: 'desktop' }) + await service.ask(request('我们第一次聊了什么')) + service.forgetConversation() + await service.ask(request('BOBO')) + + expect(calls[1].messages.map((message) => message.role)).toEqual(['system', 'user']) + }) +}) + +describe('AskWechatService — 搜索范围(conversationScope)', () => { + it('把 UI 的 scope 透传到 Tool 执行上下文,并让范围说明进入模型上下文', async () => { + const seen: Array | undefined> = [] + const execute = vi.fn(async (_name: string, _input: Record, context?: { conversationScope?: unknown }) => { + seen.push(context?.conversationScope as Record | undefined) + return { status: 'completed', returnedCount: 0 } + }) + const { provider, calls } = providerFactory([ + toolCall('search_messages', { timeRange: { kind: 'all' }, queries: ['健身'] }), + answer('范围内没有找到。') + ]) + const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' }) + + await service.ask({ + requestId: 'req-1', + text: '最近谁聊过健身', + scope: { scope: { kind: 'groups' }, label: '群聊专属' } + }) + + expect(seen[0]).toEqual({ kind: 'groups' }) + // 范围说明只描述边界;强制由 Engine 完成(越界 target 会被结构化拒绝)。 + expect(String(calls[0].messages[1]?.content)).toContain('群聊专属') + expect(String(calls[0].messages[1]?.content)).toContain('群成员实际发送的消息') + }) + + it('没有 scope 时不注入范围说明(保持毕业版本的 messages 形状)', async () => { + const { provider, calls } = providerFactory([answer('好的')]) + const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), { entry: 'agent-hub' }) + + await service.ask(request('你好')) + + expect(calls[0].messages.map((message) => message.role)).toEqual(['system', 'user']) + }) + + it('回答结果带真实统计与证据(供 UI 顶部与右侧面板使用)', async () => { + const execute = vi.fn(async () => ({ + status: 'completed', + evidenceCount: 2, + evidence: [ + { + messageRef: 'ref-1', + conversationName: 'TraceMemo 交流群', + conversationType: 'group', + sender: '张三', + timestamp: 1_787_650_302_000, + sourceKind: 'text', + text: '最近开始健身了' + } + ] + })) + const { provider } = providerFactory([ + toolCall('search_messages', { timeRange: { kind: 'all' }, queries: ['健身'] }), + answer('张三提过。') + ]) + const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' }) + + const result = answered( + await service.ask({ requestId: 'req-1', text: '谁聊过健身', scope: { scope: { kind: 'groups' } } }) + ) + + expect(result.evidence).toHaveLength(1) + expect(result.evidence[0]).toMatchObject({ + conversationName: 'TraceMemo 交流群', + conversationType: 'group', + sender: '张三', + source: 'search_messages' + }) + expect(result.stats.tools).toEqual(['search_messages']) + expect(result.stats.reads.evidenceCount).toBe(1) + expect(result.stats.scope).toEqual({ kind: 'groups' }) + }) +}) diff --git a/tests/unit/knowledge-freshness.test.ts b/tests/unit/knowledge-freshness.test.ts new file mode 100644 index 0000000..e103fde --- /dev/null +++ b/tests/unit/knowledge-freshness.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { + KNOWLEDGE_FRESHNESS_TOLERANCE_MS, + isKnowledgeFresh, + type KnowledgePassProgress, + type KnowledgeRuntimeStatus +} from '../../src/shared/knowledge' +import { formatIndexDate, knowledgeIsStale, knowledgeStateLabel } from '../../src/renderer/src/components/search/searchFormatters' + +type StatusPatch = Partial< + Pick +> + +const makePass = (phase: KnowledgePassProgress['phase']): KnowledgePassProgress => ({ + phase, + cancellable: phase !== 'idle', + startedAt: 0, + scannedMessages: 0, + indexedMessages: 0, + processedConversations: 0, + totalConversations: 0, + skippedConversations: 0, + mainLoopLagMs: 0 +}) + +const status = (patch: StatusPatch): KnowledgeRuntimeStatus => + ({ + accountId: 'fixture', + state: 'ready', + indexedMessageCount: 1, + indexedChunkCount: 1, + sourceMessageCount: 1, + processedMessages: 1, + totalMessages: 1, + estimatedRemainingMs: null, + databaseBytes: 0, + walBytes: 0, + shmBytes: 0, + indexLatestAt: null, + sourceLatestAt: null, + ...patch + }) + +describe('isKnowledgeFresh(READY 与 FRESH 是两个概念)', () => { + it('索引追平源数据时算 fresh', () => { + expect(isKnowledgeFresh({ indexLatestAt: 1000, sourceLatestAt: 1000 })).toBe(true) + }) + + it('容差内算 fresh:吸收 Session 元数据晚于消息落库的漂移', () => { + expect( + isKnowledgeFresh({ indexLatestAt: 1000, sourceLatestAt: 1000 + KNOWLEDGE_FRESHNESS_TOLERANCE_MS }) + ).toBe(true) + }) + + it('明显落后时算 stale', () => { + expect( + isKnowledgeFresh({ indexLatestAt: 1000, sourceLatestAt: 1000 + KNOWLEDGE_FRESHNESS_TOLERANCE_MS + 1 }) + ).toBe(false) + }) + + it('任一侧口径缺失时返回 null(无法判定,不冒充 fresh)', () => { + expect(isKnowledgeFresh({ indexLatestAt: null, sourceLatestAt: 1000 })).toBeNull() + expect(isKnowledgeFresh({ indexLatestAt: 1000, sourceLatestAt: null })).toBeNull() + }) +}) + +describe('Knowledge UI 状态文案', () => { + const indexLatestAt = new Date('2026-08-26T11:37:24+08:00').getTime() + const sourceLatestAt = new Date('2026-09-11T11:57:24+08:00').getTime() + + it('索引落后时显示「可用 · 待追新」,不得显示「已同步」', () => { + const label = knowledgeStateLabel(status({ indexLatestAt, sourceLatestAt })) + // READY ≠ FRESH:落后时既要说"还能用",又要如实说"还没追平"。 + expect(label).toBe('可用 · 待追新') + expect(label).not.toBe('已同步') + expect(label).not.toContain('已追至最新') + expect(knowledgeIsStale(status({ indexLatestAt, sourceLatestAt }))).toBe(true) + // 卡片必须能给出真实的覆盖边界。 + expect(formatIndexDate(indexLatestAt)).toBe('8/26') + }) + + it('索引真正追平源数据最新时才显示「可用 · 已追至最新」', () => { + expect(knowledgeStateLabel(status({ indexLatestAt: sourceLatestAt, sourceLatestAt }))).toBe( + '可用 · 已追至最新' + ) + expect(knowledgeIsStale(status({ indexLatestAt: sourceLatestAt, sourceLatestAt }))).toBe(false) + }) + + it('无法判定 freshness 时不能把"不确定"说成已经追平', () => { + // 安全侧:无法确认就按"可能还没追平"表达,绝不说「已追至最新」。 + expect(knowledgeStateLabel(status({ indexLatestAt, sourceLatestAt: null }))).toBe( + '可用 · 待追新' + ) + expect(knowledgeIsStale(status({ indexLatestAt, sourceLatestAt: null }))).toBe(false) + }) + + it('同步中必须区分「正在追新」与「正在补齐历史」', () => { + expect(knowledgeStateLabel(status({ state: 'syncing', indexLatestAt, sourceLatestAt }))).toBe( + '可用 · 正在追新' + ) + // 首轮全量建立历史时不能谎称"只是在追新"。 + expect( + knowledgeStateLabel( + status({ + state: 'syncing', + indexLatestAt, + sourceLatestAt, + pass: makePass('full') + }) + ) + ).toBe('可用 · 正在补齐历史') + }) + + it('取消与失败都不能丢掉「可用」前缀(已建立的分片仍然能查)', () => { + expect(knowledgeStateLabel(status({ state: 'cancelled' }))).toBe('可用 · 同步已取消') + expect(knowledgeStateLabel(status({ state: 'error' }))).toBe('可用 · 更新失败') + }) +}) diff --git a/tests/unit/knowledge-search-service.test.ts b/tests/unit/knowledge-search-service.test.ts index 69f8e59..4955df6 100644 --- a/tests/unit/knowledge-search-service.test.ts +++ b/tests/unit/knowledge-search-service.test.ts @@ -1,18 +1,37 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { chatState, getGroupSnapshotAsync, listContactsAsync, listMessagesAsync, knowledgeService } = +const { chatState, getGroupSnapshotAsync, getGroupMemberNamesAsync, listContactsAsync, listMessagesAsync, getSourceLatestActivityMs, getConversationActivityMs, knowledgeService } = vi.hoisted(() => ({ chatState: { ready: false, accountId: '' }, getGroupSnapshotAsync: vi.fn(), + getGroupMemberNamesAsync: vi.fn(), listContactsAsync: vi.fn(), listMessagesAsync: vi.fn(), + getSourceLatestActivityMs: vi.fn(() => null as number | null), + // 源侧每会话最后活跃时间(epoch ms)。默认空表 = 没有任何会话可被增量跳过, + // 于是每个会话都会被真实处理 —— 这正是大部分用例想要的"全量"前提。 + getConversationActivityMs: vi.fn(() => new Map()), knowledgeService: { dispose: vi.fn().mockResolvedValue(undefined), - index: vi.fn().mockResolvedValue(undefined), + // 与生产契约一致:index() 必须返回 KnowledgeIndexResult(调用方要读 cancelled)。 + index: vi.fn().mockResolvedValue({ + accountId: 'fixture-account', + processedMessages: 0, + indexedChunks: 0, + updatedChunks: 0, + unchangedConversations: 0, + databaseBytes: 0, + walBytes: 0, + elapsedMs: 0, + cancelled: false + }), search: vi.fn(), + // per-conversation checkpoint 读取:默认空表(没有会话已覆盖到任何时间点)。 + highWaterMarks: vi.fn().mockResolvedValue({}), + cancelIndex: vi.fn().mockResolvedValue(false), status: vi.fn().mockResolvedValue({ accountId: 'fixture-account', state: 'ready', @@ -24,7 +43,9 @@ const { chatState, getGroupSnapshotAsync, listContactsAsync, listMessagesAsync, estimatedRemainingMs: null, databaseBytes: 0, walBytes: 0, - shmBytes: 0 + shmBytes: 0, + indexLatestAt: null, + sourceLatestAt: null }) } })) @@ -34,8 +55,11 @@ vi.mock('../../src/main/services/chat-service', () => ({ getSelfAccountInfo: () => (chatState.accountId ? { wxid: chatState.accountId } : null), getCurrentAccountRoot: () => chatState.accountId, getGroupSnapshotAsync, + getGroupMemberNamesAsync, listContactsAsync, - listMessagesAsync + listMessagesAsync, + getSourceLatestActivityMs, + getConversationActivityMs })) vi.mock('../../src/main/knowledge/knowledge-service', () => ({ @@ -43,6 +67,8 @@ vi.mock('../../src/main/knowledge/knowledge-service', () => ({ dispose = knowledgeService.dispose index = knowledgeService.index search = knowledgeService.search + highWaterMarks = knowledgeService.highWaterMarks + cancelIndex = knowledgeService.cancelIndex status = knowledgeService.status } })) @@ -55,6 +81,8 @@ describe('KnowledgeSearchService legacy fallback', () => { chatState.ready = false chatState.accountId = '' getGroupSnapshotAsync.mockReset() + getGroupMemberNamesAsync.mockReset() + getGroupMemberNamesAsync.mockResolvedValue([]) listContactsAsync.mockReset() listMessagesAsync.mockReset() knowledgeService.dispose.mockClear() @@ -346,20 +374,16 @@ describe('KnowledgeSearchService legacy fallback', () => { createTime: 1785895200 } ]) - getGroupSnapshotAsync.mockResolvedValue({ - roomId: 'fixture-group@chatroom', - memberCount: 1, - members: [ - { - wxid: 'wxid_member', - nickname: '微信昵称', - groupNickname: '健身同学', - wechatNickname: '微信昵称', - remark: '', - avatar: '' - } - ] - }) + getGroupMemberNamesAsync.mockResolvedValue([ + { + wxid: 'wxid_member', + nickname: '微信昵称', + groupNickname: '健身同学', + wechatNickname: '微信昵称', + remark: '', + avatar: '' + } + ]) const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js') const result = await service.search({ text: '健身', terms: ['健身'], limit: 10 }) @@ -367,11 +391,13 @@ describe('KnowledgeSearchService legacy fallback', () => { expect(result.evidence).toEqual([ expect.objectContaining({ senderId: 'wxid_member', sender: '健身同学' }) ]) - expect(getGroupSnapshotAsync).toHaveBeenCalledWith('fixture-group') + expect(getGroupMemberNamesAsync).toHaveBeenCalledWith('fixture-group', ['wxid_member']) + // sender enrichment 绝不能走完整群快照 —— 后者会 hydrate 整群成员名称与头像。 + expect(getGroupSnapshotAsync).not.toHaveBeenCalled() await service.dispose() }) - it('reuses contacts and group members only within the same retrieval session', async () => { + it('reuses contacts and group member names only within the same retrieval session', async () => { listContactsAsync.mockResolvedValue([ { md5: 'fixture-group', m_nsNickName: '脱敏群聊', type: 'group' } ]) @@ -387,20 +413,16 @@ describe('KnowledgeSearchService legacy fallback', () => { createTime: 1785895200 } ]) - getGroupSnapshotAsync.mockResolvedValue({ - roomId: 'fixture-group@chatroom', - memberCount: 1, - members: [ - { - wxid: 'wxid_member', - nickname: '微信昵称', - groupNickname: '健身同学', - wechatNickname: '微信昵称', - remark: '', - avatar: '' - } - ] - }) + getGroupMemberNamesAsync.mockResolvedValue([ + { + wxid: 'wxid_member', + nickname: '微信昵称', + groupNickname: '健身同学', + wechatNickname: '微信昵称', + remark: '', + avatar: '' + } + ]) const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js') const request = { @@ -416,10 +438,855 @@ describe('KnowledgeSearchService legacy fallback', () => { expect(listContactsAsync).toHaveBeenCalledTimes(3) // Each fallback search needs contacts for scope selection; enrichment is // the only layer cached, so the second search avoids one extra lookup. - expect(getGroupSnapshotAsync).toHaveBeenCalledTimes(1) + expect(getGroupMemberNamesAsync).toHaveBeenCalledTimes(1) await service.search({ ...request, retrievalSessionId: 'retrieval-b' }) - expect(getGroupSnapshotAsync).toHaveBeenCalledTimes(2) + expect(getGroupMemberNamesAsync).toHaveBeenCalledTimes(2) + await service.dispose() + }) + + it('resolves several senders of one group with a single name lookup (batched, deduped)', async () => { + listContactsAsync.mockResolvedValue([ + { md5: 'fixture-group', m_nsNickName: '脱敏群聊', type: 'group' } + ]) + listMessagesAsync.mockResolvedValue([ + { + id: 'm1', + from: 'wxid_a', + type: '普通文本', + content: '健身打卡 A', + isSender: false, + senderId: 'wxid_a', + name: 'wxid_a', + createTime: 1785895200 + }, + { + id: 'm2', + from: 'wxid_b', + type: '普通文本', + content: '健身打卡 B', + isSender: false, + senderId: 'wxid_b', + name: 'wxid_b', + createTime: 1785895201 + }, + // 同一 sender 重复出现:不能因此多查一次(duplicate sender)。 + { + id: 'm3', + from: 'wxid_a', + type: '普通文本', + content: '健身打卡 A2', + isSender: false, + senderId: 'wxid_a', + name: 'wxid_a', + createTime: 1785895202 + } + ]) + getGroupMemberNamesAsync.mockImplementation(async (_md5: string, wxids: string[]) => + wxids.map((wxid) => ({ + wxid, + nickname: '', + groupNickname: `群昵称-${wxid}`, + wechatNickname: '', + remark: '', + avatar: '' + })) + ) + + const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js') + const result = await service.search({ text: '健身', terms: ['健身'], limit: 10 }) + + expect(getGroupMemberNamesAsync).toHaveBeenCalledTimes(1) + expect(getGroupMemberNamesAsync.mock.calls[0][0]).toBe('fixture-group') + expect(new Set(getGroupMemberNamesAsync.mock.calls[0][1])).toEqual(new Set(['wxid_a', 'wxid_b'])) + const bySender = new Map(result.evidence.map((item) => [item.senderId, item.sender])) + expect(bySender.get('wxid_a')).toBe('群昵称-wxid_a') + expect(bySender.get('wxid_b')).toBe('群昵称-wxid_b') + await service.dispose() + }) + + it('looks names up once per group when evidence spans several groups', async () => { + listContactsAsync.mockResolvedValue([ + { md5: 'group-one', m_nsNickName: '群一', type: 'group' }, + { md5: 'group-two', m_nsNickName: '群二', type: 'group' } + ]) + listMessagesAsync.mockImplementation(async (md5: string) => { + if (md5 === 'group-one') { + return [ + { + id: 'g1-m1', + from: 'wxid_1', + type: '普通文本', + content: '健身 group one', + isSender: false, + senderId: 'wxid_1', + name: 'wxid_1', + createTime: 1785895200 + } + ] + } + return [ + { + id: 'g2-m1', + from: 'wxid_2', + type: '普通文本', + content: '健身 group two', + isSender: false, + senderId: 'wxid_2', + name: 'wxid_2', + createTime: 1785895201 + } + ] + }) + getGroupMemberNamesAsync.mockImplementation(async (md5: string, wxids: string[]) => + wxids.map((wxid) => ({ + wxid, + nickname: '', + groupNickname: `${md5}:${wxid}`, + wechatNickname: '', + remark: '', + avatar: '' + })) + ) + + const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js') + const result = await service.search({ text: '健身', terms: ['健身'], limit: 10 }) + + expect(getGroupMemberNamesAsync).toHaveBeenCalledTimes(2) + expect(getGroupMemberNamesAsync.mock.calls.map((call) => call[0]).sort()).toEqual([ + 'group-one', + 'group-two' + ]) + const bySender = new Map(result.evidence.map((item) => [item.senderId, item.sender])) + expect(bySender.get('wxid_1')).toBe('group-one:wxid_1') + expect(bySender.get('wxid_2')).toBe('group-two:wxid_2') + await service.dispose() + }) + + it('keeps the display-name priority and never invents a name for an unknown wxid', async () => { + listContactsAsync.mockResolvedValue([ + { md5: 'fixture-group', m_nsNickName: '脱敏群聊', type: 'group' } + ]) + listMessagesAsync.mockResolvedValue([ + { + id: 'm1', + from: 'wxid_remark', + type: '普通文本', + content: '健身 1', + isSender: false, + senderId: 'wxid_remark', + name: 'wxid_remark', + createTime: 1785895200 + }, + { + id: 'm2', + from: 'wxid_unknown', + type: '普通文本', + content: '健身 2', + isSender: false, + senderId: 'wxid_unknown', + name: 'wxid_unknown', + createTime: 1785895201 + } + ]) + getGroupMemberNamesAsync.mockResolvedValue([ + // 群昵称缺失 → 必须回退到 contact 微信昵称(不能退化成 wxid)。 + { + wxid: 'wxid_remark', + nickname: '微信昵称A', + groupNickname: '', + wechatNickname: '微信昵称A', + remark: '备注A', + avatar: '' + }, + // 什么名字都没有 → 保持原样,绝不编造。 + { + wxid: 'wxid_unknown', + nickname: '', + groupNickname: '', + wechatNickname: '', + remark: '', + avatar: '' + } + ]) + + const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js') + const result = await service.search({ text: '健身', terms: ['健身'], limit: 10 }) + + const bySender = new Map(result.evidence.map((item) => [item.senderId, item.sender])) + expect(bySender.get('wxid_remark')).toBe('微信昵称A') + expect(bySender.get('wxid_unknown')).toBe('wxid_unknown') + await service.dispose() + }) +}) + +describe('KnowledgeSearchService freshness contract', () => { + beforeEach(() => { + chatState.ready = true + chatState.accountId = 'fixture-account' + getGroupSnapshotAsync.mockReset() + getGroupMemberNamesAsync.mockReset() + getGroupMemberNamesAsync.mockResolvedValue([]) + listContactsAsync.mockReset() + listMessagesAsync.mockReset() + getSourceLatestActivityMs.mockReset() + getSourceLatestActivityMs.mockReturnValue(null) + knowledgeService.index.mockClear() + knowledgeService.search.mockReset() + knowledgeService.status.mockClear() + listContactsAsync.mockResolvedValue([ + { m_nsUsrName: 'fixture-contact', m_nsNickName: '脱敏会话', md5: 'fixture-conversation', type: 'user' } + ]) + listMessagesAsync.mockResolvedValue([]) + }) + + it('把索引覆盖时间与源数据最新时间一起返回(READY 不等于 FRESH)', async () => { + const indexLatestAt = 1787715444000 + const sourceLatestAt = 1789099069000 + getSourceLatestActivityMs.mockReturnValue(sourceLatestAt) + knowledgeService.search.mockResolvedValue({ + state: 'ready', + evidence: [], + indexedMessageCount: 1, + indexedChunkCount: 1, + indexLatestAt, + voiceCoverage: undefined + }) + + const service = new KnowledgeSearchService('/tmp/wxe-freshness', '/missing-worker.js') + const result = await service.search({ text: '健身', terms: ['健身'], limit: 10 }) + + expect(result.source).toBe('knowledge') + expect(result.indexLatestAt).toBe(indexLatestAt) + expect(result.sourceLatestAt).toBe(sourceLatestAt) + await service.dispose() + }) + + it('派生索引不可用时也带上源数据最新时间,便于调用方判断落后程度', async () => { + const sourceLatestAt = 1789099069000 + getSourceLatestActivityMs.mockReturnValue(sourceLatestAt) + knowledgeService.search.mockResolvedValue({ + state: 'unavailable', + evidence: [], + indexedMessageCount: 0, + indexedChunkCount: 0, + indexLatestAt: null, + voiceCoverage: undefined + }) + + const service = new KnowledgeSearchService('/tmp/wxe-freshness-2', '/missing-worker.js') + const result = await service.search({ text: '健身', terms: ['健身'], limit: 10 }) + + expect(result.indexLatestAt).toBeNull() + expect(result.sourceLatestAt).toBe(sourceLatestAt) + await service.dispose() + }) + + it('requestCatchUp 复用正在跑的索引任务,不启动第二个', async () => { + // 让索引 pass 停在读取消息这一步,从而稳定地处于 in-flight 状态。 + listMessagesAsync.mockImplementation(() => new Promise(() => {})) + const service = new KnowledgeSearchService('/tmp/wxe-freshness-3', '/missing-worker.js') + + await expect(service.waitForIndexingComplete(5)).resolves.toBe(true) + + const first = service.requestCatchUp(0) + expect(first).toMatchObject({ triggered: true, inProgress: true }) + const second = service.requestCatchUp(0) + expect(second).toMatchObject({ triggered: false, inProgress: true }) + + // 有界等待:追不上就返回 false,绝不无限阻塞交互查询。 + await expect(service.waitForIndexingComplete(20)).resolves.toBe(false) + await service.dispose() + }) +}) + +describe('KnowledgeSearchService 完整 pass 记录源数据边界', () => { + beforeEach(() => { + chatState.ready = true + chatState.accountId = 'fixture-account' + listContactsAsync.mockReset() + listMessagesAsync.mockReset() + knowledgeService.index.mockClear() + knowledgeService.search.mockReset() + knowledgeService.status.mockClear() + }) + + it('只在最后处理的会话上写入 sourceLatestAt,且取未过滤原始消息的最大 createTime', async () => { + listContactsAsync.mockResolvedValue([ + { m_nsUsrName: 'fixture-a', m_nsNickName: 'A', md5: 'fixture-a', type: 'user' }, + { m_nsUsrName: 'fixture-b', m_nsNickName: 'B', md5: 'fixture-b', type: 'user' } + ]) + listMessagesAsync.mockImplementation(async (md5: string) => + md5 === 'fixture-a' + ? [{ id: 'a1', from: 'x', type: 'text', datetime: '', content: '你好', isSender: false, createTime: 1000 }] + : [ + { id: 'b1', from: 'x', type: 'text', datetime: '', content: 'hi', isSender: false, createTime: 2000 }, + // 无正文/无附件的消息不会被索引建模,但 freshness 口径必须把它算进来。 + { id: 'b2', from: 'x', type: '图片', datetime: '', content: '', isSender: false, createTime: 9000 } + ] + ) + + const service = new KnowledgeSearchService('/tmp/wxe-pass-coverage', '/missing-worker.js') + service.startCurrentAccountIndex() + await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true) + + expect(knowledgeService.index).toHaveBeenCalledTimes(2) + const calls = knowledgeService.index.mock.calls as Array<[Record]> + expect(calls[0][0].sourceLatestAt).toBeUndefined() + expect(calls[0][0].sourceMessageCount).toBeUndefined() + expect(calls[1][0].sourceLatestAt).toBe(9000 * 1000) + expect(calls[1][0].sourceMessageCount).toBe(2) + await service.dispose() + }) +}) + +/** + * 索引同步契约:必须**后台 / 可取消 / 可断点续传 / 不阻塞查询**, + * 而且取消之后不允许留下一个假的"正在同步"。 + */ +describe('KnowledgeSearchService sync contract', () => { + beforeEach(() => { + chatState.ready = true + chatState.accountId = 'fixture-account' + listContactsAsync.mockReset() + listMessagesAsync.mockReset() + getConversationActivityMs.mockReset() + getConversationActivityMs.mockReturnValue(new Map()) + knowledgeService.index.mockReset() + knowledgeService.index.mockResolvedValue({ + accountId: 'fixture-account', + processedMessages: 0, + indexedChunks: 0, + updatedChunks: 0, + unchangedConversations: 0, + databaseBytes: 0, + walBytes: 0, + elapsedMs: 0, + cancelled: false + }) + knowledgeService.highWaterMarks.mockReset() + knowledgeService.highWaterMarks.mockResolvedValue({}) + knowledgeService.cancelIndex.mockReset() + knowledgeService.cancelIndex.mockResolvedValue(false) + knowledgeService.search.mockReset() + knowledgeService.status.mockClear() + listMessagesAsync.mockResolvedValue([]) + }) + + const twoContacts = (): void => { + listContactsAsync.mockResolvedValue([ + { m_nsUsrName: 'fixture-a', m_nsNickName: 'A', md5: 'fixture-a', type: 'user' }, + { m_nsUsrName: 'fixture-b', m_nsNickName: 'B', md5: 'fixture-b', type: 'user' } + ]) + } + + const captureStatuses = ( + service: KnowledgeSearchService + ): { all: Array>; last: () => Record } => { + const all: Array> = [] + service.onStatusChange((status) => all.push(status as unknown as Record)) + return { all, last: () => all[all.length - 1] } + } + + it('skips conversations whose source activity is already covered by the checkpoint', async () => { + twoContacts() + // a 的源侧最后活跃时间早于它已经索引到的位置 → 没有任何新消息,必须整段跳过。 + getConversationActivityMs.mockReturnValue( + new Map([ + ['fixture-a', 4_000_000], + ['fixture-b', 8_000_000] + ]) + ) + knowledgeService.highWaterMarks.mockResolvedValue({ 'fixture-a': 5_000_000 }) + + const service = new KnowledgeSearchService('/tmp/wxe-sync-skip', '/missing-worker.js') + const statuses = captureStatuses(service) + service.startCurrentAccountIndex() + await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true) + + // 真正 delta 的核心:被跳过的会话**不读 WCDB**(这是把"每次全量重扫"变成分钟级以内的原因)。 + expect(listMessagesAsync).toHaveBeenCalledTimes(1) + expect(listMessagesAsync.mock.calls[0][0]).toBe('fixture-b') + expect(knowledgeService.index).toHaveBeenCalledTimes(1) + + const finished = statuses.all.find( + (status) => (status.pass as { phase?: string } | undefined)?.phase === 'idle' + ) + const pass = finished?.pass as { + skippedConversations: number + processedConversations: number + totalConversations: number + cancellable: boolean + } + expect(pass.totalConversations).toBe(2) + expect(pass.skippedConversations).toBe(1) + expect(pass.processedConversations).toBe(2) + // 一遍跑完必须清掉"可取消",否则 UI 会在结束后继续显示取消按钮。 + expect(pass.cancellable).toBe(false) + + // 有跳过时不能把这一遍的部分计数冒充成"全量总量"。 + const indexArgs = knowledgeService.index.mock.calls[0][0] as Record + expect(indexArgs.sourceMessageCount).toBeUndefined() + expect(indexArgs.sourceLatestAt).toBeUndefined() + await service.dispose() + }) + + it('never skips a conversation when the checkpoint has no entry for it', async () => { + twoContacts() + // 源侧有活跃时间,但派生库没有对应的 checkpoint(例如换库/清库之后)。 + // 这时候必须真读真同步,否则会静默丢消息。 + getConversationActivityMs.mockReturnValue( + new Map([ + ['fixture-a', 4_000_000], + ['fixture-b', 8_000_000] + ]) + ) + knowledgeService.highWaterMarks.mockResolvedValue({}) + + const service = new KnowledgeSearchService('/tmp/wxe-sync-noskip', '/missing-worker.js') + service.startCurrentAccountIndex() + await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true) + + expect(listMessagesAsync).toHaveBeenCalledTimes(2) + expect(knowledgeService.index).toHaveBeenCalledTimes(2) + await service.dispose() + }) + + it('reports a cancelled pass as cancelled and never leaves a fake indexing state', async () => { + twoContacts() + let releaseSecond: (() => void) | undefined + knowledgeService.index + .mockResolvedValueOnce({ + accountId: 'fixture-account', + processedMessages: 1, + indexedChunks: 1, + updatedChunks: 0, + unchangedConversations: 0, + databaseBytes: 0, + walBytes: 0, + elapsedMs: 0, + cancelled: false + }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseSecond = () => + resolve({ + accountId: 'fixture-account', + processedMessages: 0, + indexedChunks: 0, + updatedChunks: 0, + unchangedConversations: 0, + databaseBytes: 0, + walBytes: 0, + elapsedMs: 0, + cancelled: true + }) + }) + ) + knowledgeService.cancelIndex.mockResolvedValue(true) + + const service = new KnowledgeSearchService('/tmp/wxe-sync-cancel', '/missing-worker.js') + const statuses = captureStatuses(service) + service.startCurrentAccountIndex() + await vi.waitFor(() => expect(knowledgeService.index).toHaveBeenCalledTimes(2)) + + const ack = await service.cancelCurrentAccountIndex() + expect(ack).toEqual({ cancellable: true, cancelled: true }) + // 取消请求一发出,UI 立刻就该看到"不可再取消"(按钮变「正在取消…」)。 + const cancelling = statuses.all.find( + (status) => (status.pass as { cancellable?: boolean } | undefined)?.cancellable === false + ) + expect(cancelling).toBeDefined() + + releaseSecond?.() + await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true) + + const finalStatus = await service.getStatus() + expect(finalStatus.state).toBe('cancelled') + expect(finalStatus.pass?.phase).toBe('cancelled') + expect(finalStatus.pass?.cancellable).toBe(false) + // 全程不允许出现"同步中"残留(run_state 不得留下假的 indexing)。 + expect(statuses.all.some((status) => status.state === 'cancelled')).toBe(true) + await service.dispose() + }) + + it('keeps committed conversations when a pass is cancelled and resumes from the checkpoint', async () => { + listContactsAsync.mockResolvedValue([ + { m_nsUsrName: 'fixture-a', m_nsNickName: 'A', md5: 'fixture-a', type: 'user' }, + { m_nsUsrName: 'fixture-b', m_nsNickName: 'B', md5: 'fixture-b', type: 'user' }, + { m_nsUsrName: 'fixture-c', m_nsNickName: 'C', md5: 'fixture-c', type: 'user' } + ]) + listMessagesAsync.mockResolvedValue([ + { + id: 'fixture-message', + localId: 42, + from: 'user', + type: '普通文本', + datetime: '2026/8/5 10:00:00', + content: '断点续传', + isSender: false, + senderId: 'fixture-sender', + name: '脱敏成员', + createTime: 1785895200 + } + ]) + let releaseThird: (() => void) | undefined + knowledgeService.index + .mockResolvedValueOnce({ + accountId: 'fixture-account', + processedMessages: 1, + indexedChunks: 1, + updatedChunks: 0, + unchangedConversations: 0, + databaseBytes: 0, + walBytes: 0, + elapsedMs: 0, + cancelled: false + }) + .mockResolvedValueOnce({ + accountId: 'fixture-account', + processedMessages: 1, + indexedChunks: 1, + updatedChunks: 0, + unchangedConversations: 0, + databaseBytes: 0, + walBytes: 0, + elapsedMs: 0, + cancelled: false + }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseThird = () => + resolve({ + accountId: 'fixture-account', + processedMessages: 0, + indexedChunks: 0, + updatedChunks: 0, + unchangedConversations: 0, + databaseBytes: 0, + walBytes: 0, + elapsedMs: 0, + cancelled: true + }) + }) + ) + + const service = new KnowledgeSearchService('/tmp/wxe-sync-resume', '/missing-worker.js') + const statuses = captureStatuses(service) + service.startCurrentAccountIndex() + await vi.waitFor(() => expect(knowledgeService.index).toHaveBeenCalledTimes(3)) + await service.cancelCurrentAccountIndex() + releaseThird?.() + await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true) + + // 取消 ≠ 回滚:前两个会话已经提交的索引必须留下来。 + const cancelled = statuses.all[statuses.all.length - 1] + const pass = cancelled.pass as { indexedMessages: number; phase: string } + expect(pass.phase).toBe('cancelled') + expect(pass.indexedMessages).toBeGreaterThan(0) + + // 下一遍:两个已覆盖的会话必须被 checkpoint 跳过,只补没跑完的那个。 + knowledgeService.index.mockClear() + listMessagesAsync.mockClear() + getConversationActivityMs.mockReturnValue( + new Map([ + ['fixture-a', 1_785_895_200_000], + ['fixture-b', 1_785_895_200_000] + ]) + ) + // a 与 b 的 checkpoint 已追平它们的源侧活跃时间;c 仍然没有覆盖。 + knowledgeService.highWaterMarks.mockResolvedValue({ + 'fixture-a': 1_785_895_200_000, + 'fixture-b': 1_785_895_200_000 + }) + knowledgeService.index.mockResolvedValue({ + accountId: 'fixture-account', + processedMessages: 1, + indexedChunks: 1, + updatedChunks: 0, + unchangedConversations: 0, + databaseBytes: 0, + walBytes: 0, + elapsedMs: 0, + cancelled: false + }) + service.startCurrentAccountIndex() + await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true) + + expect(listMessagesAsync).toHaveBeenCalledTimes(1) + expect(listMessagesAsync.mock.calls[0][0]).toBe('fixture-c') + await service.dispose() + }) + + it('is honest when there is nothing to cancel', async () => { + twoContacts() + const service = new KnowledgeSearchService('/tmp/wxe-sync-nocancel', '/missing-worker.js') + // 没有在跑的 pass:不能谎报"已取消"。 + await expect(service.cancelCurrentAccountIndex()).resolves.toEqual({ + cancellable: false, + cancelled: false + }) + expect(knowledgeService.cancelIndex).not.toHaveBeenCalled() + await service.dispose() + }) + + it('does not let a cancelled pass shorten the catch-up threshold of the next pass', async () => { + twoContacts() + let releaseFirst: (() => void) | undefined + knowledgeService.index.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = () => + resolve({ + accountId: 'fixture-account', + processedMessages: 0, + indexedChunks: 0, + updatedChunks: 0, + unchangedConversations: 0, + databaseBytes: 0, + walBytes: 0, + elapsedMs: 0, + cancelled: true + }) + }) + ) + knowledgeService.cancelIndex.mockResolvedValue(true) + + const service = new KnowledgeSearchService('/tmp/wxe-sync-threshold', '/missing-worker.js') + const statuses = captureStatuses(service) + service.startCurrentAccountIndex() + await vi.waitFor(() => expect(knowledgeService.index).toHaveBeenCalledTimes(1)) + await service.cancelCurrentAccountIndex() + releaseFirst?.() + await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true) + + // 取消的一遍不计入 lastIndexPassMs;否则"是否值得再追一遍"的门槛会被一次提前结束的 pass 带偏 + // (表现为:明明落后很多却因为"上一遍很快"而拒绝追赶)。 + const last = statuses.all[statuses.all.length - 1] + expect(last.state).toBe('cancelled') + expect(await service.getStatus()).toMatchObject({ state: 'cancelled' }) + await service.dispose() + }) + + it('keeps search available while a pass is running (indexing must not block queries)', async () => { + twoContacts() + // 搜索走派生库,索引在跑 → 必须复用**已有**证据,而不是把用户挡回去等同步。 + knowledgeService.search.mockResolvedValue({ + state: 'ready', + evidence: [ + { + messageId: 'local:42', + conversationId: 'fixture-a', + sender: '脱敏成员', + senderId: 'fixture-sender', + timestamp: 1785895200000, + text: '命中', + kind: 'text', + score: 1 + } + ], + indexedMessageCount: 2, + indexedChunkCount: 1, + indexLatestAt: 1785895200000, + timings: {} + }) + let releaseIndex: (() => void) | undefined + knowledgeService.index.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseIndex = () => + resolve({ + accountId: 'fixture-account', + processedMessages: 1, + indexedChunks: 1, + updatedChunks: 0, + unchangedConversations: 0, + databaseBytes: 0, + walBytes: 0, + elapsedMs: 0, + cancelled: false + }) + }) + ) + + const service = new KnowledgeSearchService('/tmp/wxe-sync-query', '/missing-worker.js') + service.startCurrentAccountIndex() + await vi.waitFor(() => expect(knowledgeService.index).toHaveBeenCalledTimes(1)) + + const result = await service.search({ + text: '命中', + terms: ['命中'], + limit: 5 + }) + // 索引在跑也必须能查到(走派生库的既有数据),且不降级成一次 WCDB 全扫。 + expect(result.source).toBe('knowledge') + expect(result.evidence.length).toBeGreaterThan(0) + + releaseIndex?.() + await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true) + await service.dispose() + }) + + it('reads nothing at all when every checkpoint already covers the source (empty incremental pass)', async () => { + twoContacts() + getConversationActivityMs.mockReturnValue( + new Map([ + ['fixture-a', 4_000_000], + ['fixture-b', 5_000_000] + ]) + ) + knowledgeService.highWaterMarks.mockResolvedValue({ + 'fixture-a': 4_000_000, + 'fixture-b': 5_000_000 + }) + + const service = new KnowledgeSearchService('/tmp/wxe-sync-empty', '/missing-worker.js') + const statuses = captureStatuses(service) + service.startCurrentAccountIndex() + await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true) + + // 空增量 pass 的核心断言:一条源消息都不读、一个会话都不写。 + // 这必须接近 O(会话数),而不是 O(全部历史消息)。 + expect(listMessagesAsync).not.toHaveBeenCalled() + expect(knowledgeService.index).not.toHaveBeenCalled() + + const finished = statuses.all.find( + (status) => (status.pass as { phase?: string } | undefined)?.phase === 'idle' + ) + const pass = finished?.pass as { + skippedConversations: number + scannedMessages: number + indexedMessages: number + catchupConversations: number + backfillConversations: number + } + expect(pass.skippedConversations).toBe(2) + expect(pass.scannedMessages).toBe(0) + expect(pass.indexedMessages).toBe(0) + expect(pass.catchupConversations).toBe(0) + expect(pass.backfillConversations).toBe(0) + await service.dispose() + }) + + it('reads only the bounded delta window and never claims a complete snapshot', async () => { + twoContacts() + const mark = 1_700_000_000_000 + getConversationActivityMs.mockReturnValue( + new Map([['fixture-a', mark + 60_000]]) + ) + knowledgeService.highWaterMarks.mockResolvedValue({ + 'fixture-a': mark, + 'fixture-b': 1_000 + }) + // b 的 checkpoint 已经覆盖它的源侧活动 → 整段跳过,让断言只看到 a 的一次 delta 读。 + getConversationActivityMs.mockReturnValue( + new Map([ + ['fixture-a', mark + 60_000], + ['fixture-b', 1_000] + ]) + ) + listMessagesAsync.mockResolvedValue([ + { + id: 'delta-message', + localId: 7, + from: 'fixture-sender', + type: '普通文本', + content: '今天继续健身。', + isSender: false, + senderId: 'fixture-sender', + name: '脱敏成员', + createTime: 1_700_000_030 + } + ]) + + const service = new KnowledgeSearchService('/tmp/wxe-sync-delta', '/missing-worker.js') + service.startCurrentAccountIndex() + await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true) + + // 1) 只读 delta 窗口:下界 = checkpoint - 24h overlap,而不是历史开头。 + // ⚠️ 单位契约:checkpoint 是 epoch ms,但传给 WCDB 读取层的 startTime 必须是 + // epoch **秒** —— `finalizeMessages` 拿它和秒级 `create_time` 直接比较。 + // 传 ms 会让下界恒大于任何真实消息时间戳,delta 读被静默过滤成空。 + expect(listMessagesAsync).toHaveBeenCalledTimes(1) + expect(listMessagesAsync.mock.calls[0][0]).toBe('fixture-a') + const deltaLowerBound = listMessagesAsync.mock.calls[0][1] as number + expect(deltaLowerBound).toBe(Math.floor((mark - 24 * 60 * 60 * 1000) / 1000)) + // 秒级下界(< 1e11),且真的落在真实消息时间戳之前;否则整段 delta 会被过滤为空。 + expect(deltaLowerBound).toBeLessThan(1e11) + expect(deltaLowerBound).toBeLessThan(1_700_000_030) + // 2) 增量绝不能声明"完整快照",否则 store 会把没在 delta 里的历史消息 + // 误判成已删除,从而整段重建这个会话,增量就白做了。 + const conversation = ( + knowledgeService.index.mock.calls[0][0] as { + conversations: Array> + } + ).conversations[0] + expect(conversation.completeSnapshot).toBe(false) + expect(conversation.sourceHighWaterTime).toBe(mark + 60_000) + await service.dispose() + }) + + it('processes catch-up conversations before historical backfill', async () => { + twoContacts() + const mark = 1_700_000_000_000 + // a 从来没有 checkpoint(历史缺口,2020 年的时间戳);b 已有 checkpoint 且出现新消息。 + getConversationActivityMs.mockReturnValue( + new Map([ + ['fixture-a', 1_600_000_000_000], + ['fixture-b', mark + 60_000] + ]) + ) + knowledgeService.highWaterMarks.mockResolvedValue({ 'fixture-b': mark }) + + const service = new KnowledgeSearchService('/tmp/wxe-sync-order', '/missing-worker.js') + service.startCurrentAccountIndex() + await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true) + + // 「今天的新消息」必须排在 2020 年的历史缺口前面,否则用户永远等不到新数据。 + expect(listMessagesAsync.mock.calls.map((call) => call[0])).toEqual(['fixture-b', 'fixture-a']) + await service.dispose() + }) + + it('does not advance a checkpoint on a suspicious empty delta read', async () => { + twoContacts() + const mark = 1_700_000_000_000 + getConversationActivityMs.mockReturnValue( + new Map([['fixture-a', mark + 60_000]]) + ) + knowledgeService.highWaterMarks.mockResolvedValue({ 'fixture-a': mark }) + // 可疑:源侧明确说有新消息,但 delta 范围读返回了空。 + listMessagesAsync.mockResolvedValue([]) + + const service = new KnowledgeSearchService('/tmp/wxe-sync-suspicious', '/missing-worker.js') + service.startCurrentAccountIndex() + await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true) + + const conversation = ( + knowledgeService.index.mock.calls[0][0] as { + conversations: Array> + } + ).conversations[0] + // 宁可下一遍重试,也绝不能把"一次可疑的空读"升级成"谎报已覆盖"(那会静默丢消息)。 + expect(conversation.sourceHighWaterTime).toBeUndefined() + await service.dispose() + }) + + it('still reads every conversation when the source activity map is empty', async () => { + twoContacts() + // 锁死一条安全性质:**「源侧活动里没有它」不是「它没有消息」的证据**。 + // 用 activity 缺失来跳过没有 Session 行的联系人看起来很有吸引力, + // 但只要 Session 表读取不完整,那个优化就会把真有消息的会话变成永久静默不索引。 + // 因此:没有 checkpoint 就必须老实读一遍。谁加了 activity-based 跳过,这里必须红。 + getConversationActivityMs.mockReturnValue(new Map()) + knowledgeService.highWaterMarks.mockResolvedValue({}) + + const service = new KnowledgeSearchService('/tmp/wxe-sync-empty-activity', '/missing-worker.js') + service.startCurrentAccountIndex() + await expect(service.waitForIndexingComplete(2000)).resolves.toBe(true) + + expect(listMessagesAsync).toHaveBeenCalledTimes(2) await service.dispose() }) }) diff --git a/tests/unit/knowledge-store.test.ts b/tests/unit/knowledge-store.test.ts index 99beef3..5724f94 100644 --- a/tests/unit/knowledge-store.test.ts +++ b/tests/unit/knowledge-store.test.ts @@ -136,6 +136,82 @@ describe('knowledge sqlite', () => { expect(existsSync(databasePath)).toBe(false) }) + /** + * 取消之后 `run_state` 不允许留下一个假的 `indexing`,而且已提交的分片必须保留 + * (取消 ≠ 回滚),下一次索引从断点继续。 + */ + it('records a cancelled pass as cancelled and keeps the committed chunks queryable', async () => { + const root = makeRoot() + // 两个会话:取消发生在第二个会话内部,于是第一个会话必须已经提交。 + // (生产里索引是 per-conversation 事务,取消 ≠ 回滚。) + const committed = createSyntheticConversation( + FIXTURE_ACCOUNT_A, + 'conversation-a', + 0, + 300, + 'mixed' + ) + const interrupted = createSyntheticConversation( + FIXTURE_ACCOUNT_A, + 'conversation-b', + 10_000, + 2_000, + 'mixed' + ) + const controller = new AbortController() + const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts) + const cancelled = await store.index( + { conversations: [committed, interrupted], chunker: DEFAULT_KNOWLEDGE_CHUNKER }, + controller.signal, + (progress) => { + if (progress.conversationId === 'conversation-b' && progress.processedMessages >= 900) { + controller.abort() + } + } + ) + expect(cancelled.cancelled).toBe(true) + + // 取消是真实的终态,不能被当成"还在跑"。 + const status = store.getRuntimeStatus() + expect(status.state).toBe('cancelled') + expect(status.state).not.toBe('indexing') + // 已经提交的会话仍然可查 —— 取消不等于回滚。 + const evidence = store.search({ accountId: FIXTURE_ACCOUNT_A, text: '本地知识库', limit: 5 }) + expect(evidence.length).toBeGreaterThan(0) + expect(new Set(evidence.map((item) => item.conversationId))).toEqual( + new Set(['conversation-a']) + ) + store.close() + }) + + it('does not report an interrupted pass as an unusable index', async () => { + const root = makeRoot() + const source = createSyntheticConversation( + FIXTURE_ACCOUNT_A, + 'conversation-a', + 0, + 200, + 'mixed' + ) + const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts) + await store.index({ conversations: [source], chunker: DEFAULT_KNOWLEDGE_CHUNKER }) + expect(store.getRuntimeStatus().state).toBe('ready') + store.close() + + // 模拟进程被杀:派生库里分片齐全,但 run_state 残留 'indexing'。 + const databasePath = getKnowledgeDatabasePath(root, FIXTURE_ACCOUNT_A) + const raw = new DatabaseSync(databasePath) + raw + .prepare(`UPDATE knowledge_meta SET value = 'indexing' WHERE key = 'run_state'`) + .run() + raw.close() + + const reopened = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts) + // 「可查询」不能因为一次中断残留就变成「不可用」。 + expect(reopened.getRuntimeStatus().state).toBe('ready') + reopened.close() + }) + it('uses a bounded exact fallback for two-character Chinese queries with the trigram profile', async () => { const root = makeRoot() const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts) @@ -352,6 +428,49 @@ describe('knowledge sqlite', () => { store.close() }) + it('把 per-conversation 的源侧覆盖边界当作索引覆盖口径(meta / high_water_time 只作回退)', async () => { + const root = makeRoot() + const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts) + const conversation = createSyntheticConversation(FIXTURE_ACCOUNT_A, 'coverage', 0, 4, 'mixed') + const lastCreateTime = Math.max(...conversation.messages.map((message) => message.createTime)) + + await store.index({ conversations: [conversation], chunker: DEFAULT_KNOWLEDGE_CHUNKER }) + // 既没有 per-conversation 源侧边界、也没有完整 pass 口径时,退化为 + // "最新被索引的消息时间"(只会偏旧、不会冒充更新)。 + expect(store.getRuntimeStatus().indexLatestAt).toBe(lastCreateTime) + + await store.index({ + conversations: [conversation], + chunker: DEFAULT_KNOWLEDGE_CHUNKER, + sourceMessageCount: conversation.messages.length, + // 源数据边界可以比"被索引建模的最新消息"更新(例如最新一条是不可建模的图片)。 + sourceLatestAt: lastCreateTime + 60_000 + }) + const status = store.getRuntimeStatus() + expect(status.indexLatestAt).toBe(lastCreateTime + 60_000) + // 派生库自己看不到源数据,sourceLatestAt 由 KnowledgeSearchService 填。 + expect(status.sourceLatestAt).toBeNull() + + // per-conversation 的源侧边界一旦存在就是**权威口径**:它是每个成功处理的会话 + // 立刻持久化的聚合值,增量 pass 也能推进它。而 `source_latest_at` meta 只在 + // 「整遍零跳过」时才写 —— 增量世界里这让它永久冻结(冻结值落后真实 checkpoint, + // 导致 isKnowledgeFresh() 恒为 false)。 + await store.index({ + conversations: [{ ...conversation, sourceHighWaterTime: lastCreateTime + 180_000 }], + chunker: DEFAULT_KNOWLEDGE_CHUNKER + }) + expect(store.getRuntimeStatus().indexLatestAt).toBe(lastCreateTime + 180_000) + + // checkpoint 单调:一个"看起来更旧"的源侧边界不得让覆盖率回退, + // 否则已经追到最新的会话会被重新打回"有新消息",每遍都白读。 + await store.index({ + conversations: [{ ...conversation, sourceHighWaterTime: lastCreateTime + 1_000 }], + chunker: DEFAULT_KNOWLEDGE_CHUNKER + }) + expect(store.getRuntimeStatus().indexLatestAt).toBe(lastCreateTime + 180_000) + store.close() + }) + it('keeps conversation, sender and time filters when a participant question has no topic terms', async () => { const root = makeRoot() const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts) diff --git a/tests/unit/local-query-api-service.test.ts b/tests/unit/local-query-api-service.test.ts index d544c37..937c6eb 100644 --- a/tests/unit/local-query-api-service.test.ts +++ b/tests/unit/local-query-api-service.test.ts @@ -19,9 +19,37 @@ vi.mock('../../src/main/services/chat-service', () => ({ import { LocalQueryApiService } from '../../src/main/services/local-query-api-service' describe('LocalQueryApiService', () => { - const knowledge = { search: vi.fn(async () => ({ state: 'ready', evidence: [{ conversationId: 'md5-bobo', messageId: 'm1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }], conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true }, voiceCoverage: undefined })) } as any + const knowledge = { + search: vi.fn(async () => ({ state: 'ready', evidence: [{ conversationId: 'md5-bobo', messageId: 'm1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }], conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true }, voiceCoverage: undefined })), + requestCatchUp: vi.fn(() => ({ triggered: false, inProgress: false })), + waitForIndexingComplete: vi.fn(async () => false), + lastPassDurationMs: vi.fn(() => 0), + beginInteractiveQuery: vi.fn(), + endInteractiveQuery: vi.fn() + } as any + /** 固定时钟:requestedEnd 由它决定。 */ + const NOW = new Date('2026-09-09T12:00:00+08:00').getTime() + const DAY = 24 * 60 * 60 * 1000 let service: LocalQueryApiService - beforeEach(() => { vi.clearAllMocks(); if (!fixture.contacts.some((contact) => contact.md5 === 'md5-bobo-2')) fixture.contacts.push({ m_nsUsrName: 'wxid-bobo-2', m_nsNickName: 'BOBO', md5: 'md5-bobo-2', type: 'user' }); service = new LocalQueryApiService(knowledge, () => new Date('2026-09-09T12:00:00+08:00')) }) + beforeEach(() => { + vi.clearAllMocks() + // mockReset 会清掉 once 队列与实现:避免上一个用例残留的队列被下一个用例消费。 + knowledge.search.mockReset() + knowledge.search.mockImplementation(async () => ({ + state: 'ready', + evidence: [{ conversationId: 'md5-bobo', messageId: 'm1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }], + conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true }, + voiceCoverage: undefined + })) + knowledge.requestCatchUp.mockReset() + knowledge.requestCatchUp.mockReturnValue({ triggered: false, inProgress: false }) + knowledge.waitForIndexingComplete.mockReset() + knowledge.waitForIndexingComplete.mockResolvedValue(false) + knowledge.lastPassDurationMs.mockReset() + knowledge.lastPassDurationMs.mockReturnValue(0) + if (!fixture.contacts.some((contact) => contact.md5 === 'md5-bobo-2')) fixture.contacts.push({ m_nsUsrName: 'wxid-bobo-2', m_nsNickName: 'BOBO', md5: 'md5-bobo-2', type: 'user' }) + service = new LocalQueryApiService(knowledge, () => new Date('2026-09-09T12:00:00+08:00')) + }) it('publishes capability contract and resolves previous month', async () => { expect(service.capabilities().tools.query_messages.messageTypes).toContain('file') @@ -63,22 +91,333 @@ describe('LocalQueryApiService', () => { expect(searchedRef).toBe(queriedRef) await expect(service.context({ messageRef: searchedRef!, before: 0, after: 0 })).resolves.toMatchObject({ status: 'completed' }) - knowledge.search.mockResolvedValueOnce({ state: 'ready', evidence: [{ conversationId: 'md5-bobo', messageId: 'local:m1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }], conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true }, voiceCoverage: undefined }) + // 概览直读 WCDB,不再消费派生索引 → 这里不再排队 knowledge mock(残留队列会污染后续用例)。 const overview = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'all' } }) const overviewRef = overview.evidence?.[0]?.messageRef expect(overviewRef).toBe(queriedRef) await expect(service.context({ messageRef: overviewRef!, before: 0, after: 0 })).resolves.toMatchObject({ status: 'completed' }) }) - it('sorts overview evidence while preserving the selected set and exposes sampling', async () => { + it('会话概览以 WCDB 为事实来源,并在派生索引为空时不返回「0 + complete」(P0 回归)', async () => { fixture.contacts.splice(1) - knowledge.search.mockResolvedValueOnce({ state: 'ready', evidence: [ - { conversationId: 'md5-bobo', messageId: 'late', timestamp: 300, sender: 'BOBO', sourceKind: 'text', text: 'late' }, - { conversationId: 'md5-bobo', messageId: 'early', timestamp: 100, sender: 'BOBO', sourceKind: 'text', text: 'early' } - ], conversationRetrieval: { totalMessages: 82, chunkCount: 4, complete: true }, voiceCoverage: undefined }) + // 陈旧索引的真实形态:索引对这一会话在范围内 0 行,且自称 complete。 + knowledge.search.mockResolvedValue({ + state: 'ready', + evidence: [], + conversationRetrieval: { totalMessages: 0, chunkCount: 0, complete: true }, + voiceCoverage: undefined + }) const result = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' } }) - expect(result.sourceCoverage).toEqual({ state: 'complete', sourceMessageCount: 82 }) - expect(result.selection).toEqual({ mode: 'temporal_coverage', selectedEvidenceCount: 2, sampled: true }) - expect(result.evidence?.map((item) => item.timestamp)).toEqual([100, 300]) + expect(result).toMatchObject({ status: 'completed', origin: 'wcdb', sourceMessageCount: 2, evidenceCount: 2 }) + expect(result.coverage).toEqual({ state: 'complete' }) + expect(result.evidence?.map((item) => item.messageRef)).toHaveLength(2) + // 概览不再依赖派生索引(索引可能滞后于 WCDB)。 + expect(knowledge.search).not.toHaveBeenCalled() + }) + + it('会话概览保留时间片代表证据,并如实标注覆盖状态', async () => { + fixture.contacts.splice(1) + const result = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' } }) + expect(result.sourceCoverage).toEqual({ state: 'complete', sourceMessageCount: 2 }) + expect(result.evidence?.map((item) => item.timestamp)).toEqual( + [...fixture.messages].map((message) => message.createTime * 1000) + ) + expect(result.selection?.mode).toBe('temporal_coverage') + }) + + it('scope=groups 只检索群会话,并把群名与成员写进证据', async () => { + fixture.contacts.splice(1) + fixture.contacts.push({ m_nsUsrName: 'wxid-group@chatroom', m_nsNickName: 'TraceMemo 交流群', md5: 'md5-group', type: 'group' } as never) + knowledge.search.mockResolvedValue({ + state: 'ready', + evidence: [{ conversationId: 'md5-group', messageId: 'g1', timestamp: 5, sender: '张三', sourceKind: 'text', text: '最近开始健身了' }], + voiceCoverage: undefined + }) + const result = await service.search({ timeRange: { kind: 'all' }, query: '健身', scope: { kind: 'groups' } }) + expect(knowledge.search).toHaveBeenCalledWith(expect.objectContaining({ conversationIds: ['md5-group'] })) + expect(result.scope).toEqual({ kind: 'groups', conversationCount: 1 }) + expect(result.evidence?.[0]).toMatchObject({ + conversationName: 'TraceMemo 交流群', + conversationType: 'group', + sender: '张三' + }) + }) + + it('scope=all 省略 target 时按全局检索(不传 conversationIds)', async () => { + fixture.contacts.splice(1) + knowledge.search.mockResolvedValue({ state: 'ready', evidence: [], voiceCoverage: undefined }) + const result = await service.search({ timeRange: { kind: 'all' }, query: '健身', scope: { kind: 'all' } }) + expect(knowledge.search).toHaveBeenCalledWith(expect.objectContaining({ conversationIds: undefined })) + // 跨会话检索不宣称"完整":派生索引不是完整性权威。 + expect(result.coverage).toEqual({ state: 'partial' }) + }) + + it('scope 内的 target 越界会被结构化拒绝(而不是悄悄扩大范围)', async () => { + fixture.contacts.splice(1) + const result = await service.search({ + target: { query: 'BOBO' }, + timeRange: { kind: 'all' }, + query: '健身', + scope: { kind: 'groups' } + }) + expect(result).toMatchObject({ + status: 'invalid_tool_arguments', + field: 'target', + constraint: 'target_outside_scope', + actual: 'BOBO' + }) + expect(knowledge.search).not.toHaveBeenCalled() + }) + + it('scope=contact 只允许该单聊会话,未知会话会被拒绝', async () => { + fixture.contacts.splice(1) + const allowed = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, scope: { kind: 'contact', conversationId: 'md5-bobo' } }) + expect(allowed.status).toBe('completed') + + const rejected = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, scope: { kind: 'contact', conversationId: 'md5-unknown' } }) + expect(rejected.status).toBe('scope_conversation_not_found') + }) + + it('会话概览在范围内有多个会话且未指定 target 时要求指定 target', async () => { + fixture.contacts.splice(1) + const result = await service.overview({ timeRange: { kind: 'all' }, scope: { kind: 'groups' } }) + expect(result).toMatchObject({ status: 'invalid_tool_arguments', constraint: 'target_required_for_scope' }) + }) + + describe('freshness contract(索引落后时不得出现 stale complete negative)', () => { + it('交互检索全程标记为前台:后台索引必须让路(否则查询会被 pass 拖慢)', async () => { + fixture.contacts.splice(1) + const order: string[] = [] + knowledge.beginInteractiveQuery.mockImplementation(() => order.push('begin')) + knowledge.endInteractiveQuery.mockImplementation(() => order.push('end')) + knowledge.search.mockImplementation(async () => { + order.push('search') + return { state: 'ready', evidence: [], indexLatestAt: NOW - 30 * 1000, sourceLatestAt: NOW - 1000, voiceCoverage: undefined } + }) + + await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' }) + + expect(order).toEqual(['begin', 'search', 'end']) + expect(knowledge.beginInteractiveQuery).toHaveBeenCalledTimes(1) + expect(knowledge.endInteractiveQuery).toHaveBeenCalledTimes(1) + }) + + it('同一次查询的多个 probe 共用 retrieval session(避免重复读群成员快照)', async () => { + fixture.contacts.splice(1) + knowledge.search.mockResolvedValue({ state: 'ready', indexLatestAt: NOW - 30 * 1000, sourceLatestAt: NOW - 1000, evidence: [], voiceCoverage: undefined }) + + await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身', variants: ['锻炼', '跑步'] }) + + expect(knowledge.search).toHaveBeenCalledTimes(3) + const sessions = new Set(knowledge.search.mock.calls.map((call: unknown[]) => (call[0] as { retrievalSessionId?: string }).retrievalSessionId)) + expect(sessions.size).toBe(1) + expect([...sessions][0]).toEqual(expect.any(String)) + }) + const staleIndex = (): Record => ({ + state: 'ready', + evidence: [], + indexLatestAt: NOW - 10 * DAY, + voiceCoverage: undefined + }) + const freshIndex = (evidence: unknown[] = []): Record => ({ + state: 'ready', + evidence, + indexLatestAt: NOW - 30 * 1000, + voiceCoverage: undefined + }) + + it('索引明显落后 → coverage=partial、请求追赶,且 0 结果不被当成“没有”', async () => { + fixture.contacts.splice(1) + knowledge.search.mockResolvedValue({ ...staleIndex(), sourceLatestAt: NOW - 1000 }) + knowledge.requestCatchUp.mockReturnValue({ triggered: true, inProgress: true }) + knowledge.waitForIndexingComplete.mockResolvedValue(false) + + const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' }) + + expect(result.coverage).toEqual({ state: 'partial' }) + expect(result.evidenceCount).toBe(0) + expect(result.indexLatestAt).toBe(NOW - 10 * DAY) + expect(result.sourceLatestAt).toBe(NOW - 1000) + expect(result.freshness).toEqual({ catchUp: 'pending' }) + expect(knowledge.requestCatchUp).toHaveBeenCalledTimes(1) + expect(knowledge.waitForIndexingComplete).toHaveBeenCalledWith(2000) + }) + + it('追赶在预算内完成 → 用新索引重新检索(不能拿同步前的结果回答)', async () => { + fixture.contacts.splice(1) + knowledge.search + .mockResolvedValueOnce({ ...staleIndex(), sourceLatestAt: NOW - 1000 }) + .mockResolvedValueOnce({ + state: 'ready', + indexLatestAt: NOW - 30 * 1000, + sourceLatestAt: NOW - 1000, + voiceCoverage: undefined, + evidence: [{ conversationId: 'md5-bobo', messageId: 'new-1', timestamp: NOW - 60_000, sender: 'BOBO', sourceKind: 'text', text: '最近开始健身了' }] + }) + knowledge.requestCatchUp.mockReturnValue({ triggered: true, inProgress: true }) + knowledge.waitForIndexingComplete.mockResolvedValue(true) + + const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' }) + + expect(knowledge.search).toHaveBeenCalledTimes(2) + expect(result.coverage).toEqual({ state: 'complete' }) + expect(result.freshness).toEqual({ catchUp: 'completed' }) + expect(result.evidenceCount).toBe(1) + expect(result.evidence?.[0]).toMatchObject({ text: '最近开始健身了', sender: 'BOBO' }) + }) + + it('索引已追平 → 不触发同步(fresh path 不增加开销)', async () => { + fixture.contacts.splice(1) + knowledge.search.mockResolvedValue({ ...freshIndex(), sourceLatestAt: NOW - 1000 }) + + const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' }) + + expect(result.freshness).toEqual({ catchUp: 'none' }) + expect(result.coverage).toEqual({ state: 'complete' }) + expect(knowledge.requestCatchUp).not.toHaveBeenCalled() + expect(knowledge.waitForIndexingComplete).not.toHaveBeenCalled() + }) + + it('请求范围落在索引覆盖窗口内 → 不触发同步', async () => { + fixture.contacts.splice(1) + // 索引追到"昨天",而问的是"上个月"(早于索引覆盖边界)→ 完全覆盖。 + knowledge.search.mockResolvedValue({ + state: 'ready', + evidence: [], + indexLatestAt: NOW - DAY, + sourceLatestAt: NOW - 1000, + voiceCoverage: undefined + }) + + const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' }, query: '健身' }) + + expect(knowledge.requestCatchUp).not.toHaveBeenCalled() + expect(result.coverage).toEqual({ state: 'complete' }) + expect(result.indexCoverage?.covered).toBe(true) + }) + + it('有界时间范围落后于索引时必须 partial(resolvedTimeRange 是秒,freshness 口径是毫秒)', async () => { + fixture.contacts.splice(1) + // 回归:`resolvedTimeRange.endTime` 是 epoch 秒,而 indexLatestAt 是 epoch 毫秒。 + // 混单位会让请求边界被算成 1970 年,从而把"落后"误判成"已覆盖"。 + knowledge.search.mockResolvedValue({ + state: 'ready', + evidence: [], + indexLatestAt: NOW - 4 * 60 * 1000, + sourceLatestAt: NOW, + voiceCoverage: undefined + }) + + const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'today' }, query: '健身' }) + + expect(result.resolvedTimeRange?.endTime).toEqual(expect.any(Number)) + // 请求边界(今天)晚于索引覆盖边界 4 分钟 → 只能 partial。 + expect(result.coverage).toEqual({ state: 'partial' }) + expect(result.indexCoverage?.covered).toBe(false) + expect(result.indexCoverage?.summary).toContain('暂时无法确认') + }) + + it('已有索引任务在跑 → 复用而不阻塞本次查询', async () => { + fixture.contacts.splice(1) + knowledge.search.mockResolvedValue({ ...staleIndex(), sourceLatestAt: NOW - 1000 }) + knowledge.requestCatchUp.mockReturnValue({ triggered: false, inProgress: true }) + + const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' }) + + expect(result.freshness).toEqual({ catchUp: 'reused' }) + expect(knowledge.waitForIndexingComplete).not.toHaveBeenCalled() + expect(result.coverage).toEqual({ state: 'partial' }) + }) + + it('追平后 0 结果才允许 complete negative;落后时同一条件只能是 partial', async () => { + fixture.contacts.splice(1) + knowledge.search.mockResolvedValue({ ...staleIndex(), sourceLatestAt: NOW - 1000 }) + const stale = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '不存在的词' }) + expect(stale.coverage).toEqual({ state: 'partial' }) + + knowledge.search.mockResolvedValue({ ...freshIndex(), sourceLatestAt: NOW - 1000 }) + const fresh = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '不存在的词' }) + expect(fresh.coverage).toEqual({ state: 'complete' }) + expect(fresh.evidenceCount).toBe(0) + }) + + it('派生索引不可用时保持 unknown/partial,不冒充 complete', async () => { + fixture.contacts.splice(1) + knowledge.search.mockResolvedValue({ state: 'unavailable', evidence: [], indexLatestAt: null, sourceLatestAt: null, voiceCoverage: undefined }) + const empty = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' }) + expect(empty.coverage).toEqual({ state: 'unknown' }) + expect(knowledge.requestCatchUp).not.toHaveBeenCalled() + }) + + it('落后量低于门槛(max(下限, 上一遍耗时))时不触发追赶,只如实 partial', async () => { + fixture.contacts.splice(1) + // 上一遍索引耗时 15 分钟 → 15 分钟的落后不值得立刻再跑一遍(否则会连续索引)。 + knowledge.lastPassDurationMs.mockReturnValue(15 * 60 * 1000) + knowledge.search.mockResolvedValue({ + state: 'ready', + evidence: [], + indexLatestAt: NOW - 15 * 60 * 1000, + sourceLatestAt: NOW - 1000, + voiceCoverage: undefined + }) + + const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' }) + + expect(result.freshness).toEqual({ catchUp: 'skipped' }) + expect(result.coverage).toEqual({ state: 'partial' }) + expect(knowledge.requestCatchUp).not.toHaveBeenCalled() + }) + + it('落后量超过上一遍耗时 → 值得再追一遍', async () => { + fixture.contacts.splice(1) + knowledge.lastPassDurationMs.mockReturnValue(60 * 1000) + knowledge.search.mockResolvedValue({ ...staleIndex(), sourceLatestAt: NOW - 1000 }) + knowledge.requestCatchUp.mockReturnValue({ triggered: true, inProgress: true }) + + const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' }) + + expect(knowledge.requestCatchUp).toHaveBeenCalledTimes(1) + expect(result.freshness).toEqual({ catchUp: 'pending' }) + }) + }) +}) + +describe('LocalQueryApiService — indexCoverage 结论句', () => { + const NOW2 = new Date('2026-09-09T12:00:00+08:00').getTime() + let service2: LocalQueryApiService + + beforeEach(() => { + vi.clearAllMocks() + if (!fixture.contacts.some((contact) => contact.md5 === 'md5-bobo-2')) fixture.contacts.push({ m_nsUsrName: 'wxid-bobo-2', m_nsNickName: 'BOBO', md5: 'md5-bobo-2', type: 'user' }) + service2 = new LocalQueryApiService( + { + search: vi.fn(async () => ({ + state: 'ready', + evidence: [], + indexLatestAt: NOW2 - 10 * 24 * 60 * 60 * 1000, + sourceLatestAt: NOW2 - 1000, + voiceCoverage: undefined + })), + requestCatchUp: vi.fn(() => ({ triggered: false, inProgress: false })), + waitForIndexingComplete: vi.fn(async () => false), + lastPassDurationMs: vi.fn(() => 0), + beginInteractiveQuery: vi.fn(), + endInteractiveQuery: vi.fn() + } as never, + () => new Date('2026-09-09T12:00:00+08:00') + ) + fixture.contacts.splice(1) + }) + + it('落后时给出可直接引用的本地时间结论,且明确"暂时无法确认"', async () => { + const result = await service2.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' }) + expect(result.coverage).toEqual({ state: 'partial' }) + // 8/30 12:00 的本地时间标签(固定时钟下可断言) + expect(result.indexCoverage?.covered).toBe(false) + expect(result.indexCoverage?.indexLatestAtLabel).toBe('08-30 12:00') + expect(result.indexCoverage?.summary).toContain('还没进索引') + expect(result.indexCoverage?.summary).toContain('暂时无法确认') + // 不要把 epoch 数字交给模型去猜 + expect(result.indexCoverage?.summary).not.toMatch(/\d{12,}/) }) }) diff --git a/tests/unit/local-query-tool-executor.test.ts b/tests/unit/local-query-tool-executor.test.ts new file mode 100644 index 0000000..44b7dd1 --- /dev/null +++ b/tests/unit/local-query-tool-executor.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest' +import { createLocalQueryToolExecutor } from '../../src/main/services/local-query-tool-executor' +import type { LocalQueryApiService } from '../../src/main/services/local-query-api-service' + +function fakeQueryApi(): LocalQueryApiService { + return { + messages: vi.fn(async () => ({ status: 'completed', returnedCount: 0 })), + search: vi.fn(async () => ({ status: 'completed', evidenceCount: 0 })), + context: vi.fn(async () => ({ status: 'completed' })), + overview: vi.fn(async () => ({ status: 'completed' })) + } as unknown as LocalQueryApiService +} + +describe('createLocalQueryToolExecutor', () => { + it('把四个 Tool 映射到对应的 Local Query API 能力', async () => { + const api = fakeQueryApi() + const execute = createLocalQueryToolExecutor(api) + + await execute('query_messages', { target: { query: 'BOBO' }, timeRange: { kind: 'all' } }) + await execute('search_messages', { query: '房租' }) + await execute('message_context', { messageRef: 'ref' }) + await execute('conversation_overview', { target: { query: '群' }, timeRange: { kind: 'all' } }) + + expect(api.messages).toHaveBeenCalledWith({ target: { query: 'BOBO' }, timeRange: { kind: 'all' } }) + expect(api.search).toHaveBeenCalledWith({ query: '房租' }) + expect(api.context).toHaveBeenCalledWith({ messageRef: 'ref' }) + expect(api.overview).toHaveBeenCalledWith({ + target: { query: '群' }, + timeRange: { kind: 'all' } + }) + }) + + it('拒绝未知工具名(不静默落到任意查询)', async () => { + const api = fakeQueryApi() + const execute = createLocalQueryToolExecutor(api) + + await expect(execute('raw_sql', {})).rejects.toThrow('不允许的工具') + expect(api.messages).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/message-jump.test.ts b/tests/unit/message-jump.test.ts new file mode 100644 index 0000000..03fbf8a --- /dev/null +++ b/tests/unit/message-jump.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import type { Message } from '../../src/shared/types' +import { buildMessageGroups } from '../../src/renderer/src/components/chat/messageGrouping' +import { + normalizeJumpMessageId, + resolveMessageJumpTarget +} from '../../src/renderer/src/components/chat/messageJump' + +const message = (id: string, createTime: number, content = id): Message => + ({ + id, + from: 'user', + type: '普通文本', + datetime: '', + content, + isSender: false, + createTime + }) as Message + +/** + * 「跳转到原聊天」的定位契约。 + * + * 必须**真的**落在那一条消息上,而不是"路由切过去了"。 + * 秒级时间戳在群聊里对应多条消息,所以精确 id 必须优先。 + */ +describe('resolveMessageJumpTarget', () => { + it('prefers the exact stable identity over the timestamp', () => { + const messages = [ + message('m1', 1_700_000_000), + message('m2', 1_700_000_000), + message('m3', 1_700_000_000) + ] + const groups = buildMessageGroups(messages) + + // 三条消息同一秒:只有 id 能定位到正确的那一条。 + const target = resolveMessageJumpTarget(groups, 1_700_000_000, 'm3') + expect(target).toMatchObject({ messageId: 'm3', exact: true }) + }) + + it('normalizes the synthetic local: prefix on both sides', () => { + // 归档侧存的是 `local:42`,证据侧的 messageRef 解出来是 `42` —— 必须视为同一条。 + const groups = buildMessageGroups([message('local:42', 1_700_000_000)]) + expect(normalizeJumpMessageId('local:42')).toBe('42') + expect(resolveMessageJumpTarget(groups, undefined, '42')).toMatchObject({ + messageId: 'local:42', + exact: true + }) + // 反过来也一样(证据侧带前缀、归档侧不带)。 + const plain = buildMessageGroups([message('42', 1_700_000_000)]) + expect(resolveMessageJumpTarget(plain, undefined, 'local:42')).toMatchObject({ + messageId: '42', + exact: true + }) + }) + + it('returns the archive-side id so the highlight comparison cannot silently mismatch', () => { + const groups = buildMessageGroups([message('local:17078', 1_700_000_000)]) + const target = resolveMessageJumpTarget(groups, undefined, '17078') + // 传进去的是归一化后的 `17078`,但返回的必须是归档侧真实出现的 `local:17078`: + // MessageGroup 用 message.id 直接比对高亮。 + expect(target?.messageId).toBe('local:17078') + }) + + it('falls back to the first message at or after the timestamp for legacy evidence', () => { + const groups = buildMessageGroups([ + message('old', 1_690_000_000), + message('hit', 1_700_000_100), + message('newer', 1_700_000_200) + ]) + expect(resolveMessageJumpTarget(groups, 1_700_000_000, undefined)).toMatchObject({ + messageId: 'hit', + exact: false + }) + }) + + it('reports nothing to jump to when the message no longer exists', () => { + const groups = buildMessageGroups([message('m1', 1_700_000_000)]) + // 目标已被删除 / 清理,且时间戳晚于所有消息 → 调用方必须走诚实降级文案。 + expect(resolveMessageJumpTarget(groups, 1_900_000_000, 'gone')).toBeNull() + expect(resolveMessageJumpTarget(groups, undefined, 'gone')).toBeNull() + expect(resolveMessageJumpTarget(groups, null, null)).toBeNull() + }) + + it('still resolves by id when the evidence carries no usable timestamp', () => { + const groups = buildMessageGroups([message('m1', 1_700_000_000), message('m2', 1_700_000_600)]) + // 没有 createTime 的证据在真实数据里确实存在(老缓存),不能因此放弃定位。 + expect(resolveMessageJumpTarget(groups, undefined, 'm1')).toMatchObject({ + messageId: 'm1', + exact: true, + groupIndex: 0 + }) + }) +}) diff --git a/tests/unit/message-ref.test.ts b/tests/unit/message-ref.test.ts new file mode 100644 index 0000000..0417747 --- /dev/null +++ b/tests/unit/message-ref.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { + decodeMessageRef, + encodeMessageRef, + normalizeMessageIdentity +} from '../../src/shared/local-query-api' +import { + MESSAGES_AROUND_MAX_WINDOW, + messagesAroundRadii, + normalizeRadiusSeconds, + sliceMessagesAroundWindow, + widenRadiusSeconds +} from '../../src/main/services/messages-around' + +/** + * 「跳到原聊天」的稳定身份。 + * + * 只靠「会话 + 秒级时间戳」定位不到**这一条**消息:同一秒可能有多条, + * 时间戳也只能定位到"附近"。messageRef 是唯一跨层可用的稳定身份, + * 所以它的编解码必须与 Buffer / Node API 解耦(renderer 在 contextIsolation 下没有 Buffer)。 + */ +describe('messageRef codec', () => { + it('round-trips conversation and message identity', () => { + const ref = encodeMessageRef('conversation-md5', '17078') + expect(decodeMessageRef(ref)).toEqual({ + conversationId: 'conversation-md5', + messageId: '17078' + }) + }) + + it('strips the synthetic local: prefix so both sides normalize to the same identity', () => { + const ref = encodeMessageRef('conversation-md5', 'local:17078') + expect(decodeMessageRef(ref)).toEqual({ + conversationId: 'conversation-md5', + messageId: '17078' + }) + // 两条不同的入参(带/不带前缀)必须归一到同一个身份。 + expect(encodeMessageRef('conversation-md5', '17078')).toBe(ref) + }) + + it('produces an opaque, URL-safe token that does not leak raw ids', () => { + const ref = encodeMessageRef('conversation-md5', '17078') + expect(ref).not.toContain('conversation-md5') + expect(ref).not.toContain('17078') + expect(ref).toMatch(/^[A-Za-z0-9_-]+$/) + expect(ref).not.toMatch(/[+/=]/) + }) + + it('returns null instead of throwing for garbage, tampered or empty references', () => { + expect(decodeMessageRef('')).toBeNull() + expect(decodeMessageRef('not-base64!!')).toBeNull() + expect(decodeMessageRef(encodeMessageRef('conversation-md5', '17078') + 'x')).toBeNull() + expect(decodeMessageRef(undefined)).toBeNull() + expect(decodeMessageRef(42)).toBeNull() + expect(decodeMessageRef({ c: 'a', m: 'b' })).toBeNull() + // 合法 base64 但不是我们的结构。 + expect(decodeMessageRef(btoa('{"x":1}').replace(/=+$/, ''))).toBeNull() + }) + + it('rejects identities missing either half', () => { + expect(normalizeMessageIdentity('', '17078')).toBeNull() + expect(normalizeMessageIdentity('conversation-md5', ' ')).toBeNull() + expect(normalizeMessageIdentity('conversation-md5', '17078')).toEqual({ + conversationId: 'conversation-md5', + messageId: '17078' + }) + }) +}) + +/** + * 锚点窗口规则:加载必须围绕这条消息,**不能**退化成整段历史。 + */ +describe('messages around anchor window', () => { + it('never plans an unbounded read when the anchor time is missing', () => { + const base = normalizeRadiusSeconds(undefined) + // 没有锚点 = 没有半径可试:调用方必须走「已打开会话但无法定位原消息」的降级文案。 + expect(messagesAroundRadii(undefined, base, widenRadiusSeconds(base))).toEqual([]) + expect(messagesAroundRadii(0, base, widenRadiusSeconds(base))).toEqual([]) + expect(messagesAroundRadii(-5, base, widenRadiusSeconds(base))).toEqual([]) + }) + + it('tries the narrow window first, then one bounded widening', () => { + const base = normalizeRadiusSeconds(undefined) + const widened = widenRadiusSeconds(base) + expect(messagesAroundRadii(1_785_900_000, base, widened)).toEqual([base, widened]) + // 放宽有上限:一次点击不许变成全库扫描。 + expect(widened).toBeLessThanOrEqual(3 * 24 * 3600) + expect(widened).toBeGreaterThanOrEqual(base) + }) + + it('clamps the requested radius into a sane band', () => { + expect(normalizeRadiusSeconds(1)).toBe(60) + expect(normalizeRadiusSeconds(6 * 3600)).toBe(6 * 3600) + expect(normalizeRadiusSeconds(365 * 24 * 3600)).toBe(24 * 3600) + }) + + it('caps the returned window so a huge conversation cannot flood the IPC payload', () => { + const messages = Array.from({ length: 5000 }, (_, index) => ({ id: `local:${index}` })) + const { window, index, truncated } = sliceMessagesAroundWindow( + messages, + 'conversation-md5', + '4999' + ) + expect(truncated).toBe(true) + expect(window).toHaveLength(MESSAGES_AROUND_MAX_WINDOW) + // 目标落在被截断的部分之外 → 如实报告"没找到",而不是伪装成找到。 + expect(index).toBe(-1) + }) + + it('locates the exact message by normalized identity, not by position', () => { + const messages = [{ id: 'local:17076' }, { id: '17077' }, { id: 'local:17078' }] + const { window, index, truncated } = sliceMessagesAroundWindow( + messages, + 'conversation-md5', + '17078' + ) + expect(truncated).toBe(false) + expect(index).toBe(2) + expect(window[index]).toEqual({ id: 'local:17078' }) + }) +}) diff --git a/tests/unit/query-agent-conversation-memory.test.ts b/tests/unit/query-agent-conversation-memory.test.ts new file mode 100644 index 0000000..d460c9d --- /dev/null +++ b/tests/unit/query-agent-conversation-memory.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { + CONVERSATION_MEMORY_ANSWER_MAX_CHARS, + CONVERSATION_MEMORY_MAX_TURNS, + CONVERSATION_MEMORY_QUESTION_MAX_CHARS, + CONVERSATION_MEMORY_TTL_MS, + QueryAgentConversationMemory +} from '../../src/main/services/query-agent-conversation-memory' + +describe('QueryAgentConversationMemory', () => { + it('只保留最近 N 轮,且有明确长度边界', () => { + const memory = new QueryAgentConversationMemory() + memory.record('k', 'q1', 'a1') + memory.record('k', 'q2', 'a2') + memory.record('k', 'q3', 'a3') + + const history = memory.history('k') + expect(history).toHaveLength(CONVERSATION_MEMORY_MAX_TURNS) + expect(history.map((turn) => turn.question)).toEqual(['q2', 'q3']) + }) + + it('超长问题与回答会被截断', () => { + const memory = new QueryAgentConversationMemory() + memory.record('k', 'q'.repeat(500), 'a'.repeat(2000)) + + const [turn] = memory.history('k') + expect(turn.question.length).toBe(CONVERSATION_MEMORY_QUESTION_MAX_CHARS + 1) + expect(turn.answer.length).toBe(CONVERSATION_MEMORY_ANSWER_MAX_CHARS + 1) + }) + + it('超过 TTL 后自动失效', () => { + let now = 1_000 + const memory = new QueryAgentConversationMemory(() => now) + memory.record('k', 'q', 'a') + expect(memory.history('k')).toHaveLength(1) + + now += CONVERSATION_MEMORY_TTL_MS + 1 + expect(memory.history('k')).toEqual([]) + }) + + it('按会话隔离,forget 只清目标会话', () => { + const memory = new QueryAgentConversationMemory() + memory.record('a', 'q-a', 'a-a') + memory.record('b', 'q-b', 'a-b') + + memory.forget('a') + expect(memory.history('a')).toEqual([]) + expect(memory.history('b')).toHaveLength(1) + + memory.forget() + expect(memory.history('b')).toEqual([]) + }) + + it('空白内容不进入记忆', () => { + const memory = new QueryAgentConversationMemory() + memory.record('k', ' ', 'a') + memory.record('k', 'q', ' ') + expect(memory.history('k')).toEqual([]) + }) +}) diff --git a/tests/unit/query-agent-history.test.ts b/tests/unit/query-agent-history.test.ts new file mode 100644 index 0000000..367eeb8 --- /dev/null +++ b/tests/unit/query-agent-history.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest' +import { QueryAgentService, type QueryAgentProvider } from '../../src/main/services/query-agent-service' + +function capturingProvider(configured = true): { + provider: QueryAgentProvider + calls: Array<{ messages: Array>; tools: string[] }> +} { + const calls: Array<{ messages: Array>; tools: string[] }> = [] + const provider: QueryAgentProvider = { + getRuntimeConfig: () => ({ + configured, + providerName: 'Fixture Provider', + model: 'fixture-model', + modelName: 'Fixture Model' + }), + chatWithTools: vi.fn(async (messages, tools) => { + calls.push({ messages, tools: tools.map((tool) => tool.function.name) }) + return { success: true, data: 'ok' } + }) + } + return { provider, calls } +} + +describe('QueryAgentService — 历史上下文不改变毕业语义', () => { + it('不传 history 时 messages 仍然只有 system + user(与毕业版本一致)', async () => { + const { provider, calls } = capturingProvider() + const service = new QueryAgentService(provider, vi.fn()) + + await service.run('我和 BOBO 第一次聊了什么') + + expect(calls[0].messages.map((message) => message.role)).toEqual(['system', 'user']) + expect(calls[0].messages[1]).toMatchObject({ content: '我和 BOBO 第一次聊了什么' }) + expect(calls[0].tools).toEqual([ + 'query_messages', + 'search_messages', + 'message_context', + 'conversation_overview' + ]) + }) + + it('传 history 时按 问 / 答 顺序插在 system 之后', async () => { + const { provider, calls } = capturingProvider() + const service = new QueryAgentService(provider, vi.fn()) + + await service.run('是 BOBO', { + history: [{ question: '我们第一次聊了什么', answer: '你说的是哪位联系人?' }] + }) + + expect(calls[0].messages.map((message) => message.role)).toEqual([ + 'system', + 'user', + 'assistant', + 'user' + ]) + }) + + it('失败分类是 additive 字段:成功时不存在', async () => { + const { provider } = capturingProvider() + const service = new QueryAgentService(provider, vi.fn()) + + const result = await service.run('你好') + + expect(result.errorKind).toBeUndefined() + expect(result.answer).toBe('ok') + }) + + it('Provider 未配置 / 空问题仍返回可区分的失败分类', async () => { + const notConfigured = new QueryAgentService(capturingProvider(false).provider, vi.fn()) + const unconfigured = await notConfigured.run('你好') + expect(unconfigured.errorKind).toBe('provider_unavailable') + expect(unconfigured.modelCallCount).toBe(0) + + const configured = new QueryAgentService(capturingProvider().provider, vi.fn()) + const empty = await configured.run(' ') + expect(empty.errorKind).toBe('invalid_question') + }) +}) diff --git a/tests/unit/query-agent-poc-cli.test.ts b/tests/unit/query-agent-poc-cli.test.ts index dfee3d1..b8dcc39 100644 --- a/tests/unit/query-agent-poc-cli.test.ts +++ b/tests/unit/query-agent-poc-cli.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parsePocQuestion } from '../../src/main/query-agent-poc-cli' +import { parsePocInvocation, parsePocQuestion } from '../../src/main/query-agent-poc-cli' describe('Query Agent POC CLI 参数解析', () => { it('移除开头的分隔符 --', () => { @@ -34,3 +34,40 @@ describe('Query Agent POC CLI 参数解析', () => { expect(argv).toEqual(['--', '问题']) }) }) + +describe('parsePocInvocation — 诊断模式与搜索范围', () => { + it('无 --tool 时是自然语言模式,并把 --scope 从问题里剥掉', () => { + expect(parsePocInvocation(['我和 BOBO 第一次聊了什么'])).toMatchObject({ + kind: 'question', + question: '我和 BOBO 第一次聊了什么' + }) + const scoped = parsePocInvocation([ + '--scope', + '{"kind":"groups"}', + '最近谁聊过健身' + ]) + expect(scoped).toMatchObject({ kind: 'question', question: '最近谁聊过健身' }) + if (scoped.kind !== 'question') throw new Error('unreachable') + expect(scoped.scope).toEqual({ kind: 'groups' }) + }) + + it('裸工具模式把 scope 合并进工具参数(Host 注入,LLM 提供不了)', () => { + const invocation = parsePocInvocation([ + '--tool', + 'search_messages', + '--args', + '{"timeRange":{"kind":"all"},"query":"健身"}', + '--scope', + '{"kind":"groups"}' + ]) + if (invocation.kind !== 'tool') throw new Error('unreachable') + expect(invocation.toolName).toBe('search_messages') + expect(invocation.args).toMatchObject({ query: '健身', scope: { kind: 'groups' } }) + }) + + it('非法 scope / 缺少 conversationId 会明确报错', () => { + expect(() => parsePocInvocation(['--scope', '{"kind":"nope"}', 'x'])).toThrow(/scope.kind/) + expect(() => parsePocInvocation(['--scope', '{"kind":"contact"}', 'x'])).toThrow(/conversationId/) + expect(() => parsePocInvocation(['--scope', 'not-json', 'x'])).toThrow(/合法 JSON/) + }) +}) diff --git a/tests/unit/query-agent-poc-report.test.ts b/tests/unit/query-agent-poc-report.test.ts index dfaeeda..ef4517a 100644 --- a/tests/unit/query-agent-poc-report.test.ts +++ b/tests/unit/query-agent-poc-report.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { formatProviderDiagnostics, formatTiming } from '../../src/main/query-agent-poc-report' -import type { QueryAgentPocResult } from '../../src/main/services/query-agent-poc-service' +import type { QueryAgentResult } from '../../src/main/services/query-agent-service' -function result(patch: Partial = {}): QueryAgentPocResult { +function result(patch: Partial = {}): QueryAgentResult { return { question: 'q', provider: 'Fixture Provider', diff --git a/tests/unit/query-agent-progress.test.ts b/tests/unit/query-agent-progress.test.ts new file mode 100644 index 0000000..6da527e --- /dev/null +++ b/tests/unit/query-agent-progress.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { + QUERY_AGENT_PROGRESS_STEPS, + queryAgentProgressLabel, + queryAgentProgressStepIndex, + type QueryAgentProgressState +} from '../../src/renderer/src/components/search/hooks/useQueryAgentProgress' + +const state = (patch: Partial = {}): QueryAgentProgressState => ({ + stage: 'understanding', + elapsedMs: 0, + modelCallCount: 1, + toolCallCount: 0, + ...patch +}) + +/** + * 进度文案必须来自**真实 Runtime 生命周期**,而且要说清"卡在哪一步": + * 静态 N 步文案加一个计时器,用户什么都判断不出来。 + */ +describe('queryAgentProgressLabel', () => { + it('maps every real lifecycle stage to a user-readable sentence', () => { + expect(queryAgentProgressLabel(null, true)).toBe('正在准备查询') + expect(queryAgentProgressLabel(state({ stage: 'understanding' }), true)).toBe( + '正在理解你的问题' + ) + expect( + queryAgentProgressLabel(state({ stage: 'understanding', modelCallCount: 2 }), true) + ).toBe('正在重新理解你的问题') + expect(queryAgentProgressLabel(state({ stage: 'organizing_evidence' }), true)).toBe( + '正在整理找到的聊天记录' + ) + expect(queryAgentProgressLabel(state({ stage: 'generating_answer' }), true)).toBe( + '正在生成回答' + ) + expect(queryAgentProgressLabel(state({ stage: 'completed' }), true)).toBe('已完成') + }) + + it('explains the wider scope during the Tool phase instead of silently hanging', () => { + // 跨会话检索是另一个量级的耗时;提前说清楚范围,用户才不会被"突然要好几十秒"劝退。 + expect(queryAgentProgressLabel(state({ stage: 'searching' }), true)).toBe( + '正在搜索较大范围的聊天记录…' + ) + expect(queryAgentProgressLabel(state({ stage: 'searching' }), false)).toBe( + '正在搜索聊天记录…' + ) + }) + + it('never leaks internal tool names, SQL, FTS or ids', () => { + const stages: QueryAgentProgressState['stage'][] = [ + 'understanding', + 'searching', + 'organizing_evidence', + 'generating_answer', + 'completed' + ] + for (const stage of stages) { + for (const wide of [true, false]) { + const label = queryAgentProgressLabel(state({ stage, toolName: 'search_messages' }), wide) + expect(label).not.toMatch(/query_messages|search_messages|message_context|overview/) + expect(label).not.toMatch(/SELECT|FTS|fts|knowledge_|local:|[0-9a-f]{16,}/) + } + } + }) + + it('keeps the declared step list aligned with the runtime lifecycle', () => { + expect(QUERY_AGENT_PROGRESS_STEPS.map((step) => step.stage)).toEqual([ + 'understanding', + 'searching', + 'organizing_evidence', + 'generating_answer' + ]) + }) +}) + +describe('queryAgentProgressStepIndex', () => { + it('advances monotonically with the lifecycle and completes at the end', () => { + expect(queryAgentProgressStepIndex(null)).toBe(0) + expect(queryAgentProgressStepIndex(state({ stage: 'understanding' }))).toBe(0) + expect(queryAgentProgressStepIndex(state({ stage: 'searching' }))).toBe(1) + expect(queryAgentProgressStepIndex(state({ stage: 'organizing_evidence' }))).toBe(2) + expect(queryAgentProgressStepIndex(state({ stage: 'generating_answer' }))).toBe(3) + expect(queryAgentProgressStepIndex(state({ stage: 'completed' }))).toBe( + QUERY_AGENT_PROGRESS_STEPS.length + ) + }) +}) diff --git a/tests/unit/query-agent-poc-service.test.ts b/tests/unit/query-agent-service.test.ts similarity index 82% rename from tests/unit/query-agent-poc-service.test.ts rename to tests/unit/query-agent-service.test.ts index a7cf292..ddbb509 100644 --- a/tests/unit/query-agent-poc-service.test.ts +++ b/tests/unit/query-agent-service.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { QueryAgentPocService, type QueryAgentProvider, validateToolArguments } from '../../src/main/services/query-agent-poc-service' +import { QueryAgentService, type QueryAgentProvider, validateToolArguments } from '../../src/main/services/query-agent-service' function provider(responses: Array>>, configured = true): QueryAgentProvider { return { @@ -12,10 +12,10 @@ function provider(responses: Array { +describe('QueryAgentService', () => { it('runs a bounded model -> tool -> model loop and records sanitized trace', async () => { const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 1, messages: [{ messageRef: 'secret-ref' }] })) - const service = new QueryAgentPocService(provider([ + const service = new QueryAgentService(provider([ { success: true, toolCalls: [{ id: 'call-1', name: 'query_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, temporalBasis: { kind: 'none' }, limit: 1 }) }] }, { success: true, data: '第一条消息是图片。' } ]), execute) @@ -24,7 +24,13 @@ describe('QueryAgentPocService', () => { expect(result.modelCallCount).toBe(2) expect(result.toolCallCount).toBe(1) expect(result.traces[0]).toMatchObject({ toolName: 'query_messages', status: 'completed', resultCount: 1 }) - expect(JSON.stringify(result)).not.toContain('secret-ref') + // trace / input 必须保持脱敏(opaque messageRef 不出现在 trace 里)。 + expect(JSON.stringify(result.traces)).not.toContain('secret-ref') + // 证据是 ADDITIVE 的展示契约:只有 evidence[] 携带 opaque messageRef,且不含会话身份。 + expect(result.evidence).toEqual([ + expect.objectContaining({ messageRef: 'secret-ref', source: 'query_messages' }) + ]) + expect(JSON.stringify(result.evidence)).not.toMatch(/wxid|md5|conversationId/) }) it('presents planning boundaries and remaining budget with tool results', async () => { @@ -39,7 +45,7 @@ describe('QueryAgentPocService', () => { { success: true, toolCalls: [{ id: 'call-1', name: 'search_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, queries: ['topic'] }) }] }, { success: true, data: '根据这条证据可以回答。' } ]) - await new QueryAgentPocService(configuredProvider, execute).run('查找相关记录') + await new QueryAgentService(configuredProvider, execute).run('查找相关记录') const calls = vi.mocked(configuredProvider.chatWithTools).mock.calls const firstMessages = calls[0]?.[0] || [] expect(String(firstMessages[0]?.content)).toContain('Evidence 是否已经足以') @@ -56,7 +62,7 @@ describe('QueryAgentPocService', () => { { success: true, toolCalls: [{ id: 'call-1', name: 'query_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, temporalBasis: { kind: 'none' }, limit: 1 }) }] }, { success: true, data: '完成' } ]) - await new QueryAgentPocService(configuredProvider, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('第一条消息') + await new QueryAgentService(configuredProvider, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('第一条消息') expect(vi.mocked(configuredProvider.chatWithTools).mock.calls[1]?.[1]).toEqual([]) }) @@ -67,7 +73,7 @@ describe('QueryAgentPocService', () => { { success: true, toolCalls: [{ id: 'call-2', name: 'conversation_overview', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' } }) }] }, { success: true, data: '完成' } ]) - const result = await new QueryAgentPocService(configuredProvider, execute).run('第一条消息') + const result = await new QueryAgentService(configuredProvider, execute).run('第一条消息') expect(execute).toHaveBeenCalledTimes(1) expect(result.traces[1]).toMatchObject({ toolName: 'conversation_overview', status: 'invalid_tool_arguments' }) const thirdCallMessages = vi.mocked(configuredProvider.chatWithTools).mock.calls[2]?.[0] || [] @@ -78,7 +84,7 @@ describe('QueryAgentPocService', () => { it('rejects unknown tools and stops after five calls', async () => { const execute = vi.fn(async () => ({ status: 'completed' })) const responses = Array.from({ length: 6 }, () => ({ success: true, toolCalls: [{ id: 'x', name: 'unknown', arguments: '{}' }] })) - const result = await new QueryAgentPocService(provider(responses), execute).run('test') + const result = await new QueryAgentService(provider(responses), execute).run('test') expect(result.toolCallCount).toBe(5) expect(result.traces.every((trace) => trace.status === 'invalid_tool_arguments')).toBe(true) expect(result.error).toContain('最大工具调用次数') @@ -87,14 +93,14 @@ describe('QueryAgentPocService', () => { it('clarifies unavailable configuration without making a model call', async () => { const configuredProvider = provider([], false) - const result = await new QueryAgentPocService(configuredProvider, vi.fn()).run('test') + const result = await new QueryAgentService(configuredProvider, vi.fn()).run('test') expect(result.error).toContain('尚未配置') expect(configuredProvider.chatWithTools).not.toHaveBeenCalled() }) it('maps LLM queries[] to the Local Query API query + variants contract', async () => { const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 0 })) - const valid = await new QueryAgentPocService(provider([ + const valid = await new QueryAgentService(provider([ { success: true, toolCalls: [{ id: 'valid', name: 'search_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, queries: ['答应', '承诺', '保证', '说好'] }) }] }, { success: true, data: 'done' } ]), execute).run('test') @@ -105,7 +111,7 @@ describe('QueryAgentPocService', () => { expect(execute.mock.calls[0][1]).not.toHaveProperty('queries') execute.mockClear() - const invalid = await new QueryAgentPocService(provider([ + const invalid = await new QueryAgentService(provider([ { success: true, toolCalls: [{ id: 'invalid', name: 'search_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, queries: ['1', '2', '3', '4', '5'] }) }] }, { success: true, data: '修正后完成' } ]), execute).run('test') @@ -233,7 +239,7 @@ describe('QueryAgent zero-result limited retry', () => { it('keeps search_messages available once after a zero-evidence search', async () => { const execute = vi.fn(emptySearch) const configured = provider([searchCall('c1', ['答应']), { success: true, data: '没有找到相关证据。' }]) - await new QueryAgentPocService(configured, execute).run('找承诺') + await new QueryAgentService(configured, execute).run('找承诺') expect(vi.mocked(configured.chatWithTools).mock.calls[1]?.[1].map((tool) => tool.function.name)).toEqual(['search_messages']) const toolMessage = vi.mocked(configured.chatWithTools).mock.calls[1]?.[0].find((message) => message.role === 'tool') const presented = JSON.parse(String(toolMessage?.content)) as Record @@ -244,7 +250,7 @@ describe('QueryAgent zero-result limited retry', () => { it('allows exactly one substantively different second search and then closes tools', async () => { const execute = vi.fn(emptySearch) const configured = provider([searchCall('c1', ['答应']), searchCall('c2', ['公积金']), { success: true, data: '仍然没有。' }]) - const result = await new QueryAgentPocService(configured, execute).run('找承诺') + const result = await new QueryAgentService(configured, execute).run('找承诺') expect(execute).toHaveBeenCalledTimes(2) expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'completed']) expect(vi.mocked(configured.chatWithTools).mock.calls[2]?.[1]).toEqual([]) @@ -254,7 +260,7 @@ describe('QueryAgent zero-result limited retry', () => { it('rejects an identical search retry instead of spending the budget again', async () => { const execute = vi.fn(emptySearch) const configured = provider([searchCall('c1', ['答应']), searchCall('c2', ['答应']), { success: true, data: '没有找到。' }]) - const result = await new QueryAgentPocService(configured, execute).run('找承诺') + const result = await new QueryAgentService(configured, execute).run('找承诺') expect(execute).toHaveBeenCalledTimes(1) expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' }) expect(vi.mocked(configured.chatWithTools).mock.calls[2]?.[1]).toEqual([]) @@ -265,7 +271,7 @@ describe('QueryAgent zero-result limited retry', () => { it('treats a reordered identical probe set as an identical retry', async () => { const execute = vi.fn(emptySearch) const configured = provider([searchCall('c1', ['答应', '承诺']), searchCall('c2', ['承诺', '答应']), { success: true, data: 'x' }]) - const result = await new QueryAgentPocService(configured, execute).run('找承诺') + const result = await new QueryAgentService(configured, execute).run('找承诺') expect(execute).toHaveBeenCalledTimes(1) expect(result.traces[1].status).toBe('invalid_tool_arguments') }) @@ -273,14 +279,14 @@ describe('QueryAgent zero-result limited retry', () => { it('keeps the efficient path when the first search already returned evidence', async () => { const execute = vi.fn(async () => ({ status: 'completed', evidenceCount: 2, evidence: [{ messageRef: 'ref', text: 't' }] })) const configured = provider([searchCall('c1', ['答应']), { success: true, data: 'ok' }]) - await new QueryAgentPocService(configured, execute).run('找承诺') + await new QueryAgentService(configured, execute).run('找承诺') expect(vi.mocked(configured.chatWithTools).mock.calls[1]?.[1].map((tool) => tool.function.name)).toEqual(['message_context']) }) it('rejects an identical query_messages retry', async () => { const execute = vi.fn(emptyQuery) const configured = provider([queryCall('c1', { kind: 'previous_month' }), queryCall('c2', { kind: 'previous_month' }), { success: true, data: 'x' }]) - const result = await new QueryAgentPocService(configured, execute).run(MONTH_QUESTION) + const result = await new QueryAgentService(configured, execute).run(MONTH_QUESTION) expect(execute).toHaveBeenCalledTimes(1) expect(result.traces[1].status).toBe('invalid_tool_arguments') }) @@ -288,7 +294,7 @@ describe('QueryAgent zero-result limited retry', () => { it('keeps the efficient stop when the exact query already returned messages', async () => { const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 3, messages: [] })) const configured = provider([queryCall('c1'), { success: true, data: 'ok' }]) - await new QueryAgentPocService(configured, execute).run(MONTH_QUESTION) + await new QueryAgentService(configured, execute).run(MONTH_QUESTION) expect(vi.mocked(configured.chatWithTools).mock.calls[1]?.[1]).toEqual([]) }) @@ -302,7 +308,7 @@ describe('QueryAgent zero-result limited retry', () => { queryCall('c5', { kind: 'this_year' }), queryCall('c6', { kind: 'this_month' }) ] - const result = await new QueryAgentPocService(provider(responses), execute).run(MONTH_QUESTION) + const result = await new QueryAgentService(provider(responses), execute).run(MONTH_QUESTION) expect(result.toolCallCount).toBe(5) expect(result.error).toContain('最大工具调用次数') }) @@ -317,7 +323,7 @@ describe('QueryAgent zero-result limited retry', () => { toolCalls: [{ id, name: 'query_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: range, temporalBasis: { kind: 'constraint', sourceText: '8 月' }, messageTypes }) }] }) const configured = provider([call('c1', ['file']), call('c2', ['text']), { success: true, data: '找到。' }]) - const result = await new QueryAgentPocService(configured, execute).run('今年 8 月有没有给我发过文件') + const result = await new QueryAgentService(configured, execute).run('今年 8 月有没有给我发过文件') expect(execute).toHaveBeenCalledTimes(2) expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'completed']) // the explicit range is preserved on the retry — only the non-temporal condition changed @@ -346,7 +352,7 @@ describe('QueryAgent temporal basis policy', () => { it('requires temporalBasis on query_messages', async () => { const execute = vi.fn(emptyQuery) const call = { success: true as const, toolCalls: [{ id: 'c1', name: 'query_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' } }) }] } - const result = await new QueryAgentPocService(provider([call, { success: true, data: 'x' }]), execute).run(CONSTRAINT_Q) + const result = await new QueryAgentService(provider([call, { success: true, data: 'x' }]), execute).run(CONSTRAINT_Q) expect(execute).not.toHaveBeenCalled() expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' }) }) @@ -354,7 +360,7 @@ describe('QueryAgent temporal basis policy', () => { it('exposes the declared temporalBasis on the trace and never forwards it to the Local Query API', async () => { const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 1, messages: [] })) const configured = provider([queryCall('c1', { kind: 'last_7_days' }, HINT), { success: true, data: 'ok' }]) - const result = await new QueryAgentPocService(configured, execute).run(HINT_Q) + const result = await new QueryAgentService(configured, execute).run(HINT_Q) expect(result.traces[0].temporalBasis).toEqual({ kind: 'recall_hint', sourceText: '前阵子' }) expect(result.traces[0].input).not.toHaveProperty('temporalBasis') expect(execute.mock.calls[0][1]).not.toHaveProperty('temporalBasis') @@ -363,7 +369,7 @@ describe('QueryAgent temporal basis policy', () => { it('rejects a sourceText that is not literally in the user question', async () => { const execute = vi.fn(emptyQuery) const configured = provider([queryCall('c1', { kind: 'previous_month' }, { kind: 'constraint', sourceText: '去年冬天' }), { success: true, data: 'x' }]) - const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q) + const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q) expect(execute).not.toHaveBeenCalled() expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' }) expect(JSON.stringify(toolMessageAt(configured, 1))).toContain('source_not_in_question') @@ -372,7 +378,7 @@ describe('QueryAgent temporal basis policy', () => { it('requires sourceText for constraint and recall_hint', async () => { const execute = vi.fn(emptyQuery) const configured = provider([queryCall('c1', { kind: 'previous_month' }, { kind: 'constraint' }), { success: true, data: 'x' }]) - const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q) + const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q) expect(execute).not.toHaveBeenCalled() expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' }) }) @@ -380,7 +386,7 @@ describe('QueryAgent temporal basis policy', () => { it('rejects sourceText when kind is none', async () => { const execute = vi.fn(emptyQuery) const configured = provider([queryCall('c1', { kind: 'all' }, { kind: 'none', sourceText: '上个月' }), { success: true, data: 'x' }]) - const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q) + const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q) expect(execute).not.toHaveBeenCalled() expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' }) expect(JSON.stringify(toolMessageAt(configured, 1))).toContain('forbidden_for_none') @@ -392,7 +398,7 @@ describe('QueryAgent temporal basis policy', () => { const sourceText = '8 月 1 日到 9 月 1 日' const range = { kind: 'absolute', startTime: '2026-08-01T00:00:00+08:00', endTime: '2026-09-01T00:00:00+08:00' } const configured = provider([queryCall('c1', range, { kind: 'constraint', sourceText }), { success: true, data: 'ok' }]) - const result = await new QueryAgentPocService(configured, execute).run(question) + const result = await new QueryAgentService(configured, execute).run(question) expect(execute).toHaveBeenCalledTimes(1) expect(result.traces[0].temporalBasis).toEqual({ kind: 'constraint', sourceText }) }) @@ -400,7 +406,7 @@ describe('QueryAgent temporal basis policy', () => { it('never broadens a constraint relative range to all when it returns zero', async () => { const execute = vi.fn(emptyQuery) const configured = provider([queryCall('c1', { kind: 'previous_month' }, CONSTRAINT), { success: true, data: '上个月没有。' }]) - const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q) + const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q) expect(execute).toHaveBeenCalledTimes(1) expect(execute.mock.calls[0][1]).toMatchObject({ timeRange: { kind: 'previous_month' } }) expect(result.traces[0].autoFallback).toBeUndefined() @@ -410,7 +416,7 @@ describe('QueryAgent temporal basis policy', () => { const execute = vi.fn(emptyQuery) const range = { kind: 'absolute', startTime: '2026-08-01T00:00:00+08:00', endTime: '2026-08-31T23:59:59+08:00' } const configured = provider([queryCall('c1', range, { kind: 'constraint', sourceText: '8 月' }), { success: true, data: '没有。' }]) - const result = await new QueryAgentPocService(configured, execute).run('2026 年 8 月有没有给我发过文件') + const result = await new QueryAgentService(configured, execute).run('2026 年 8 月有没有给我发过文件') expect(execute).toHaveBeenCalledTimes(1) expect(result.traces[0].autoFallback).toBeUndefined() expect(result.traces[0].input.timeRange).toMatchObject({ kind: 'absolute' }) @@ -419,7 +425,7 @@ describe('QueryAgent temporal basis policy', () => { it('rejects a constraint retry that replaces the user time range', async () => { const execute = vi.fn(emptyQuery) const configured = provider([queryCall('c1', { kind: 'previous_month' }, CONSTRAINT), queryCall('c2', { kind: 'all' }, CONSTRAINT), { success: true, data: 'x' }]) - const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q) + const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q) expect(execute).toHaveBeenCalledTimes(1) expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' }) expect(JSON.stringify(toolMessageAt(configured, 2))).toContain('constraint_time_range_immutable') @@ -433,7 +439,7 @@ describe('QueryAgent temporal basis policy', () => { queryCall('c3', { kind: 'all' }, CONSTRAINT), { success: true, data: 'x' } ] - const result = await new QueryAgentPocService(provider(responses), execute).run(CONSTRAINT_Q) + const result = await new QueryAgentService(provider(responses), execute).run(CONSTRAINT_Q) expect(execute).toHaveBeenCalledTimes(1) expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'invalid_tool_arguments', 'invalid_tool_arguments']) }) @@ -447,7 +453,7 @@ describe('QueryAgent temporal basis policy', () => { queryCall('c2', { kind: 'previous_month' }, CONSTRAINT, { messageTypes: ['text'] }), { success: true, data: '找到。' } ]) - const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q) + const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q) expect(execute).toHaveBeenCalledTimes(2) expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'completed']) }) @@ -455,7 +461,7 @@ describe('QueryAgent temporal basis policy', () => { it('does not broaden when a recall_hint range already returned messages', async () => { const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 3, messages: [] })) const configured = provider([queryCall('c1', { kind: 'last_7_days' }, HINT), { success: true, data: 'ok' }]) - const result = await new QueryAgentPocService(configured, execute).run(HINT_Q) + const result = await new QueryAgentService(configured, execute).run(HINT_Q) expect(execute).toHaveBeenCalledTimes(1) expect(result.traces[0].autoFallback).toBeUndefined() expect(vi.mocked(configured.chatWithTools).mock.calls[1]?.[1]).toEqual([]) @@ -466,7 +472,7 @@ describe('QueryAgent temporal basis policy', () => { .mockResolvedValueOnce({ status: 'completed', returnedCount: 0, messages: [] }) .mockResolvedValueOnce({ status: 'completed', returnedCount: 1, messages: [{ messageRef: 'ref', sourceKind: 'file' }], resolvedTimeRange: { kind: 'all', label: '全部历史' } }) const configured = provider([queryCall('c1', { kind: 'last_7_days' }, HINT), { success: true, data: '找到了。' }]) - const result = await new QueryAgentPocService(configured, execute).run(HINT_Q) + const result = await new QueryAgentService(configured, execute).run(HINT_Q) expect(execute).toHaveBeenCalledTimes(2) expect(execute.mock.calls[1][1]).toMatchObject({ timeRange: { kind: 'all' } }) // the corrective lookup is Host orchestration, not a model tool call @@ -477,7 +483,7 @@ describe('QueryAgent temporal basis policy', () => { it('does not repeat an all-history lookup when a recall_hint query already used all', async () => { const execute = vi.fn(emptyQuery) const configured = provider([queryCall('c1', { kind: 'all' }, HINT), queryCall('c2', { kind: 'all' }, HINT), { success: true, data: 'x' }]) - const result = await new QueryAgentPocService(configured, execute).run(HINT_Q) + const result = await new QueryAgentService(configured, execute).run(HINT_Q) expect(execute).toHaveBeenCalledTimes(1) expect(result.traces[0].autoFallback).toBeUndefined() expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' }) @@ -488,7 +494,7 @@ describe('QueryAgent temporal basis policy', () => { .mockResolvedValueOnce({ status: 'completed', returnedCount: 0, messages: [], query: { resolvedTimeRange: { kind: 'last_7_days', label: '近 7 天' } } }) .mockResolvedValueOnce({ status: 'completed', returnedCount: 1, messages: [{ messageRef: 'ref', text: 'x' }], resolvedTimeRange: { kind: 'all', label: '全部历史' } }) const configured = provider([queryCall('c1', { kind: 'last_7_days' }, HINT), { success: true, data: 'ok' }]) - await new QueryAgentPocService(configured, execute).run(HINT_Q) + await new QueryAgentService(configured, execute).run(HINT_Q) const presented = toolMessageAt(configured, 1) expect(presented.returnedCount).toBe(0) expect(presented.fallbackLookup).toMatchObject({ reason: 'soft_temporal_hint_zero_result', timeRange: { kind: 'all' }, returnedCount: 1 }) @@ -498,7 +504,7 @@ describe('QueryAgent temporal basis policy', () => { it('stops after a corrective lookup that also finds nothing', async () => { const execute = vi.fn(emptyQuery) const configured = provider([queryCall('c1', { kind: 'last_7_days' }, HINT), queryCall('c2', { kind: 'this_month' }, HINT), { success: true, data: '都没找到。' }]) - const result = await new QueryAgentPocService(configured, execute).run(HINT_Q) + const result = await new QueryAgentService(configured, execute).run(HINT_Q) // primary + one corrective lookup only; the third attempt is refused because tools are closed expect(execute).toHaveBeenCalledTimes(2) expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' }) @@ -507,7 +513,7 @@ describe('QueryAgent temporal basis policy', () => { it('accepts none with timeRange all', async () => { const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 2, messages: [] })) const configured = provider([queryCall('c1', { kind: 'all' }, NONE), { success: true, data: 'ok' }]) - const result = await new QueryAgentPocService(configured, execute).run('BOBO 给我发过文件吗') + const result = await new QueryAgentService(configured, execute).run('BOBO 给我发过文件吗') expect(execute).toHaveBeenCalledTimes(1) expect(result.traces[0].temporalBasis).toEqual({ kind: 'none' }) }) @@ -515,7 +521,7 @@ describe('QueryAgent temporal basis policy', () => { it('rejects none combined with a bounded time range', async () => { const execute = vi.fn(emptyQuery) const configured = provider([queryCall('c1', { kind: 'last_7_days' }, NONE), { success: true, data: 'x' }]) - const result = await new QueryAgentPocService(configured, execute).run('BOBO 给我发过文件吗') + const result = await new QueryAgentService(configured, execute).run('BOBO 给我发过文件吗') expect(execute).not.toHaveBeenCalled() expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' }) expect(JSON.stringify(toolMessageAt(configured, 1))).toContain('temporal_basis_mismatch') @@ -531,7 +537,7 @@ describe('QueryAgent 耗时与请求级诊断记录', () => { const configured = provider([ { success: false, error: '模型服务返回了网页而不是 JSON(HTTP 502 Bad Gateway)', errorStatus: 502, errorContentType: 'text/html', htmlInsteadOfJson: true, elapsedMs: 99419 } ]) - const result = await new QueryAgentPocService(configured, vi.fn()).run('测试') + const result = await new QueryAgentService(configured, vi.fn()).run('测试') expect(result.modelCallCount).toBe(1) expect(result.modelDurationsMs).toHaveLength(1) expect(result.modelDiagnostics).toEqual([ @@ -545,7 +551,7 @@ describe('QueryAgent 耗时与请求级诊断记录', () => { it('2 次模型调用 + 1 次工具:耗时数组与调用次数一致', async () => { const configured = provider([queryCall('c1'), { success: true, data: '完成' }]) - const result = await new QueryAgentPocService(configured, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('测试') + const result = await new QueryAgentService(configured, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('测试') expect(result.modelCallCount).toBe(2) expect(result.modelDurationsMs).toHaveLength(2) expect(result.modelDurationsMs.every((value) => typeof value === 'number' && value >= 0)).toBe(true) @@ -555,7 +561,7 @@ describe('QueryAgent 耗时与请求级诊断记录', () => { it('1 次模型调用 + 0 次工具:无工具诊断噪声', async () => { const configured = provider([{ success: true, data: '直接回答' }]) - const result = await new QueryAgentPocService(configured, vi.fn()).run('测试') + const result = await new QueryAgentService(configured, vi.fn()).run('测试') expect(result.modelCallCount).toBe(1) expect(result.toolCallCount).toBe(0) expect(result.modelDurationsMs).toHaveLength(1) @@ -567,10 +573,101 @@ describe('QueryAgent 耗时与请求级诊断记录', () => { queryCall('c1'), { success: false, error: 'AI 请求超时', timedOut: true } ]) - const result = await new QueryAgentPocService(configured, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('测试') + const result = await new QueryAgentService(configured, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('测试') expect(result.modelDurationsMs).toHaveLength(2) expect(result.modelDiagnostics[1]).toMatchObject({ index: 2, timedOut: true }) expect(result.error).toBe('AI 请求超时') expect(result.answer).toBeUndefined() }) }) + +/** + * 生产查询进度。 + * + * 进度必须来自**真实 Runtime 生命周期边界**,不允许定时器伪进度 —— + * 只有一个静态步骤界面加一个计时器时,用户看不到是在等模型、等本地检索,还是等知识库追新。 + */ +describe('QueryAgentService progress lifecycle', () => { + it('emits the real understanding -> searching -> organizing -> generating lifecycle', async () => { + const stages: Array<{ stage: string; toolName?: string }> = [] + const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 1, messages: [{ messageRef: 'r1' }] })) + const service = new QueryAgentService( + provider([ + { + success: true, + toolCalls: [ + { + id: 'call-1', + name: 'query_messages', + arguments: JSON.stringify({ + target: { query: 'BOBO' }, + timeRange: { kind: 'all' }, + temporalBasis: { kind: 'none' }, + limit: 1 + }) + } + ] + }, + { success: true, data: '第一条消息是图片。' } + ]), + execute + ) + await service.run('我和 BOBO 最开始聊了什么', { + onProgress: (event) => stages.push({ stage: event.stage, toolName: event.toolName }) + }) + + expect(stages.map((item) => item.stage)).toEqual([ + 'understanding', + 'searching', + 'organizing_evidence', + 'generating_answer', + 'completed' + ]) + // toolName 只在 Host 侧用于诊断片段,UI 文案不会把它暴露给用户。 + expect(stages[1].toolName).toBe('query_messages') + }) + + it('reports understanding again when the model re-plans after a bad call', async () => { + const stages: string[] = [] + const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 0, messages: [] })) + await new QueryAgentService( + provider([ + { success: true, toolCalls: [{ id: 'c1', name: 'not_a_tool', arguments: '{}' }] }, + { success: true, data: '无法完成。' } + ]), + execute + ).run('测试', { onProgress: (event) => stages.push(event.stage) }) + + // 第一个 tool call 被拒绝(没有真正执行工具)→ 仍然算"还没有 Tool 结果", + // 所以第二次模型调用依然是理解阶段,而不是凭空跳到"生成回答"。 + expect(stages[0]).toBe('understanding') + expect(stages).toContain('generating_answer') + expect(stages[stages.length - 1]).toBe('completed') + }) + + it('always finishes the stream even when the provider fails', async () => { + const stages: string[] = [] + const result = await new QueryAgentService( + provider([{ success: false, error: 'AI 请求超时' }]), + vi.fn() + ).run('测试', { onProgress: (event) => stages.push(event.stage) }) + + expect(result.errorKind).toBe('provider_failure') + // 不允许把 UI 永久停在某个中间阶段。 + expect(stages[0]).toBe('understanding') + expect(stages[stages.length - 1]).toBe('completed') + }) + + it('never lets a throwing progress listener break the query', async () => { + const result = await new QueryAgentService( + provider([{ success: true, data: '直接回答' }]), + vi.fn() + ).run('测试', { + onProgress: () => { + throw new Error('listener 崩了') + } + }) + + expect(result.answer).toBe('直接回答') + }) +})